【问题标题】:How do I verify a TLS SMTP certificate is valid in PHP?如何验证 TLS SMTP 证书在 PHP 中是否有效?
【发布时间】:2012-11-04 08:50:24
【问题描述】:

为了防止中间人攻击(伪装成其他人的服务器),我想验证我通过 SSL 连接的 SMTP 服务器是否具有有效的 SSL 证书,证明它就是我认为的人是。

例如,在端口 25 上连接到 SMTP 服务器后,我可以像这样切换到安全连接:

<?php

$smtp = fsockopen( "tcp://mail.example.com", 25, $errno, $errstr ); 
fread( $smtp, 512 ); 

fwrite($smtp,"HELO mail.example.me\r\n"); // .me is client, .com is server
fread($smtp, 512); 

fwrite($smtp,"STARTTLS\r\n");
fread($smtp, 512); 

stream_socket_enable_crypto( $smtp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT ); 

fwrite($smtp,"HELO mail.example.me\r\n");

但是,没有提到 PHP 在哪里检查 SSL 证书。 PHP 是否有内置的根 CA 列表?它只是接受什么吗?

验证证书是否有效以及 SMTP 服务器确实是我认为的身份的正确方法是什么?

更新

基于this comment on PHP.net,我似乎可以使用一些流选项进行 SSL 检查。最好的部分是stream_context_set_option 接受上下文或流资源。因此,在 TCP 连接的某个时刻,您可以使用 CA cert bundle 切换到 SSL。

$resource = fsockopen( "tcp://mail.example.com", 25, $errno, $errstr ); 

...

stream_set_blocking($resource, true);

stream_context_set_option($resource, 'ssl', 'verify_host', true);
stream_context_set_option($resource, 'ssl', 'verify_peer', true);
stream_context_set_option($resource, 'ssl', 'allow_self_signed', false);

stream_context_set_option($resource, 'ssl', 'cafile', __DIR__ . '/cacert.pem');

$secure = stream_socket_enable_crypto($resource, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
stream_set_blocking($resource, false);

if( ! $secure)
{
    die("failed to connect securely\n");
}

另外,请参阅 Context options and parameters,它扩展了 SSL options

然而,虽然这现在解决了主要问题 - 我如何验证 有效 证书确实属于我要连接的域/IP?

换句话说,我连接的服务器的证书也可能有一个有效的证书 - 但我怎么知道它对“example.com”有效,而不是另一个使用有效证书的服务器像“example.com”一样“?

更新 2

看来您可以使用 Steam 上下文参数 capture the SSL certificate 并使用 openssl_x509_parse 解析它。

$cont = stream_context_get_params($r);
print_r(openssl_x509_parse($cont["options"]["ssl"]["peer_certificate"]));

【问题讨论】:

  • 是的,我知道 cURL 和 OpenSSL 通过指定根 CA 列表的参数支持 Certificate Verification。但是,我不确定如何(或是否)可以让它与 PHP 流/套接字一起工作。 This looks promising.
  • 证书不属于IP。您可以检查的是:如果您尝试连接的主机名与证书中的主机名匹配,是否正向和反向 DNS 查找匹配(主机仍然可以是多宿主但可行),证书颁发者是否受信任并且证书链是否保留,最后如果证书没有被吊销
  • @WebnetMobile.com 所以问题是,我如何在 PHP 中做到这一点?
  • 如果您已经使用 CA 验证了证书,您现在可以检查证书中的 CN 属性。如果证书是自签名的,您应该将该证书安装在某处以便以后将其用作 CA,

标签: php smtp ssl-certificate starttls


【解决方案1】:

如何验证有效证书确实属于我要连接的域/IP?

为域名颁发证书(从不为 IP)。它可以是单个域名(如mail.example.com)或通配符*.example.com)。使用 openssl 解码证书后,您可以从字段 cn 中读取该名称,称为 common name。然后您只需要检查您尝试连接的机器是否是来自证书的机器。由于您已经在连接到远程对等方名称,因此检查非常简单,但是,根据您想要执行的偏执检查方式,您可能会尝试找出您是否没有使用中毒的 DNS,这会解析您的 @ 987654331@ 伪造 IP 的主机名。这应该通过首先用gethostbynamel() 解析mail.example.com 来完成,这将为您提供至少一个IP 地址(假设您只得到1.2.3.4)。然后,您使用gethostbyaddr() 为每个返回的IP 地址检查反向DNS,其中一个应该返回mail.example.com(请注意我使用gethostbynamel(),而不是gethostbyname(),因为服务器获得多个IP 并不罕见每个名称分配的地址)。

