This is a working exploit lab. It 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.
CVE-2026-109618 · CVE-2026-109619 · CWE-416 / CWE-755 · PHP core

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.

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

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.

What the patch actually changes The fix replaces 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").

Why this is not a one-request bug The producer and consumer must hit the same worker. On a busy pool, the exploit pins one child by holding the other workers open with partial POST bodies. On a single-worker lab, it just sends the next request. This is also why 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/Dockerfile
ARG 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.

php2shell/php/99-lab.ini
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.

flowchart LR subgraph P["Apache prefork, stock config"] W1["149 workers held by partial POST bodies"] W2["1 free worker: A1, B1, A2, B2"] end A["Attacker"] --> W1 A --> W2
Why the app is a hello world I wanted the proof to be boring. There is no login, no upload form, no session code, and no framework. If the exploit needs the app to be vulnerable, it would fail here. It does not. The trigger happens before the script can do anything useful.

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.

What Docker gives you
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
What Docker does not fix
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
Windows and ARM Docker Desktop on Windows runs Linux containers in a VM. The lab is still a Linux PHP build, which is what the PoC targets. Use WSL2 or Git Bash for the scripts. I could only test on Debian 13 x86_64, so Windows is expected to work but is not verified here. ARM64 is a different story. The offsets in the public PoC are for amd64. Use an x86_64 host or VM, or port the exploit yourself. Do not assume the same numbers apply.

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.

Step 1 Check the build and the runtime. This catches the common failures before the exploit does.
You run
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
Step 2 Run the full chain. The PoC sends A1, B1, A2, and B2, then checks for the proof file.
You run
./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.

Step 3 Run the same PoC against the patched image. This is the differential test that matters.
You run
./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.

Step 4 Optional: demonstrate the memory disclosure without executing a command.
You run
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.

Reproducibility notes The final lab succeeded on the first attempt in the initial clean run and in three fresh-ASLR restarts. The PoC still has five retries because heap layout is not a guarantee. If your first attempt fails, let it retry. If it fails five times, run ./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 exploit is build-specific The fake structures have to line up with the real Zend layouts for this PHP version and this allocator. The offsets depend on the binary, the compiler, glibc, and the loaded extensions. A patch release can move them. That is why the lab pins the image digest and why the PoC is not a universal point-and-click exploit.

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.

What you got 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.

Known scanning sources from AWS 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.

A test plan that fits in one page Staging with the same image and INI. Run the app test suite. Run the plugin and extension matrix. Run ./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.

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 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