Reports
BAJA[TLS Y CRIPTOGRAFÍA]#3558277

pyOpenSSL acepta la conexión TLS aunque el callback de SNI lance una excepción (fallo abierto)

Si la función que el servidor registra con set_tlsext_servername_callback lanza una excepción, pyOpenSSL devuelve «OK» a OpenSSL y el handshake sigue adelante, con lo que se saltan los controles hechos en ese punto.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

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

  1. Instalar pyopenssl.
  2. Guardar el siguiente script como poc.py. Levanta un servidor TLS en localhost:4433 con un certificado autofirmado y un callback de SNI que siempre lanza ValueError, y después conecta un cliente que envía el nombre test.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()
  1. Ejecutarlo con python poc.py.
  2. 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 0 fijo 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.