Skip to content
Php2shell Php Core Rce Cve 2026 109618 109619

Php2shell Php Core Rce Cve 2026 109618 109619

0x4148.com • October 21, 2026

You can validate every input, patch every dependency, and write perfectly reasonable PHP. Unfortunately, none of that matters much if the dangerous operation happens before your application executes its first line.

That is what got me interested in PHP2Shell , the name being used for the reported PHP remote code execution issues CVE-2026-109618 and CVE-2026-109619 . The PHP security team has confirmed two critical vulnerabilities, each rated 9.2 under CVSS v4.0 , and scheduled a security release for October 21, 2026, at 14:00 UTC . The detailed pre-release reports focus on PHP’s native multipart parser and the built-in session upload-progress handler.

Instead of another article telling people to update PHP (excellent advice, by the way), I went through the PHP source and put together a small PHP 8.4.4 lab with nginx, PHP-FPM, and a Python request-streaming PoC. I wanted to understand which callbacks run, how session data changes during an upload, and why an otherwise innocent byte counter could become a memory-lifetime problem.

The result is more interesting than the headline. Also, apparently we needed another reason to be suspicious of perfectly ordinary HTTP POST requests.

Research scope, October 21, 2026, before the official release: The PHP announcement and the source-level behavior described below can be independently checked. The Docker setup, request timing, session-progress observations, and mitigation comparison are from my lab write-up. My PoC demonstrates the upload-progress path and visible session updates; it does not independently prove a write into freed memory or achieve code execution . The broader RCE chain and the precise mapping of the two CVEs remain unconfirmed until the vendor’s fixes and advisories are public. I have not presented someone else’s exploit as my own.

Research scope, October 21, 2026, before the official release: The PHP announcement and the source-level behavior described below can be independently checked. The Docker setup, request timing, session-progress observations, and mitigation comparison are from my lab write-up. My PoC demonstrates the upload-progress path and visible session updates; it does not independently prove a write into freed memory or achieve code execution . The broader RCE chain and the precise mapping of the two CVEs remain unconfirmed until the vendor’s fixes and advisories are public. I have not presented someone else’s exploit as my own.

# What we know CVE-2026-109618 and CVE-2026-109619

The PHP security team has confirmed the severity and the planned release time. It has not yet provided a final affected-version matrix or patch-level root cause. Public technical reporting describes a potential use-after-free in PHP’s upload handling, but the two CVEs should not be treated as proven identical bugs solely because an early write-up groups them together.

There is already a version-range problem in some of the early coverage. The upload-progress feature dates to PHP 5.4 , but that does not prove that every release from 5.4 onward contains either defect. There was also no generally released PHP 6. I’d rather publish a correct affected-version table after the advisory than invent one now.

The currently listed upstream versions before the security release are PHP 8.5.11, 8.4.26, 8.3.35, and 8.2.34. Do not mistake these for confirmed fixed releases. Distribution packages can also carry security backports without changing their upstream-looking version numbers.

# Why a PHP endpoint can process an upload it never asked for

The first wrong assumption is that a file-upload vulnerability needs an application feature called /upload.php .

When a PHP-handled endpoint receives multipart/form-data , PHP’s native RFC1867 machinery parses the request body. It processes fields and file parts and issues callbacks before ordinary PHP script execution begins. The application does not need to call move_uploaded_file() , inspect $_FILES , or even have a visible upload form for multipart parsing to matter.

A simplified request path looks like this:

The PHP manual says upload-progress information is written to the session before scripts execute . The same documentation explains why setting a custom session handler inside the application does not affect this early processing: application code has not run yet.

This is why checking upload permissions in a controller is not enough to protect vulnerable code in PHP’s request parser. The controller gets its turn afterward.

It does not follow that every PHP page on the internet is exploitable. The request must reach the PHP handler, the necessary feature and session conditions must be present, and the reported memory-lifetime problem must actually be triggerable for that build and deployment. Parser reachability and working RCE are very different findings.

