Commit 7e86f2c4433 for php
commit 7e86f2c443382b2f03a546b51ca065872454c087
Author: Jakub Zelenka <bukka@php.net>
Date: Sun Nov 1 17:22:19 2026 +0100
openssl: Fix use-after-free in TLS session remove callback
The session remove callback reads the listener stream from the SSL_CTX
ex_data, but the context is shared with accepted connections and can
outlive the listener. Closing the listener left a dangling pointer that
the callback later dereferenced. Clear it on close when it refers to the
stream being freed.
The added test reproduces it via a TCP proxy and crashes under ASan
without the fix.
Closes GH-25132
diff --git a/NEWS b/NEWS
index 20daec0d497..2f148c6d54c 100644
--- a/NEWS
+++ b/NEWS
@@ -145,6 +145,8 @@ PHP NEWS
. Fixed build with OpenSSL 4.1. (Weilin Du)
. Fixed bug GH-24784 (openssl_pkcs7_read() and openssl_cms_read() overwrite
certificates with CRLs). (Alexander Danilov)
+ . Fixed use-after-free in the TLS session remove callback when the listener
+ stream is closed while a shared context is still in use. (Jakub Zelenka)
- PDO_Firebird:
. Fixed bug GH-25006 (getColumnMeta() reads out of bounds for an
diff --git a/ext/openssl/tests/session_resumption_remove_cb_closed_listener.phpt b/ext/openssl/tests/session_resumption_remove_cb_closed_listener.phpt
new file mode 100644
index 00000000000..05476a6ae37
--- /dev/null
+++ b/ext/openssl/tests/session_resumption_remove_cb_closed_listener.phpt
@@ -0,0 +1,138 @@
+--TEST--
+TLS session resumption - session_remove_cb with a closed listener stream
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+if (substr(PHP_OS, 0, 3) == 'WIN') die("skip not reliable on Windows");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'session_remove_cb_closed_listener.pem.tmp';
+
+/* A TCP proxy relays the handshake, then injects a bad record so the server
+ * removes the cached session after the listener has been closed. */
+
+$serverCode = <<<'CODE'
+ $flags = STREAM_SERVER_BIND|STREAM_SERVER_LISTEN;
+ $ctx = stream_context_create(['ssl' => [
+ 'local_cert' => '%s',
+ 'verify_peer' => false,
+ 'no_ticket' => true,
+ 'session_cache' => true,
+ 'session_id_context' => 'app',
+ 'session_new_cb' => function($stream, $session) {},
+ 'session_get_cb' => function($stream, $sessionId) { return null; },
+ 'session_remove_cb' => function($stream, $sessionId) {},
+ ]]);
+
+ $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr, $flags, $ctx);
+ phpt_notify_server_start($server);
+
+ // Handshake completes during accept, so the session is cached here.
+ $client = @stream_socket_accept($server, 30);
+
+ // Free the listener while the accepted connection keeps the ctx alive.
+ fclose($server);
+ phpt_notify();
+
+ // Reuse the freed listener's memory to make a dangling access fault.
+ $reuse = [];
+ for ($i = 0; $i < 8; $i++) {
+ $reuse[] = fopen('php://memory', 'r+');
+ }
+
+ @fread($client, 1024);
+ phpt_notify(message: "server alive");
+ fclose($client);
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$proxyCode = <<<'CODE'
+ $flags = STREAM_SERVER_BIND|STREAM_SERVER_LISTEN;
+ $proxy = stream_socket_server("tcp://127.0.0.1:0", $errno, $errstr, $flags);
+ phpt_notify_server_start($proxy);
+
+ $upstream = stream_socket_client("tcp://{{ ADDR }}", $errno, $errstr, 30, STREAM_CLIENT_CONNECT);
+ $conn = stream_socket_accept($proxy, 30);
+ stream_set_blocking($upstream, false);
+ stream_set_blocking($conn, false);
+ stream_set_blocking(STDIN, false);
+
+ $injected = false;
+ while (true) {
+ $r = [$conn, $upstream, STDIN];
+ $w = $e = [];
+ if (@stream_select($r, $w, $e, 10) === false) {
+ break;
+ }
+ foreach ($r as $s) {
+ if ($s === STDIN) {
+ if (fgets(STDIN) === false) {
+ break 2;
+ }
+ if (!$injected) {
+ $injected = true;
+ fwrite($upstream, "\x15\x03\x03\x00\x20" . str_repeat("\x41", 0x20));
+ }
+ } elseif ($s === $conn) {
+ $d = @fread($conn, 8192);
+ if ($d === '' || $d === false) {
+ if (feof($conn)) break 2;
+ } else {
+ @fwrite($upstream, $d);
+ }
+ } else {
+ $d = @fread($upstream, 8192);
+ if ($d === '' || $d === false) {
+ if (feof($upstream)) break 2;
+ } else {
+ @fwrite($conn, $d);
+ }
+ }
+ }
+ }
+ fclose($conn);
+ fclose($upstream);
+ fclose($proxy);
+CODE;
+
+$clientCode = <<<'CODE'
+ $flags = STREAM_CLIENT_CONNECT;
+ $ctx = stream_context_create(['ssl' => [
+ 'verify_peer' => false,
+ 'verify_peer_name' => false,
+ ]]);
+
+ $client = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr, 30, $flags, $ctx);
+ if (!$client) {
+ echo "client connect failed: $errstr\n";
+ phpt_notify('proxy');
+ return;
+ }
+
+ // Listener is closed; tell the proxy to inject the bad record.
+ phpt_wait('server');
+ phpt_notify('proxy');
+
+ echo trim(phpt_wait('server')) . "\n";
+ fclose($client);
+CODE;
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveNewCertAsFileWithKey('session_remove_cb_closed_listener', $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, [
+ 'server' => $serverCode,
+ 'proxy' => $proxyCode,
+]);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'session_remove_cb_closed_listener.pem.tmp');
+?>
+--EXPECT--
+server alive
diff --git a/ext/openssl/xp_ssl.c b/ext/openssl/xp_ssl.c
index 5cead037715..cf7a2802ee1 100644
--- a/ext/openssl/xp_ssl.c
+++ b/ext/openssl/xp_ssl.c
@@ -3376,6 +3376,10 @@ static int php_openssl_sockop_close(php_stream *stream, int close_handle) /* {{{
sslsock->ssl_handle = NULL;
}
if (sslsock->ctx) {
+ /* Clear the ctx stream pointer as the ctx may outlive this stream. */
+ if (SSL_CTX_get_ex_data(sslsock->ctx, php_openssl_get_ctx_stream_data_index()) == stream) {
+ SSL_CTX_set_ex_data(sslsock->ctx, php_openssl_get_ctx_stream_data_index(), NULL);
+ }
SSL_CTX_free(sslsock->ctx);
sslsock->ctx = NULL;
}