How do I verify a TLS SMTP certificate is valid in

2020-01-31 03:39发布

问题:

To prevent man-in-the-middle attacks (a server pretending to be someone else), I would like to verify that the SMTP server I connect too over SSL has a valid SSL certificate which proves it is who I think it is.

For example, after connecting to an SMTP server on port 25, I can switch to a secure connection like so:

<?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");

However, there is no mention of where PHP is checking the SSL certificate against. Does PHP have a built-in list of root CA's? Is it just accepting anything?

What is the proper way to verify the certificate is valid and that the SMTP server really is who I think it is?

Update

Based on this comment on PHP.net it seems I can do SSL checks using some stream options. The best part is that the stream_context_set_option accepts a context or a stream resource. Therefore, at some point in your TCP connection you can switch to SSL using a CA cert bundle.

$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");
}

Also, see Context options and parameters which expands on the SSL options.

However, while this now solves the main problem - how do I verify that the valid certificate actually belongs to the domain/IP I'm connecting to?

In other words, the cert the server I'm connecting too may have a valid cert - but how do I know it's valid for "example.com" and not another server using a valid cert to act like "example.com"?

Update 2

It seems that you can capture the SSL certificate using the steam context params and parse it with openssl_x509_parse.

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

回答1:

In order not to load an already overlong, and no longer too much on topic, answer with more text, I leave that one to deal with the why's and wherefore's, and here I'll describe the how.

I tested this code against Google and a couple other servers; what comments there are are, well, comments in the code.

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


回答2:

UPDATE: there's a better way of doing this, see the comments.

You can capture the certificate and have a conversation with the server using openssl as a filter. This way you can extract the certificate and examine it during the same connection.

This is an incomplete implementation (the actual mail sending conversation isn't present) that ought to get you started:

<?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);
?>

Of course you will move the certificate parsing and checking before you send anything meaningful to the server.

In the $par array you should find (among the rest) the name, the same parsed as subject.

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
        )

To check for validity, apart from date checking etc., which SSL does on its own, you must verify that EITHER of these conditions apply:

  • the CN of the entity is your DNS name, e.g. "CN = smtp.your.server.com"

  • there are extensions defined and they contain a subjectAltName, which once exploded with explode(',', $subjectAltName), yield an array of DNS:-prefixed records, at least one of which matches your DNS name. If none match, the certificate is rejected.

Certificate verification in PHP

The meaning of verify host in different softwares seems murky at best.

So I decided to get at the bottom of this, and downloaded OpenSSL's source code (openssl-1.0.1c) and tried to check out for myself.

I found no references to the code I was expecting, namely:

  • attempts to parse a colon-delimited string
  • references to subjectAltName (which OpenSSL calls SN_subject_alt_name)
  • use of "DNS[:]" as delimiter

OpenSSL seems to put all certificate details into a structure, run very basic tests on some of them, but most "human readable" fields are left alone. It makes sense: it could be argued that name checking is at a higher level than certificate signature checking

I then downloaded also the latest cURL and the latest PHP tarball.

In the PHP source code I found nothing either; apparently any options are just passed down the line and otherwise ignored. This code ran with no warning:

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

and stream_context_get_options later dutifully retrieved

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

This too makes sense: PHP can't know, in the "context-option-setting" context, what options will be used down the line.

Just as well, the certificate parsing code parses the certificate and extracts the information OpenSSL put there, but it does not validate that same information.

So I dug a little deeper and finally found a certificate verification code in cURL, here:

// curl-7.28.0/lib/ssluse.c

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

where it does what I expected: it looks for subjectAltNames, it checks all of them for sanity and runs them past hostmatch, where checks like hello.example.com == *.example.com are ran. There are additional sanity checks: "We require at least 2 dots in pattern to avoid too wide wildcard match." and xn-- checks.

To sum it up, OpenSSL runs some simple checks and leaves the rest to the caller. cURL, calling OpenSSL, implements more checks. PHP too runs some checks on CN with verify_peer, but leaves subjectAltName alone. These checks do not convince me too much; see below under "Test".

Lacking the ability to access cURL's functions, the best alternative is to reimplement those in PHP.

Variable wildcard domain matching for example could be done by dot-exploding both actual domain and certificate domain, reversing the two arrays

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

and verify that corresponding items are either equal, or the certificate one is a *; if that happens, we have to have already checked at least two components, here com and example.