# The PHP feature hiding in plain sight

PHP has shipped built-in session upload progress since PHP 5.4. An uploading request can update a session record, while another request reads that record to display progress.

The documented default settings are:

A multipart field whose name matches session.upload_progress.name activates progress tracking. PHP joins the configured prefix to the field value to build the session key. For example, this harmless form uses the public mechanism:

The order matters: the progress field must appear before the file part to establish the tracking key in time. A usable session identifier must also be available. In the PHP 8.4.4 callback, that identifier can be found in a matching multipart session field or, according to the active session settings, through early cookie or GET parsing.

A session record can resemble this shortened example:

Two details are easy to miss:

First, session.upload_progress.cleanup = On removes the progress entry after the upload finishes. If the record is missing when the application finally runs, that does not prove the callback was never executed.

Second, a framework-level session_set_save_handler() does not change the pre-script progress handler for the current request. A test based only on the application’s own session abstraction may miss this entire path.

# Following the request into php-src

I used the PHP 8.4.4 source tag for this part. Keeping the tag fixed matters because line numbers move and code gets refactored between branches. These are the relevant files:

The key connection is php_rfc1867_callback . The multipart parser emits events, and the session extension handles upload-progress bookkeeping through php_session_rfc1867_callback() .

# The two cached pointers

In ext/session/php_session.h , the progress structure includes these members:

They point to the total upload counter and the current file’s byte counter. A zval is the Zend value representation used for PHP variables. A lookup through zend_hash_str_find() returns a pointer to a value stored inside an array’s HashTable. It does not transfer ownership of that slot to the caller.

In PHP 8.4.4, the callback populates the pointers during a MULTIPART_EVENT_FILE_START event:

Those lookups are reasonable at the instant they occur . The concern begins if the owning HashTable or its slots change after the pointers have been cached.

# The file-data event writes through those pointers

For PHP 8.4.4 specifically , the MULTIPART_EVENT_FILE_DATA handler uses Z_LVAL_P(...) assignments:

An earlier draft of my lab notes called these ZVAL_LONG writes. That was not the literal implementation in the PHP 8.4.4 tag. The important observation is the same: native code writes directly through cached zval * addresses.

A write like that is normal while the referenced slots are alive. It becomes a use-after-free only if the memory backing a slot has already been released .

# The state update and session reinitialization

The function worth reading is php_session_rfc1867_update() . It checks the byte-count threshold and, on platforms with gettimeofday() , a minimum elapsed-time threshold before performing a regular progress update.

The significant operations are:

php_session_initialize() reads session state again. In its initialization path, php_session_track_init() replaces the existing PHP session variable storage. The notable operation is:

That destructor is only the beginning of the reasoning. What actually gets freed depends on reference counts, copy-on-write separation, and which array owns the particular zval slot. It would be incorrect to say that every call to zval_ptr_dtor() frees progress->data , or that keeping progress->data alive necessarily keeps every earlier HashTable slot alive.

The potential ownership problem is this:

Here the distinction between a PHP value and an address in a HashTable matters. Increasing the refcount of an array value can keep that value alive, while another array or copy-on-write separation still affects which memory a cached slot pointer belongs to. The exact invalidation event has to be shown with runtime evidence or the eventual vendor patch. A plausible object-lifetime explanation is not the same as a confirmed crash trace.

# Why I paid attention to the order of operations

This is a useful correction to the early timeline diagrams circulating around the issue, including one in my first lab notes.

The PHP 8.4.4 file-data callback writes its counters and then calls php_session_rfc1867_update() . If the callback’s update causes an owner to be replaced or freed, the write in that same event occurred first. It is a later event, using the original cached pointers after the update, that would be the interesting stale-access point.

In other words, seeing the progress counter change once is not proof of a UAF. The order of the data events, the progress update, and the pointer access matters.

# My test environment: PHP 8.4.4, nginx, and PHP-FPM

