Как проверить, что сертификат 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-сервер действительно является тем, кем я считаю?

Обновить

Основываясь на этом комментарии на PHP.net, кажется, я могу выполнять проверки SSL с использованием некоторых параметров потока. Наилучшая часть состоит в том, что stream_context_set_option принимает контекст или ресурс потока . Поэтому в какой-то момент вашего TCP-соединения вы можете переключиться на SSL, используя комплект сертификатов CA.

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

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

    Я тестировал этот код против 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 $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... ?> 

    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); ?> эта <?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); ?> не <?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 explode(',', $subjectAltName) массив DNS: -prefixed records, по крайней мере один из которых соответствует вашему DNS-имени. Если они не совпадают, сертификат отклоняется.

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

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

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

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

    • попытки разбора строки с разделителями двоеточия
    • ссылки на subjectAltName (который OpenSSL вызывает SN_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) { 

    где он делает то, что я ожидал: он ищет subjectAltNames, он проверяет все из них на предмет здравомыслия и запускает их мимо hostmatch , где 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 , или каким-то другим способом я mail.bob.com DNS, что мой сервер – Боб, а на самом деле он все еще Ева.

    Я ожидаю, что 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 и поэтому я могу проверить сам, обнаружив олицетворение.

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

    Сертификаты выдаются для доменных имен (никогда для IP). Это может быть одно доменное имя (например, mail.example.com ) или подстановочный знак *.example.com ). После того как вы получили свой сертификат, декодированный с помощью openssl, вы можете прочитать это имя, которое называется common name из поля cn . Тогда вам просто нужно проверить, является ли машина, к которой вы пытаетесь подключиться, от сертификата. Поскольку у вас есть имя удаленного однорангового узла, когда вы уже подключаетесь к нему, тогда проверка довольно тривиальная, однако, в зависимости от того, как вы проведете проверку параноидальных данных, вы можете попытаться выяснить, не используете ли вы отравленный DNS, который разрешает вашу mail.example.com hostname для подделанного IP- mail.example.com . Это должно быть сделано, сначала разрешив 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 независимо от того, добавляются или удаляются клиенты с сервера.

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

    EDIT # 1

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

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

    EDIT # 2

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