October 23, 2026
PHP2Shell, Explained: How a Server That Runs Out of Memory Can Hand an Attacker the Keys
github : https://github.com/xC0D3F4TH3R/PHP2SHELL-DEFENSIVE-KIT

By xC0D3F4TH3R
10 min read
PHP2Shell, Explained: How a Server That Runs Out of Memory Can Hand an Attacker the Keys
linkedin : https://lnkd.in/p/gdFVQAr3
A beginner-friendly deep dive into CVE-2026-109618 and CVE-2026-109619, plus the defensive toolkit I built to understand them.
Reading time: about 12 minutes Suggested tags: Cybersecurity, PHP, Vulnerability Research, Detection Engineering, Information Security
Alternative titles, if you want to A/B test:
- The Cleanup Crew Never Showed Up: Inside the PHP2Shell Vulnerabilities
- When PHP Runs Out of Memory, Attackers Move In
- PHP2Shell for Beginners: From Memory Bugs to Detection Rules
The hook: a bug that fires before your code even runs
Most web vulnerabilities live in your application: a bad SQL query, a missing permission check, an unsafe file upload. You can read the code, find the mistake and fix it.
PHP2Shell is different. The flaw lives in the PHP engine itself. The dangerous moment happens while PHP is still parsing the request, before your login checks, your framework or your WAF-in-the-app have run a single line.
On October 21, 2026, the PHP team released fixes for two critical flaws, CVE-2026-109618 and CVE-2026-109619. The release notes say both can allow unauthenticated remote code execution. They also say there are no complete mitigations, and that updating is the only safe choice. Hosting providers rated both 9.2 out of 10.
I'm early in my security career. Instead of just reading the headlines, I spent a week trying to understand how this kind of bug works, then built tools to detect it and defend against it. This post is the guided tour I wish I'd had.
A note on honesty before we start. I worked from public vendor and researcher write-ups published within a day or two of disclosure. I did not independently review the official advisory text or the patch diffs, and the sources disagree in places. I'll flag those spots as we go.
Part 1: The restaurant analogy (no C knowledge required)
Imagine a restaurant with a few tables. Each customer is an HTTP request. Each table is a PHP worker, a process that stays alive and serves many requests in a row.
Between customers, staff must:
- Clear the table.
- Reset everything for the next guest.
That reset is why PHP is fast. It reuses the same worker instead of building a new restaurant for every customer.
Now imagine a customer who leaves abruptly, halfway through the meal. The cleanup crew never gets the signal. The next customer sits down at a table that still holds the last customer's leftovers, and the leftovers may have been arranged on purpose.
That's the heart of PHP2Shell.
Part 2: How PHP manages memory
Two ideas are enough to follow the rest.
1. Request memory vs. process memory. PHP gives each request its own memory pool, which is wiped all at once when the request ends. But some variables live in the process, in global C variables that survive across requests.
2. The dangerous combination. If a process-lifetime variable holds a pointer into request-lifetime memory, and nobody clears the pointer before the memory is wiped, you get a dangling pointer. It points at memory that no longer belongs to it.
Reading or writing through that pointer is called a use-after-free (UAF). It's one of the oldest and most dangerous classes of memory bug in C programs. PHP's engine is written in C.
Part 3: The trigger, a "bailout"
PHP limits how much memory a script can use (memory_limit). When a request crosses that limit, PHP raises a fatal error and performs a bailout: it jumps straight out of whatever it was doing.
That jump is implemented with longjmp. It means everything between the failure point and the landing spot is skipped. No cleanup lines run.
Here's the pattern in plain pseudocode:
publish_pointer(); // a global now points into the request's memory
do_more_work(); // <-- out of memory HERE: PHP jumps away
unpublish_pointer(); // <-- never runspublish_pointer(); // a global now points into the request's memory
do_more_work(); // <-- out of memory HERE: PHP jumps away
unpublish_pointer(); // <-- never runsThe request's memory gets freed anyway, but the global pointer is still there.
According to public analyses, two things make this especially nasty:
- Timing. Multipart upload parsing (including a feature called session upload progress) runs early in request startup, before the engine has finished resetting its internal state for the new request.
- Reuse. Because PHP-FPM, Apache
mod_phpand FrankenPHP keep workers alive, a later request can pick up the leftovers.
Part 4: The two CVEs in plain English
| Descriptions below follow the advisory-style titles | |---|
CVE-2026-109618, "RCE via OOM corruption." PHP runs out of memory while some internal data is only half-built. The request's own shutdown routine then treats that half-built data as valid and acts on garbage.
CVE-2026-109619, "RCE via stale global state." State owned by one request survives after its memory is freed. A later request on the same worker uses that stale state before normal initialization resets it.
If an attacker can influence what ends up occupying the freed memory in between, the engine ends up trusting attacker-shaped bytes as if they were its own internal structures. Researchers described outcomes ranging from memory disclosure (leaking heap contents, for example through a session cookie value) to type confusion and code execution.
Where the sources disagree
Here's where being honest matters. I found conflicting write-ups:
- Some sources map CVE-2026-109618 to an unserializer use-after-free and CVE-2026-109619 to backtrace/OPcache issues. Others use the OOM-corruption versus stale-global-state split above. The advisory-style titles are the more consistent ones, so I use those. Per-variant attribution is still unsettled.
- One early analysis pointing to a specific session-handling function was reportedly withdrawn.
- The confirmed application-independent attack chains target Apache
mod_php. Researchers say PHP-FPM is also susceptible through state published after startup, but that path is less clearly demonstrated. - One vendor found a particular exhaustion path only reachable when
memory_limitis less than 4×post_max_size. PHP's defaults (128M and 8M) don't meet that, but don't take comfort from it. Other paths exist.
Bug classes like this often get refined for weeks after disclosure. Treat any single write-up, including mine, as a snapshot.
Part 5: Seeing the bug with your own eyes
You don't need PHP internals to feel this bug. I wrote a ~100-line C program modelling the same pattern. It isn't PHP code and it can't attack anything.
It has three parts:
- A per-request memory arena that's freed all at once.
- A global pointer into that arena.
- A simulated bailout that skips the line that would clear the global.
Compile it with AddressSanitizer and run the vulnerable mode:
ERROR: AddressSanitizer: heap-use-after-free
READ of size 2 ... in request() stale_pointer.c:58
freed by thread T0 here: ... heap_end() stale_pointer.c:43ERROR: AddressSanitizer: heap-use-after-free
READ of size 2 ... in request() stale_pointer.c:58
freed by thread T0 here: ... heap_end() stale_pointer.c:43It tells you who allocated the memory, who freed it and who tried to read it afterward. Now the fix. One line, an unconditional reset at shutdown regardless of how the request ended:
if (fixed) g_session_id = NULL; // reset long-lived state before freeing request memory
free(req_heap.base);if (fixed) g_session_id = NULL; // reset long-lived state before freeing request memory
free(req_heap.base);Run it again and the error disappears.
The public descriptions of the real patches are consistent with this philosophy, which JFrog's analysis calls defense in depth. Even if a request dies in an unexpected way, long-lived state (session, error state, OPcache-related state, backtrace) is reset at shutdown so nothing stale reaches the next request.
Three hardening patterns are worth remembering:
- Reset on shutdown, on every exit path, not only the happy one.
- Nullify after free, so a late dereference becomes a harmless NULL check.
- Publish after initialization, so a half-built object is never reachable.
(I haven't diffed the real commits yet. That's on my to-do list, and I describe the real patches only as the public summaries do.)
Part 6: What attackers actually did
Honeypot and telemetry vendors reported scanning and exploitation attempts within roughly a day of disclosure. The traffic had recognizable shapes:
- A multipart field named
PHP_SESSION_UPLOAD_PROGRESS. - Hundreds of empty file fields in one request.
- Form field names nested dozens of levels deep, like
a[b][c][d].... - Scanner user-agents with "php2shell" in the name.
These shapes aren't magic. They're amplifiers designed to push memory use to a precise point. And because they're observable, they're detectable.
Part 7: Defend in layers
No single control is enough. Here's the stack I built, strongest first.
Layer 1: Patch and restart
Upgrade to PHP 8.2.35, 8.3.36, 8.4.27 or 8.5.12 or later. PHP 8.1 and older have no upstream fix, so you need to upgrade the branch.
Then restart your workers. Old code keeps running in memory until you do. And check your distribution's advisory: many vendors backport fixes without changing the version number.
Layer 2: Interim hardening, if you can't patch today
; php.ini or conf.d drop-in
session.upload_progress.enabled = 0
memory_limit = -1
fatal_error_backtraces = 0 ; PHP 8.5 only; php.ini or conf.d drop-in
session.upload_progress.enabled = 0
memory_limit = -1
fatal_error_backtraces = 0 ; PHP 8.5 onlyAnd for PHP-FPM, use the admin-level directives so applications can't override them:
php_admin_flag[session.upload_progress.enabled] = off
php_admin_value[memory_limit] = -1php_admin_flag[session.upload_progress.enabled] = off
php_admin_value[memory_limit] = -1The catch with memory_limit = -1
Removing the limit means PHP never takes the out-of-memory bailout, which removes the trigger. But now one hostile request can eat the host.
The fix is a limit enforced by the kernel instead of PHP: a cgroup or systemd MemoryMax=, Docker mem_limit, or Kubernetes limits. Don't use RLIMIT_AS, because it can reintroduce allocator failures inside PHP.
One more lesson: use php_admin_*, not php_value or .user.ini. Settings that application code can change, or that apply too late, don't protect you.
Layer 3: Detect at the edge (WAF)
A ModSecurity rule for the upload-progress field name:
SecRule ARGS_NAMES|REQUEST_COOKIES_NAMES "@streq PHP_SESSION_UPLOAD_PROGRESS" \
"id:9100101,phase:2,t:none,pass,log,auditlog,severity:'CRITICAL',\
msg:'PHP2Shell: session upload-progress field present',\
setvar:'tx.php2shell_score=+5'"SecRule ARGS_NAMES|REQUEST_COOKIES_NAMES "@streq PHP_SESSION_UPLOAD_PROGRESS" \
"id:9100101,phase:2,t:none,pass,log,auditlog,severity:'CRITICAL',\
msg:'PHP2Shell: session upload-progress field present',\
setvar:'tx.php2shell_score=+5'"And one for the amplifier shapes:
# Deeply nested parameter names like a[b][c][d]...
SecRule ARGS_NAMES|FILES_NAMES "@rx (?:\[[^\]]*\]){8,}" \
"id:9100104,phase:2,t:none,pass,log,auditlog,severity:'ERROR',\
msg:'PHP2Shell: deeply nested parameter names',\
setvar:'tx.php2shell_score=+3'"# Deeply nested parameter names like a[b][c][d]...
SecRule ARGS_NAMES|FILES_NAMES "@rx (?:\[[^\]]*\]){8,}" \
"id:9100104,phase:2,t:none,pass,log,auditlog,severity:'ERROR',\
msg:'PHP2Shell: deeply nested parameter names',\
setvar:'tx.php2shell_score=+3'"Beginner translation of that regex: \[[^\]]*\] matches one [something] pair, and {8,} means "eight or more in a row". Real applications rarely nest that deep.
I score signals instead of blocking on any weak one. A scanner user-agent alone adds 3, a shape anomaly adds 3, and the field name alone adds 5. The block threshold is 5.
An honest caveat: a WAF is a speed bump, not a fix. Attackers can reshape payloads, so start in DetectionOnly mode and tune.
Layer 4: Detect on the host (behavior beats signatures)
If an attacker does get code execution, the process tree gives it away. A web worker should almost never launch a shell or a downloader.
detection:
parent:
ParentImage|endswith: ['/php-fpm', '/apache2', '/httpd', '/frankenphp', '/nginx']
child:
Image|endswith: ['/sh', '/bash', '/curl', '/wget', '/nc', '/python3', '/perl']
condition: parent and child
level: highdetection:
parent:
ParentImage|endswith: ['/php-fpm', '/apache2', '/httpd', '/frankenphp', '/nginx']
child:
Image|endswith: ['/sh', '/bash', '/curl', '/wget', '/nc', '/python3', '/perl']
condition: parent and child
level: highPair it with:
- Crash-burst detection. Memory-corruption attempts often crash workers first, so watch for
exited on signal 11and bursts of "Allowed memory size exhausted". - File-integrity monitoring on web roots, for newly dropped
.phpfiles. - auditd logging of every
execveby the web user.
Layer 5: Limit the blast radius
Run PHP unprivileged. Mount upload directories noexec. Make the root filesystem read-only where you can. Filter outbound traffic from web servers. And after any suspected exposure, rotate every secret the app could read: environment variables, database passwords, API keys.
Part 8: The toolkit
To make this practical, I packaged everything into an open-source project.
# Is this PHP version fixed?
p2sdefense version 8.3.35
# [VULNERABLE] 8.3.35 < 8.3.36: upgrade to 8.3.36 or later.
# Audit real configs (files or directories)
p2sdefense audit -c /etc/php/8.3/fpm/php.ini -c /etc/php/8.3/fpm/pool.d/ --php-version 8.3.35
# Triage logs
p2sdefense scan-access /var/log/nginx/access.log
p2sdefense scan-errors /var/log/php8.3-fpm.log --burst 5# Is this PHP version fixed?
p2sdefense version 8.3.35
# [VULNERABLE] 8.3.35 < 8.3.36: upgrade to 8.3.36 or later.
# Audit real configs (files or directories)
p2sdefense audit -c /etc/php/8.3/fpm/php.ini -c /etc/php/8.3/fpm/pool.d/ --php-version 8.3.35
# Triage logs
p2sdefense scan-access /var/log/nginx/access.log
p2sdefense scan-errors /var/log/php8.3-fpm.log --burst 5A few design decisions I'm proud of:
- It's patch-aware. Once PHP is fixed, the tool tells you to remove the interim
memory_limit=-1instead of demanding it forever. Leaving emergency mitigations in place is a classic way to create the next incident. - It fails safe. If you don't give it a version, it assumes unpatched.
- It understands precedence. It treats
php.iniandphp_admin_valueas locked, andphp_valueas overridable, and it resolves conflicts the way PHP-FPM does. - It explains itself. Every finding says why it exists and how to fix it.
The repo includes 43 tests, CI, WAF/Sigma/Falco/auditd rules, hardening configs, an incident-response playbook and the C lab.
Defensive only: there is no exploit code in it. Publishing weaponized payloads wouldn't make anyone safer, and it isn't what a responsible researcher does.
Part 9: What this taught me
1. Memory-safety bugs aren't "old-school". They're infrastructure-scale. A bug in an engine that powers a large share of the web turns a niche concept (stale pointers) into an internet-wide patch cycle.
2. Attackers get a head start. Scanning began within about a day of disclosure. The window between "patch released" and "someone's scanning you" is shrinking, so patch pipelines matter as much as the patch.
3. Interim fixes have costs. Every mitigation shifts risk somewhere else. memory_limit=-1 trades one risk for another unless you add a kernel-level cap.
4. Behavior-based detection ages better than signatures. A WAF rule on a field name is useful today and trivially evaded tomorrow. A rule that fires when a web worker spawns /bin/sh survives changes in the payload.
5. Being honest about uncertainty is a skill. The sources conflicted and one analysis was withdrawn. Saying "this is unsettled" is more useful, and more credible, than a confident wrong story.
Your checklist
If you run PHP anywhere, do these today:
- ☐
php -vandphp-fpm -von every host and container image. - ☐ Upgrade to a fixed release, or confirm your vendor's backport advisory.
- ☐ Restart PHP-FPM, Apache or FrankenPHP so workers load the new code.
- ☐ If patching must wait, apply the interim settings with
php_admin_*, plus a kernel-level memory cap. - ☐ Turn on WAF rules (DetectionOnly first) and process-lineage alerts.
- ☐ Look for unexpected
.phpfiles in upload and writable directories. - ☐ If anything looks off, rotate secrets and rebuild from a known-good image.
Limitations (because credibility matters)
- I relied on secondary sources, so the patch analysis is conceptual until I read the actual diffs.
- Log files don't contain request bodies, so the log scanner finds only weak indicators.
- My WAF rules haven't been load-tested against production traffic.
- Details of this incident were still evolving when I wrote this. Always verify against the primary advisories (GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp).
Where to go next
If you want to learn this properly, here's the path I'm following:
- Learn C pointers and the stack versus the heap.
- Write a use-after-free yourself and catch it with AddressSanitizer.
- Read the PHP Internals Book on memory and the request lifecycle.
- Read real security patches, starting with this release:
git diff php-8.3.35..php-8.3.36. - Write a detection rule, a test for it, and a note on how an attacker would evade it.
The repo: [GitHub link] Issues, corrections and pull requests are welcome, especially from people who've read the patch diffs.
References
- PHP 8.5.12 and 8.4.27 release announcements (php-src)
- GHSA-w8hw-w9fx-j79w and GHSA-6wmf-4rv5-p3rp (php-src security advisories)
- Analyses from CrowdStrike, VulnCheck, Sansec, JFrog, Wiz, Imperva and ELLIO
- Hosting-provider guidance (Sevalla)
- PHP Internals Book and the AddressSanitizer documentation
If this helped you, a clap or a share helps other people find it. And if you spot an error, tell me. I'd rather be corrected than confidently wrong.