To explore the callback behavior, I used a small Docker setup based on PHP 8.4.4 FPM on Debian Bookworm , with nginx in front of it. The lab notes use port 19268 and a static FPM pool with four workers.

The important configuration difference from a typical nginx/PHP-FPM deployment is:

This setting allows incoming request data to reach PHP-FPM while the client is still sending the body. It makes the timing of a deliberately interrupted upload visible to the PHP multipart parser, rather than having nginx collect the entire body first.

The lab inventory is:

The write-up describes SYS_PTRACE support for debugging. That is useful in a private lab, but it should not become part of a production deployment checklist.

The Docker setup and PoC script are not embedded in this article. They are published at github.com/0x4148/CVE-2026-109618-php2shell-lab . The commands below assume those files are present in the working directory, cloned from that repository.

The build and startup command from my lab is:

The service is then checked locally:

The expected environment is PHP 8.4.4 , the FPM/FastCGI SAPI, and:

A useful lab does not need to look like production. It needs to make its assumptions explicit, which is why I fixed the PHP version and the nginx buffering behavior rather than testing against whichever PHP package happened to be installed that morning.

# How the request-streaming PoC works

My Python PoC does not contain a working RCE payload. Its purpose is to make the upload-progress lifecycle visible during a request where the client controls the timing of body delivery.

It uses one multipart request delivered in two phases. The test includes a session identifier, the configured upload-progress field, and a file part. It uses a raw socket so the client can pause partway through the body rather than sending the entire request as one operation.

The behavior being studied is:

The beginning of the multipart body reaches PHP. PHP sees the form fields and then the file part, creating a progress record when the session conditions are met.

An initial data event updates the progress counters. A subsequent progress update can write session state.

The client pauses long enough to make the elapsed-time condition relevant. With nginx request buffering disabled, PHP-FPM experiences this timing at the parser rather than receiving a single fully buffered body.

Further file data is delivered. The source-level question is whether a state reinitialization invalidates any cached slot pointers before another data event uses them.

A separate lab observer can inspect the session-progress record while the upload is in flight.

For context, the PHP 8.4.4 multipart parser defines:

That is a 5,120-byte internal buffer unit , not a promise that one TCP write maps to exactly one parser event. The source has its own buffering logic and may read or emit events in ways that do not match network packet boundaries. This is why the PoC uses a request larger than one internal read and checks the actual observed outcome rather than counting packets.

The lab notes use an approximately 1.5-second pause to exceed the configured one-second minimum update interval. That makes sense as a controlled timing experiment, but it does not turn min_freq into a universal exploit requirement. The byte-based update threshold and the actual runtime path also matter.

Here is the corrected event ordering at the level that matters for the suspected stale pointer:

A two-phase network request is a useful way to study this path. It is not by itself a proof that memory was freed between the events.

# Running the local PoC

The commands from the lab write-up are:

The source lab notes also support additional timing options. I kept the examples here pointed at the private lab rather than turning this article into instructions for hitting unrelated public PHP servers.

# What the progress monitor observes

With --monitor , the lab performs a second request to check_progress.php during the upload. The observer reads the PHP session corresponding to the test request and looks for the progress key and bytes_processed value.

A representative output in my write-up is:

That result is useful. It indicates the session upload-progress machinery was activated and a progress record was readable during the request. It is consistent with the counter-update code paths identified in PHP source.

It does not establish that a later access occurred after the same backing memory had been freed. To establish that, I would need a reliable heap trace, a debugger or sanitizer finding, or a source-level proof that captures the pointer before invalidation and the subsequent access. The word Armed in the script is an internal test label, not a memory-corruption verdict.

There is another wrinkle: reading a session through a second request can affect session locking and timing. A monitoring endpoint is a useful instrument, but the observer is not completely invisible to the thing being observed. Anyone who has debugged session concurrency has probably discovered this the irritating way.

# Comparing the mitigated configuration

The Docker lab includes an alternate PHP configuration that disables the built-in progress handler:

