From 784022998c921aac983502fa9c6850189daff261 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Wed, 28 Oct 2026 10:32:36 -0500 Subject: [PATCH] gh-156293: Do not call the SNI callback through a released SSLContext (CVE-2026-19445) Backport of python/cpython gh-156293 (87665c9b1f; 3.10: ec44b5a3258) to 2.7.18.x. set_servername_callback() registered the SSLContext as a borrowed pointer in the SSL_CTX's servername arg, and _servername_callback() used that pointer to find the Python callback. When the callback switches the connection to another context (sslsock.context = other), the original SSLContext can be deallocated while its SSL_CTX lives on as the connection's session_ctx. The callback then reads freed memory: right after the Python callback returns (if it dropped the last reference and raised), or on a later ClientHello such as one after a HelloRetryRequest. On 2.7.18.15, a callback that switches context, drops it and raises segfaults the server. Under captured stderr it reports "in ignored", read from the freed object. _servername_callback() now finds the context through the SSL object's app data (the PySSLSocket and its current, live context), and holds strong references to that context and its callback while they are used. set_servername_callback() no longer registers the servername arg, and context_dealloc() unregisters the servername callback before freeing the SSL_CTX. As upstream, after the callback switches contexts, any further ClientHello on that connection goes to the new context's callback. 2.7.18.x: the legacy set_servername_callback()/set_hostname is the only API (there is no sni_callback property); no Py_NewRef; context_dealloc's unregister is guarded by HAVE_SNI like the rest of the SNI code. 2.7 has no MemoryBIO, so the tests run the handshake over a socketpair with the client in a thread. The "callback raises" test also checks that the unraisable error names the callback, which is what fails on 2.7.18.15 (""). The HelloRetryRequest test passes on 2.7.18.15 in this release build; it is kept, as upstream, as a regression guard. Co-Authored-By: Claude Opus 5.5 --- Lib/test/test_ssl.py | 98 ++++++++++++++++++++++++++++++++++++++++++++ Modules/_ssl.c | 41 +++++++++++++----- 2 files changed, 129 insertions(+), 10 deletions(-) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 2381baa847cd7bd..57ee6c37f0ce7e9 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -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 (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 diff --git a/Modules/_ssl.c b/Modules/_ssl.c index 0df84021d3814a6..c8f07f92b2622e3 100644 --- a/Modules/_ssl.c +++ b/Modules/_ssl.c @@ -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); @@ -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 { @@ -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 { @@ -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; } @@ -3506,6 +3524,8 @@ _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 @@ -3513,6 +3533,8 @@ _servername_callback(SSL *s, int *al, void *args) 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 @@ -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