[php-src] Issue #24119: stream_socket_shutdown(STREAM_SHUT_WR) on a TLS stream makes fclose() write to the half-closed socket

From: Date: Sun, 04 Oct 2026 15:42:39 +0000
Subject: [php-src] Issue #24119: stream_socket_shutdown(STREAM_SHUT_WR) on a TLS stream makes fclose() write to the half-closed socket
Groups: php.bugs 
Request: Send a blank email to php-bugs+get-252899@lists.php.net to get a copy of this message
Issue: https://github.com/php/php-src/issues/24119 Author: bukka ### Description Follow-up to the discussion in #2605, which was closed without a fix. The WANT_READ/WANT_WRITE part of that discussion was addressed by #22193, the shutdown part was not. stream_socket_shutdown() on a TLS stream only does the kernel shutdown(). The TLS layer is never told, so when the stream is closed later, php_openssl_sockop_close() calls SSL_shutdown(), which writes the close_notify alert on a socket whose write side is already shut down. The write fails with EPIPE and the kernel raises SIGPIPE: ``` shutdown(3, SHUT_WR) = 0 write(3, "\27\3\3\0\23\30...", 24) = -1 EPIPE (Broken pipe) --- SIGPIPE {si_signo=SIGPIPE, si_code=SI_USER, ...} --- +++ killed by SIGPIPE +++ ``` All shipped SAPIs install signal(SIGPIPE, SIG_IGN), so in practice the EPIPE is swallowed and the process survives. The process is killed where that is not the case: an embedding host with its own SIGPIPE handling, phpdbg, or userland pcntl_signal(SIGPIPE, SIG_DFL). OpenSSL's socket BIO does not use MSG_NOSIGNAL, and neither does php_sockop_write(). Even with SIGPIPE ignored the behaviour is wrong: the user asked for a half-close, which in TLS is a close_notify, and instead the peer gets a bare FIN (OpenSSL 3 reports it as "unexpected eof while reading") and PHP issues a write the user cannot prevent, other than by calling stream_socket_enable_crypto($s, false) before the shutdown. ```php <?php $pem = tempnam(sys_get_temp_dir(), 'cert'); $key = openssl_pkey_new(['private_key_bits' => 2048]); $csr = openssl_csr_new(['commonName' => 'localhost'], $key); $crt = openssl_csr_sign($csr, null, $key, 1); openssl_x509_export($crt, $certPem); openssl_pkey_export($key, $keyPem); file_put_contents($pem, $certPem . $keyPem); $addr = 'tls://127.0.0.1:' . random_int(10000, 30000); $ctx = stream_context_create(['ssl' => ['local_cert' => $pem, 'verify_peer' => false, 'verify_peer_name' => false]]); $srv = stream_socket_server($addr, $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx); if (pcntl_fork() === 0) { $c = stream_socket_accept($srv, 5); fwrite($c, "hello\n"); usleep(300000); exit(0); } fclose($srv); $cli = stream_socket_client($addr, $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $ctx); echo fgets($cli); pcntl_signal(SIGPIPE, SIG_DFL); // CLI ignores SIGPIPE, undo that to see the effect var_dump(stream_socket_shutdown($cli, STREAM_SHUT_WR)); fclose($cli); echo "survived fclose\n"; ``` Resulted in this output: ``` hello bool(true) ``` with exit status 141 (killed by SIGPIPE). But I expected this output instead: ``` hello bool(true) survived fclose ``` ### Proposed fix Handle STREAM_XPORT_OP_SHUTDOWN for STREAM_SHUT_WR and STREAM_SHUT_RDWR in php_openssl_sockop_set_option() while crypto is active: 1. Call SSL_shutdown() once, without retrying. On a blocking socket this sends close_notify before the FIN. On a non-blocking socket with a full send buffer the alert is lost, which is what happens today anyway; a clean non-blocking close is done by disabling crypto first and polling with stream_socket_get_crypto_status(). 2. Mark SSL_SENT_SHUTDOWN via SSL_set_shutdown() so the close path does not write again. Do not clear ssl_active, the read side stays encrypted and must keep working (OpenSSL refuses reads only after SSL_RECEIVED_SHUTDOWN). 3. Fall through to the kernel shutdown and keep its return value. The close path needs a matching guard: SSL_shutdown() is two-phase, and a second call with SSL_SENT_SHUTDOWN already set tries to read the peer's close_notify, which on a blocking stream with an open read side waits for the peer. php_openssl_sockop_close() must skip SSL_shutdown() when SSL_get_shutdown() already reports SSL_SENT_SHUTDOWN. Because the peer now sees close_notify before FIN, this is a wire-visible change and should go to master only, with an UPGRADING note. This should be done after #22638 lands. That branch moves TLS and DTLS onto a PHP-owned BIO, where the close_notify is queued and sent by the stream's own flush, so the fix there is queued close_notify, best-effort flush, discard the rest of the queue, mark sent, then dispatch the shutdown to the socket, the inner stream, or nothing for a shared dtls:// port. Owning the send call also allows MSG_NOSIGNAL / SO_NOSIGPIPE on that transport, which removes the SIGPIPE exposure for TLS and DTLS streams in the peer-reset case too. fclose() after the peer reset the connection hits the same EPIPE today and is covered only by the SAPI-level SIG_IGN, same as plain tcp:// streams. ### PHP Version master (8.7.0-dev), reproduced with OpenSSL 3.0; the code path is unchanged in 8.4 to 8.6. ### Operating System Linux

« previous php.bugs (#252899) next »