如何验证 TLS SMTP 证书在 PHP 中是否有效?更新更新 2PHP 中的证书验证测试

2022-08-30 23:40:01

为了防止中间人攻击(冒充其他人的服务器),我想验证我通过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服务器确实是我认为它的正确方法是什么?

更新

基于对 PHP.net 的评论,似乎我可以使用一些流选项进行SSL检查。最好的部分是stream_context_set_option接受上下文或流资源。因此,在 TCP 连接中的某个时刻,您可以使用 CA 证书捆绑包切换到 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");
}

另请参阅在 SSL 选项上展开的上下文选项和参数

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

换句话说,我正在连接的服务器的证书也可能具有有效的证书 - 但是我如何知道它对“example.com”有效,而不是另一个使用有效证书来充当“example.com”的服务器?

更新 2

似乎您可以使用steam上下文参数捕获SSL证书,并使用openssl_x509_parse对其进行解析。

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

答案 1

更新:有更好的方法来做到这一点,请参阅评论。

您可以捕获证书并使用过滤器与服务器进行对话。这样,您可以提取证书并在同一连接期间对其进行检查。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,一旦与 爆炸式增长,就会生成一个前缀记录数组,其中至少有一个与您的 DNS 名称匹配。如果没有匹配项,则证书将被拒绝。explode(',', $subjectAltName)DNS:

PHP 中的证书验证

验证主机在不同软件中的含义似乎充其量是模糊的

所以我决定了解一下,并下载了OpenSSL的源代码(openssl-1.0.1c),并试图自己检查一下。

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

  • 尝试分析冒号分隔的字符串
  • 对 (OpenSSL 调用的引用)subjectAltNameSN_subject_alt_name)
  • 使用“DNS[:]”作为分隔符

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

然后我还下载了最新的cURL和最新的PHP压缩包。

在PHP源代码中,我也什么也没找到。显然,任何选项都只是向下传递,否则将被忽略。此代码在没有警告的情况下运行:

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

后来尽职尽责地检索stream_context_get_options

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

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

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

所以我挖得更深一点,终于在cURL中找到了一个证书验证码,在这里:

// curl-7.28.0/lib/ssluse.c

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

它在哪里做了我期望的事情:它寻找主题AltNames,它检查所有这些名称的合理性并运行它们过去,其中运行了像 hello.example.com == *.example.com 这样的检查。还有额外的健全性检查:“我们要求模式中至少有 2 个点,以避免通配符匹配太宽”和 xn-- 检查。hostmatch

总而言之,OpenSSL运行一些简单的检查,其余的留给调用方。cURL调用OpenSSL,实现更多的检查。PHP 也使用 在 CN 上运行一些检查,但单独留下。这些检查并没有说服我太多;见下文“测试”下的内容。verify_peersubjectAltName

由于缺乏访问cURL函数的能力,最好的选择是在PHP中重新实现这些函数。

例如,可变通配符域匹配可以通过对实际域和证书域进行点爆炸来完成,从而反转两个数组

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

并验证相应的项目是否相等,或者证书一是 *;如果发生这种情况,我们必须已经检查了至少两个组件,此处和。comexample

我相信,如果您想一次性检查证书,上面的解决方案是最好的解决方案之一。更好的是能够直接打开流而不求助于客户端 - 这是可能的;请参阅注释。openssl

测试

我有一个良好,有效且完全信任的Thawte证书,该证书颁发给“mail.eve.com”。

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

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

我希望SSL连接仍然有效(证书有效且受信任的),但证书不是颁发给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.eve.com”冒充“mail.bob.com”,我收到“成功!”消息。

我认为这意味着,除非我有一些愚蠢的错误,否则PHP本身不会根据名称检查证书

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


答案 2

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

我针对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...

推荐