17

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"]));
Community
  • 1
  • 1
Xeoncross
  • 50,836
  • 73
  • 238
  • 351
  • 1
    Have you seen http://php.net/manual/en/book.openssl.php – Lawrence Cherone Nov 15 '12 at 17:29
  • Yes, I know that both cURL and OpenSSL support [Certificate Verification](http://www.php.net/manual/en/openssl.cert.verification.php) through a param that specifies the root CA's list. However, I'm not sure how (or if) I can get it to work with a PHP stream/socket. [This looks promising](http://www.php.net/manual/en/function.openssl-x509-check-private-key.php). – Xeoncross Nov 15 '12 at 17:35
  • 1
    Certificate does not belong to IP. What you can check is: if hostname ou are trying to connect to matches the one from certificate, if forward and reverse DNS lookups match (still host can be multihome but it doable), if certificate issuer is trusted and certificate chain is retained and finally if certificate is not revoked – Marcin Orlowski Nov 17 '12 at 18:58
  • @WebnetMobile.com so the question is, how do I do that in PHP? – Xeoncross Nov 17 '12 at 19:35
  • If you already verified certificate using CA, you can now check the CN attribute in certificate. if certificate is self signed you should install that certificate somewhere to use it as CA later, – zb' Nov 17 '12 at 23:17

3 Answers3

8

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.

LSerni
  • 49,775
  • 9
  • 56
  • 97
  • But does `stream_context_set_option($resource, 'ssl', 'verify_host', true);` do this same check though? – Xeoncross Nov 18 '12 at 03:00
  • I did some research and hope I'm wrong, but it does not seem to work. Later today I'll set up a site with a wrong certificate name to see if somehow I missed the verification stage. – LSerni Nov 18 '12 at 12:22
  • It is still possible that I'm wrong, but the test confirmed that PHP is not (at least by default) checking certificate domain name match, not even with the various SSL options in place. – LSerni Nov 18 '12 at 14:44
  • The `verify_host` option does not exist (it's `verify_peer`). And no, it does not check *that*. But it is possible to capture the certificate as you indicated in your update 2 (slightly modified): `stream_context_set_option($smtp, 'ssl', 'capture_peer_cert', true); ... $cont = stream_context_get_options($smtp); $cert = openssl_x509_parse($cont["ssl"]["peer_certificate"]);`. Once you have done that, you can verify whether a `subjectAltName` exists. If it does not, you check the CN. If it does, you explode it and verify all DNS: entries evaluating the matches. – LSerni Nov 18 '12 at 15:29
  • @Iserni, that [document you linked to](http://www.cs.utexas.edu/~shmat/shmat_ccs12.pdf) answers a lot of security questions about SSL's *not* testing the CN in the certificate correctly. However, I found that that `stream_context_set_option($stream, 'ssl', 'CN_match', $host);` is what is needed to verify the host matches who I think I'm connecting too. After enabling this option I can no longer connect to Google via STARTTLS as the SSL library correctly rejects their invalid CN name. Shame on Google. – Xeoncross Nov 18 '12 at 22:53
  • You still are at risk of false negatives though, i.e., a multihomed host having some CN at random (even if a wildcard might help) and the real certified domains in `subjectAltName`. But it's been a really interesting ride :-) – LSerni Nov 18 '12 at 23:40
  • Awesome overview, I finally just finished it all. But for me it's not over yet. Like in your first example, there is actually a way to pull the cert (`stream_context_set_option($stream, 'ssl', 'capture_peer_cert', true);`) without dropping down to a direct system call OpenSSL. `$meta = stream_context_get_params($stream); $cert = openssl_x509_parse($meta["options"]["ssl"]["peer_certificate"]);` **Update: I just realized you did this below** – Xeoncross Nov 19 '12 at 01:13
  • I was running mantis on chrooted nginx and sending emails through PHPMailer was not working. Thanks to your article I managed to fix my problem - thanks!!! – Greg0ry Feb 03 '15 at 21:59
3

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...
LSerni
  • 49,775
  • 9
  • 56
  • 97
2

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

Marcin Orlowski
  • 67,279
  • 10
  • 112
  • 132
  • How does IP6 play into this? – Xeoncross Nov 18 '12 at 00:53
  • It seems like if a box was installed in the correct location downstream it could hyjack your DNS queries as well as spoof it's IP to match. In this case it seems like the thing you can do is compare the string name of the domain on the cert to the domain you think you connected too - which again can be lying. So, I guess there is no final solution. – Xeoncross Nov 18 '12 at 02:12
  • Added some more notes on DNS hijacking and related subject. As for IPv6 vs IPv4 - it does not really matter - you at the moment you just think about "IP address", no matter what format it is.. – Marcin Orlowski Nov 18 '12 at 11:37
  • Added EDIT #2 on IPv4/v6 as well – Marcin Orlowski Nov 18 '12 at 12:01
  • Firstly, certificates can be issued to IP addresses, at least for HTTPS (but it's a bad idea indeed). Secondly, you should never have to check the reverse DNS entry. Certificate check ought to be based on the name you intend to connect to, always (and preferably using the Subject Alt Names, if available). Reverse lookups aren't part of the specs and don't add any security for this: either you trust the certificate or you don't. DNS resolution doesn't come into any of this at all. – Bruno Nov 18 '12 at 19:03
  • Issuing certificate for IP address technically can be done, yet it makes so little sense that I bed you will not be able to buy such from any CA. As for revDNS check - if OP wants to check, or got the reason to do that - he can do it, it's him to judge. Nobody says it's part of anything – Marcin Orlowski Nov 18 '12 at 19:05
  • I'm just saying that DNS resolution has nothing to do with certificate host name verification. Doing a reverse lookup will tend to cause problems and add no extra security. If you have a poisoned DNS and if you connect to the wrong IP address because of that, the fake host will simply not have a trusted certificate for the host you were looking for. The identity verification is done solely using a certificate that can be trusted and the host name that was looked for. DNS has nothing to do with this. – Bruno Nov 18 '12 at 19:15
  • You basically wrote same I did in my answer. – Marcin Orlowski Nov 18 '12 at 19:29
  • 3/4 of your answer is about dealing with reverse DNS and poisoned DNS, which is completely irrelevant (and almost misleading). Only the first 4 sentences are useful and you don't even mention Subject Alternative Names. – Bruno Nov 18 '12 at 19:33
  • that's mostly because OP asked about how to do that. Not if that should be done. – Marcin Orlowski Nov 18 '12 at 19:34
  • Thanks @Bruno and WebnetMobile, I was assuming a poisoned DNS would render rDNS/PTR checks useless so checking a certificate against the host I assume I'm connecting too seems like the only option. In addition, (an extreme case) if they were lying it seems like they could still register for a valid certificate for *the host they wanted to hyjack*, and then position themselves between my server and the outside world and present their valid certificate claiming to be that host and I would have no way to know something was wrong. It's a lot of effort, and a stretch, but seems possible. – Xeoncross Nov 18 '12 at 22:44