php2shell Calif Research ↗

A tale of two PHP remote vulnerabilities

This story outlines the details of two PHP remote vulnerabilities (CVE-2026-109618, CVE-2026-109619) discovered by Calif, OpenAI and Wiz, and addressed in PHP October 2026 Security Release. To understand the bugs, first join us on a journey through time...

2004The origin

1 / 6

The year was 2004. The iPhone was yet to be invented and our very own Stefan Esser was still the PHP security guy.

Comic page 1: The origin.
Stefan searches for forbidden knowledge in a street marked 2004, Göbekli Tepe.
An ancient PHP elephant artifact covered with code.
A mysterious figure offers the artifact. Stefan asks, “Are you sure this is safe?” The reply: “I am certain.”
Stefan accepts the challenge, despite his suspicions.
The PHP artifact changes hands across a table.
Stefan unlocks the mystery of the original memory-limit RCE.
Read the scene

2004. Stefan searches for forbidden knowledge and discovers an ancient PHP artifact. “Are you sure this is safe?” he asks. “I am certain,” comes the reply. Suspicious, he accepts the challenge—and unlocks the mystery of the original memory-limit RCE.

Back then, I was a kid. I've followed Stefan's PHP research to improve my own understanding of PHP as an attack surface. Long before the 2008 call-time-pass-by-reference / interruption vulnerabilities and the more famous unserialize() use-after-frees and POP(object) injections, Stefan unearthed the first PHP memory limit remote vulnerability in 2004. I remembered the vulnerability as it was described by Stefan as being critical enough that he would not release his exploit to the public. Eventually, two other commercial exploit vendors developed non-public exploits as well - CORE and Immunity Inc. Other than those, we only had fake exploits circulating on IRC and designed to compromise your machine.

The years went on and all of the default unserializable classes with use-after-free or type confusion bugs in PHP were found and patched as well. We verified this earlier in the year when we released our first PHP research attempting to find any remnants that might have been missed; we found no exploitable vectors that would be exploitable by default.

2020The OOM

2 / 6

Then, in 2020, jr@coredu.edu reported a seemingly harmless bug #78876 to the PHP project, noting that certain long variables could cause an out-of-memory condition and the failure to clean up temporary files. At the time, not much was made of the report:

Comic page 2: The OOM.
London, 2020. A detective surveys the city, deep in thought.
A detective examines a bug report: long variables caused out-of-memory conditions and temporary files were not cleaned. An inset identifies PHP bug #78876.
Read the scene

London, 2020. A detective is called to investigate a bug report. “Long variables must have caused OOM, and temp files were not cleaned.” The clue is PHP bug #78876.

2024The audit

3 / 6

Fast forward to 2024 when OSTIF and Quarkslab performed an audit of PHP and rediscovered the same OOM issue. However, exploitation was not proven, hence the bug was not fixed.

Comic page 3: The audit.
2024. A scientist investigates an anomaly in a PHP laboratory. The result: OOM exploitation of unknown origin.
The scientist examines a circuit board through oversized goggles.
“Great Scott!” The circuit reveals an unexpected connection.
The audit finds a related OOM exploitation. A printer produces an elephant-shaped PHP hacked message.
Read the scene

2024. In the PHP laboratory, a scientist investigates an anomaly: out-of-memory exploitation of unknown origin. Looking closer at the circuitry, he exclaims, “Great Scott!” The audit finds a related OOM exploitation.

July 30, 2026Patch the Planet

4 / 6

Finally, we arrive in 2026. OpenAI invited us to join its Patch the Planet initiative, and PHP stood out as a high-impact target. We wanted the PHP Foundation involved, so Stefan connected us with Volker Dusch, who accepted our invitation. Then we got to work hacking PHP.

Comic page 4: The discovery in Florida.
2026, somewhere in Florida. A researcher sleeps at a console beside a robot.
The console beeps, waking the researcher.
The researcher asks, “Huh… another bug?”
Someone rings the doorbell.
The researcher opens the door.
An envelope waits at the doorstep.
The envelope asks, “Are you ready to take the red pill?”
“Let’s go!” The researcher takes a device from the console.
Read the scene

2026, somewhere in Florida. A researcher dozes beside a robot until a beeping console wakes him. “Huh… another bug?” The doorbell rings, and an envelope arrives asking, “Are you ready to take the red pill?” He retrieves a device from the console. “Let’s go!”

Aug 01, 2026The discovery

5 / 6

We were not expecting to find anything critical - everyone and every lab had already done extensive AI assisted audits of this target. But, only a few days after starting the audit and asking the right questions related to Stefan's earlier findings, we managed to surface the first OOM-fatal (more about that soon) and the first of the three interruption points that allowed us to piece together an RCE against WordPress.

The first bug isn't usually the best one, so we continued looking for better variants. In the end, we found two more that lead to reliable exploits against mod_php and PHP-FPM.

Comic page 5: Reporting a critical PHP RCE.
A cloaked figure greets the researcher: “We’ve been expecting you.”
The researcher approaches a council of cloaked figures and a glowing PHP elephant.
A close-up of the glowing PHP elephant.
The researcher announces, “I’m here to report a critical RCE.”
The researcher presents a device to the PHP elephant.
Read the scene

A cloaked figure welcomes the researcher: “We’ve been expecting you.” Inside, a council gathers around a glowing PHP elephant. “I’m here to report a critical RCE,” the researcher announces, presenting his device to the elephant.

Oct 21, 2026and here we are...

6 / 6
A glowing blue PHP elephant beneath branching blue light. Comic illustrated by Stephanie Cortes — Ibis Craft.
A glowing blue PHP elephant beneath branching blue light. Comic illustrated by Stephanie Cortes — Ibis Craft.
Read the scene

A glowing blue PHP elephant beneath branching blue light. Comic illustrated by Stephanie Cortes — Ibis Craft.

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 with session.upload_progress.enabled=0 and set memory_limit=-1.
  • For mod_php, set memory_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.