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
$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:
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:
- 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().
- 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).
- 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
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 kernelshutdown(). The TLS layer is never told, so when the stream is closed later,php_openssl_sockop_close()callsSSL_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: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 userlandpcntl_signal(SIGPIPE, SIG_DFL). OpenSSL's socket BIO does not useMSG_NOSIGNAL, and neither doesphp_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.Resulted in this output:
with exit status 141 (killed by SIGPIPE).
But I expected this output instead:
Proposed fix
Handle
STREAM_XPORT_OP_SHUTDOWNforSTREAM_SHUT_WRandSTREAM_SHUT_RDWRinphp_openssl_sockop_set_option()while crypto is active: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 withstream_socket_get_crypto_status().SSL_SENT_SHUTDOWNviaSSL_set_shutdown()so the close path does not write again. Do not clearssl_active, the read side stays encrypted and must keep working (OpenSSL refuses reads only afterSSL_RECEIVED_SHUTDOWN).The close path needs a matching guard:
SSL_shutdown()is two-phase, and a second call withSSL_SENT_SHUTDOWNalready 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 skipSSL_shutdown()whenSSL_get_shutdown()already reportsSSL_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_NOSIGPIPEon 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 plaintcp://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