From f3adf6a7ca3d08f9dbc44d6b062e2151e095ab46 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Tue, 16 Jun 2026 14:42:12 +0200 Subject: [PATCH 1/2] Fix stream_socket_get_crypto_status() after supplemental read Let's assume we have a stream with 5 queued bytes. When calling fread($s, 1), the underlying buffered stream will request a chunk of up to 8192 bytes immediately, but only return the requested 1 byte back to the reader. If the reader then requests fread($s, 10), _php_stream_read() will first return anything that was buffered but not yet read. If the requested length exceeds the number of buffered bytes (as is the case above), another read call is issued. This call will return nothing, because the stream only provides 4 more readable bytes, all of which are buffered. php_openssl_handle_ssl_error() (called by php_openssl_sockop_io()) will then incorrectly set last_status to WANT_READ, even though we've already read the remaining data. Furthermore, stream_select() can cause the same issue via php_openssl_sockop_cast(castas: PHP_STREAM_AS_FD_FOR_SELECT), which pre-fills the read buffer on SSL_pending() > 0. The subsequent fread() will lead to the same condition as above. There's a second issue here. If the stream is blocking, the supplement read will block for the duration of the timeout. This will be addressed in a second PR. --- ...t_get_crypto_status_supplemental_read.phpt | 83 +++++++++++++++++++ ext/openssl/xp_ssl.c | 10 +++ 2 files changed, 93 insertions(+) create mode 100644 ext/openssl/tests/stream_socket_get_crypto_status_supplemental_read.phpt diff --git a/ext/openssl/tests/stream_socket_get_crypto_status_supplemental_read.phpt b/ext/openssl/tests/stream_socket_get_crypto_status_supplemental_read.phpt new file mode 100644 index 000000000000..aca6bf4073dd --- /dev/null +++ b/ext/openssl/tests/stream_socket_get_crypto_status_supplemental_read.phpt @@ -0,0 +1,83 @@ +--TEST-- +stream_socket_get_crypto_status(): reports status NONE after supplemental read +--EXTENSIONS-- +openssl +--SKIPIF-- + +--FILE-- + ['local_cert' => '%s']]); + $flags = STREAM_SERVER_BIND|STREAM_SERVER_LISTEN; + $server = stream_socket_server("tls://127.0.0.1:0", $errno, $errstr, $flags, $ctx); + phpt_notify_server_start($server); + + $conn = stream_socket_accept($server, 30); + + fwrite($conn, "hello\n"); + + phpt_wait(); + fclose($conn); +CODE; +$serverCode = sprintf($serverCode, $certFile); + +$clientCode = <<<'CODE' + $ctx = stream_context_create(['ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'peer_name' => '%s', + ]]); + + $client = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $ctx); + stream_set_blocking($client, false); + + $buf = ''; + $read = [$client]; + $write = $except = null; + while (stream_select($read, $write, $except, 5)) { + // Initially, read only the first char, then request more than is stored + // in the buffer, triggering a supplemental read. + $chunk = fread($client, strlen($buf) === 0 ? 1 : 10); + if ($chunk === '' || $chunk === false) { + /* A non-application record (e.g. a TLS 1.3 session ticket) may arrive first. */ + if (feof($client)) { + break; + } + } else { + $buf .= $chunk; + if (strlen($buf) >= 6) { + break; + } + } + $read = [$client]; + $write = $except = null; + } + + echo trim($buf), "\n"; + /* A successful read clears the pending status back to NONE. */ + var_dump(stream_socket_get_crypto_status($client) === STREAM_CRYPTO_STATUS_NONE); + + phpt_notify(); + fclose($client); +CODE; +$clientCode = sprintf($clientCode, $peerName); + +include 'CertificateGenerator.inc'; +$certificateGenerator = new CertificateGenerator(); +$certificateGenerator->saveNewCertAsFileWithKey($peerName, $certFile); + +include 'ServerClientTestCase.inc'; +ServerClientTestCase::getInstance()->run($clientCode, $serverCode); +?> +--CLEAN-- + +--EXPECT-- +hello +bool(true) diff --git a/ext/openssl/xp_ssl.c b/ext/openssl/xp_ssl.c index a154aa4572f7..2fc335ca1d55 100644 --- a/ext/openssl/xp_ssl.c +++ b/ext/openssl/xp_ssl.c @@ -2997,6 +2997,16 @@ static ssize_t php_openssl_sockop_io(int read, php_stream *stream, char *buf, si php_stream_notify_progress_increment(PHP_STREAM_CONTEXT(stream), nr_bytes, 0); } + /* This might be a supplemental read after consuming buffered data. If + * the read returned nothing, ignore status WANT_READ. */ + if (read && + nr_bytes <= 0 && + sslsock->last_status == STREAM_CRYPTO_STATUS_WANT_READ && + stream->has_buffered_data + ) { + sslsock->last_status = STREAM_CRYPTO_STATUS_NONE; + } + /* And if we were originally supposed to be blocking, let's reset the socket to that. */ if (began_blocked) { php_openssl_set_blocking(sslsock, 1); From afd7a17d462b80d16644e8511b2dd1a85b1d72bc Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Wed, 17 Jun 2026 15:32:31 +0200 Subject: [PATCH 2/2] De-duplicate cert name --- .../stream_socket_get_crypto_status_supplemental_read.phpt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ext/openssl/tests/stream_socket_get_crypto_status_supplemental_read.phpt b/ext/openssl/tests/stream_socket_get_crypto_status_supplemental_read.phpt index aca6bf4073dd..9044b1bbdbe4 100644 --- a/ext/openssl/tests/stream_socket_get_crypto_status_supplemental_read.phpt +++ b/ext/openssl/tests/stream_socket_get_crypto_status_supplemental_read.phpt @@ -8,8 +8,8 @@ if (!function_exists("proc_open")) die("skip no proc_open"); ?> --FILE-- ['local_cert' => '%s']]); @@ -76,7 +76,7 @@ ServerClientTestCase::getInstance()->run($clientCode, $serverCode); ?> --CLEAN-- --EXPECT-- hello