The mitigation variant is selected through the compose override:

Then the same checks can be run against the local service:

The example output in the lab write-up is:

This is the result I would expect when the progress callback is disabled: the test no longer sees that upload-progress session entry. But Armed: NO alone can also result from a monitoring or timing mismatch, so the stronger check is to verify the effective configuration in the web SAPI .

The lab can be returned to its original configuration with:

This A/B comparison is useful because it tests whether the configured feature is active. It does not establish that disabling upload progress patches both CVEs. The PHP security update remains the required fix.

# The part the PoC does not prove: heap lifetime and reclamation

The source exposes a plausible lifetime hazard. The lab confirms that the application can enter the upload-progress path and that session counters are updated. The missing part is proving a stale memory access from this exact request sequence, followed by controlling its consequence.

# What needs to happen for a use-after-free

A cached pointer to a HashTable value starts life valid. It becomes stale only when an operation releases or replaces the memory it addresses. That can be subtle in Zend because arrays use reference counts and copy-on-write behavior.

Consider two separate things:

progress->data , a zval representing the progress array.

progress->post_bytes_processed , an address pointing into one of that array’s value slots.

The update function calls Z_TRY_ADDREF(progress->data) before making the progress structure available in the session state. That affects the lifetime of the array value. It does not mean every pointer into every array representation remains stable after decoding, separation, mutation, or cleanup.

The hypothesis in the lab notes is that session reinitialization and array replacement can free storage still referenced by those cached pointers. The trace needed to confirm this must identify the precise HashTable allocation and free, not simply observe that php_session_track_init() ran.

A clean source or debugger investigation would record the slot addresses at FILE_START , track the owning HashTable and arData allocation, watch any SEPARATE_ARRAY() or destructor path, and stop at the write in FILE_DATA . Without those observations, calling the UAF demonstrated in my lab would go further than the evidence allows.

# Why memory corruption may not cause an immediate crash

The lab notes correctly draw attention to ZendMM , PHP’s default memory allocator. Freed blocks can remain mapped and available for reuse, so writing through a stale pointer does not have to produce an immediate segmentation fault. Memory corruption is not obliged to announce itself politely.

The notes a typical 320-byte allocation class for relevant array storage under the studied build. That is a useful allocator hypothesis, but the precise bin used by a particular HashTable or zend_string must be measured on that build. Size classes depend on the object allocation size and allocator behavior, not the name of a PHP structure.

This leaves at least four separate questions:

Did the exact allocation containing the cached zval slot get freed?

Was that block still mapped, and did the access actually use the stale address?

Did the allocator reuse the block for another object?

Could a controlled change to that object produce a meaningful effect beyond a crash?

The first two establish the actual lifetime bug. The last two are part of exploitability analysis. They should not be collapsed into one line in a PoC screenshot.

# From a stale counter to reported code execution

Public technical material describes a path from the memory-lifetime issue toward forged Zend callable-related structures, with zend_internal_function , zend_fcall_info , and zend_call_function() appearing in the discussion. That is substantially more work than causing a counter to update during multipart parsing.

An RCE chain would depend on reliably reclaiming freed memory, interpreting the replacement bytes as suitable internal state, and surviving differences in PHP branch, build options, allocator behavior, and distribution patches. A layout that seems stable under one Docker image may fail under another image built from the same upstream version.

My lab did not attempt controlled heap reclamation, a forged callable, or execution redirection. I have therefore not borrowed build-specific offsets or a working heap-spray recipe and relabeled them as an original PoC. If I eventually demonstrate that stage, it deserves its own evidence, debugger output, and reproducible methodology.

# What USE_ZEND_ALLOC=0 tells us

The write-up also notes the difference between the normal Zend allocator and a run with:

This setting changes PHP’s allocation behavior and can be useful during instrumentation or debugging. A memory layout dependent on ZendMM’s size classes will not behave the same way when PHP uses the system allocator. That can affect whether a particular heap-reuse technique works.

