mailsender mail lib automailer php smtp ssl-certificate starttls

lib - phpmailer



¿Cómo verifico que un certificado TLS SMTP sea válido en PHP? (3)

Para evitar ataques de intermediario (un servidor que pretende ser otra persona), me gustaría verificar que el servidor SMTP al que me conecto también tenga un certificado SSL válido que demuestre que es quien creo que es.

Por ejemplo, después de conectarme a un servidor SMTP en el puerto 25, puedo cambiar a una conexión segura como:

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

Sin embargo, no se menciona dónde PHP está verificando el certificado SSL. ¿PHP tiene una lista integrada de CA raíz? ¿Es solo aceptar algo?

¿Cuál es la forma correcta de verificar que el certificado es válido y que el servidor SMTP realmente es quien creo que es?

Actualizar

Según este comentario en PHP.net , parece que puedo hacer verificaciones de SSL usando algunas opciones de flujo. La mejor parte es que stream_context_set_option acepta un contexto o un recurso de flujo . Por lo tanto, en algún momento de su conexión TCP puede cambiar a SSL utilizando un paquete de certificados 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"); }

Además, vea Opciones de contexto y parámetros que se expanden en las opciones de SSL .

Sin embargo, mientras esto resuelve el problema principal, ¿cómo verifico que el certificado válido pertenece realmente al dominio / IP al que me estoy conectando?

En otras palabras, el certificado al que estoy conectando el servidor también puede tener un certificado válido, pero ¿cómo sé que es válido para "example.com" y no otro servidor que use un certificado válido para actuar como "example.com"?

Actualización 2

Parece que puede capturar el certificado SSL utilizando los parámetros de contexto de Steam y analizarlo con openssl_x509_parse .

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


¿Cómo verifico que el certificado válido pertenece realmente al dominio / IP al que me estoy conectando?

Los certificados se emiten para nombres de dominio (nunca para IP). Puede ser un nombre de dominio único (como mail.example.com ) o comodín *.example.com ). Una vez que haya descifrado su certificado con openssl, puede leer ese nombre, que se llama common name del campo cn . Entonces, solo necesita verificar si la máquina que está intentando conectar es la del certificado. Ya que tienes un nombre de compañero remoto ya que te estás conectando a él, entonces la verificación es bastante trivial, sin embargo, dependiendo de qué tan paranoicas desees realizar, puedes intentar averiguar si no estás usando un DNS envenenado, lo que resuelve tu mail.example.com nombre de host a IP falsificada. Esto debe hacerse resolviendo primero mail.example.com con gethostbynamel() que le dará al menos una dirección IP (digamos que solo obtiene 1.2.3.4). Luego verifica el DNS inverso con gethostbyaddr() para cada dirección IP devuelta, y uno de ellos debería devolver mail.example.com (tenga en cuenta que usé gethostbynamel() , no gethostbyname() ya que no es raro que el servidor tenga más de una dirección IP asignada por nombre).

