Как проверить, что сертификат TLS SMTP действителен в PHP?

Чтобы предотвратить атаки "человек-в-середине" (сервер, притворяющийся кем-то другим), я хотел бы проверить, что SMTP-сервер, с которым я подключаюсь слишком по SSL, имеет действительный сертификат SSL, который доказывает, что это тот, есть.

Например, после подключения к SMTP-серверу на порту 25 я могу переключиться на безопасное соединение, например:

<?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 встроенный список корневых центров сертификации? Это просто что-то принимает?

Каков правильный способ проверки сертификата, и что SMTP-сервер действительно является тем, кем я считаю?

Update

На основе этот комментарий на PHP.net кажется, что я могу выполнять проверки SSL с использованием некоторых параметров потока. Наилучшая часть состоит в том, что stream_context_set_option принимает контекст или ресурс потока. Поэтому в какой-то момент вашего TCP-соединения вы можете переключиться на SSL, используя 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");
}

Также см. Параметры и параметры контекста, которые расширяются на Параметры SSL.

Однако, хотя теперь это решает основную проблему - как проверить, действительно ли действительный сертификат принадлежит домену /IP, к которому я подключаюсь?

Другими словами, сертификат, к которому подключается сервер, может иметь действительный сертификат, но как я могу его узнать для "example.com", а не для другого сервера, использующего действительный сертификат, чтобы действовать как "example.com"?

Обновление 2

Кажется, что вы можете захватить сертификат SSL с использованием параметров пара и проанализировать его с помощью openssl_x509_parse.

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

Ответы

Ответ 1

Чтобы не загружать уже перекрытые и уже не слишком много на тему, ответьте на большее количество текста, я оставляю это, чтобы разобраться с тем, почему и почему, и здесь я опишу, как.

Я тестировал этот код против 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 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 unlikely to become a FAIL anytime soon.

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

    // Establish the connection
    $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 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: там лучший способ сделать это, см. комментарии.

Вы можете захватить сертификат и провести беседу с сервером, используя 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 делает сам по себе, вы должны убедиться, что EITHER этих условий применяется:

  • CN сущности - это ваше DNS-имя, например. "CN = smtp.your.server.com"

  • существуют расширенные расширения, и они содержат subjectAltName, которые после вставки с explode(',', $subjectAltName) выдают массив записей DNS: -prefixed, по крайней мере один из которых соответствует вашему DNS-имени. Если они не совпадают, сертификат отклоняется.

Проверка сертификата в PHP

Значение проверки хоста в разных программах кажется мутным в лучшем случае.

Итак, я решил разобраться в этом и загрузил исходный код OpenSSL (openssl-1.0.1c) и попытался проверить сам.

Я не нашел ссылок на код, который я ожидал, а именно:

  • пытается проанализировать строку с разделителями двоеточия
  • ссылки на subjectAltName (который OpenSSL вызывает SN_subject_alt_name)
  • использование "DNS [:]" в качестве разделителя

OpenSSL, кажется, помещает все данные сертификата в структуру, запускает очень простые тесты для некоторых из них, но большинство "удобочитаемых" полей остаются в силе. Это имеет смысл: можно утверждать, что проверка имени на более высоком уровне, чем проверка подписи сертификата

Затем я загрузил также последнюю версию cURL и последнюю версию 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 не может знать, в контексте контекстного параметра-параметра, какие параметры будут использоваться в строке.

Точно так же код синтаксического анализа сертификата анализирует сертификат и извлекает информацию 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 также запускает некоторые проверки на CN с помощью verify_peer, но оставляет subjectAltName один. Эти проверки не слишком меня убеждают; см. ниже в разделе "Тест".

Не имея возможности доступа к функциям cURL, лучшей альтернативой является повторная реализация функций в PHP.

Переменные подстановочных подстановочных подстановок, например, могут выполняться путем точечного взлома как фактического домена, так и домена сертификата, изменяя два массива

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

и убедитесь, что соответствующие элементы либо равны, либо сертификат является *; если это произойдет, мы должны были проверить хотя бы две компоненты, здесь com и example.

Я считаю, что вышеприведенное решение является одним из лучших, если вы хотите проверить сертификаты за один раз. Еще лучше было бы открыть поток напрямую, не прибегая к клиенту openssl - , и это возможно; см. комментарий.

Тест

У меня есть хороший, действительный и полностью доверенный сертификат от Thawte, выпущенный на адрес mail.eve.com.

Вышеприведенный код, работающий на Alice, будет безопасно подключаться с помощью mail.eve.com, и он, как и ожидалось, будет

Теперь я устанавливаю тот же сертификат на mail.bob.com, или каким-то другим способом я убеждаю DNS, что мой сервер - Боб, а на самом деле это еще Eve.