It is not a vendor-approved mitigation , and changing allocators in production to frustrate an unverified exploit recipe would be a risky substitute for patching. Performance, stability, and diagnostic behavior can change along with the memory layout.

# Results and limits of my lab

That is enough to make the source review worthwhile. It is not enough to claim I reproduced both critical RCEs.

# A deployment detail that changes the experiment: FastCGI buffering

The nginx setting deserves its own section because it can completely change what a client-side timing test looks like.

With request buffering enabled, nginx can collect the request body before sending it to PHP-FPM. A pause between two client writes may then be invisible to PHP: the FPM worker receives an already-buffered body instead of sitting between two incoming chunks.

For my controlled test, I used:

That allowed the timing gap to propagate toward PHP-FPM and made the relationship between request delivery and progress updates easier to investigate.

The important distinction is that buffering changes timing; it does not remove the multipart parser . It should not be sold as a fix for PHP2Shell. A different frontend, a direct FastCGI path, an alternate request pattern, or a different underlying CVE can invalidate the assumption that buffering stops the bug.

The same goes for session.upload_progress.cleanup = On . Cleanup happens in a different part of the lifecycle. It does not automatically protect code that may access invalid memory during an upload.

# Checking the PHP installation that actually handles web traffic

I also wanted the article to be useful to someone managing a real server, especially because checking the wrong PHP runtime is an easy mistake.

# Apache: mod_php or PHP-FPM?

On Apache, start with:

The last two commands inspect the CLI interpreter. They do not necessarily tell you what handles incoming web requests. Apache may load mod_php , forward requests to PHP-FPM, or route different virtual hosts through different handlers and PHP versions.

If you need to inspect the web SAPI, temporarily run a small diagnostic script through the same virtual host and PHP handler used by the application:

Restrict that endpoint to a trusted administrator, use it briefly, and delete it after checking. Publishing phpinfo() or equivalent diagnostics for the whole internet is usually an unnecessary gift to whoever is scanning you.

A value of On confirms that upload progress is enabled in that SAPI. It is not proof that a particular CVE is exploitable. Off does not guarantee that a different multipart-parser issue is gone.

# Disabling upload progress as a temporary measure

For applications that do not depend on PHP’s built-in progress records, disable the feature in the effective PHP configuration:

With Apache mod_php , make sure the applicable Apache or PHP configuration is changed, and then reload or restart Apache as appropriate. With PHP-FPM, change the correct FPM configuration or pool and reload or restart that FPM service, not just Apache.

Example service commands on some Debian/Ubuntu systems:

Other distributions use httpd , different pool names, and different service units. Check the service name and configuration path before copying commands.

For PHP-FPM pools, an administrator-controlled pool setting can make the policy harder for a site configuration to override:

Verify the effective value through the web SAPI afterward. A successful reload message and a convincing-looking CLI php -i output are not sufficient.

Disabling progress tracking reduces the suspected session-upload-progress attack surface. It is not a complete mitigation for both CVEs , and PHP’s own security updates must still be applied.

# Restricting unnecessary multipart requests

A reverse proxy or WAF can reject multipart/form-data requests on routes where the application never expects multipart input, before the body reaches PHP.

That decision needs application knowledge. Some APIs legitimately use multipart without presenting a classic upload form, and a blanket block can break expected traffic. Header-only filtering is also weaker than careful request parsing when frontends and backends disagree how an HTTP message should be interpreted.

If a host never accepts file uploads, reviewing file_uploads = Off is reasonable. But do not change it across a production estate without compatibility testing, and do not claim it is an official workaround for both vulnerabilities before PHP confirms the actual paths.

# Patching after the release

Once PHP publishes the security updates, patch the interpreter actually serving the application . A patched CLI binary is irrelevant if Apache continues to run an affected FPM container or mod_php module.