I believe that the solution above is one of the best if you want to check certificates all in one go. Even better would be being able to open the stream directly without resorting to the openssl client - and this is possible; see comment.

Test

I have a good, valid, and fully trusted certificate from Thawte issued to "mail.eve.com".

The above code running on Alice would then connect securely with mail.eve.com, and it does, as expected.

Now I install that same certificate on mail.bob.com, or in some other way I convince the DNS that my server is Bob, while it actually is still Eve.

I expect the SSL connection to still work (the certificate is valid and trusted), but the certificate isn't issued to Bob -- it's issued to Eve. So someone has to make this one last check and warn Alice that Bob is actually being impersonated by Eve (or equivalently, that Bob is employing Eve's stolen certificate).

I used the code below:

    $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";

and:

  • if the certificate is not verifiable with a trusted authority:
    • verify_host does nothing
    • verify_peer TRUE causes an error
    • verify_peer FALSE allows connection
    • allow_self_signed does nothing
  • if the certificate is expired:
    • I get an error.
  • if the certificate is verifiable:
    • connection is allowed to "mail.eve.com" impersonating "mail.bob.com" and I get a "Success!" message.

I take this to mean that, barring some stupid error on my part, PHP does not by itself check certificates against names.

Using the proc_open code at the beginning of this post, I again can connect, but this time I have access to the subjectAltName and can therefore check by myself, detecting the impersonation.



回答3:

how do I verify that the valid certificate actually belongs to the domain/IP I'm connecting to?

Certificates are issued for domain names (never for IP). It can be single domain name (like mail.example.com) or wildcard *.example.com). Once you got your certificate decoded with openssl, you can read that name, which is called common name from field cn. Then you just need to check if the machine you are trying to connect is that one from certificate. Since you got remote peer name as you are connecting to it already, then check is quite trivial, however, depending on how paranoid checks you want to perform, you may try to find out if you are not using poisoned DNS, which resolves your mail.example.com hostname to forged IP. This should be done by first resolving mail.example.com with gethostbynamel() which shall give you at least one IP address (let's say you get just 1.2.3.4). Then you check reverse DNS with gethostbyaddr() for each IP address returned, and one of them should should return mail.example.com (please note I used gethostbynamel(), not gethostbyname() as it is not rare that server got more than one IP address assigned per name).

NOTE: please be careful trying to apply too strict policy - you may hurt your users. It's quite popular scenario for single server to host many domains (like with shared hosting). In such case server is using IP 1.2.3.4, customer's domain example.com is being given that IP address (so resolving example.com will give you 1.2.3.4, however reverse DNS for this host will most likely be something different, bond to ISP domain name, not customer's domain, like box0123.hosterdomain.com or 4-3-2-1.hosterdomain.com. And this is all perfectly fine and legit. Hosters do that because technically you can assign single IP to multiple domain names at the same time, but with reverse DNS you can assign one entry per IP only. And by using own domain name instead of customers' you do not need to bother revDNS no matter customers are added or removed from the server.

So if you got closed lists of hosts you will be connecting to - you can do this test, but if your users may try to connect wherever, then I'd just stick to checking certificate chain only.

EDIT #1

If you query DNS you do not control, then you cannot fully trust it. Such DNS can be turned into zombie, poisoned and it simply can lie all the time and fake response to any query you ask him, both "forward" (FQDN to ip) and reverse (ip to FQDN). If dns server is hacked (rooted) it can (if attacker is motivated enough) to make it not forward in-addr.arpa queries and fake the response to match other replies (more on reverse lookups here). So in fact unless you use DNSSEC there's still a way to fool your checks. So you have to think how paranoid you need to act as - forward queres can be spoofed by dns poisoning, while this is not working for reverse lookups if the host is not yours (I mean its reverse DNS zone is hosted on some other server than one replying your normal queries). YOu can try to secure yourself agains local dns poisoning by i.e. querying more than one DNS directly, so even one is hacked, others will probably be not. If all is fine, all DNSes queries should give you the same answer. If something is fishy, then some replies would differ, which you can easily detect.

So it all depends on how secure you want to be and what you want to achieve. If you need to be high secure, you should not use "public" services and directly tunnel your traffic to target serves by i.e. using VPN.

EDIT #2

As for IPv4 vs IPv6 - PHP lacks features for both, so if you want to do mentioned above checks I'd rather consider calling tools like host to do the job (or write PHP extension).