[php-src] Issue #24119: stream_socket_shutdown(STREAM_SHUT_WR) on a TLS stream makes fclose() write to the half-closed socket
| From: | bukka | 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