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.
What we know about 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.
| Item | Status at the time of this analysis |
|---|---|
| Vulnerability names | CVE-2026-109618 and CVE-2026-109619 |
| Severity | CVSS v4.0 9.2 for each, confirmed by PHP |
| Reported outcome | Remote code execution |
| Suspected area | main/rfc1867.c and session upload-progress handling in ext/session/session.c |
| Memory-safety concern | Cached pointers to Zend values surviving changes to their owning storage |
| My test build | PHP 8.4.4, PHP-FPM, Debian Bookworm, nginx |
| What my PoC observed | The progress callback path, persisted progress counters, and configuration-dependent request behavior |
| What my PoC did not demonstrate | Confirmed heap UAF instrumentation, a controlled crash, or RCE |
| Official fixed versions | Awaiting PHP’s security release at publication time |
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:
Browser / HTTP client
|
| POST multipart/form-data
v
Apache or nginx / reverse proxy
|
| Request passed to PHP
v
PHP SAPI: mod_php or PHP-FPM
|
+--> Native request-body parsing
| |
| +--> RFC1867 multipart parser
| |
| +--> FORM_DATA events
| +--> FILE_START events
| +--> FILE_DATA events
| +--> FILE_END events
| |
| +--> Session upload-progress callback
| when enabled and activated
v
Execute index.php
|
v
Framework / routing / middleware / controllersCode language: plaintext (plaintext)
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:
session.upload_progress.enabled = On
session.upload_progress.cleanup = On
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.freq = "1%"
session.upload_progress.min_freq = "1"Code language: TOML, also INI (ini)
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:
<form method="POST" action="/index.php" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="job42">
<input type="file" name="attachment">
<button type="submit">Upload</button>
</form>Code language: HTML, XML (xml)
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:
$_SESSION['upload_progress_job42'] = [
'start_time' => 1234567890,
'content_length' => 57343257,
'bytes_processed' => 453489,
'done' => false,
'files' => [
0 => [
'field_name' => 'attachment',
'name' => 'example.bin',
'tmp_name' => '/tmp/phpXXXXXX',
'error' => 0,
'done' => false,
'bytes_processed' => 453489,
],
],
];Code language: PHP (php)
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:
main/rfc1867.c Multipart body parsing and event dispatch
ext/session/session.c Session upload-progress callback and updates
ext/session/php_session.h The upload-progress state structureCode language: plaintext (plaintext)
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:
zval *post_bytes_processed;
zval *current_file_bytes_processed;Code language: Arduino (arduino)
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:
progress->post_bytes_processed = zend_hash_str_find(
Z_ARRVAL(progress->data), "bytes_processed",
sizeof("bytes_processed") - 1
);
progress->current_file_bytes_processed = zend_hash_str_find(
Z_ARRVAL(progress->current_file), "bytes_processed",
sizeof("bytes_processed") - 1
);Code language: Arduino (arduino)
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:
Z_LVAL_P(progress->current_file_bytes_processed) =
data->offset + data->length;
Z_LVAL_P(progress->post_bytes_processed) =
data->post_bytes_processed;
php_session_rfc1867_update(progress, 0);Code language: Arduino (arduino)
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 next 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:
/* Selected PHP 8.4.4 operations, with unrelated code omitted. */
php_session_initialize();
PS(session_status) = php_session_active;
/* Within the active-session branch: */
Z_TRY_ADDREF(progress->data);
zend_hash_update(Z_ARRVAL_P(sess_var), progress->key.s, &progress->data);
php_session_flush(1);Code language: Arduino (arduino)
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:
zval_ptr_dtor(&PS(http_session_vars));Code language: Arduino (arduino)
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:
FILE_START
|
+--> Create progress arrays
+--> Look up bytes_processed slots
+--> Cache raw zval* addresses
|
v
FILE_DATA
|
+--> Write counters through cached addresses
+--> Periodic session update
|
+--> Session reinitialization
+--> Replace/decode mutable session state
+--> Refcount changes / possible COW separation
+--> Storage previously referenced by a raw pointer
may no longer be valid
|
v
Another FILE_DATA
|
+--> Reuse cached pointer without another lookup
+--> Potential stale access if its owner was freedCode language: plaintext (plaintext)
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 next 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:
fastcgi_request_buffering off;Code language: Nginx (nginx)
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:
| File | What it controls |
|---|---|
docker-compose.yml |
Primary PHP-FPM and nginx lab setup, published locally on port 19268 |
docker-compose.mitigated.yml |
Compose override used for the mitigated configuration |
conf/php.ini |
Upload progress enabled for the test |
conf/php-mitigated.ini |
Upload progress disabled for the comparison |
conf/nginx.conf |
nginx FastCGI routing and request-buffering behavior |
conf/www.conf |
FPM pool settings, including worker count and listener |
scripts/verify.sh |
Lab environment checks |
scripts/php2shell_poc.py |
Python streaming-request test and optional progress monitoring |
check_progress.php |
Lab-only endpoint that observes session progress |
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.
Starting the lab
The build and startup command from my lab is:
docker compose up -d --buildCode language: Bash (bash)
The service is then checked locally:
curl http://127.0.0.1:19268/index.php
bash scripts/verify.sh
python3 scripts/php2shell_poc.py --verifyCode language: Bash (bash)
The expected environment is PHP 8.4.4, the FPM/FastCGI SAPI, and:
session.upload_progress.enabled = OnCode language: TOML, also INI (ini)
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:
#define FILLUNIT (1024 * 5)Code language: Arduino (arduino)
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:
Client nginx PHP-FPM / session callback
| | |
|-- Initial body ----->|-- forwarded progressively ->| FORM_DATA / FILE_START
| | | cached zval* created
| | | FILE_DATA updates counters
| | |
| Client pause / request remains open |
| | |
|-- More data -------->|-- forwarded progressively ->| FILE_DATA writes counters
| | | update() may run afterward
| | | session state can be rebuilt
| | |
|-- Later file data -->|-- forwarded progressively ->| next FILE_DATA reuses cached zval*
| | | potential stale access
| | |
|-- End boundary ----->|---------------------------->| FILE_END / cleanupCode language: plaintext (plaintext)
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:
# Run against the local lab
python3 scripts/php2shell_poc.py
# Include progress monitoring and verbose output
python3 scripts/php2shell_poc.py --monitor --verbose
# Repeat the experiment locally
python3 scripts/php2shell_poc.py --monitor --rounds 5Code language: Bash (bash)
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:
Round 1/1
SID: ph2sh_a3f8e1b204c97d52
Status: 200 OK
Armed: YES - bytes_processed = 5376Code language: plaintext (plaintext)
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:
session.upload_progress.enabled = OffCode language: TOML, also INI (ini)
The mitigation variant is selected through the compose override:
docker compose -f docker-compose.yml \
-f docker-compose.mitigated.yml up -dCode language: Bash (bash)
Then the same checks can be run against the local service:
python3 scripts/php2shell_poc.py --verify --monitorCode language: Bash (bash)
The example output in the lab write-up is:
Round 1/1
SID: ph2sh_7c2d09f5b3e618a4
Status: 200 OK
Armed: NO - session emptyCode language: plaintext (plaintext)
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:
docker compose up -dCode language: Bash (bash)
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, azvalrepresenting 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 next 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 discuss 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
zvalslot get freed? - Was that block still mapped, and did the next 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:
USE_ZEND_ALLOC=0Code language: Bash (bash)
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
| Investigation point | What the current evidence supports |
|---|---|
| PHP 8.4.4 upload-progress callback is reachable | Yes, through documented behavior and the lab’s progress observations |
post_bytes_processed / current_file_bytes_processed slots are cached |
Yes, present in PHP 8.4.4 source |
FILE_DATA writes through those cached slots |
Yes, present in PHP 8.4.4 source |
| Session updates can reinitialize session state | Yes, present in PHP 8.4.4 source |
| Freed owner and subsequent stale write | Source-derived hypothesis; no original heap trace included with this write-up |
| PHP-FPM crash from the ungroomed PoC | Not observed in the supplied lab notes |
| Controlled crash with memory reclamation | Not attempted |
| Remote code execution in this lab | Not reproduced |
| Both CVEs share one confirmed root cause | Not established by the official pre-release announcement |
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.
By default, nginx commonly uses:
fastcgi_request_buffering on;Code language: Nginx (nginx)
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:
fastcgi_request_buffering off;Code language: Nginx (nginx)
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:
apachectl -M 2>/dev/null | grep -Ei 'php|proxy_fcgi|mpm'
ps -eo pid,comm,args | grep -E '[p]hp-fpm|[a]pache2|[h]ttpd'
php -v
php --iniCode language: Bash (bash)
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:
<?php
header('Content-Type: text/plain');
printf("PHP version: %s\n", PHP_VERSION);
printf("PHP SAPI: %s\n", PHP_SAPI);
printf("Loaded php.ini: %s\n", php_ini_loaded_file() ?: '(none)');
printf("Scanned .ini files: %s\n", php_ini_scanned_files() ?: '(none)');
foreach ([
'session.upload_progress.enabled',
'session.upload_progress.cleanup',
'session.upload_progress.name',
'session.upload_progress.freq',
'session.upload_progress.min_freq',
'file_uploads',
'post_max_size',
'upload_max_filesize',
] as $key) {
printf("%s = %s\n", $key, var_export(ini_get($key), true));
}Code language: PHP (php)
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:
session.upload_progress.enabled = OffCode language: TOML, also INI (ini)
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:
sudo apachectl configtest
sudo systemctl reload apache2
# Only when this installed FPM version is actually the web handler:
sudo systemctl reload php8.4-fpmCode language: Bash (bash)
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:
php_admin_flag[session.upload_progress.enabled] = offCode language: TOML, also INI (ini)
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 about 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:
# Use the official tags after PHP announces the fixed releases.
git fetch --tags origin
git diff <affected-tag>..<fixed-tag> -- \
main/rfc1867.c \
ext/session/session.c \
ext/session/php_session.h
git log --oneline <affected-tag>..<fixed-tag> -- \
main/rfc1867.c \
ext/session/session.c \
ext/session/php_session.hCode language: Bash (bash)
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 next access to the same address. If that is reproduced under a debugger or memory sanitizer, the next 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 about 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.
References
- 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 next time,
Ahmed