From 6ae56fd5f309235506cee68cf764675e1c74d69a Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Mon, 24 Aug 2026 10:59:29 -0400 Subject: [PATCH] main/streams: Bound user stream wrapper recursion with a depth counter The userspace wrapper guard only compared the filename being opened against the currently open one, so a wrapper whose stream_open() opens a different path at every level, an incrementing counter in the URL for instance, recursed without bound and exhausted the C stack. Add a per-request nesting counter shared by user_wrapper_opener() and user_wrapper_opendir() that rejects opens past 64 levels with the existing "infinite recursion prevented" error, decremented on every exit including the bailout branch. Sibling audit, no other entry point nests user-wrapper opens: stat, unlink, rename, mkdir, rmdir and the metadata handlers do not recurse through fopen/opendir. --- NEWS | 4 +++ ext/standard/file.h | 1 + .../user_stream_recursion_unique_names.phpt | 25 +++++++++++++++++++ main/streams/streams.c | 1 + main/streams/userspace.c | 17 ++++++++++--- 5 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 ext/standard/tests/streams/user_stream_recursion_unique_names.phpt diff --git a/NEWS b/NEWS index e7f45c810b23..5fec96d1fe8c 100644 --- a/NEWS +++ b/NEWS @@ -79,6 +79,10 @@ PHP NEWS PROCESS_INFORMATION, an indeterminate comspec pointer after a failed lookup, and an unchecked CreateFileA() failure. (Ilia Alshanetsky) +- Streams: + . Fixed unbounded recursion when a userland stream wrapper opens a different + path at every nesting level. (Ilia Alshanetsky) + - XSL: . Fixed bug GH-23730 (use-after-free when XSLTProcessor::importStylesheet() is called during a transformation). (David Carlier) diff --git a/ext/standard/file.h b/ext/standard/file.h index 3a9cf1435b14..34895953be66 100644 --- a/ext/standard/file.h +++ b/ext/standard/file.h @@ -108,6 +108,7 @@ typedef struct { char *tmp_host_buf; size_t tmp_host_buf_len; #endif + int user_stream_recursion_depth; } php_file_globals; #ifdef ZTS diff --git a/ext/standard/tests/streams/user_stream_recursion_unique_names.phpt b/ext/standard/tests/streams/user_stream_recursion_unique_names.phpt new file mode 100644 index 000000000000..06ca94ba28c2 --- /dev/null +++ b/ext/standard/tests/streams/user_stream_recursion_unique_names.phpt @@ -0,0 +1,25 @@ +--TEST-- +User stream wrapper recursion is bounded even when each nested open uses a unique filename +--FILE-- += 100000) { + return true; + } + @fopen('rec://' . self::$depth, 'r'); + return true; + } +} +stream_wrapper_register('rec', 'RecWrapper'); +@fopen('rec://start', 'r'); +var_dump(RecWrapper::$depth < 100000); +echo "OK\n"; +--EXPECT-- +bool(true) +OK diff --git a/main/streams/streams.c b/main/streams/streams.c index 368de1a64774..ccba43eec620 100644 --- a/main/streams/streams.c +++ b/main/streams/streams.c @@ -1886,6 +1886,7 @@ static void stream_resource_persistent_dtor(zend_resource *rsrc) void php_shutdown_stream_hashes(void) { FG(user_stream_current_filename) = NULL; + FG(user_stream_recursion_depth) = 0; if (FG(stream_wrappers)) { zend_hash_destroy(FG(stream_wrappers)); efree(FG(stream_wrappers)); diff --git a/main/streams/userspace.c b/main/streams/userspace.c index e21f9f062cfd..af83a69eec39 100644 --- a/main/streams/userspace.c +++ b/main/streams/userspace.c @@ -35,6 +35,8 @@ static int le_protocols; +#define USER_STREAM_MAX_RECURSION_DEPTH 64 + struct php_user_stream_wrapper { php_stream_wrapper wrapper; char * protoname; @@ -302,12 +304,13 @@ static php_stream *user_wrapper_opener(php_stream_wrapper *wrapper, const char * php_stream *stream = NULL; bool old_in_user_include; - /* Try to catch bad usage without preventing flexibility */ - if (FG(user_stream_current_filename) != NULL && strcmp(filename, FG(user_stream_current_filename)) == 0) { + if ((FG(user_stream_current_filename) != NULL && strcmp(filename, FG(user_stream_current_filename)) == 0) + || FG(user_stream_recursion_depth) >= USER_STREAM_MAX_RECURSION_DEPTH) { php_stream_wrapper_log_error(wrapper, options, "infinite recursion prevented"); return NULL; } FG(user_stream_current_filename) = filename; + FG(user_stream_recursion_depth)++; /* if the user stream was registered as local and we are in include context, we add allow_url_include restrictions to allow_url_fopen ones */ @@ -328,6 +331,7 @@ static php_stream *user_wrapper_opener(php_stream_wrapper *wrapper, const char * user_stream_create_object(uwrap, context, &us->object); if (Z_TYPE(us->object) == IS_UNDEF) { FG(user_stream_current_filename) = NULL; + FG(user_stream_recursion_depth)--; PG(in_user_include) = old_in_user_include; efree(us); return NULL; @@ -345,6 +349,7 @@ static php_stream *user_wrapper_opener(php_stream_wrapper *wrapper, const char * call_result = call_method_if_exists(&us->object, &zfuncname, &zretval, 4, args); } zend_catch { FG(user_stream_current_filename) = NULL; + FG(user_stream_recursion_depth)--; zend_bailout(); } zend_end_try(); @@ -379,6 +384,7 @@ static php_stream *user_wrapper_opener(php_stream_wrapper *wrapper, const char * zval_ptr_dtor(&args[0]); FG(user_stream_current_filename) = NULL; + FG(user_stream_recursion_depth)--; PG(in_user_include) = old_in_user_include; return stream; @@ -402,12 +408,13 @@ static php_stream *user_wrapper_opendir(php_stream_wrapper *wrapper, const char int call_result; php_stream *stream = NULL; - /* Try to catch bad usage without preventing flexibility */ - if (FG(user_stream_current_filename) != NULL && strcmp(filename, FG(user_stream_current_filename)) == 0) { + if ((FG(user_stream_current_filename) != NULL && strcmp(filename, FG(user_stream_current_filename)) == 0) + || FG(user_stream_recursion_depth) >= USER_STREAM_MAX_RECURSION_DEPTH) { php_stream_wrapper_log_error(wrapper, options, "infinite recursion prevented"); return NULL; } FG(user_stream_current_filename) = filename; + FG(user_stream_recursion_depth)++; us = emalloc(sizeof(*us)); us->wrapper = uwrap; @@ -417,6 +424,7 @@ static php_stream *user_wrapper_opendir(php_stream_wrapper *wrapper, const char user_stream_create_object(uwrap, context, &us->object); if (Z_TYPE(us->object) == IS_UNDEF) { FG(user_stream_current_filename) = NULL; + FG(user_stream_recursion_depth)--; efree(us); return NULL; } @@ -454,6 +462,7 @@ static php_stream *user_wrapper_opendir(php_stream_wrapper *wrapper, const char zval_ptr_dtor(&args[0]); FG(user_stream_current_filename) = NULL; + FG(user_stream_recursion_depth)--; return stream; }