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