If you use a distribution security backport, verify its advisory and package changelog. If you run an old, unsupported PHP branch, plan a supported runtime migration or obtain a backport from a vendor that still maintains that package.

For source-level verification, compare the vendor-confirmed affected and fixed tags, not two arbitrarily chosen release numbers:

Then follow the code that changed, locate any .phpt regression tests, and map each CVE separately. I would look for changes in pointer lookup, ownership, refcounting, state reinitialization, and cleanup order. That patch review is more valuable than trying to infer final vulnerability boundaries from an early exploit screenshot.

# What I would hunt for on an exposed PHP server

An HTTP request containing PHP_SESSION_UPLOAD_PROGRESS is not automatically malicious. It is a documented PHP field name. Similarly, multipart requests alone are ordinary web traffic.

The more useful signals combine request context with runtime behavior:

Multipart POST requests sent to routes that normally accept only JSON or URL-encoded bodies.

Unusually long or repeatedly interrupted upload requests, where timing data is actually available.

PHP-FPM worker crashes, SIGSEGV , unexpected 502/503 responses, or container restarts around suspicious requests.

Unexpected shell processes, outbound network connections, or file changes attributable to the PHP worker account.

Session-progress records from unusual sources, if the deployment logs the necessary metadata without exposing session secrets.

These are investigation leads , not validated PHP2Shell indicators of compromise. Typical Apache and nginx access logs do not include multipart field names or full bodies. A missing field in an access log therefore says very little. Check whether WAF logs, upstream request metadata, application telemetry, and process monitoring have enough context before promising reliable detection.

If there is credible evidence of exploitation, preserve logs and relevant volatile evidence before rebuilding or rotating systems. Patching the runtime does not remove persistence or recover credentials that might have been exposed earlier.

# What I still want to validate

My source review and Docker experiments narrow down an interesting lifetime question. They do not complete the exploit analysis.

What remains is specific: identify the allocation returned by each zend_hash_str_find() , trace the reference counts and any separation of its owning array, confirm the actual free, and record the access to the same address. If that is reproduced under a debugger or memory sanitizer, the task is understanding the consequences without assuming that every stale write turns into RCE.

The final vendor patch will also settle whether the two CVEs correspond to the two cached counters, to separate parser issues, or to something else. I have not treated the proposed one-CVE-per-pointer mapping from early research material as official fact.

The real lesson here is where the trust boundary sits. Developers often think security at the controller or middleware layer. PHP’s multipart parser sits earlier in the request lifecycle. That is exactly why native runtime vulnerabilities can be so unpleasant: your application can be waiting patiently to reject the request while the interesting code has already run.

I’ll update the CVE mapping, affected releases, and patch diff once the official security fixes are published. Until then, know which PHP runtime is accepting your requests, reduce unnecessary upload-progress exposure, and be ready to patch.

And if your deployment has six different PHP versions installed, finding the one actually serving traffic is probably the first exploit you should fix.

PHP Security Team: Upcoming Critical PHP Security Release , October 20, 2026.

PHP Manual: Session Upload Progress .

PHP Manual: Session Runtime Configuration .

PHP Manual: POST Method Uploads .

PHP 8.4.4 source: main/rfc1867.c .

PHP 8.4.4 source: ext/session/session.c .

PHP 8.4.4 source: ext/session/php_session.h .

PHP Wiki: Session Upload Progress RFC .

CERT-EU: Critical Vulnerabilities in PHP, Advisory 2026-018 .

watchTowr: CVE-2026-109618 FAQ .

PHP-UP-2026: Third-party technical report , cited for its separately reported exploitation claims, not as evidence that I reproduced RCE.

GMO Flatt Security: PHP2Shell response guidance , October 21, 2026.

CVE-2026-109618-php2shell-lab : my Docker/nginx/PHP-FPM lab and PoC script used throughout this analysis.

Till time, Ahmed

Extracted Entities

Attack Types (1)

Companies (1)

CWE Weaknesses (1)

Domains (1)

Platforms (1)

Tools (2)