Resumen
pyOpenSSL permite a un servidor registrar una función que se ejecuta cuando el cliente envía la extensión SNI (Server Name Indication) durante el handshake TLS. Es un sitio habitual para decidir qué certificado usar o, directamente, para rechazar conexiones a nombres no permitidos.
El problema está en cómo la librería envuelve esa función. En src/OpenSSL/SSL.py, el método set_tlsext_servername_callback la mete dentro de un envoltorio que siempre devuelve 0:
# OpenSSL/SSL.py
@wraps(callback)
def wrapper(ssl, alert, arg):
callback(Connection._reverse_mapping[ssl])
return 0 # Always returns Success (0)
Si la función del usuario lanza una excepción, la capa CFFI la captura, imprime la traza en stderr y devuelve el valor por defecto del tipo declarado (int), que también es 0. Para OpenSSL, en SSL_CTX_set_tlsext_servername_callback, 0 significa SSL_TLSEXT_ERR_OK, así que el handshake continúa como si la comprobación hubiera ido bien.
Es decir: un error dentro de la lógica de validación no cierra la conexión, la deja pasar. Es un fallo «abierto» (fail-open) en lugar de «cerrado» (fail-closed).
Pasos de reproducción
- Instalar
pyopenssl. - Guardar el siguiente script como
poc.py. Levanta un servidor TLS enlocalhost:4433con un certificado autofirmado y un callback de SNI que siempre lanzaValueError, y después conecta un cliente que envía el nombretest.local:
import socket
import threading
import time
from OpenSSL import SSL, crypto
import os
def generate_cert():
k = crypto.PKey()
k.generate_key(crypto.TYPE_RSA, 2048)
cert = crypto.X509()
cert.get_subject().CN = 'VulnerableServer'
cert.set_serial_number(1000)
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(10*365*24*60*60)
cert.set_issuer(cert.get_subject())
cert.set_pubkey(k)
cert.sign(k, 'sha256')
return cert, k
cert, key = generate_cert()
def crashing_callback(connection):
# Simulates a security check that crashes (e.g., DB error, logic bug)
print("\n[SERVER] Callback executing... Raising Exception!")
raise ValueError("Critical Security Check Failed!")
# The code never reaches a point to explicitly reject the connection.
def start_server():
context = SSL.Context(SSL.TLS_SERVER_METHOD)
context.use_privatekey(key)
context.use_certificate(cert)
context.set_tlsext_servername_callback(crashing_callback)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Using port 4433 to avoid conflicts
try:
server.bind(('localhost', 4433))
except Exception as e:
print(f"[SERVER] Bind failed (port busy?): {e}")
return
server.listen(1)
try:
conn, addr = server.accept()
ssl_conn = SSL.Connection(context, conn)
ssl_conn.set_accept_state()
try:
ssl_conn.do_handshake()
# If we reach here, the vulnerability is triggered
ssl_conn.sendall(b"VULNERABILITY CONFIRMED: Connection Accepted")
ssl_conn.shutdown()
except Exception as e:
print(f"[SERVER] Handshake Failed (Safe): {e}")
finally:
ssl_conn.close()
conn.close()
finally:
server.close()
def start_client():
time.sleep(1)
context = SSL.Context(SSL.TLS_CLIENT_METHOD)
context.set_verify(SSL.VERIFY_NONE, lambda *args: True)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.connect(('localhost', 4433))
ssl_conn = SSL.Connection(context, sock)
ssl_conn.set_connect_state()
ssl_conn.set_tlsext_host_name(b"test.local")
try:
ssl_conn.do_handshake()
data = ssl_conn.recv(1024)
print(f"\n[CLIENT] Received: {data.decode()}")
except Exception as e:
print(f"\n[CLIENT] Handshake Failed: {e}")
finally:
sock.close()
if __name__ == '__main__':
t = threading.Thread(target=start_server)
t.start()
start_client()
t.join()
- Ejecutarlo con
python poc.py. - Comprobar la salida: el servidor muestra que el callback se ha ejecutado y ha lanzado la excepción, pero aun así el handshake termina bien y el cliente recibe
VULNERABILITY CONFIRMED: Connection Accepted.
Impacto
Afecta a las aplicaciones que usan set_tlsext_servername_callback como control de acceso, por ejemplo «solo acepto la conexión si el SNI coincide con X». Si un atacante consigue que esa lógica lance una excepción (con una entrada malformada o agotando algún recurso del que dependa), la conexión TLS se establece igualmente y la restricción queda sin efecto.
El investigador lo presenta como una violación del principio de «fallar de forma segura» que se espera de una librería criptográfica. El reporte está clasificado con severidad baja.
Remediación
Según el investigador, el comportamiento correcto es que el envoltorio capture cualquier excepción no controlada del callback y devuelva SSL_TLSEXT_ERR_ALERT_FATAL (o un valor equivalente) para abortar el handshake, de modo que un error termine siempre en rechazo.
Qué aprender de este caso
- Revisa cualquier puente entre Python (u otro lenguaje de alto nivel) y una librería en C mediante CFFI o ctypes: si un callback lanza una excepción, ¿qué valor recibe el lado en C? Si el valor por defecto (
0) coincide con el código de «éxito», tienes un fallo abierto. - No dependas de que una excepción dentro del callback de SNI corte la conexión: captura los errores tú mismo y, si la validación falla, marca esa conexión para cerrarla nada más acabar el handshake, sin servir ningún dato.
- Al probar un servidor TLS que filtra por nombre, no basta con enviar un SNI no permitido: prueba también entradas que hagan fallar la validación (nombres muy largos, caracteres raros, una dependencia caída) y comprueba si el handshake se corta.
- Un envoltorio que termina con un
return 0fijo después de llamar al código del usuario es una señal clara de alarma: el valor de retorno debería depender de lo que haya pasado, no ser constante.