Я ожидаю, что SSL-соединение все еще будет работать (сертификат действительный и доверенный), но сертификат не выдается Бобу - он выдан Еве. Поэтому кто-то должен сделать эту последнюю проверку и предупредить Алису, что Боб фактически олицетворен Евой (или, что то же самое, что Боб использует 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 ИСТИНА вызывает ошибку
    • verify_peer FALSE позволяет подключиться
    • allow_self_signed ничего не делает
  • если срок действия сертификата истек:
    • Я получаю сообщение об ошибке.
  • если сертификат подлежит проверке:
    • соединение разрешено "mail.eve.com" выдавать себя за "mail.bob.com" , и я получаю "Успех!". сообщение.

Я полагаю, что это означает, что, за исключением некоторой глупой ошибки с моей стороны, PHP сам по себе не проверяет сертификаты на имена.

Используя код proc_open в начале этого сообщения, я снова могу подключиться, но на этот раз у меня есть доступ к subjectAltName, и поэтому я могу проверить его самостоятельно, обнаружив олицетворение.

Ответ 3

как проверить, действительно ли действительный сертификат принадлежит домену /IP, к которому я подключаюсь?

Сертификаты выдаются для доменных имен (никогда для IP). Это может быть одно доменное имя (например, mail.example.com) или подстановочный знак *.example.com). После того, как вы получили свой сертификат, декодированный с помощью openssl, вы можете прочитать это имя, которое называется common name из поля cn. Тогда вам просто нужно проверить, является ли машина, к которой вы пытаетесь подключиться, от сертификата. Поскольку у вас есть удаленное одноранговое имя, когда вы уже подключаетесь к нему, тогда проверка довольно тривиальная, однако, в зависимости от того, как вы проведете проверки параноида, вы можете попытаться выяснить, не используете ли вы отравленный DNS, который разрешает ваш mail.example.com имя хоста для подделанного IP. Это нужно сделать, разрешив mail.example.com с помощью gethostbynamel(), который даст вам хотя бы один IP-адрес (скажем, вы получите всего 1,2. 3.4). Затем вы проверяете обратный DNS с gethostbyaddr() для каждого возвращаемого IP-адреса, и один из них должен возвращать mail.example.com (обратите внимание, что я использовал gethostbynamel(), а не gethostbyname(), так как не редкость сервер получил более одного IP-адреса, назначенного для имени).

ПРИМЕЧАНИЕ. будьте осторожны, пытаясь применить слишком строгую политику - вы можете повредить своим пользователям. Это довольно популярный сценарий для одиночного сервера для размещения многих доменов (например, с помощью совместного хостинга). В таком случае сервер использует IP 1.2.3.4, домену клиента example.com присваивается этот IP-адрес (так что разрешение example.com даст вам 1.2.3.4, однако обратный DNS для этого хоста будет скорее всего быть чем-то другим, связываться с доменным именем ISP, а не с доменом клиента, например box0123.hosterdomain.com или 4-3-2-1.hosterdomain.com. И все это прекрасно и законно. Хостеры делают это, потому что технически вы можете назначить один IP нескольким доменным именам на одном и том же но с обратным DNS вы можете назначить запись одна только для каждого IP-адреса. И используя собственное доменное имя вместо клиентов, вам не нужно беспокоиться о revDNS независимо от того, добавляются или удаляются клиенты с сервера.

Итак, если у вас есть закрытые списки хостов, с которыми вы будете подключаться, вы можете выполнить этот тест, но если ваши пользователи могут попытаться соединиться где угодно, тогда я просто придерживаюсь проверки цепочки сертификатов.

РЕДАКТИРОВАТЬ № 1

Если вы запрашиваете DNS, который вы не контролируете, вы не можете полностью доверять ему. Такой DNS можно превратить в зомби, отравлено, и он просто может лежать все время и поддельный ответ на любой запрос, который вы ему задаете, и "вперед", (FQDN на ip) и reverse (ip для полного доменного имени). Если DNS-сервер взломан (укоренен), он может (если злоумышленник достаточно мотивирован), чтобы он не пересылал запросы in-addr.arpa и подделка ответа на соответствие другим ответам (подробнее о обратный поиск здесь). Так что, если вы не используете DNSSEC, еще есть способ обмануть ваши чеки. Таким образом, вам нужно подумать, как параноик вам нужно действовать, поскольку перехваченные запросы могут быть подделаны отравлением dns, в то время как это не работает для обратного поиска, если хост не принадлежит вам (я имею в виду, что его обратная зона DNS размещается на каком-то другом сервере, чем один отвечает на ваши обычные запросы). Вы можете попытаться снова защитить себя от локального отравления dns, например, напрямую обращаться к нескольким DNS, поэтому даже один из них взломан, другие, вероятно, не будут. Если все в порядке, все запросы DNSs должны дать вам тот же ответ. Если что-то подозрительное, тогда некоторые ответы будут отличаться, что вы можете легко обнаружить.

Итак, все зависит от того, насколько вы безопасны и чего хотите достичь. Если вам нужно быть в безопасности, вы не должны использовать "общедоступные" услуги и напрямую туннелировать свой трафик для целей, например, используя VPN.

РЕДАКТИРОВАТЬ № 2

Что касается IPv4 vs IPv6 - PHP не имеет функций для обоих, поэтому, если вы хотите сделать упомянутые выше проверки, я бы предпочел использовать такие инструменты, как host, чтобы выполнить задание (или написать расширение PHP).