PHP2Shell Docker Lab: CVE-2026-109618 and CVE-2026-109619
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.
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.
This is what this lab is about. 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.
CVE-2026-109618: When OOM Leaves a Row Half Written
Think of a Zend hash table as a row of slots with a counter. When PHP inserts a form field, it bumps the counter, then allocates and fills the slot. If the allocation fails, the counter already includes a slot that was never filled. Cleanup walks up to the counter and reads the unfilled slot. The slot contains whatever was on the heap before. If the attacker can shape that memory, cleanup reads a fake PHP value.
In plain English: PHP claims a bucket before it allocates the key. If the allocation fails, the counter still says the bucket exists, but the bucket was never filled. Cleanup walks the counter and reads whatever happens to be in that slot.
Zend/zend_hash.c, simplified/* 1. Claim the next bucket and count it as live. */
idx = ht->nNumUsed++;
ht->nNumOfElements++;
/* 2. The bucket is now visible to teardown, but it is still empty. */
p = ht->arData + idx;
/* 3. This allocation can fail. If it does, PHP bails out here. */
p->key = zend_string_init(str, len, ...);
/* 4. These assignments never run if step 3 fails. */
p->h = ...;
ZVAL_COPY_VALUE(&p->val, pData);
| Line | What it means |
|---|---|
nNumUsed++ |
"Teardown should visit one more bucket." |
arData + idx |
"Here is the bucket it will visit." |
zend_string_init(...) |
"Allocate the key. This can fail." |
| Assignments after it | "Fill the bucket. Skipped on failure." |
Teardown then does exactly what the counter told it to do:
/* Walk every bucket the counter claims exists. */
for (p = ht->arData; p != ht->arData + ht->nNumUsed; p++) {
i_zval_ptr_dtor(&p->val); /* read the value */
if (p->key) {
zend_string_release(p->key); /* read the key */
}
}
That is the whole bug in two snippets. The counter says the row exists. The row was never written. Cleanup trusts the counter. The bytes in the row are whatever was on the heap before, and the attacker gets a say in what those bytes look like.
The OOM is not the hard part
Getting PHP to run out of memory in the right place is the part that makes
the exploit practical. PHP compares post_max_size with the
declared Content-Length before automatic POST parsing starts.
If the request uses Transfer-Encoding: chunked or has no usable
length, that check does not bound what PHP actually consumes. A compressed
input filter can create the same mismatch.
The advisory lists several ways to put pressure on the parser without exceeding the advertised request size:
| Technique | Why it works |
|---|---|
| Chunked or lengthless multipart |
PHP compares post_max_size to the declared length, then
keeps consuming decoded body data. The wire body can be larger than
the limit PHP thinks it is enforcing.
|
| Many multipart headers |
The in-memory representation of the headers costs more than their
wire representation. A body under post_max_size can
still exhaust memory_limit.
|
| Empty file controls |
A part with filename="" does not count toward
max_file_uploads, but PHP still creates the six
$_FILES metadata entries for it.
|
| Nested field names |
Names like files[a][b][c] make PHP build the hierarchy
separately for every metadata entry. One short upload record expands
into many hash tables, buckets, and string keys.
|
That is how a request that looks small enough can still push PHP over its
own limit at a controlled moment. The public PoC uses nested empty file
fields and a PHP_SESSION_UPLOAD_PROGRESS field to hit the OOM in
session cleanup.
Why mod_php is the loudest case
Apache with mod_php calls php_request_shutdown()
even when request startup failed. That gives the malformed
$_POST or $_FILES table a chance to be destroyed,
and the destruction walks the stale bucket. PHP-FPM takes a different
branch: if php_request_startup() fails, it shuts the worker
down and exits. That exact startup path does not survive on FPM. FPM is still
in scope, but the validated FPM chains happen after a successful startup and
need a suitable application operation.
This is why the lab uses the stock Apache image. It is the cleanest demonstration of the pre-auth path, and it is also the configuration PHP says is highest risk.
longjmp() for OOM with a delayed soft OOM that
fires at a safe point, plus a hard OOM that terminates the worker with
_exit(137) if memory use crosses a higher threshold. It also
resets request-owned globals that could be reached before normal
initialization. The hash table still updates its counters before it
allocates the key in 8.5.12. The fix is around the error handling, not a
rewrite of that ordering. That is why the advisory says the underlying
pattern is risky and future variants are possible.
CVE-2026-109619: The Next Request Inherits the Mess
A PHP worker is a long-lived process. It handles one request, cleans up, and waits for the next one. Some globals in that worker point into the current request's heap. At the end of the request, PHP frees the heap and expects those globals to be reset. A fatal error can skip the reset. The next request on the same worker then finds a pointer into freed memory.
That is a use-after-free, but the trigger and the payoff are in different HTTP requests. The first request is the producer. The second request is the consumer. They have to land on the same worker. That is why the exploit either uses one Apache child or pins one child out of a larger pool.
The session upload progress feature is the star of this part. It is enabled
by default, and it runs before normal module initialization. The application
does not have to call session_start(). In fact, the app does not
have to use sessions at all. The request startup order is roughly:
php_hash_environment(); /* parses request variables and multipart input */
zend_activate_modules(); /* normal module RINIT functions */
PG(modules_activated) = 1;
If multipart parsing fatals before modules_activated is set,
the matching session shutdown is skipped. The request heap is still freed.
The Apache worker survives with PS(id),
PS(http_session_vars), or unserializer bookkeeping still
pointing into the freed heap.
The leak: PHP trusts a length field
The first useful consumer is the session cookie. A later multipart request
reaches the upload-progress cleanup path before normal session initialization
resets the stale ID. PHP treats the stale pointer as a live
zend_string and URL-encodes it into a Set-Cookie
header. If the attacker can reclaim the freed block with a fake
zend_string header, they control the length. PHP then reads past
the end of the string and writes memory into the cookie. That is the
Heartbleed-style part of this bug.
struct.pack_into("<I", v, hdr + 0x00, 0) # refcount
struct.pack_into("<I", v, hdr + 0x04, 0x16) # IS_STRING
struct.pack_into("<Q", v, hdr + 0x08, 0) # hash
struct.pack_into("<Q", v, hdr + 0x10, 0xC8000) # forged length
In the lab, that produced an 819,208-byte cookie and a 1.6 MB response from a page that normally returns 24 bytes. The leaked region contains pointers into libphp and the Zend heap, which is exactly what the exploit needs to defeat ASLR. It can also contain request data, tokens, or anything else that happened to be in that worker's heap.
The write: cleanup calls a function pointer
The second consumer is PS(http_session_vars). The exploit leaves
a dangling reference to a value that looks like a PHP session array. A later
request reclaims the freed block with a fake zend_reference and
a fake zend_array. The fake array has one job: its
pDestructor field points at zif_system. When PHP
destroys the array during shutdown, it calls the destructor on the fake
element. The fake execute_data makes the handler see one string
argument. That argument is the command.
struct.pack_into("<Q", ht, 0x30, zif_system) # pDestructor
# fake execute_data at S+0x20, command string at S+0x80
This is why the final proof is a shell command and not a weird crash. PHP's
normal array cleanup becomes the call site. The attacker controls the
function pointer and the argument. In the lab, the call is
system("id > /out/pwned").
pm.max_requests=1 breaks the chain, at a performance cost.
It is a mitigation, not a fix.
The Chain in One Picture
The public PoC I used splits the work into four requests. A1 and B1 get the leak. A2 and B2 get the write. The app is never part of the conversation.
| 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") during shutdown. 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.
Building the Lab
The lab lives in ~/www/php2shell. It is a normal Docker
Compose project with a Dockerfile. The Dockerfile derives from the exact
official image, copies the production INI, and adds only the lab config and
the hello-world app. It does not recompile PHP.
I tested the final lab on Debian 13.7 x86_64 with Docker 29.9.0 and Compose v5.6.0. The host had ASLR on. The container was unprivileged.
php2shell/DockerfileARG PHP_IMAGE=php:8.4.26-apache@sha256:60111400ff77...
FROM ${PHP_IMAGE}
# The PoC's heap layout depends on zend.assertions=-1 and the other
# production defaults.
RUN cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini
COPY php/99-lab.ini /usr/local/etc/php/conf.d/99-lab.ini
COPY apache/zz-single-worker.conf /etc/apache2/conf-enabled/zz-single-worker.conf
COPY src/ /var/www/html/
RUN mkdir -p /out && chown www-data:www-data /out
EXPOSE 80
The Compose file builds that Dockerfile twice. The vulnerable service uses PHP 8.4.26. The patched profile uses 8.4.27. Both base images are pinned by digest, because the exploit's offsets are tied to the build.
php2shell/docker-compose.yml (excerpt)services:
vuln:
build:
context: .
args:
PHP_IMAGE: php:8.4.26-apache@sha256:60111400ff77...
platform: linux/amd64
image: php2shell-lab:8.4.26
ports: ["127.0.0.1:18088:80"]
volumes: ["./out:/out"]
patched:
profiles: ["patched"]
build:
context: .
args:
PHP_IMAGE: php:8.4.27-apache@sha256:a0b75cee7023...
platform: linux/amd64
image: php2shell-lab:8.4.27
ports: ["127.0.0.1:18089:80"]
volumes: ["./out:/out"]
The PHP overrides are intentionally small. The base is
php.ini-production. The lab sets
memory_limit=128M, post_max_size=8M,
display_errors=Off, and keeps session upload progress on,
because that is the feature the chain abuses.
display_errors = Off
display_startup_errors = Off
log_errors = On
error_reporting = E_ALL
memory_limit = 128M
post_max_size = 8M
upload_max_filesize = 2M
session.save_path = "/tmp"
session.upload_progress.enabled = On
session.upload_progress.cleanup = On
The Apache config sets one prefork worker. That is a teaching choice. It removes the worker lottery from the demo. The stock 150-worker config also works: the public PoC holds 149 workers open with partial POSTs, then sends A1, B1, A2, and B2 to the last one. I tested both.
Does using Docker Defeat the Purpose?
It is easy to assume a container somehow contains a memory-safety bug. It doesn't.
A container is a set of Linux namespaces and cgroups. It is not a memory
safety boundary. The PHP process inside the container is a normal Linux
process running on the host kernel. It uses the same PHP binary, the same
glibc allocator, and the same ASLR behavior as it would on a VM or bare
metal. The exploit sends HTTP requests to that process and runs a command
inside the container as www-data. That is the correct scope for
this bug. It does not need to escape the container, and it does not need
host access.
| Environment | Does the bug work? | What it is good for |
|---|---|---|
| Docker on Linux x86_64 | Yes, verified here. | Reproducible builds, pinned digests, isolated network, easy teardown, safe default. Best choice for this lab. |
| VM or VPS | Yes, assuming the same PHP build and SAPI. | Snapshot and rollback, different kernel or glibc, a stronger safety boundary for untrusted code. Useful, not required. |
| Bare metal | Yes, same caveat. | Closest to a production host. More setup and more cleanup. Not necessary to prove the bug. |
The thing that actually matters is the exact build. The PoC's offsets are
calibrated for this amd64 image: PHP 8.4.26, Apache 2.4.69 prefork,
mod_php, glibc 2.41, Debian 13 base. A different PHP build,
different allocator, different MPM, or a different architecture changes the
heap layout. The vulnerability is universal. The exploit is not.
I did not need --privileged, SYS_PTRACE,
seccomp=unconfined, host networking, or a mounted
docker.sock. The container ran with default capabilities and
default seccomp. The host had full ASLR:
randomize_va_space=2. The exploit is pure HTTP.
Pinned PHP build and digest
Reproducible heap layout
Isolated network namespace
Easy teardown
No host PHP install
No ASLR changes
No privileged container
Same exploit result
The PHP memory-safety bug
The pre-auth request path
The stale worker state
The container's own compromise
The secrets inside the app
The need to patch
One practical warning: do not put the container under a very low cgroup
memory limit. The exploit wants PHP's own memory_limit to fire
at a controlled moment. If the kernel OOM killer takes the worker out first,
you get a different failure. Give the container some room, or leave the
cgroup limit out of the lab.
Running the PoC
The first build pulls the official PHP image and clones the third-party PoC
into .poc/ at a pinned commit. The PoC repository has no
license file, so the lab does not vendor it. It fetches it, pins it, and
prints a warning. Read the code before you run it.
cd ~/www/php2shell
./scripts/preflight.sh
You see
PHP 8.4.26 (cli) (built: Oct 21 2026 00:31:19) (NTS)
display_errors => Off => Off
display_startup_errors => Off => Off
max_file_uploads => 20 => 20
memory_limit => 128M => 128M
post_max_size => 8M => 8M
zend.assertions => -1 => -1
session.save_path => /tmp => /tmp
session.upload_progress.cleanup => On => On
session.upload_progress.enabled => On => On
[+] preflight passed
./scripts/run-demo.sh
I trimmed the build and preflight noise here. The stage lines and proof below are exactly what the lab printed.
You see[+] PoC pinned at 17e98ee81025cccfd94b4f9c9a17bf269c65ff75
[!] Third-party code: https://github.com/9bie/php2shell-bypass-aslr-exp.git
(no license file). Review it before running.
[*] target says: hello from php2shell lab
[*] running the PoC (workers=1 because this lab uses one prefork child)
[try 1] A1: END-cleanup send_cookie OOM
[try 1] B1: stale-id OOB read (chunk base + libphp)
decoded=819208 zval_ptr_dtor=0x7fc1b6806b60 -> libphp=0x7fc1b61a8000
chunk buckets: [('0x7fc1b3e00000', 7495), ('0x7fc1b6800000', 1095),
('0x7fc1b4600000', 87), ('0x7fc1b3c00000', 79)]
[try 1] A2 + B2: main=0x7fc1b4600000 S=0x7fc1b46f8398
zif_system=0x7fc1b660d370
[4/4] RCE OK; proof ~/www/php2shell/out/pwned:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
hello from php2shell
================ PROOF ================
uid=33(www-data) gid=33(www-data) groups=33(www-data)
hello from php2shell
=======================================
That is the whole demo. The app printed one line. The exploit wrote a file as the Apache worker. The addresses will change on every fresh container because ASLR is on. The shape of the output should not.
The command is intentionally small. It is a shell command, but the carrier
has a length limit of roughly 200 bytes. id,
whoami, pwd, or a short echo all fit.
A reverse shell is not the point of this lab. The point is that arbitrary
command execution crossed the boundary.
./scripts/run-patched.sh
You see
PHP 8.4.27 (cli) (built: Oct 21 2026 15:53:29) (NTS)
[*] running the same PoC against patched PHP
[try 1] A1: END-cleanup send_cookie OOM
[try 1] B1: stale-id OOB read (chunk base + libphp)
leak failed: RuntimeError('no heap pointers in leak window')
[4/4] all attempts failed
[+] PASS: no proof on the patched target
The patched build still takes the OOM. It just does not leave the malformed state behind. The leak stage finds no pointers, and the chain stops. That is what a real fix looks like for this bug class.
docker compose restart vuln
python3 scripts/leak-only.py
You see
[*] A1: OOM during session-cookie cleanup
[*] B1: stale session ID -> forged zend_string -> Set-Cookie leak
response bytes: 1669198
decoded cookie bytes: 819208
most common pointer: 0x7fed4f6ebb60
derived libphp base: 0x7fed4f08d000
main chunk candidates:
0x7fed4d400000
0x7fed4ca00000
bucket counts:
0x7fed4cc00000 7495
0x7fed4f600000 1095
0x7fed4d400000 87
0x7fed4ca00000 79
[!] The cookie contains live worker memory. Do not paste it into
[!] reports or tickets. The numbers above are enough for the demo.
This stage is worth showing to people who think "RCE or nothing." The leak is a confidentiality breach on its own. The cookie can contain data from other requests handled by that worker. You do not need code execution to have a bad day.
./scripts/preflight.sh and check the
output. The usual causes are the wrong PHP build, a different INI, more than
one worker, or background traffic hitting the single worker.
Under the Hood, Without the Full Memory Course
You do not need to understand every offset to use the lab. If you only care about the operational lesson, skip to the next section. If you want to know why the chain works, here is the short version.
The leak gives you the map
B1 forges a zend_string header at the stale session ID and sets
the length to 0xC8000. PHP's session cookie code URL-encodes the
string into a Set-Cookie. The result is about 800 KB of worker
memory. The exploit scans that memory for pointers in the expected address
range.
The most common pointer in the leaked region is zval_ptr_dtor,
a function pointer stored in a real Zend hash table. Its offset from the
libphp base is constant for this build. The exploit subtracts
0x65EB60 to get the libphp base. It then groups the leaked
pointers into 2 MB aligned buckets to find the main ZendMM chunk. From there,
it knows where to place the fake array.
| Value | Offset in this build | How it is used |
|---|---|---|
zval_ptr_dtor |
libphp + 0x65EB60 |
Leaked function pointer, used to derive the libphp base. |
zif_system |
libphp + 0x465370 |
The handler the fake array calls during destruction. |
Fake array S |
main + 0xF8398 |
Chosen landing spot in the main ZendMM chunk. |
Fake execute_data |
S + 0x20 |
Makes the handler see one string argument. |
| Command string | S + 0x80 |
The argument passed to system(). |
These numbers are specific to the pinned image. They are not universal constants. That is the difference between "PHP is vulnerable" and "this exploit works on this build." Both statements are true here, but they are not the same statement.
The write turns cleanup into a call
B2 sprays 384-byte values containing fake zend_reference
structures. The reference points at the fake array at S. The
fake array's pDestructor is zif_system. The fake
execute_data sets num_args = 1 and points the first
argument at a fake zend_string containing the command.
When PHP destroys the array, it walks the bucket and calls
pDestructor. That is normally zval_ptr_dtor. Here
it is zif_system. The handler runs the command string. The
command writes to /out/pwned, and the worker may crash on the
way out.
Why the worker crashes after the command
The fake array and the fake reference are not real PHP objects. After the destructor runs, PHP continues tearing down structures that the exploit partially overwrote. A segfault is a normal outcome. The command has already executed. If you were writing a real implant, you would pick a command that survives the crash, such as writing a file or opening a reverse shell. For this lab, `id` is enough.
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.
./scripts/run-patched.sh against the
patched stage. Run a load test. Canary a small percentage. Watch error rates,
OOM logs, worker crashes, and plugin errors. Then roll forward.
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 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 |