[php-src] PHP-8.4: GH-22617: avoid null byte truncation of persistent stream keys

David Carlier <[email protected]>
Newsgroups gmane.comp.php.cvs.general
Message-ID <[email protected]>
Author: David Carlier (devnexen)
Date: 2026-07-14T20:11:02+01:00

Commit: https://github.com/php/php-src/commit/df773093effb5f0479634b254753b6d42c645c35
Raw diff: https://github.com/php/php-src/commit/df773093effb5f0479634b254753b6d42c645c35.diff

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

Changed paths:
  A  ext/standard/tests/streams/gh22617.phpt
  M  NEWS
  M  ext/standard/fsock.c
  M  ext/standard/streamsfuncs.c
  M  main/streams/php_streams_int.h
  M  main/streams/streams.c


Diff:

diff --git a/NEWS b/NEWS
index 9bcb68e52712..d4f7af6f7692 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 2b9e00a57554..c8e6ed08e4c0 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 1929075b60b7..391a72b5d9e0 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 000000000000..83dbd832e652
--- /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 7580088fba31..f9dda9a52ff7 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 6feb8b7c01b5..368de1a64774 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)) {
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.