Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions Lib/test/test_ssl.py
Original file line number Diff line number Diff line change
Expand Up @@ -3304,6 +3304,104 @@ def cb_wrong_return_type(ssl_sock, server_name, initial_context):
self.assertEqual(cm.exception.reason, 'TLSV1_ALERT_INTERNAL_ERROR')
self.assertIn("TypeError", stderr.getvalue())

def sni_handshake_released_context(self, sni_cb, ecdh_curve=None):
# Run a handshake over a socketpair whose server side was wrapped
# by a context that only the C-level SSL object references once
# sni_cb has switched the connection to another context.
# Returns (server socket, server handshake error or None,
# weakref to the released context).
def make_server(sock):
dispatch_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS)
dispatch_ctx.load_cert_chain(SIGNED_CERTFILE)
if ecdh_curve is not None:
dispatch_ctx.set_ecdh_curve(ecdh_curve)
dispatch_ctx.set_servername_callback(sni_cb)
server = dispatch_ctx.wrap_socket(
sock, server_side=True, do_handshake_on_connect=False)
return server, weakref.ref(dispatch_ctx)

s1, s2 = [socket.socket(_sock=s) for s in socket.socketpair()]
self.addCleanup(s1.close)
self.addCleanup(s2.close)
server, dispatch_ref = make_server(s1)
client_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS)
client = client_ctx.wrap_socket(s2, server_hostname='localhost',
do_handshake_on_connect=False)
self.addCleanup(client.close)
self.addCleanup(server.close)
client_thread = threading.Thread(target=self._quiet_handshake,
args=(client,))
client_thread.start()
error = None
try:
server.do_handshake()
except ssl.SSLError as e:
error = e
finally:
if error is not None:
s1.shutdown(socket.SHUT_RDWR)
client_thread.join()
gc.collect()
return server, error, dispatch_ref

@staticmethod
def _quiet_handshake(sock):
try:
sock.do_handshake()
except (ssl.SSLError, socket.error):
pass

@needs_sni
def test_sni_callback_context_released_and_callback_raises(self):
# CVE-2026-19445: the callback switches the connection to another
# context, drops the last reference to the context that carries
# it, and raises. The C callback must not touch that context
# after the Python callback returned.
leaf_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS)
leaf_ctx.load_cert_chain(SIGNED_CERTFILE)

def sni_cb(ssl_sock, server_name, ctx):
ssl_sock.context = leaf_ctx
del ctx
raise LookupError("no certificate for %r" % (server_name,))

with support.captured_stderr() as stderr:
server, error, dispatch_ref = \
self.sni_handshake_released_context(sni_cb)
self.assertIsInstance(error, ssl.SSLError)
self.assertIn("LookupError", stderr.getvalue())
# The unraisable error names the callback. Read through the
# freed context it was <NULL> (and writing that to a real stderr
# crashed).
self.assertIn("sni_cb", stderr.getvalue())
self.assertIs(server.context, leaf_ctx)
self.assertIsNone(dispatch_ref())

@needs_sni
@unittest.skipUnless(ssl.HAS_TLSv1_3, "needs TLS 1.3")
def test_sni_callback_context_released_before_second_client_hello(self):
# CVE-2026-19445: after the callback switched the connection to
# another context, the server forces a HelloRetryRequest (the
# client offers an X25519 key share first, the server accepts
# only P-384). The second ClientHello must not reach the
# deallocated context through the original SSL_CTX.
leaf_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS)
leaf_ctx.load_cert_chain(SIGNED_CERTFILE)
calls = []

def sni_cb(ssl_sock, server_name, ctx):
calls.append(server_name)
ssl_sock.context = leaf_ctx

server, error, dispatch_ref = \
self.sni_handshake_released_context(sni_cb, "secp384r1")
self.assertIsNone(error)
self.assertIsNone(dispatch_ref())
self.assertGreaterEqual(len(calls), 1)
self.assertEqual(calls[0], 'localhost')
self.assertIs(server.context, leaf_ctx)
self.assertIsNotNone(server.cipher())

