Commit df773093eff for php.net
commit df773093effb5f0479634b254753b6d42c645c35
Author: David Carlier <devnexen@gmail.com>
Date: Sun Jul 12 23:47:06 2026 +0100
GH-22617: avoid null byte truncation of persistent stream keys
Abstract unix domain socket addresses begin with a null byte, but the
persistent stream list is keyed by a NUL-terminated string. The key was
truncated at that null byte, so distinct abstract sockets collapsed onto
the same persistent resource in p(f)sockopen() and stream_socket_client().
Escape null bytes (and backslashes, to stay unambiguous) when building the
persistent hash key so the full address is preserved. The escaping helper
lives in ext/standard rather than the public streams header, since only
p(f)sockopen() and stream_socket_client() need it.
Fix #22617
close GH-22704
diff --git a/NEWS b/NEWS
index 9bcb68e5271..d4f7af6f769 100644
--- a/NEWS
+++ b/NEWS
@@ -94,6 +94,10 @@ PHP NEWS
. Fixed bug GH-22678 (Use-after-free in array_multisort() when the comparator
mutates the array being sorted). (azchin, iliaal)
+- Streams:
+ . Fixed bug GH-22617 (persistent stream keys truncated at null bytes, causing
+ distinct abstract unix domain sockets to share a resource). (David Carlier)
+
- Zip:
. Fixed bug GH-22649 (ZipArchive::setCommentName() and setCommentIndex()
could crash after overwriting an entry and resetting its inherited
diff --git a/ext/standard/fsock.c b/ext/standard/fsock.c
index 2b9e00a5755..c8e6ed08e4c 100644
--- a/ext/standard/fsock.c
+++ b/ext/standard/fsock.c
@@ -22,6 +22,7 @@
#include <stddef.h>
#include "php_network.h"
#include "file.h"
+#include "streams/php_streams_int.h"
static size_t php_fsockopen_format_host_port(char **message, const char *prefix, size_t prefix_len,
const char *host, size_t host_len, zend_long port)
@@ -84,8 +85,9 @@ static void php_fsockopen_stream(INTERNAL_FUNCTION_PARAMETERS, int persistent)
}
if (persistent) {
- php_fsockopen_format_host_port(&hashkey, "pfsockopen__", strlen("pfsockopen__"), host,
- host_len, port);
+ zend_string *escaped = php_stream_escape_persistent_key(host, host_len);
+ spprintf(&hashkey, 0, "pfsockopen__%s:" ZEND_LONG_FMT, ZSTR_VAL(escaped), port);
+ zend_string_release_ex(escaped, false);
}
if (port > 0) {
diff --git a/ext/standard/streamsfuncs.c b/ext/standard/streamsfuncs.c
index 1929075b60b..391a72b5d9e 100644
--- a/ext/standard/streamsfuncs.c
+++ b/ext/standard/streamsfuncs.c
@@ -23,6 +23,7 @@
#include "streamsfuncs.h"
#include "php_network.h"
#include "php_string.h"
+#include "streams/php_streams_int.h"
#ifdef HAVE_UNISTD_H
#include <unistd.h>
#endif
@@ -130,7 +131,9 @@ PHP_FUNCTION(stream_socket_client)
context = php_stream_context_from_zval(zcontext, flags & PHP_FILE_NO_DEFAULT_CONTEXT);
if (flags & PHP_STREAM_CLIENT_PERSISTENT) {
- spprintf(&hashkey, 0, "stream_socket_client__%s", ZSTR_VAL(host));
+ zend_string *escaped = php_stream_escape_persistent_key(ZSTR_VAL(host), ZSTR_LEN(host));
+ spprintf(&hashkey, 0, "stream_socket_client__%s", ZSTR_VAL(escaped));
+ zend_string_release_ex(escaped, false);
}
/* prepare the timeout value for use */
diff --git a/ext/standard/tests/streams/gh22617.phpt b/ext/standard/tests/streams/gh22617.phpt
new file mode 100644
index 00000000000..83dbd832e65
--- /dev/null
+++ b/ext/standard/tests/streams/gh22617.phpt
@@ -0,0 +1,45 @@
+--TEST--
+GH-22617: Persistent abstract unix domain sockets resolved to wrong resource
+--CREDITS--
+Roysten
+--SKIPIF--
+<?php
+if (PHP_OS_FAMILY !== "Linux") die("skip abstract unix domain sockets are Linux-only");
+if (!function_exists("pfsockopen")) die("skip pfsockopen() not available");
+?>
+--FILE--
+<?php
+// Abstract sockets: the address is prefixed with a null byte.
+$name1 = "gh22617_" . getmypid() . "_1";
+$name2 = "gh22617_" . getmypid() . "_2";
+
+$server1 = stream_socket_server("unix://\0$name1", $errno, $errstr);
+$server2 = stream_socket_server("unix://\0$name2", $errno, $errstr);
+var_dump($server1 !== false, $server2 !== false);
+
+// Connect to the first abstract socket.
+$socket1 = pfsockopen("unix://\0$name1", 0, $errno1, $errstr1);
+var_dump($socket1 !== false);
+var_dump(get_resource_type($socket1));
+
+// Connect to the second abstract socket.
+$socket2 = pfsockopen("unix://\0$name2", 0, $errno2, $errstr2);
+var_dump($socket2 !== false);
+var_dump(get_resource_type($socket2));
+
+// The two distinct abstract sockets must resolve to distinct resources.
+var_dump((int) $socket1 !== (int) $socket2);
+
+fclose($socket1);
+fclose($socket2);
+fclose($server1);
+fclose($server2);
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+string(17) "persistent stream"
+bool(true)
+string(17) "persistent stream"
+bool(true)
diff --git a/main/streams/php_streams_int.h b/main/streams/php_streams_int.h
index 7580088fba3..f9dda9a52ff 100644
--- a/main/streams/php_streams_int.h
+++ b/main/streams/php_streams_int.h
@@ -62,3 +62,7 @@
* any other function that expects standard modes and you allow non-standard
* ones. result should be a char[5]. */
void php_stream_mode_sanitize_fdopen_fopencookie(php_stream *stream, char *result);
+
+/* Escapes NUL and backslash bytes so a host containing them cannot truncate or
+ * collide the persistent stream hash key. */
+zend_string *php_stream_escape_persistent_key(const char *host, size_t hostlen);
diff --git a/main/streams/streams.c b/main/streams/streams.c
index 6feb8b7c01b..368de1a6477 100644
--- a/main/streams/streams.c
+++ b/main/streams/streams.c
@@ -140,6 +140,27 @@ PHPAPI int php_stream_from_persistent_id(const char *persistent_id, php_stream *
/* }}} */
+zend_string *php_stream_escape_persistent_key(const char *host, size_t hostlen)
+{
+ zend_string *escaped = zend_string_safe_alloc(hostlen, 2, 0, 0);
+ char *ptr = ZSTR_VAL(escaped);
+ for (size_t i = 0; i < hostlen; i++) {
+ if (host[i] == '\0') {
+ *ptr++ = '\\';
+ *ptr++ = '0';
+ } else if (host[i] == '\\') {
+ *ptr++ = '\\';
+ *ptr++ = '\\';
+ } else {
+ *ptr++ = host[i];
+ }
+ }
+ *ptr = '\0';
+ ZSTR_LEN(escaped) = ptr - ZSTR_VAL(escaped);
+
+ return escaped;
+}
+
static zend_llist *php_get_wrapper_errors_list(php_stream_wrapper *wrapper)
{
if (!FG(wrapper_errors)) {