NOTA: tenga cuidado al aplicar políticas demasiado estrictas, ya que puede dañar a sus usuarios. Es un escenario bastante popular para que un solo servidor aloje muchos dominios (como con el alojamiento compartido). En tal caso, el servidor está utilizando la IP 1.2.3.4 , al dominio example.com del cliente se le asigna esa dirección IP (por lo que al resolver example.com le dará 1.2.3.4 , sin embargo, el DNS inverso para este host probablemente será algo diferente, enlace con El nombre del dominio ISP, no el dominio del cliente, como box0123.hosterdomain.com o 4-3-2-1.hosterdomain.com . Y todo esto está perfectamente bien y es legítimo. Los hosters lo hacen porque, técnicamente, puede asignar una única IP a múltiples nombres de dominio. al mismo tiempo, pero con DNS inverso, puede asignar una entrada solo por IP. Y al usar su propio nombre de dominio en lugar del de los clientes, no tiene por qué molestar a revDNS, sin importar si los clientes se agregan o eliminan del servidor.

Por lo tanto, si tiene listas cerradas de hosts a los que se conectará, puede hacer esta prueba, pero si los usuarios intentan conectarse donde sea, entonces me limitaré a verificar solo la cadena de certificados.

EDITAR # 1

Si consulta DNS que no controla, no puede confiar completamente en él. Dicho DNS se puede convertir en zombie, poisoned y simplemente puede estar todo el tiempo y responder falsamente a cualquier consulta que le haga, tanto "reenviar" ( FQDN a ip) como a la inversa (ip a FQDN). Si el servidor dns está pirateado (enraizado), puede (si el atacante está lo suficientemente motivado) hacer que no reenvíe las consultas in-addr.arpa y falsifique la respuesta para que coincida con otras respuestas (más información sobre búsquedas inversas aquí ). De hecho, a menos que use DNSSEC , todavía hay una manera de engañar a sus cheques. Por lo tanto, tiene que pensar en qué tan paranoico debe actuar como se puede falsificar las consultas mediante el envenenamiento por DNS, mientras que esto no funciona para las búsquedas inversas si el host no es suyo (me refiero a que su zona DNS inversa está alojada en algún otro servidor que no sea el servidor). una respondiendo a tus consultas normales). Usted puede intentar protegerse contra el envenenamiento local de DNS mediante la consulta de más de un DNS directamente, por lo que incluso uno es pirateado, otros probablemente no. Si todo está bien, todas las consultas de DNS deben darle la misma respuesta. Si algo es sospechoso, entonces algunas respuestas serían diferentes, lo que puede detectar fácilmente.

Entonces, todo depende de qué tan seguro quieres estar y qué quieres lograr. Si necesita ser altamente seguro, no debe usar servicios "públicos" y canalizar directamente su tráfico a los servicios de destino, es decir, usar VPN.

EDITAR # 2

En cuanto a IPv4 vs IPv6: PHP carece de funciones para ambas, así que si quiere hacer las comprobaciones mencionadas anteriormente, prefiero considerar llamar a herramientas como host para hacer el trabajo (o escribir una extensión de PHP).


Con el fin de no cargar un tema ya demasiado largo y no demasiado extenso sobre el tema, responder con más texto, lo dejo para tratar los por qué y por qué, y aquí describiré cómo .

Probé este código contra Google y un par de otros servidores; Qué comentarios hay, bueno, comentarios en el código.

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


ACTUALIZACIÓN : hay una mejor manera de hacer esto, ver los comentarios.

Puede capturar el certificado y mantener una conversación con el servidor utilizando openssl como filtro. De esta manera puede extraer el certificado y examinarlo durante la misma conexión.

Esta es una implementación incompleta (la conversación de envío de correo real no está presente) que debería comenzar:

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

Por supuesto, moverá el análisis y comprobación de certificados antes de enviar cualquier cosa significativa al servidor.

En la matriz $par debe encontrar (entre el resto) el nombre, el mismo analizado como asunto.

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 )

Para verificar la validez, aparte de la verificación de fechas, etc., lo que SSL hace por sí solo, debe verificar que SE CUMPLE DE estas condiciones:

  • el CN ​​de la entidad es su nombre DNS, por ejemplo, "CN = smtp.your.server.com"

  • hay extensiones definidas y contienen un subjectAltName, que una vez que explotó con explode('','', $subjectAltName) , produce una matriz de DNS: -prefixed registros, al menos uno de los cuales coincide con su nombre de DNS. Si ninguno coincide, el certificado es rechazado.

Verificación de certificado en PHP

El significado de verificar host en diferentes softwares parece turbio en el mejor de los casos .

Así que decidí llegar al final de esto, y descargué el código fuente de OpenSSL (openssl-1.0.1c) e intenté comprobarlo por mí mismo.

No encontré referencias al código que esperaba, a saber:

  • intenta analizar una cadena delimitada por dos puntos
  • referencias a subjectAltName (que OpenSSL llama SN_subject_alt_name )
  • uso de "DNS [:]" como delimitador

OpenSSL parece colocar todos los detalles de los certificados en una estructura, ejecutar pruebas muy básicas en algunos de ellos, pero la mayoría de los campos "legibles por humanos" se quedan solos. Tiene sentido: se podría argumentar que la comprobación de nombres se encuentra en un nivel más alto que la comprobación de firma de certificado

Luego descargué también el último cURL y el último tarball de PHP.

En el código fuente de PHP tampoco encontré nada; aparentemente, cualquier opción se pasa por la línea y, de lo contrario, se ignora Este código se ejecutó sin advertencia:

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

y stream_context_get_options más tarde recuperados debidamente

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

Esto también tiene sentido: PHP no puede saber, en el contexto de "configuración de opciones de contexto", qué opciones se utilizarán más adelante.

Del mismo modo, el código de análisis de certificado analiza el certificado y extrae la información que OpenSSL pone allí, pero no valida esa misma información.

Así que profundicé un poco más y finalmente encontré un código de verificación de certificado en cURL, aquí:

// curl-7.28.0/lib/ssluse.c static CURLcode verifyhost(struct connectdata *conn, X509 *server_cert) {

donde hace lo que esperaba: busca subjectAltNames, verifica la cordura de todos ellos y los ejecuta en el hostmatch pasado, donde se ejecutan cheques como hello.example.com == * .example.com. Hay comprobaciones de validez adicionales: "Requerimos al menos 2 puntos en el patrón para evitar una coincidencia de comodín demasiado amplia". y xn-- cheques.

Para resumir, OpenSSL ejecuta algunas comprobaciones simples y deja el resto a la persona que llama. cURL, llamando a OpenSSL, implementa más verificaciones. PHP también ejecuta algunas comprobaciones en CN con verify_peer , pero deja a subjectAltName solo. Estas verificaciones no me convencen demasiado; ver más abajo en "Prueba".

Al carecer de la capacidad de acceder a las funciones de cURL, la mejor alternativa es volver a implementar las de PHP.

La coincidencia de dominios con comodines variables, por ejemplo, se podría realizar explotando puntos tanto en el dominio real como en el dominio de certificado, invirtiendo las dos matrices

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

y verifique que los elementos correspondientes sean iguales o que el certificado sea un *; Si eso sucede, debemos haber verificado al menos dos componentes, aquí com y example .

Creo que la solución anterior es una de las mejores si desea verificar los certificados de una sola vez. Aún mejor sería poder abrir el flujo directamente sin tener que recurrir al cliente de openssl , y esto es posible ; Ver comentario.

Prueba

Tengo un certificado bueno, válido y de plena confianza de Thawte emitido para "mail.eve.com".

El código anterior que se ejecuta en Alice se conectará de forma segura con mail.eve.com , y lo hace, como se esperaba.

Ahora instalo el mismo certificado en mail.bob.com , o de alguna otra manera convenzo al DNS de que mi servidor es Bob, mientras que en realidad aún es Eve.

Espero que la conexión SSL aún funcione (el certificado es válido y de confianza), pero el certificado no se emite a Bob, se emite a Eve. Así que alguien tiene que hacer este último cheque y advertirle a Alice que Bob está suplantando a Bob (o, de manera equivalente, que Bob está utilizando el certificado robado de Eve).

Utilicé el siguiente código:

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

y:

  • Si el certificado no es verificable con una autoridad de confianza:
    • verificar_host no hace nada
    • Verify_peer TRUE provoca un error
    • Verify_peer FALSE permite la conexión
    • allow_self_signed no hace nada
  • Si el certificado ha caducado:
    • Me sale un error.
  • Si el certificado es verificable:
    • la conexión está permitida a "mail.eve.com" suplantando a "mail.bob.com" y obtengo un "¡Éxito!" mensaje.

Entiendo que esto significa que, salvo un error estúpido de mi parte, PHP no verifica los certificados contra los nombres .

Al usar el código proc_open al principio de esta publicación, puedo conectarme de nuevo, pero esta vez tengo acceso a subjectAltName y, por lo tanto, puedo verificar por mí mismo, detectando la suplantación.