def test_read_write_after_close_raises_valuerror(self):
context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
context.verify_mode = ssl.CERT_REQUIRED
Expand Down
41 changes: 31 additions & 10 deletions Modules/_ssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -2413,6 +2413,11 @@ context_dealloc(PySSLContext *self)
/* bpo-31095: UnTrack is needed before calling any callbacks */
PyObject_GC_UnTrack(self);
context_clear(self);
#if HAVE_SNI && !defined(OPENSSL_NO_TLSEXT)
/* The SSL_CTX may outlive this object as the session_ctx of sockets that
were switched to another context; leave no Python callback behind. */
SSL_CTX_set_tlsext_servername_callback(self->ctx, NULL);
#endif
SSL_CTX_free(self->ctx);
#if HAVE_NPN
PyMem_FREE(self->npn_protocols);
Expand Down Expand Up @@ -3430,29 +3435,42 @@ static int
_servername_callback(SSL *s, int *al, void *args)
{
int ret;
PySSLContext *ssl_ctx = (PySSLContext *) args;
PySSLContext *ssl_ctx;
PySSLSocket *ssl;
PyObject *servername_o;
PyObject *servername_idna;
PyObject *result;
/* The high-level ssl.SSLSocket object */
PyObject *ssl_socket;
PyObject *sni_cb;
const char *servername = SSL_get_servername(s, TLSEXT_NAMETYPE_host_name);
#ifdef WITH_THREAD
PyGILState_STATE gstate = PyGILState_Ensure();
#endif

if (ssl_ctx->set_hostname == NULL) {
/* remove race condition in this the call back while if removing the
* callback is in progress */
/* Do not use the SSL_CTX's servername arg to find the context: it is a
borrowed pointer to whichever _SSLContext installed the callback, and
that object may already be gone while OpenSSL still reaches this
callback through the connection's session_ctx (e.g. on the second
ClientHello after a HelloRetryRequest, once the callback has switched
the socket to another context). The socket's current context is
always alive; hold strong references to it and to the callback while
they are used here. */
(void)args;
ssl = SSL_get_app_data(s);
assert(ssl != NULL && PySSLSocket_Check(ssl));
ssl_ctx = ssl->ctx;
Py_INCREF(ssl_ctx);
sni_cb = ssl_ctx->set_hostname;
if (sni_cb == NULL) {
Py_DECREF(ssl_ctx);
#ifdef WITH_THREAD
PyGILState_Release(gstate);
#endif
return SSL_TLSEXT_ERR_OK;
}
Py_INCREF(sni_cb);

ssl = SSL_get_app_data(s);
assert(PySSLSocket_Check(ssl));
if (ssl->ssl_sock == NULL) {
ssl_socket = Py_None;
} else {
Expand All @@ -3464,7 +3482,7 @@ _servername_callback(SSL *s, int *al, void *args)
}

if (servername == NULL) {
result = PyObject_CallFunctionObjArgs(ssl_ctx->set_hostname, ssl_socket,
result = PyObject_CallFunctionObjArgs(sni_cb, ssl_socket,
Py_None, ssl_ctx, NULL);
}
else {
Expand All @@ -3480,14 +3498,14 @@ _servername_callback(SSL *s, int *al, void *args)
goto error;
}
Py_DECREF(servername_o);
result = PyObject_CallFunctionObjArgs(ssl_ctx->set_hostname, ssl_socket,
result = PyObject_CallFunctionObjArgs(sni_cb, ssl_socket,
servername_idna, ssl_ctx, NULL);
Py_DECREF(servername_idna);
}
Py_DECREF(ssl_socket);

if (result == NULL) {
PyErr_WriteUnraisable(ssl_ctx->set_hostname);
PyErr_WriteUnraisable(sni_cb);
*al = SSL_AD_HANDSHAKE_FAILURE;
ret = SSL_TLSEXT_ERR_ALERT_FATAL;
}
Expand All @@ -3506,13 +3524,17 @@ _servername_callback(SSL *s, int *al, void *args)
Py_DECREF(result);
}

Py_DECREF(sni_cb);
Py_DECREF(ssl_ctx);
#ifdef WITH_THREAD
PyGILState_Release(gstate);
#endif
return ret;

error:
Py_DECREF(ssl_socket);
Py_DECREF(sni_cb);
Py_DECREF(ssl_ctx);
*al = SSL_AD_INTERNAL_ERROR;
ret = SSL_TLSEXT_ERR_ALERT_FATAL;
#ifdef WITH_THREAD
Expand Down Expand Up @@ -3555,7 +3577,6 @@ set_servername_callback(PySSLContext *self, PyObject *args)
Py_INCREF(cb);
self->set_hostname = cb;
SSL_CTX_set_tlsext_servername_callback(self->ctx, _servername_callback);
SSL_CTX_set_tlsext_servername_arg(self->ctx, self);
}
Py_RETURN_NONE;
#else
Expand Down