Black-box detection and proof tooling for the php2shell PHP RCE flaws (CVE-2026-109618 / CVE-2026-109619), plus a reproducible vulnerable lab.
These are companion tools for the public proof-of-concept by enes sönmez at eness/php2shell-CVE-2026-109618-CVE-2026-109619-PoC. The exploit itself is not reproduced here — clone that repo for it. This one adds the pieces that were missing when testing against a real target: a detector that distinguishes "not vulnerable" from "could not tell", a proof harness that writes artifacts instead of claims, and the offsets extractor the PoC expects but does not ship.
Authorised testing only.
prove.pyand the detector's--oommode exhaustmemory_limitand kill PHP workers by design.
Running the upstream PoC against a real server produces HEAP_LEAK_FAILED and
nothing else. Its stage 1 returns 0 for three unrelated situations — no
Set-Cookie at all, a cookie shorter than 1000 bytes, and fewer than 8 pointers
sharing a 2MB alignment — and prints all of them identically. There is no way to
express "indeterminate", so every inconclusive run looks like a negative.
The exploit also needs a very specific configuration (memory_limit below
post_max_size, max_input_vars=1000, session.use_strict_mode=0, single-worker
prefork mod_php) and heap constants measured on one build. Each is its own
false-negative source, and none of them mean the target is patched.
Five signals with different preconditions, and a three-state verdict:
| # | Signal | Impact |
|---|---|---|
| S1 | PHP version from X-Powered-By / Server vs fixed releases |
passive |
| S2 | Differential Set-Cookie presence probe |
harmless |
| S2b | Differential Set-Cookie value probe — is a supplied session id adopted? |
harmless |
| S2c | session.use_strict_mode, which disambiguates an S2b negative |
harmless |
| S3 | max_input_vars / post_max_size / memory_limit, from error text or behaviour |
mixed |
| S4 | Memory disclosure, relaxed criteria + parameter sweep | kills workers |
S2 confirms the vulnerable path is reachable with no OOM, grooming or corruption, so
it survives every config that breaks the exploit. S2b rescues the common case where
S2 goes blind because the application calls session_start() itself — it compares
the cookie's value instead of its presence. S4 lowers the acceptance bar from the
PoC's exploitation thresholds to detection thresholds.
Exit 0 not vulnerable, 1 vulnerable, 2 indeterminate, 3 error. Code 2 is
the point: treating it as 0 is the false negative.
| Mode | Proves | Impact |
|---|---|---|
precheck |
which exploit preconditions hold, and names the ones that don't | harmless |
leak |
real memory disclosure → raw bytes, hexdump, recovered pointers and the exact parameters, written to out/ |
kills workers |
crash |
corruption attributable to the upload-progress path, via a byte-size-matched control that omits only the progress field | kills workers |
crash mode distinguishes corruption from plain resource exhaustion: if both the
UAF shape and the size-matched control kill the worker, that is OOM and the tool
says so rather than claiming a finding.
Command execution is deliberately absent — it needs zif_system and
zval_ptr_dtor for the target's exact build, which cannot be derived over HTTP.
poc_109618.py reads out/offsets.env and shells out to docker/offsets.py, which
is not in the upstream repo. So --profile bookworm silently falls back to the
lab zif/dtor constants, and stage 2 then rejects every leak, because
libphp_base_from_cookie validates p & 0xFFF == dtor_offset & 0xFFF.
Two recovery paths, pure stdlib, no pyelftools:
- Symbols.
zval_ptr_dtorisZEND_API, so it is in.dynsymeven when the binary is stripped.zif_systemusually is not. - Function table. PHP's module entry is an array of
zend_function_entry { const char *fname; zif_handler handler; ... }. In a PIC object both fields are filled byR_X86_64_RELATIVErelocations, so: find the vaddr of"system\0"in.rodata, find the RELATIVE reloc whose addend is that vaddr (that is&fname), and the reloc at&fname+8carrieszif_system. Candidates are rejected unless the handler lands in an executable section.
Python 3.8+, no dependencies.
# 1. is it exposed? safe, ~8 requests, nothing corrupted
python detect/php2shell_detect.py -u http://target/index.php
# 2. are the exploit preconditions even satisfiable? harmless
python detect/prove.py -u http://target/index.php --mode precheck
# 3. only if precheck green-lights. kills workers.
python detect/prove.py -u http://target/index.php --mode leak --sweep
python detect/prove.py -u http://target/index.php --mode crash --reps 5
# 4. offsets, for driving the upstream PoC
python detect/offsets.py --so /usr/lib/apache2/modules/libphp8.3.so
python detect/offsets.py --container php2shell-targetoffsets.py is pure stdlib, so it can simply be copied to the target host and run
there against the live module.
lab/ builds a deliberately vulnerable target pinned to php:8.4.26-apache, the
tag the upstream PoC's BOOKWORM_* constants were measured against. Note that
php2shell.ini sets memory_limit (8M) below post_max_size (64M) — that
inversion is what lets a large body be accepted and only then exhaust the heap, and
it is why no default-configured server is exploitable by this chain.
Run the tools against the lab before trusting a negative on a real host: if they do
not fire against a deliberately vulnerable target, a clean result elsewhere means
nothing. See lab/README.md.
Everything here except S1 covers the session upload-progress path only. Public
reporting splits php2shell into an OOM-corruption bug and a stale-global-state bug,
with disputed CVE-to-component mapping, so a clean result does not clear the
non-session path. Version comparison is the only signal covering both — and if
expose_php=Off, there is no black-box coverage of the other path at all.
Version comparison has its own blind spot: Debian and RHEL backport security fixes
without changing PHP_VERSION. When the Server header names a distro, the
detector says so in the verdict, and you have to confirm against the package
changelog on the host:
zcat /usr/share/doc/php8.3-common/changelog.Debian.gz \
| grep -E 'CVE-2026-109618|CVE-2026-109619'8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 (released 2026-10-21). PHP 8.1 and earlier are
EOL and get no official patch. session.upload_progress.enabled = Off is a partial
stopgap that closes the session path only — upgrading is the fix.
Validated against mock targets and synthetic ELF objects, no live host required:
- detector — 15 scenarios: unpatched/patched × progress on/off, EOL branch,
app-managed sessions (S2 blind, S2b rescues),
expose_php=Offvariants, edge-blocked multipart,use_strict_mode=1,use_only_cookies=0, and observed disclosure on both an unpatched and a nominally patched build prove.py— 9 end-to-end checks including artifact creation and, importantly, that it refuses to claim proof on a closed or healthy targetoffsets.py— 5 checks covering both recovery paths, a handler outside.text, a non-ELF file and ELF32
offsets.py has not been run against a real libphp.so — no Docker was
available where it was written. The ELF parsing is validated; the real-world symbol
layout is not.
Vulnerability research and the original PoC: enes sönmez (@enes_dev) — eness/php2shell-CVE-2026-109618-CVE-2026-109619-PoC.
MIT, covering the contents of this repository only. The upstream PoC is a separate work under its own terms.