Percivalll/php2shell-wordpress-rce
Percivalll/php2shell-wordpress-rce has 34 GitHub stars and 2 forks. GitHot measured -1 stars in 24 hours. Last checked 2026-11-09 16:33 UTC. Last push 2026-10-23.
php2shell WordPress RCE proof of concept (PHP 8.3.35 / WordPress 7.1.2)
Measured star growth
- in 24h
- -1
- in 7 days
- Collecting history
- in 15 days
- Collecting history
- in 1 month
- Collecting history
Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.
README.md
Originalphp2shell WordPress RCE
External HTTP-only RCE against the default official Docker Hub
wordpress:7.1.2 image (PHP 8.3.35 / Apache 2.4.68 / mod_php prefork).
Image digest:
sha256:e04cf657f24889e96b5544828cdd5a92a0b51819f3e1899183f9be6ea0485edf.
The target is a fresh container with image defaults: no custom php.ini, no
WordPress plugin or authentication, no extra environment variables, volumes,
capabilities, or privileged mode. All target interaction is HTTP.
Result
Verified command execution as the web user:
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Default-container demo: worker pin, address leak, R1/R2, and the webroot proof.
A fresh container completes in about 25 seconds. Address discovery, both
session-file producers, and arming each run on a newly forked prefork child,
and the driver verifies that the previous child really died before moving on.
After one successful leak, --base/--h reuse the addresses and let later
runs on the same container skip discovery entirely.
Addresses are obtained entirely over HTTP through a 4 KiB Set-Cookie
out-of-bounds read; the chain then installs a fake PHP session serializer and
calls zif_system.
Layout
README.md project overview
REPORT.md full implementation and verification report
SECURITY.md authorized-use and disclosure notice
demo.sh one-command PoC runner
assets/demo.gif demo recording embedded below
exploit/transport.py HTTP and multipart transport
exploit/profiles.py A/B request profiles and leak parser
exploit/arm.py arm producer and trigger
exploit/arm_file2.py arm trigger with dummy-filename grooming
exploit/big_a.py parameterised A request (heap grooming knobs)
exploit/b_leak20.py B request / 4 KiB OOB read
exploit/early_ok.py arm producer
exploit/poc.py legacy request helpers used by the A/B tools
exploit/stage_rce.py session-file producers, R1, and R2
exploit/run_ext.py end-to-end orchestration
exploit/pin_adaptive.py discover the worker count and free exactly one
exploit/pin_slow_pause.py 149-worker pin
Quick start
Requirements: Docker, Python 3, and network access from the client to the target HTTP port. The exploit itself uses only the Python standard library.
docker run -d --name php2shell-target -p 18100:80 \
wordpress:7.1.2
cd exploit
python3 run_ext.py --host 127.0.0.1 --port 18100 \
--shell 'id>/var/www/html/pwn.txt'
curl http://127.0.0.1:18100/pwn.txt
--host defaults to 127.0.0.1, so existing local runs are unchanged. The
driver exports it as WP_HOST to every helper (pin_adaptive, big_a,
b_leak20, early_ok, arm_file2, stage_rce), so the same code can point
at a remote HTTP endpoint. demo.sh accepts the same value through the
HOST environment variable.
run_ext.py starts the worker pin itself (all workers but one), performs the leak,
arms the worker, installs the fake serializer, executes the command, and fetches
the proof. It does not call Docker, gdb, or /proc.
Chain overview
- Pin every worker but one with unfinished chunked POSTs. Each pin carries
4 KiB/s, well above
mod_reqtimeout's 500 B/s body minimum. - Leak first, on the fresh child. A request OOMs in
php_session_reset_id/php_url_encode, leaving a stalePS(id). A second request reclaims it with a forgedzend_stringof length0x1000, andSet-Cookiereturns 4096 bytes of heap. The first compilation ofindex.phpleaves an op_array at the head of the ZendMM chunk; this is what puts libphp handler pointers in the window, so the leak must run before any request that executesindex.php. raw[0x6c8] - 0x50ddbfgives the libphp base. The 4 KiB window's pointer cluster gives the chunk (the 2 MiB window sees a different cluster and must not be used forH);H = chunk + 0x6a000.- Rotate to a clean child, write the 30 MiB
sess_r1big, write the 16 KiBsess_trig2, then rotate once more and arm the fresh child (BG.data = H,BG.level = 1). A dirty worker's raisedreal_sizemakes the 30 MiB session write stop at a few hundred bytes. - R1 installs
PS(serializer) = H + 0x310(BG.level = 2); R2 makesphp_session_decode()callzif_systemwith the command string.
Detailed request shapes, offsets, and failure modes are in REPORT.md.
Verified runs
| Run | Access | libphp base | chunk | H |
|---|---|---|---|---|
run-1 |
published port 18099 | 0x751c8b681000 |
0x751c89a00000 |
0x751c89a6a000 |
run-2 |
published port 18100 | 0x78cde6d86000 |
0x78cde5000000 |
0x78cde506a000 |
default |
no published port, external tunnel | 0x73e6330d7000 |
0x73e631400000 |
0x73e63146a000 |
Stability batch after the pin/leak/ordering fixes:
| Target | Result | Time |
|---|---|---|
| 9 fresh containers (final driver) | 9/9 uid=33(www-data) |
24-26 s each |
same container, --base/--h reuse |
1/1 uid=33(www-data) |
24 s |
| 1 further fresh container | stopped before arming: 0x7c in H pointer bytes |
- |
The single pre-exploitation stop is the ASLR gate documented below, not a heap or timing failure.
Limitations
- Offsets are calibrated for
wordpress:7.1.2with PHP 8.3.35 / Apache 2.4.68. Other PHP builds or images need re-calibration. - The adaptive pin discovers the worker count; the fixed 149-connection mode
assumes the default
MaxRequestWorkers=150. - The leak must run before
producer-bigfn(or any other request that compilesindex.php). Once the script is cached in opcache, the 4 KiB window becomes zeroed heap with no libphp pointers. Retry the leak in place; rotating during the leak loses the useful layout. - If the fake-string pointer contains a
0x7cbyte, PHP rejects the upload progress key as a session delimiter. This is a per-process ASLR condition: every address in that process shares the offending byte, so a different chunk or filler layout cannot escape it. The driver detects it before arming; use a fresh container (or another target process).
Alternative primitives considered
The working chain is the fake ps_serializer route. Two alternatives were
implemented far enough to measure their limits; both are negative results,
not used code.
PG(auto_prepend_file) overwrite
core_globalsislibphp+0x1059100; the runtime ini descriptor confirmsauto_prepend_fileat+0xa0(mh_arg1=0xa0,mh_arg2=&core_globals).- The fake
var_entrieswrite does reachbase+0x10591a0, but the value it writes is the address of the unserializer's dtor temp zval, not a string. The dtor zval then occupies that slot. - The fake-serializer chain survives this because its
decodepointer is atH+0x320, after the 16-byte dtor zval atH+0x310; a path string atH+0x310is overwritten. - A 30 MiB
sess_r1bigis required to keep the session buffer out ofH; a 326-byte session buffer was allocated atH, overwrote the fakephp_unserialize_data(H+0x10became 326) and crashed invar_tmp_var. - Upload-progress stores only one file (
files a:1), andtypeis not in the array, so there is no attacker-controlled large allocation after a small filename. - A 30 MiB filename starting with the path does set
PG=H+0x310, but after the OOM the huge allocation is no longer a mappedzend_string, so it cannot serve as the dtor slot.
Conclusion: this route needs a raw 8-byte write primitive, or a much more
precise mapped-chunk groom than var_push currently allows.
OPcache type confusion
Measured on the default image (opcache.enable=On,
memory_consumption=128M, max_accelerated_files=4000,
interned_strings_buffer=8M):
| Item | Value |
|---|---|
| WordPress PHP files in webroot | 1,513 |
| Source bytes | 21,412,735 |
| Opcache used after compiling all | 52,307,936 B |
| Opcache free | 81,909,792 B |
| Cached scripts | 1,513 |
oom_restarts / hash_restarts |
0 / 0 |
At roughly 34.6 KiB of SHM per script, filling 128 MiB needs about 3,700
unique scripts. The default tree has 1,513; the /usr/src/wordpress copy is
not web-reachable; there is no unauthenticated way to add thousands of unique
PHP files. The persist failure that drives the type confusion is a
shared-memory allocation failure, not a request memory_limit OOM. This
route is not reachable on the default image without an application or plugin
set that supplies more scripts.
Fatal-error backtrace
last_fatal_error_backtrace and fatal_error_backtraces were added in PHP
8.5. PHP 8.3/8.4, including this image's 8.3.35, have neither, so there is no
stale backtrace field to consume. On PHP 8.5 the setting is on by default and
is the confirmed mod_php path; display_errors=Off does not block it, and the
8.5-only mitigation is fatal_error_backtraces=0.
See REPORT.md section 15 for the measurements and exact failure points.
Safety
This project contains a working exploit for an authorized laboratory environment. Do not use it against systems without explicit permission. See SECURITY.md.
Explore related projects
codecrafters-io/build-your-own-x
Master programming by recreating your favorite technologies from scratch.
561,321 starspublic-apis/public-apis
A collective list of free APIs
497,569 starsmorluto/rea
Reverse engineer anything with agents, from app behavior down to native binaries.
446,496 starsopenclaw/openclaw
The AI that really does things. Any OS. Any Platform. The lobster way. 🦞
394,246 stars