Skip to content

TLS close_notify is sent after stream_socket_shutdown(SHUT_WR) #24119

Description

@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
$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

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions