From 1c999e585e357afc8ed1bc033878130c29a5c3ac Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Mon, 24 Aug 2026 10:59:29 -0400 Subject: [PATCH] [streams] Bound user stream wrapper recursion with a depth counter The userspace wrapper recursion 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 (e.g. an incrementing counter in the URL) recursed without bound and exhausted the C stack. Add a per-request nesting depth counter shared by user_wrapper_opener() and user_wrapper_opendir(), rejecting opens beyond 64 nested levels with the existing "infinite recursion prevented" error. Sibling audit: no other entry point nests user-wrapper opens; stat/unlink/rename/mkdir/rmdir and metadata handlers do not recurse through fopen/opendir. --- NEWS | 2 ++ ext/standard/file.h | 1 + .../user_stream_recursion_unique_names.phpt | 25 +++++++++++++++++++ main/streams/streams.c | 1 + main/streams/userspace.c | 16 +++++++++--- 5 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 ext/standard/tests/streams/user_stream_recursion_unique_names.phpt diff --git a/NEWS b/NEWS index a2c65685b4ce..36c7b1757a41 100644 --- a/NEWS +++ b/NEWS @@ -144,6 +144,8 @@ PHP NEWS - Streams: . Fixed bug GH-15836 (Use-after-free when a user stream filter accesses $this->stream during the close flush). (iliaal) + . Prevented stack exhaustion through unbounded recursion in user stream + wrappers whose nested opens use distinct filenames. (iliaal) - Sysvshm: . Fixed out-of-bounds write when shm_attach() opens an existing segment with diff --git a/ext/standard/file.h b/ext/standard/file.h index 3a9cf1435b14..a6bf4cdfed7d 100644 --- a/ext/standard/file.h +++ b/ext/standard/file.h @@ -98,6 +98,7 @@ typedef struct { char *user_agent; /* for the http wrapper */ char *from_address; /* for the ftp and http wrappers */ const char *user_stream_current_filename; /* for simple recursion protection */ + int user_stream_recursion_depth; php_stream_context *default_context; HashTable *stream_wrappers; /* per-request copy of url_stream_wrappers_hash */ HashTable *stream_filters; /* per-request copy of stream_filters_hash */ 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..7ce0352bcfce 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; @@ -379,6 +383,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 +407,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 +423,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 +461,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; }