This series includes a working exploit lab. Part 3 runs a public PoC that executes a command inside a vulnerable PHP container. Keep it bound to 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

CVE-2026-109618 · CVE-2026-109619 · CWE-416 / CWE-755 · PHP core

This is part 1 of 3: Covers the story, impact, and patch. Part 2 explains the exploit internals Part 3 builds and runs the Docker lab.
PHP2Shell is not a WordPress bug, a Laravel bug, or a plugin bug. It is two memory-safety bugs in PHP itself. One is an out-of-memory handling problem. The other is stale request-owned state that survives into the next request on the same worker. Put them together, and an unauthenticated HTTP request can run a command as the web user before your application code runs. I reproduced it in Docker against a hello-world page. The page prints one line and has no auth, no uploads, and no session code. The exploit still ran 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.
Two IDs, one messy family The two CVEs are umbrella IDs. Different researchers map the sub-bugs a little differently. The public chain I used combines the OOM corruption primitive from 109618 with the cross-request stale session state from 109619. Do not expect a clean one-CVE trigger.

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.

Why AI changed the timeline AI did not find the bug by itself, and the researchers did not just type "find a PHP RCE" into a chat box(no A.I bros were involved in the process). They knew the target's history. They knew a 2004 memory-limit bug existed, and they knew the 2024 audit had found something similar. They pointed the models at that pattern. The models compressed the iteration loop: read the allocator, trace the shutdown path, propose a heap layout, test it, adjust it. What used to take a world-class team months took a much smaller team days. That is the real change.

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.

Code execution as the web user inside the target container, plus a memory disclosure primitive that can leak data outside your request. From there, the usual post-exploitation path applies: read secrets, talk to internal services, look for cloud metadata, and persist if the worker survives.
This is what you did not get: A container escape. A host root shell. A universal exploit that works on every PHP build. A claim that FPM, ARM64, or every CMS was tested here. The lab is one specific build, chosen because it is the cleanest demonstration of the mod_php path. The vulnerability is broader than the lab. The exploit is narrower than the vulnerability.

Securing PHP2Shell

1
Patch PHP. This is the only complete fix*.

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.58.5.12Supported branch. Also covers the 8.5 fatal backtrace path.
8.48.4.27Supported branch. Used as the patched lab target.
8.38.3.36Supported branch.
8.28.2.35Security support ends December 31, 2026. Plan the upgrade.
7.4, 8.0, 8.1Vendor or source backportNo normal upstream release. Use a distribution or LTS vendor, or PHP's minimal backports.
7.3 and olderUpgradeNo upstream fix. This is the uncomfortable answer.
2
If you cannot patch today, reduce the paths you expose.

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.
Do not treat any of these as "done" The advisory says updating is the only complete mitigation. If you use a temporary control, put it on a calendar with an owner and an expiry date.
3
Detect, respond, and assume the window was real.

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 0 means 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.

AWS published these IPs with the caveat that they are shared anonymization infrastructure, not exclusive attribution. Use them for triage, not as a blocklist you trust forever: 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.

Scope the exposure. Which PHP version and SAPI served HTTP? Was it mod_php, FPM, CGI, FrankenPHP, or LiteSpeed? Was the host internet-facing? Was session.upload_progress on? Was the PHP build unpatched between October 17 and the update?
Read the logs from October 17 onward. Look for OOM at Unknown on line 0, huge multipart POSTs, chunked bodies, session startup warnings, 500s followed by 200s with large responses, and AH00052 segfaults.
Check the process tree. A shell, curl, wget, nc, or interpreter spawned by apache2 or php-fpm is a serious signal. Build a baseline for apps that legitimately shell out.
Check the filesystem. New PHP files, modified .htaccess, cron entries, systemd units, SSH keys, SUID binaries, or files in /tmp that look executable. Compare against your image or package manifest.
Check the network. Unexpected outbound connections from the web worker, new DNS lookups, cloud metadata access, or traffic to unfamiliar hosts. Egress filtering is your friend here.
Check the container. 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.
Rotate secrets if you have any doubt. Database credentials, API keys, session keys, cloud instance credentials, and anything the web user could read. Rebuild from a clean image after preserving evidence.

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