PHP >= 5.4.0 RCE
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:
You said OOM-fatal?
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.
But what does an OOM buy you, really?
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.
The generalized pattern
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.
But what about PHP-FPM? Or, a gift from an old friend...
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 serialized references and values awaiting destruction. php_var_unserialize_init()
allocates this state from the request heap and publishes it in the Basic extension’s globals. Nested decoding operations reuse it:
/* php_var_unserialize_init(), abbreviated */
if (BG(serialize_lock) || !BG(unserialize).level) {
d = emalloc(sizeof(struct php_unserialize_data));
/* Initialize the reference and destructor lists. */
/* ... */
if (!BG(serialize_lock)) {
BG(unserialize).data = d;
BG(unserialize).level = 1;
}
} else {
d = BG(unserialize).data;
++BG(unserialize).level;
}
Normal completion calls php_var_unserialize_destroy(), which releases the state and clears the global pointer when
the nesting level reaches zero. A fatal inside the live decoder can skip that operation.
The Session decoder gives us a straightforward example:
PHP_VAR_UNSERIALIZE_INIT(var_hash);
/* Decode the Session's values. */
current = var_tmp_var(&var_hash);
if (php_var_unserialize(current,
(const unsigned char **)&q,
(const unsigned char *)endptr,
&var_hash)) {
/* ... */
}
/* ... */
PHP_VAR_UNSERIALIZE_DESTROY(var_hash);
In phpMyAdmin, a normal session_start() loads a Session record that we previously built through HTTP requests.
WordPress reaches the same generic decoder through ordinary maybe_unserialize() calls. By controlling the memory pressure around
those operations, we can let the decoder build useful reference and destructor lists before a later allocation hits memory_limit.
The outer Session error handler cleans up Session, but it does not clear Basic’s abandoned decoder state. Request shutdown releases the heap while
BG(unserialize).data still points into it and the nesting level remains nonzero. Because this fatal happened after successful
request startup, PHP-FPM can keep the worker alive.
The next multipart request reaches Session upload progress before Basic’s normal request initialization. The stale nesting level makes
php_var_unserialize_init() treat the dangling pointer as an active nested decoder. Ordinary request allocations can reclaim the
freed storage before that happens:
Application request
→ request startup succeeds
→ application starts decoding
→ decoder state is published
→ allocation fatal skips decoder destruction
→ request heap is freed; PHP-FPM worker survives
Next multipart request
→ request data reclaims the freed storage
→ upload progress starts Session before Basic RINIT
→ decoder reuses the stale bookkeeping
Serialized R: and r: references can follow selected entries in the reclaimed reference list. The destructor list
gives us a second primitive: a controlled native zval copy.
Each destructor block has a signed used_slots count. Its allocation helper checks whether adding another entry would exceed the block’s
capacity, but assumes the existing count cannot be negative:
var_hash = (*var_hashx)->last_dtor;
if (!var_hash ||
var_hash->used_slots + num > VAR_DTOR_ENTRIES_MAX) {
/* Allocate a fresh destructor block. */
/* ... */
}
/* Initialize and return the selected slots. */
After reclamation, we can violate that assumption. On the tested 64-bit layout, the selected slot is calculated as:
slot = destructor_block + 16 + 16 × used_slots
A negative count selects memory before the intended array. Duplicate keys in the serialized input then reach var_push_dtor_value(),
which preserves the previous value by copying its genuine zval into that selected slot:
zval *tmp_var = var_tmp_var(var_hashx);
/* ... */
ZVAL_COPY_VALUE(tmp_var, rval);
We therefore control where PHP places a pointer and its associated type information. This is application-dependent RCE under both Apache mod_php and PHP-FPM.
An application exposes the vulnerable transaction through a reachable post-startup decoding operation such as session_start(),
session_decode() or unserialize() - including wrappers such as WordPress’s maybe_unserialize().
The decoded data can be legitimate. An attacker who controls the surrounding memory pressure may still interrupt that decode at a useful point,
then use a later upload-progress request to consume the stale state in the surviving worker.
This demo shows the exploit running against Nginx with PHP-FPM:
An interesting timeline of events
After finishing investigating the PHP vulnerabilities we wanted to check if Hack/HHVM was vulnerable to similar issues.
As it turns out the vulnerability was "mitigated" in HHVM by an upstream PHP patch that was submitted in 2020 #78876 Long variables in multipart/form-data cause OOM and temp files
While looking at this patch we noted a tag in the git that said security-audit-2024 even though this patch was from 2020 which pointed to a similar finding being found in the 2024 OSTIFY audit. We were able to confirm from PHP a similar/related issue was uncovered but exploitation was not proven in 2024.
As such the timeline from our point of view is:
- Stefan Esser discovered the first PHP memory limit remote vulnerability.
- #78876 was reported by
jr@coredu.mp. - OSTIFY & Quarkslab identified a related bug of #78876 that exploitation was unproven.
- Calif discovered multiple different paths independently leading to RCE that resurfaced the 2024 issues and new trigger points.
- Calif researcher Ryan Austin tweeted hash of one finding.
- Calif reported the first bug to PHP and started coordinated disclosure process.
- Calif shared an RCE PoC with PHP.
- PHP confirmed that the RCE PoC worked as expected.
- Calif reported the second bug to PHP.
- Patched versions released.
How did we find this?
No special harness, or access to models that weren't generally available to everyone were used in the initial bug discovery. I focused on something I had a background in, knew its vulnerability history well, and held a theory that specific bugs lurk within the codebase. This shows that "know your target" is still as applicable as ever, even in the LLM dominated VR age. No model is simply capable of unearthing "all bugs" with simple prompts. At least for now.
There are some practical research implications here. LLMs may have ingested public historical vuln data, but in their default variant analysis tends to move from the newest and most prominent bugs toward nearby older code. They do not reliably begin with obscure twenty-year-old exploitation primitives. Keeping your own mental catalog of a target's vulnerability history lets you prompt that direction explicitly.
Recommendations
Patch now. Upgrade to a fixed PHP release as soon as possible.
If you cannot patch immediately, the following temporary, partial mitigations may buy you some time:
- For
PHP-FPM, disable session upload progress withsession.upload_progress.enabled=0and setmemory_limit=-1. - For
mod_php, setmemory_limit=-1.
These changes do not replace a patch. Setting memory_limit=-1 removes PHP’s memory cap, increasing the risk of memory exhaustion.
But seriously: patch. See the PHP October 2026 Security Release for official recommendations.
Credits
Research and writeup by Ryan Austin of Calif.
Comic illustrated by Stephanie Cortes of Ibis Craft.
Poster and comic story by Michal Zalewski of Calif.































