
A tale of two PHP remote vulnerabilities
PHP can leave dangling pointers/state when an allocation would exceed memory_limit, or another fatal error interrupts native code before its normal cleanup completes. Request shutdown may then free memory that surviving…
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
PHP can leave dangling pointers/state when an allocation would exceed memory_limit, or another fatal error interrupts native code before its normal cleanup completes. Request shutdown may then free memory that surviving state still references.
To reach an exploitable state we found two general OOM/memory_limit based fatal producers and three different interruptible points in the interpreter that can leave a stale/consumable state that allows leaking memory and remote code execution.
The earliest affected path we found dates to PHP 5.4.0; the available exploitation paths depend on the PHP version and SAPI but are always exploitable under default configurations.
We confirmed pre-auth RCE on the following PHP apps as well as arbitrary PHP scripts in some instances.
- WordPress 7.1.2
- Drupal 11.4.3
- Joomla 6.1.4
- MediaWiki 1.46.2
- Roundcube 1.7.4
- Magento 2.4.9
- phpMyAdmin 5.2.3
- Nextcloud 35.0.1
- Laravel 13.11
- Symfony 8.1
- WHMCS
Here is a demo video of the exploits running:
Right. We should explain from the start. My first step of finding if any long-lost siblings of Stefan's 2004 bug were still lingering in the codebase was to look for ways to exceed the memory_limit that allows us to throw a controllable fatal. I asked my LLM companion for help.
The first useful finding was a way to bypass the smaller post_max_size barrier that normally prevents a request from delivering enough memory pressure to reach memory_limit. PHP compares post_max_size with the declared Content-Length before automatic POST parsing begins, but it does not enforce the same limit against the bytes it actually consumes. Apache can therefore pass a chunked HTTP/1.1 or lengthless HTTP/2 multipart request with no meaningful declared length. A configured input-decompression filter creates the same mismatch between the compressed length and the decoded stream.
The second useful finding was a way to exhaust memory_limit with a multipart request that remains below post_max_size. An RFC1867 file-upload part with filename="" contains no file data, and PHP does not count it toward max_file_uploads. PHP still creates all six corresponding $_FILES entries: name, full_path, type, tmp_name, error, and size.
When the form field name is nested, such as files[a][b][c]..., PHP must create that hierarchy separately for each of the six entries. One short empty-upload record expands into many native HashTables, Buckets and string-key allocations. Hundreds of these records can occupy more than the default 128 MiB PHP heap while their complete HTTP body remains below the default 8 MiB post_max_size. This lets us make a chosen later allocation hit memory_limit.
Now that we can cause OOM/fatal on demand, the question is what can we interrupt that leaves the process in an exploitable state. We asked our audit buddy to go over ZendMM allocation behavior, fatal reporting, and request shutdown for interesting points which may be vulnerable to unexpected interruptions. During that source review our agent noticed this ordering in _zend_hash_str_add_or_update_i:
idx = ht->nNumUsed++; ht->nNumOfElements++;
p = ht->arData + idx; p->key = zend_string_init(str, len, persistent); /* may zend_bailout() */
p->h = ZSTR_H(p->key) = h; ZVAL_COPY_VALUE(&p->val, pData);
PHP increments both nNumUsed and nNumOfElements before fully initializing the new bucket. Increasing nNumUsed makes the bucket visible to teardown, even if the subsequent key allocation fatals as can be seen in the relevant branch of zend_array_destroy():
for (p = ht->arData; p != ht->arData + ht->nNumUsed; p++) { i_zval_ptr_dtor(&p->val); if (p->key) { zend_string_release(p->key); } }
This allows us to ensure that local control flow never reaches the key, hash, zval or collision-chain assignments. But the count remains incremented, so teardown later consumes stale allocation bytes as a live bucket:
increment live count | v Bucket becomes teardown-visible | v allocate owned key ----fatal----+ | | v v initialize Bucket assignments skipped | | v v valid table teardown trusts live count | v stale Bucket consumption
My agent then traced the source root to automatic nested POST parsing and discovered that mod_php shutdown consumes the malformed table. The vulnerable _zend_hash_str_add_or_update_i is reached through normal request-variable registration:
URL-encoded POST root[x]=v php_std_post_handler() → php_register_variable_ex() → zend_symtable_str_update_ind() → _zend_hash_str_add_or_update_i()
RFC1867 upload root[x], filename="..." rfc1867_post_handler() → register_http_post_files_variable_ex() → php_register_variable_ex() → zend_symtable_str_update_ind() → _zend_hash_str_add_or_update_i()
Both paths execute during php_request_startup(), before the target PHP file runs. When the key allocation fatals, the SAPI determines whether the partial Bucket is ever consumed:
/* mod_php: registered request cleanup runs even after startup failure */ php_request_shutdown(NULL); // destroys malformed $_POST/$_FILES
/* PHP-FPM: startup failure takes the process-exit branch */ php_module_shutdown(); return FPM_EXIT_SOFTWARE; // no request-level table destruction
Apache reaches HashTable teardown and interprets the stale bucket, while PHP-FPM discards it with the terminated worker. This is RCE against arbitrary PHP scripts on Apache mod_php and PHP >= 7.3.
As I hinted earlier, the first bug is usually not the best bug, but can be a good indication other juicy bugs hiding nearby. After the initial finding in _zend_hash_str_add_or_update_i, we had the LLM generalize the pattern from “count before key allocation” to a SKILL.md that looks for:
publish or destroy state → perform fallible work → initialize, commit or restore
nonlocal fatal during fallible work → commit/rollback skipped → surviving consumer trusts false state
The skill and pattern knowledge brought us a new finding in Session upload-progress that confirms we discovered something more than a one-off flaw.
The Session upload-progress feature can open and update a session while PHP is still parsing the request body, before application code executes. The PHP manual documents this early behavior. The relevant request-startup ordering in main/main.c is:
PG(modules_activated) = 0;
/* ... */
php_hash_environment(); /* Parses request variables and multipart input. / zend_activate_modules(); / Calls normal module RINIT functions. */ PG(modules_activated) = 1;
Multipart parsing can therefore enter the session extension before PHP considers its modules activated. In php_session_rfc1867_callback(), the first qualifying file part takes this path:
case MULTIPART_EVENT_FILE_START: { /* ... */
if (Z_ISUNDEF(progress->data)) { /* Initialize the progress arrays. / / ... */
php_rinit_session(0); PS(id) = zend_string_copy(Z_STR(progress->sid));
/* ... */ }
/* Initialize this file's progress record. / / ... */
php_session_rfc1867_update(progress, 0); } break;
The update calls php_session_initialize(), which also initializes PS(http_session_vars). At this point, Session module globals hold pointers into the current request’s heap, but normal module activation has not finished.
The matching cleanup is vulnerable to interruption. Ordinarily, the multipart END callback cleans up this early session state. If parsing fatals first, that callback is skipped. Request shutdown also gates extension shutdown on the activation flag:
/* php_request_shutdown() */
if (PG(modules_activated)) { zend_deactivate_modules(); }
/* ... */
shutdown_memory_manager( CG(unclean_shutdown) || !report_memleaks, 0);
On the affected mod_php path, PG(modules_activated) is still false, so session RSHUTDOWN does not run. The request heap is destroyed regardless. The Apache worker survives with PS(id) and PS(http_session_vars) still pointing into that freed heap.
The remaining question for exploitation is "what reaches those globals before they are reset?". A second multipart request is the answer. It can contain a Session ID and the PHP_SESSION_UPLOAD_PROGRESS field, but no file part. That reaches MULTIPART_EVENT_END without passing through FILE_START, so the early php_rinit_session(0) reset never happens:
case MULTIPART_EVENT_END: { /* ... */
if (Z_TYPE(progress->sid) && progress->key.s) { if (PS(rfc1867_cleanup)) { php_session_rfc1867_cleanup(progress); }
/* ... */ php_rshutdown_session_globals(); }
/* ... */ } break;
With upload-progress cleanup enabled, php_session_rfc1867_cleanup() calls php_session_initialize(). That function first encounters the stale (dangling) Session ID:
if (!PS(id) || !ZSTR_VAL(PS(id))[0]) { /* ... */ }
Initialization then reaches php_session_track_init(), which destroys the previous Session value if it still appears defined:
if (!Z_ISUNDEF(PS(http_session_vars))) { zval_ptr_dtor(&PS(http_session_vars)); }
Both operations trust state left behind by the failed request:
Request A: FILE_START → initialize Session before normal module activation → store request-owned pointers in Session globals → later multipart parsing fatals → skip multipart END and Session RSHUTDOWN → free request heap; worker survives
Request B: multipart form without a file → no FILE_START reset → END cleanup initializes Session → read freed Session ID → destroy freed Session value
In mod_php, php_apache_request_ctor() calls php_request_startup(). A failure causes a bailout into the surrounding handler, but the code after that handler still runs. From sapi_apache2.c:665:
zend_first_try { /* ... */
if (php_apache_request_ctor(r, ctx) != SUCCESS) { zend_bailout(); }
/* Execute the PHP script. / / ... */ } zend_end_try();
if (!parent_req) { php_apache_request_dtor(r); /* ... */ }
return OK;
The request destructor calls ordinary PHP request shutdown:
static void php_apache_request_dtor(request_rec *r) { php_request_shutdown(NULL); }
That produces the dangerous combination. Because the fatal happened before normal module activation, request shutdown skips Session’s RSHUTDOWN hook, but still frees the request heap:
/* php_request_shutdown(), abbreviated */
if (PG(modules_activated)) { zend_deactivate_modules(); }
/* ... */
shutdown_memory_manager( CG(unclean_shutdown) || !report_memleaks, 0);
The Apache child can now handle another request. Its Session globals survive, while the request allocations they pointed to have been freed. PHP-FPM makes a different decision. Its request loop (fpm_main.c:1826) handles the same startup failure like this:
if (UNEXPECTED(php_request_startup() == FAILURE)) { fcgi_finish_request(request, 1); SG(server_context) = NULL; php_module_shutdown(); return FPM_EXIT_SOFTWARE; }
That return exits the PHP-FPM worker. There is no second request in the same process to consume the dangling Session state.
Startup fatal | +-- mod_php → request shutdown → heap freed → worker reused | | | v | stale Session consumed | +-- PHP-FPM → module shutdown → worker exits
This results in RCE against arbitrary PHP scripts on Apache mod_php and PHP >= 5.4.0.
We now have two different interruption points that make mod_php exploitable, but many modern PHP deployments use PHP-FPM and we haven't found an exploitable interruption that affects it yet. The important distinction is when the fatal happens. PHP-FPM terminates its worker when php_request_startup() fails. Once startup has succeeded, however, an ordinary fatal during script execution follows the normal request-shutdown path, and the worker can accept another request.
That gave our agents a new direction: find request-owned state that an application can leave dangling after startup, then reach it through upload progress before the next request resets it.
The old friend we mentioned earlier is commonly known as PHP’s generic unserializer, and its gift is the exact pattern we needed. The unserializer keeps shared bookkeeping for
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。