如何在PHP中验证TLS SMTP证书是否有效?

时间:2012-11-15 17:21:56

标签: php smtp ssl-certificate starttls

为了防止中间人攻击(假装是其他人的服务器),我想验证我通过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

您似乎可以capture the SSL certificate使用steam context params并使用openssl_x509_parse解析它。

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

2 个答案:

答案 0 :(得分:3)

为了不加载已经过长,不再过多的主题,回答更多文字,我留下那个来处理为什么和为什么,在这里我将描述如何

我针对Google和其他几台服务器测试了此代码;有什么评论,以及代码中的评论。

<?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...

答案 1 :(得分:2)

  

如何验证有效证书是否实际属于我正在连接的域/ IP?

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

注意:请小心尝试应用过于严格的政策 - 您可能会伤害您的用户。单个服务器托管许多域(如共享托管)是非常流行的场景。在这种情况下,服务器使用IP gethostbyname(),客户的域1.2.3.4被赋予IP地址(因此解析example.com将为您提供example.com,但此主机的反向DNS将最有可能是不同的东西,与ISP域名绑定,而不是客户的域名,如1.2.3.4box0123.hosterdomain.com。这一切都非常好,合法。主持人这样做是因为技术上您可以同时为多个域名分配单个IP,但使用反向DNS,您只能为每个IP分配一个条目。并且通过使用自己的域名代替客户,您无需费心revDNS无论客户是从服务器添加还是删除。

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

编辑#1

如果您查询DNS无法控制,那么您无法完全信任它。这样的DNS可以变成僵尸poisoned,它可以随时撒谎并对你问他的任何查询做出假响应,“转发”(FQDN到ip)和反向(ip到FQDN) )。如果dns服务器被黑客攻击(root),它可以(如果攻击者足够激励)使其不转发4-3-2-1.hosterdomain.com查询并伪造响应以匹配其他回复(更多关于reverse lookups here)。所以事实上除非你使用DNSSEC,否则还有办法愚弄你的支票。所以你必须考虑你需要扮演的偏执行为 - 前进的queres可以被dns中毒欺骗,而如果主机不是你的话,这不适用于反向查找(我的意思是它的反向DNS区域托管在其他服务器上而不是一个回复你的正常查询)。你可以尝试通过直接查询多个DNS来保护自己的本地dns中毒,所以即使一个被黑客攻击,其他人也可能没有。如果一切正常,所有DNSes查询应该给你相同的答案。如果有些东西是腥的,那么一些回复就会有所不同,你可以很容易地发现它们。

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

编辑#2

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