PHP2Shell: CVE-2026-109618 and CVE-2026-109619 Explained
127.0.0.1, do not expose it to the internet, and
only run it against systems you own or have written permission to test.
Github Repo Here
id
and wrote uid=33(www-data) to a file. The patched 8.4.27 image
blocked the same chain. Docker did not get in the way, and I did not disable
ASLR. The app is not the vulnerability.
The majority of web exploits need something from the application. A missing auth check,
an upload that trusts the filename, a template that evaluates user input, a
deserialization sink. This is different, the vulnerable code runs while PHP
is building the request, before index.php is compiled or
executed. Your router has not run. Your auth middleware has not run. Your
WAF rules for /admin have not run. PHP is still parsing
multipart/form-data.
That is what the lab in part 3 demonstrates.
A hello-world application is enough to demonstrate it. I put one line in
src/index.php:
echo "hello from php2shell lab\n";. The exploit never needs it
to do anything useful.
A reverse proxy or an edge WAF can still block the request before it reaches PHP, and that is worth doing while you patch. But the application itself cannot defend this path. By the time the app is involved, the interesting part is already over.
The 30-Second Version
| CVE-2026-109618 |
RCE via OOM corruption. PHP uses longjmp() for
out-of-memory errors. Some code paths publish request state before a
fallible allocation. If the allocation fails, request shutdown can
read or branch on memory that was never fully initialized.
|
|---|---|
| CVE-2026-109619 | RCE via stale global state. PHP worker globals can still point into a previous request's heap after that heap has been freed. A later request on the same worker can read, free, or branch on the stale value before normal initialization resets it. |
| Affected releases | PHP before 8.2.35, before 8.3.36, before 8.4.27, and before 8.5.12. Older branches are affected too. PHP provided minimal source backports for 7.4, 8.0, and 8.1. PHP 7.3 and older have no upstream fix, so the answer there is upgrade. |
| Fixed releases | 8.2.35, 8.3.36, 8.4.27, 8.5.12. Update, rebuild container images, and restart every PHP worker. |
| Authentication required | No. The mod_php path runs before application code. Application login, API keys, and admin checks do not get a vote. |
| Highest-risk SAPI |
Apache mod_php. PHP considers it the highest risk
because an attack can succeed before any application code runs.
PHP-FPM, php-cgi, FrankenPHP, and LiteSpeed are in
scope too, but the exact chain differs by SAPI.
|
| Exploited in the wild | Yes, per watchTowr's honeypot network on October 21. AWS's MadPot recorded an attempt on October 17, four days before public disclosure. Public PoCs appeared within hours, some before the patch. |
| Severity | CVSS v4.0 9.2 for both, rated by PHP. NVD also lists a v3.1 score of 9.8 for 109618 and 8.1 for 109619. The v4 score reflects high attack complexity and present attack requirements. The practical impact is still critical. |
The Bug Is Older Than the Exploit
The pattern here is not new. It has been sitting in PHP for a long time, and people kept walking past it for a very reasonable reason: proving that a half-initialized hash table can become code execution is hard.
In 2004, Stefan Esser found the first PHP memory-limit remote vulnerability. He did not release a public exploit. Commercial exploit vendors built private ones. For years, the public story was mostly folklore and fake exploits passed around IRC. The primitive was real, but it was not something a random researcher could reproduce from a blog post.
In 2020, someone reported PHP bug #78876. Long variables in
multipart/form-data could cause an out-of-memory condition and
leave temporary files behind. It looked like a cleanup annoyance, not a
remote code execution bug. In 2024, the OSTIF and Quarkslab audit of PHP
found a related OOM issue. They could not prove exploitation, so it was not
treated as an urgent RCE. That is the important part of the story. The bug
was findable. The exploit was not practical yet.
In 2026, two research efforts converged. Calif's Ryan Austin joined OpenAI's Patch the Planet initiative and started auditing PHP with an LLM companion, guided by the 2004 and 2020 history. Wiz Research ran its own AI-assisted audit and found four variants, then demonstrated RCE against WordPress, Drupal, and Joomla. The PHP Foundation and the PHP security team coordinated the fix. Calif reported the first bug on August 11 and the second on August 21. Wiz reported its set on August 24. PHP acknowledged the reports on September 21 and shipped the patch on October 21.
The disclosure did not go to plan. Details leaked on social media on October 20, before the planned date. PHP moved the release to October 21 at 14:00 UTC. AWS had already seen an exploitation attempt on October 17, from an anonymized IP, four days before the patch existed. watchTowr saw exploitation on its honeypots the next day. Public PoCs showed up within hours. This is the part defenders should remember: the public disclosure date is not the first day of exploitation.
The Chain in One Picture
The public PoC splits the work into four requests. Two create the dangling state; two consume it. Here is the chain at a high level:
| Stage | What it sends | What it gets |
|---|---|---|
| A1 |
A multipart body with hundreds of nested empty file fields, filler
values, and a PHP_SESSION_UPLOAD_PROGRESS field.
|
OOM during session-cookie cleanup. The worker survives with a
dangling PS(id) and PS(mod_data).
|
| B1 |
A second multipart body that reclaims the stale ID block with a fake
zend_string header.
|
About 800 KB of worker memory in a Set-Cookie, plus
libphp and heap addresses. ASLR is now a solved problem.
|
| A2 | Another nested multipart body that exhausts memory at a controlled point. |
A dangling PS(http_session_vars) reference in the same
worker.
|
| B2 |
A spray of fake zend_reference values and a fake
zend_array whose pDestructor is
zif_system.
|
system("id > /out/pwned") when the upload-progress
cleanup callback destroys the fake array. The proof is written as
www-data.
|
The worker can crash after B2. In the lab it usually does, with
AH00052: child pid ... exit signal Segmentation fault (11). The
command has already run by then. A crash is common, but it is not required,
and it is not a reliable success signal.
What This Shows and What It Doesn’t
Pre-auth command execution
An unauthenticated HTTP request ran a command as the Apache worker. No login, no app bug, no plugin.
Memory disclosure
The leak stage returned about 800 KB of worker memory in a cookie. That can include tokens, request data, and pointers.
Secrets and internal access
As www-data, an attacker can read env vars, app config, database credentials, and reach internal services.
Container compromise
The container is compromised, not the host. That is still a full compromise of the app's trust boundary.
Denial of service
The OOM path and the post-exploit crash can take workers out. A successful exploit can also return HTTP 200 and keep serving.
Patch verification
The same chain fails on 8.4.27. The differential test is the useful part for defenders.
Securing PHP2Shell
Update to 8.2.35, 8.3.36, 8.4.27, or 8.5.12. Rebuild container images, redeploy, and restart every worker. A package update that leaves the old FPM master or Apache children running is not a fix. Check the runtime that actually serves HTTP, not just the CLI binary on the host.
| Branch | Fixed release | Notes |
|---|---|---|
| 8.5 | 8.5.12 | Supported branch. Also covers the 8.5 fatal backtrace path. |
| 8.4 | 8.4.27 | Supported branch. Used as the patched lab target. |
| 8.3 | 8.3.36 | Supported branch. |
| 8.2 | 8.2.35 | Security support ends December 31, 2026. Plan the upgrade. |
| 7.4, 8.0, 8.1 | Vendor or source backport | No normal upstream release. Use a distribution or LTS vendor, or PHP's minimal backports. |
| 7.3 and older | Upgrade | No upstream fix. This is the uncomfortable answer. |
PHP is explicit that no configuration change fully mitigates these. The measures below reduce specific paths. They are a bridge, not a destination.
| Control | What it helps with | What it does not do |
|---|---|---|
memory_limit=-1, enforced administratively |
Removes the predictable PHP-limit OOM used by the known exploit. | An underlying allocation failure can still hit the vulnerable path. It also increases the risk of host memory exhaustion. Pair it with frontend limits and a container memory boundary. |
session.upload_progress.enabled=0 |
Blocks the confirmed session upload-progress paths. | Does not cover every stale-state variant. |
fatal_error_backtraces=0 on 8.5 |
Blocks the confirmed fatal-backtrace path. | Does not cover session or unserializer state. |
Low post_max_size, bounded decoded body size |
Makes the OOM delivery harder, especially for lengthless or chunked multipart. | Does not fix the engine bug. A large enough request can still allocate. |
| Block multipart if the app does not use it | Removes the main pre-auth delivery path for that app. | Apps that need uploads cannot use this. |
pm.max_requests=1 |
Breaks the cross-request stale-state chains by recycling workers. | Performance cost. Does not fix the OOM corruption path. |
| WAF or reverse-proxy rules | Can block oversized multipart, chunked bodies, and known field names before PHP sees them. |
A rule that truncates the body or only checks
Content-Length may miss the attack. Test it against
the real PoC.
|
disable_functions |
Might block system() in one chain. |
Not a fix. Attackers can target other functions or primitives, and it often breaks apps. |
The exploit can return HTTP 200 and leave the worker alive. It can also crash the worker after the command runs. Do not use "no crash" as proof that nothing happened. Correlate the request, the PHP error log, and the process tree.
This is what the final lab left in docker compose logs vuln:
[php:error] PHP Fatal error: Allowed memory size of 134217728 bytes exhausted
(tried to allocate 4096 bytes) in Unknown on line 0
"POST /index.php?q0000000=v&q0000001=v..." 500 211
[php:warn] PHP Request Startup: Session ID is too long or contains illegal
characters. Only the A-Z, a-z, 0-9, "-", and "," characters are allowed
in Unknown on line 0
"POST /index.php" 200 1680406
[core:notice] AH00052: child pid 17 exit signal Segmentation fault (11)
The important details:
Unknown on line 0means the OOM happened during request startup, not in application code.- The 500 responses are the OOM stages. The 200 with a 1.6 MB body is the leak stage.
- The session warnings are PHP complaining about the stale ID before the script runs.
- The segfault can come after the command already ran.
Queries to start with:
# OOM during request startup
grep -R "Allowed memory size.*exhausted.*Unknown on line 0" \
/var/log/php* /var/log/apache2 /var/log/nginx 2>/dev/null
# Apache worker segfaults
grep -R "AH00052.*Segmentation fault" /var/log/apache2 2>/dev/null
# Shell or interpreter children of a web worker
ps -eo pid,ppid,user,comm,args --forest | grep -E "apache2|php-fpm|www-data"
pstree -ap "$(pgrep -o apache2)" 2>/dev/null
# New files under the web root
find /var/www -type f -mtime -14 -printf '%TY-%Tm-%Td %TH:%TM %u %p\n' \
2>/dev/null | sort
# Container drift
docker diff <container>
docker top <container>
docker logs --since 2026-10-17T00:00:00 <container>
On the proxy or WAF side, look for oversized multipart POSTs, hundreds
of nested field names, PHP_SESSION_UPLOAD_PROGRESS in the
body, PHPSESSID as a form field, chunked multipart without a
usable length, and response headers with a huge Set-Cookie.
Log the header size, not just the status code.
Investigate from October 17, not October 21. AWS saw an attempt before disclosure. If a host was internet-facing, running an affected PHP build, and unpatched during that window, treat it as exposed until you can prove otherwise.
103.216.220.92, 31.223.184.166,
37.19.200.5, 134.209.159.64,
103.114.163.188, 95.217.98.37.
Update Risk: The Part with side effects
The patch is not a one-line change. The main fix commit touches a large part of the engine because it changes how OOM unwinds and how request-owned state is reset. That is the right fix, but it means you should not treat the update as a silent package bump.
| Area | What can break | How to test it |
|---|---|---|
| Binary extensions and loaders | ionCube, SourceGuardian, Xdebug, Imagick, Redis, and other compiled extensions can lag a PHP release or check the exact version. |
Load every extension with php -m, run the app smoke
test, and check the extension vendor's support matrix.
|
| CMS and plugins | Deprecations, dynamic properties, session handlers, upload behavior, and custom error handlers can surface after the update. |
Run the plugin matrix in staging with E_ALL. Test
login, uploads, checkout, cron, and queue workers.
|
| OOM behavior | Code that relied on catching a fatal OOM, or on shutdown functions running after one, may behave differently. |
Test long-running imports, image processing, report generation, and
anything that intentionally pushes memory_limit.
|
| Session and serialization |
Custom session handlers, unserialize() edge cases, and
upload progress integrations can be sensitive to state reset
changes.
|
Test session login, cart flows, file uploads, and any code that decodes stored data. |
| Container images |
Rebuilding from a new base can change the INI, extensions, or
Apache config. Do not patch a running container with
docker exec.
|
Build a new image, pin the new digest, diff php -i, and
run the app tests before the rollout.
|
| Vendor backports | The version string may not change even when the vendor backported the fix. A version-only scanner will miss it or report a false positive. | Check the package changelog, build date, and vendor advisory. Test the runtime that actually serves HTTP. |
If you use the Remi RPM repository, PHP warned that it would not have updates on release day and urged users to switch to distribution packages or build from source. That is not a reason to wait. It is a reason to know where your PHP package actually comes from.
Rollback is a valid emergency lever, but be honest about what it means: rolling back reintroduces the CVE. Keep the previous image digest, roll back only with compensating controls, and put a hard deadline on the second attempt.
Were We Already Compromised?
Patching closes the door. It does not answer whether someone walked through it. If the host was exposed during the window, do the boring checks.
session.upload_progress on? Was the
PHP build unpatched between October 17 and the update?
Unknown on line 0, huge multipart POSTs, chunked bodies,
session startup warnings, 500s followed by 200s with large responses,
and AH00052 segfaults.
curl,
wget, nc, or interpreter spawned by
apache2 or php-fpm is a serious signal. Build
a baseline for apps that legitimately shell out.
.htaccess, cron entries, systemd units, SSH keys, SUID
binaries, or files in /tmp that look executable. Compare
against your image or package manifest.
docker diff,
docker top, new packages, modified binaries, unexpected
volumes, and changes to the entrypoint. A compromised container can hide
in a running pod for a long time.
If you find evidence, treat it as an incident, not a patching task. Preserve logs and disk state, isolate the host or container, and follow your notification process. The exploit gives an attacker a web user's view of your app and its secrets. That is enough to matter.
Final Thoughts
The bug is older than the exploit. The exploit is narrower than the vulnerability. The lesson is not narrow at all: a memory-safety bug in the runtime sits below every framework, plugin, and auth check you have. You can write perfect application code and still be owned by the thing that parses the request.
The other lesson is about time. The patch window for a critical internet-facing bug is hours, not weeks. AWS saw an attempt before the advisory. Public PoCs appeared within hours of disclosure. AI did not create the bug, but it made the exploit cheaper to build. That genie is not going back in the bottle.
Patch PHP. Rebuild the images. Restart the workers. Then go read the logs from October 17. The lab in part 3 is here if you want to see what the traffic looks like before you decide whether your detection rules are real.
Credits and Sources
| What | Who | Where |
|---|---|---|
| Vulnerability research and disclosure | Calif (Ryan Austin), Wiz Research, OpenAI Patch the Planet, and the PHP Foundation / PHP security team |
calif.io/php2shell wiz.io/blog/php2shell-php-rce |
| Public PoC used by the lab | 9bie |
github.com/9bie/php2shell-bypass-aslr-exp commit 17e98ee81025cccfd94b4f9c9a17bf269c65ff75
|
| Detection rules reference | Qi Deng (ikow), MIT | github.com/ikow/php2shell-poc |
| PHP advisory and patch | PHP project / PHP security team |
PHP October 2026 security release GHSA-w8hw-w9fx-j79w GHSA-6wmf-4rv5-p3rp |
| Technical analysis and detection guidance | Datadog Security Labs, Rapid7, AWS, watchTowr, Unit 42, NVD, AhnLab ASEC |
Datadog Security Labs Rapid7 AWS MadPot watchTowr FAQ Unit 42 NVD 109618 NVD 109619 AhnLab ASEC |