注意:请谨慎尝试应用过于严格的政策 - 您可能会伤害您的用户。单个服务器托管多个域(例如共享托管)是非常流行的场景。在这种情况下,服务器使用 IP 1.2.3.4,客户的域 example.com 被赋予该 IP 地址(因此解析 example.com 将为您提供 1.2.3.4,但是此主机的反向 DNS 很可能 有所不同,绑定到 ISP 域名,而不是客户的域,例如 box0123.hosterdomain.com4-3-2-1.hosterdomain.com。这一切都非常好和合法。主机这样做是因为从技术上讲,您可以将单个 IP 同时分配给多个域名时间,但是使用反向 DNS,您只能为每个 IP 分配 一个 条目。并且通过使用自己的域名而不是客户的域名,无论从服务器中添加或删除客户,您都无需打扰 revDNS。

因此,如果您获得了将要连接的主机的封闭列表 - 您可以进行此测试,但如果您的用户可能会尝试连接到任何地方,那么我只会坚持只检查证书链。

编辑#1

如果您查询不受您控制的 DNS,则您无法完全信任它。这样的 DNS 可以变成僵尸,poisoned,它可以一直撒谎,对你问他的任何查询做出虚假响应,“转发”(FQDN 到 ip)和反向(ip 到 FQDN)。如果 dns 服务器被黑(root),它可以(如果攻击者有足够的动机)使其不转发in-addr.arpa 查询并伪造响应以匹配其他回复(更多关于reverse lookups here)。所以事实上,除非你使用DNSSEC,否则仍有办法欺骗你的支票。所以你必须考虑你需要表现得多么偏执 - dns中毒可以欺骗前向查询,而如果主机不是你的,这对于反向查找不起作用(我的意思是它的反向DNS区域托管在其他服务器上,而不是一个回复您的正常查询)。您可以尝试通过直接查询多个 DNS 来保护自己免受本地 dns 中毒,因此即使一个被黑客入侵,其他人也可能不会。如果一切正常,所有 DNS 查询都应该为您提供相同的答案。如果某事是可疑的,那么某些回复会有所不同,您可以轻松检测到。

所以这一切都取决于您想要的安全程度以及您想要实现的目标。如果您需要高度安全,则不应使用“公共”服务,而是通过即使用 VPN 将您的流量直接隧道传输到目标服务。

编辑#2

至于 IPv4 与 IPv6 - PHP 两者都缺乏功能,所以如果你想做上面提到的检查,我宁愿考虑调用像 host 这样的工具来完成这项工作(或编写 PHP 扩展)。

【讨论】:

  • IP6 如何参与其中?
  • 看起来如果一个盒子安装在下游的正确位置,它可能会劫持您的 DNS 查询并欺骗它的 IP 以进行匹配。在这种情况下,您似乎可以做的是将证书上域的字符串名称与您认为您也连接的域进行比较——这又可能是在撒谎。所以,我想没有最终的解决方案。
  • 添加了更多关于 DNS 劫持和相关主题的注释。至于 IPv6 与 IPv4 - 这并不重要 - 你现在只考虑“IP 地址”,不管它是什么格式..
  • 在 IPv4/v6 上添加了 EDIT #2
  • 首先,证书可以颁发给 IP 地址,至少对于 HTTPS 来说是这样(但这确实是个坏主意)。其次,您永远不必检查反向 DNS 条目。证书检查应该始终基于您打算连接的名称(如果可用,最好使用主题替代名称)。反向查找不是规范的一部分,并且不为此添加任何安全性:您信任证书或不信任证书。 DNS 解析根本不涉及其中。
【解决方案2】:

更新:有更好的方法,请参阅 cmets。

您可以捕获证书并使用openssl 作为过滤器与服务器进行对话。这样您就可以提取证书并在同一连接期间对其进行检查。

这是一个不完整的实现(不存在实际的邮件发送对话),应该可以帮助您入门:

<?php
    $server = 'smtp.gmail.com';

    $pid    = proc_open("openssl s_client -connect $server:25 -starttls smtp",
                    array(
                            0 => array('pipe', 'r'),
                            1 => array('pipe', 'w'),
                            2 => array('pipe', 'r'),
                    ),
                    $pipes,
                    '/tmp',
                    array()
            );
    list($smtpout, $smtpin, $smtperr) = $pipes; unset($pipes);

    $stage  = 0;
    $cert   = 0;
    $certificate = '';
    while(($stage < 5) && (!feof($smtpin)))
    {
            $line = fgets($smtpin, 1024);
            switch(trim($line))
            {
                    case '-----BEGIN CERTIFICATE-----':
                            $cert   = 1;
                            break;
                    case '-----END CERTIFICATE-----':
                            $certificate .= $line;
                            $cert   = 0;
                            break;
                    case '---':
                            $stage++;
            }
            if ($cert)
                    $certificate .= $line;
    }
    fwrite($smtpout,"HELO mail.example.me\r\n"); // .me is client, .com is server
    print fgets($smtpin, 512);
    fwrite($smtpout,"QUIT\r\n");
    print fgets($smtpin, 512);

    fclose($smtpin);
    fclose($smtpout);
    fclose($smtperr);
    proc_close($pid);

    print $certificate;

    $par    = openssl_x509_parse($certificate);
?>

当然,在向服务器发送任何有意义的内容之前,您将移动证书解析和检查。

$par 数组中,您应该找到(在其余部分中)名称,与主题相同。

Array
(
    [name] => /C=US/ST=California/L=Mountain View/O=Google Inc/CN=smtp.gmail.com
    [subject] => Array
        (
            [C] => US
            [ST] => California
            [L] => Mountain View
            [O] => Google Inc
            [CN] => smtp.gmail.com
        )

    [hash] => 11e1af25
    [issuer] => Array
        (
            [C] => US
            [O] => Google Inc
            [CN] => Google Internet Authority
        )

    [version] => 2
    [serialNumber] => 280777854109761182656680
    [validFrom] => 120912115750Z
    [validTo] => 130607194327Z
    [validFrom_time_t] => 1347451070
    [validTo_time_t] => 1370634207
    ...
    [extensions] => Array
        (
            ...
            [subjectAltName] => DNS:smtp.gmail.com
        )

要检查有效性,除了 SSL 自行执行的日期检查等之外,您必须验证以下条件是否适用:

  • 实体的 CN 是您的 DNS 名称,例如"CN = smtp.your.server.com"

  • 定义了扩展并且它们包含一个 subjectAltName,它曾经与 explode(',', $subjectAltName) 展开,产生一组以 DNS: 为前缀的记录,其中至少一个与您的 DNS 匹配姓名。如果不匹配,则证书被拒绝。

PHP 中的证书验证

verify host在不同软件中的意思好像murky at best.

所以我决定深入了解,并下载了 OpenSSL 的源代码 (openssl-1.0.1c) 并尝试自己查看。

我没有发现对我期望的代码的引用,即:

  • 尝试解析冒号分隔的字符串
  • subjectAltName 的引用(OpenSSL 称之为SN_subject_alt_name
  • 使用“DNS[:]”作为分隔符

OpenSSL 似乎将所有证书详细信息放入一个结构中,对其中的一些 运行非常基本的测试,但大多数“人类可读”字段都被单独留下。这是有道理的:可以说名称检查比证书签名检查处于更高级别

然后我还下载了最新的 cURL 和最新的 PHP tarball。

在 PHP 源代码中我也一无所获;显然,任何选项都只是向下传递,否则会被忽略。此代码运行时没有警告:

    stream_context_set_option($smtp, 'ssl', 'I-want-a-banana', True);

stream_context_get_options后来尽职尽责地找回了

    [ssl] => Array
        (
            [I-want-a-banana] => 1
            ...

这也是有道理的:PHP 无法知道,在“context-option-setting”上下文中,将使用哪些选项。

同样,证书解析代码解析证书并提取 OpenSSL 放在那里的信息,但它不会验证相同的信息。

于是又挖得更深了,终于在cURL中找到了一个证书验证码,这里:

// curl-7.28.0/lib/ssluse.c

static CURLcode verifyhost(struct connectdata *conn,
                       X509 *server_cert)
{

它符合我的预期:它查找 subjectAltNames,检查所有这些名称的完整性并将它们运行通过 hostmatch,在那里运行 hello.example.com == *.example.com 之类的检查。还有额外的健全性检查:“我们要求模式中至少有 2 个点,以避免通配符匹配过宽。”和 xn-- 检查。

总而言之,OpenSSL 会运行一些简单的检查,并将其余的留给调用者。 cURL,调用 OpenSSL,实现了更多的检查。 PHP 也使用verify_peer 在CN 上运行一些 检查,但单独留下subjectAltName。这些检查并不能说服我太多;请参阅下面的“测试”。

缺乏访问 cURL 函数的能力,最好的替代方法是 重新实现 PHP 中的那些。

例如,可变通配符域匹配可以通过点爆炸实际域和证书域来完成,颠倒两个数组

com.example.site.my
com.example.*

并验证对应的项目要么相等,要么证书一为*;如果发生这种情况,我们必须已经检查了至少两个组件,这里是 comexample

我相信上述解决方案是如果您想一次性检查所有证书的最佳解决方案之一。更好的是能够直接打开流而不诉诸openssl 客户端 - 这是可能的;见评论。

测试

我有一份来自 Thawte 的良好、有效且完全受信任的证书颁发给“mail.eve.com”。

然后,在 Alice 上运行的上述代码将与 mail.eve.com 安全连接,并且正如预期的那样。

现在我在 mail.bob.com 上安装相同的证书,或者以其他方式让 DNS 相信我的服务器是 Bob,而实际上它仍然是 Eve。

我希望 SSL 连接仍然有效(证书有效且受信任),但证书不是颁发给 Bob,而是颁发给 Eve。因此,某人必须进行最后一次检查,并警告 Alice Bob 实际上是由 Eve 冒充的(或者等效地,Bob 正在使用 Eve 被盗的证书)。

我使用了下面的代码:

    $smtp = fsockopen( "tcp://mail.bob.com", 25, $errno, $errstr );
    fread( $smtp, 512 );
    fwrite($smtp,"HELO alice\r\n");
    fread($smtp, 512);
    fwrite($smtp,"STARTTLS\r\n");
    fread($smtp, 512);
    stream_set_blocking($smtp, true);
    stream_context_set_option($smtp, 'ssl', 'verify_host', true);
    stream_context_set_option($smtp, 'ssl', 'verify_peer', true);
    stream_context_set_option($smtp, 'ssl', 'allow_self_signed', false);
    stream_context_set_option($smtp, 'ssl', 'cafile', '/etc/ssl/cacert.pem');
    $secure = stream_socket_enable_crypto($smtp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
    stream_set_blocking($smtp, false);
    print_r(stream_context_get_options($smtp));
    if( ! $secure)
            die("failed to connect securely\n");
    print "Success!\n";

和:

  • 如果证书不可验证具有受信任的权威:
    • verify_host 什么都不做
    • verify_peer TRUE 会导致错误
    • verify_peer FALSE 允许连接
    • allow_self_signed 什么都不做
  • 如果证书过期:
    • 我收到一个错误。
  • 如果证书是可验证的:
    • 允许连接到冒充“mail.bob.com”的“mail.eve.com”,我得到“成功!”消息。

我的意思是,除非我犯了一些愚蠢的错误,PHP 本身不会根据名称检查证书

使用本文开头的proc_open 代码,我再次可以连接,但这次我可以访问subjectAltName,因此可以自行检查,检测假冒。

【讨论】:

  • 但是stream_context_set_option($resource, 'ssl', 'verify_host', true); 会做同样的检查吗?
  • 我做了一些研究,希望我错了,但它似乎不起作用。今天晚些时候,我将设置一个证书名称错误的网站,看看我是否错过了验证阶段。
  • 我仍然有可能是错的,但测试证实 PHP 没有(至少在默认情况下)检查证书域名匹配,即使有各种 SSL 选项。跨度>
  • verify_host 选项不存在(它是verify_peer)。不,它不检查那个。但是可以按照您在更新 2(稍作修改)中指出的那样捕获证书:stream_context_set_option($smtp, 'ssl', 'capture_peer_cert', true); ... $cont = stream_context_get_options($smtp); $cert = openssl_x509_parse($cont["ssl"]["peer_certificate"]);。完成后,您可以验证 subjectAltName 是否存在。如果没有,请检查 CN。如果是,则分解它并验证所有 DNS:评估匹配项的条目。
  • @Iserni,document you linked to 回答了很多关于 SSL not 正确测试证书中 CN 的安全问题。但是,我发现 stream_context_set_option($stream, 'ssl', 'CN_match', $host); 是验证主机匹配我认为我也正在连接的人所需要的。启用此选项后,我无法再通过 STARTTLS 连接到 Google,因为 SSL 库正确拒绝了其无效的 CN 名称。在 Google 上感到羞耻。
【解决方案3】:

为了不加载已经过长且不再过多的主题,请回答更多文本,我让那个来处理原因和原因,这里我将描述如何 em>。

我在 Google 和其他几台服务器上测试了这段代码;有什么 cmets,嗯,代码中的 cmets。

<?php
    $server   = "smtp.gmail.com";        // Who I connect to
    $myself   = "my_server.example.com"; // Who I am
    $cabundle = '/etc/ssl/cacert.pem';   // Where my root certificates are

    // Verify server. There's not much we can do, if we suppose that an attacker
    // has taken control of the DNS. The most we can hope for is that there will
    // be discrepancies between the expected responses to the following code and
    // the answers from the subverted DNS server.

    // To detect these discrepancies though, implies we knew the proper response
    // and saved it in the code. At that point we might as well save the IP, and
    // decouple from the DNS altogether.

    $match1   = false;
    $addrs    = gethostbynamel($server);
    foreach($addrs as $addr)
    {
        $name = gethostbyaddr($addr);
        if ($name == $server)
        {
            $match1 = true;
            break;
        }
    }
    // Here we must decide what to do if $match1 is false.
    // Which may happen often and for legitimate reasons.
    print "Test 1: " . ($match1 ? "PASSED" : "FAILED") . "\n";

    $match2   = false;
    $domain   = explode('.', $server);
    array_shift($domain);
    $domain = implode('.', $domain);
    getmxrr($domain, $mxhosts);
    foreach($mxhosts as $mxhost)
    {
        $tests = gethostbynamel($mxhost);
        if (0 != count(array_intersect($addrs, $tests)))
        {
            // One of the instances of $server is a MX for its domain
            $match2 = true;
            break;
        }
    }
    // Again here we must decide what to do if $match2 is false.
    // Most small ISP pass test 2; very large ISPs and Google fail.
    print "Test 2: " . ($match2 ? "PASSED" : "FAILED") . "\n";
    // On the other hand, if you have a PASS on a server you use,
    // it's unlikely to become a FAIL anytime soon.

    // End of maybe-they-help-maybe-they-don't checks.

    // Establish the connection on SMTP port 25
    $smtp = fsockopen( "tcp://{$server}", 25, $errno, $errstr );
    fread( $smtp, 512 );

    // Here you can check the usual banner from $server (or in general,
    // check whether it contains $server's domain name, or whether the
    // domain it advertises has $server among its MX's.
    // But yet again, Google fails both these tests.

    fwrite($smtp,"HELO {$myself}\r\n");
    fread($smtp, 512);

    // Switch to TLS
    fwrite($smtp,"STARTTLS\r\n");
    fread($smtp, 512);
    stream_set_blocking($smtp, true);
    stream_context_set_option($smtp, 'ssl', 'verify_peer', true);
    stream_context_set_option($smtp, 'ssl', 'allow_self_signed', false);
    stream_context_set_option($smtp, 'ssl', 'capture_peer_cert', true);
    stream_context_set_option($smtp, 'ssl', 'cafile', $cabundle);
    $secure = stream_socket_enable_crypto($smtp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
    stream_set_blocking($smtp, false);
    $opts = stream_context_get_options($smtp);
    if (!isset($opts['ssl']['peer_certificate'])) {
        $secure = false;
    } else {
        $cert = openssl_x509_parse($opts['ssl']['peer_certificate']);
        $names = '';
        if ('' != $cert) {
            if (isset($cert['extensions'])) {
                $names = $cert['extensions']['subjectAltName'];
            } elseif (isset($cert['subject'])) {
                if (isset($cert['subject']['CN'])) {
                    $names = 'DNS:' . $cert['subject']['CN'];
                } else {
                    $secure = false; // No exts, subject without CN
                }
            } else {
                $secure = false; // No exts, no subject
            }
        }
        $checks = explode(',', $names);

        // At least one $check must match $server
        $tmp    = explode('.', $server);
        $fles   = array_reverse($tmp);
        $okay   = false;
        foreach($checks as $check) {
            $tmp = explode(':', $check);
            if ('DNS' != $tmp[0])    continue;  // candidates must start with DNS:
            if (!isset($tmp[1]))     continue;  // and have something afterwards
            $tmp  = explode('.', $tmp[1]);
            if (count($tmp) < 3)     continue;  // "*.com" is not a valid match
            $cand = array_reverse($tmp);
            $okay = true;
            foreach($cand as $i => $item) {
                if (!isset($fles[$i])) {
                    // We connected to www.example.com and certificate is for *.www.example.com -- bad.
                    $okay = false;
                    break;
                }
                if ($fles[$i] == $item) {
                    continue;
                }
                if ($item == '*') {
                    break;
                }
            }
            if ($okay) {
                break;
            }
        }
        if (!$okay) {
            $secure = false; // No hosts matched our server.
        }
    }

    if (!$secure) {
            die("failed to connect securely\n");
    }
    print "Success!\n";
    // Continue with connection...

【讨论】:

  • 请务必阅读 @lserni 之前关于此代码背后讨论的详细答案。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2015-05-19
  • 1970-01-01
  • 2015-09-19
  • 2017-08-15
  • 2014-02-14
  • 2016-07-30
  • 1970-01-01
相关资源
最近更新 更多