Skip to content

Latest commit

 

History

History
191 lines (150 loc) · 7.8 KB

exploit-report.md

File metadata and controls

191 lines (150 loc) · 7.8 KB

PHP2Shell exploitation against the WordPress laboratory — results

Date: 21 October 2026. Scope: http://127.0.0.1:8080 (WordPress 7.1.1, Apache 2.4.68 + mod_php, PHP 8.3.33), local loopback laboratory. All tests were run against the wp-711-wordpress-1 container.

1. Summary

The workspace now contains a version-aware exploitation framework, a profile factory and a verified RCE chain for PHP 8.4.26. The WordPress laboratory (8.3.33) is confirmed as exposed and denial-of-service reachable, but the RCE stage is not calibrated for that build.

Stage WordPress 8.3.33 PHP 8.4.26 lab
Fingerprint and version classification Affected (8.3.33 < 8.3.36) Affected (8.4.26 < 8.4.27)
Pre-RINIT OOM Confirmed Confirmed
Worker survives the bailout Confirmed Confirmed
Heap leak (stage 1) Confirmed Confirmed
libphp leak (stage 2) Confirmed Confirmed
Fake HashTable / zif_system (stage 3) Not calibrated Confirmed
Command execution No uid=33(www-data)

The same 8.4.26 profile was verified at memory_limit=128M with huge_mb=140; the heap calibration did not change. The dispatcher now sweeps 8/16/32/64/128/256/512M automatically.

The profile factory also verified RCE on PHP 8.5.11 (fixed in 8.5.12): stage 1 chunk=0xffff8f000000, stage 2 libphp=0xffff8bc00000, stage 3 uid=33(www-data).

Verified RCE output on PHP 8.4.26:

[+] chunk base = 0x7ffffc400000
[+] libphp base = 0x7ffffe013000
[+] W3 attempt 1
PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)

2. Laboratory state

The original container was created from a /private/tmp/php-vuln directory that no longer exists; after a restart, Docker Desktop's bind mount was empty and the site returned 403 (Server unable to read htaccess file). To make it reproducible:

  • lab/wordpress/docker-compose.yml now uses the named volume wp_data instead of the ./wp bind mount (Docker Desktop applies fakeowner on ~/Downloads and breaks permissions inside the container).
  • The volume was populated from lab/wordpress/wp and the service was started with docker compose -p wp-711 up -d.
  • session.upload_progress.enabled was restored to On (the default). A previous session had left disable-upload-progress.ini in the container, which neutralised the vector; recreating the container removed it.
  • wp-config.php and the database were preserved; the site answers 200.

php2shell-lab.php remains available only on loopback and with a token. It is not used by the exploit: the tooling does not depend on it.

3. Tooling

Path Role
tools/php2shell_auto.py Version-aware dispatcher: fingerprint → catalog → profile → chain.
tools/php2shell_calibrate.py Generates a verified profile for one build.
tools/php2shell_matrix.py Profile factory over a matrix of PHP images.
tools/php2shell_rce.py Manual launcher for the upstream exploit.
tools/php2shell_assess.py Non-destructive exposure/leak assessment.
profiles/ Build profiles (offsets, heap parameters, verified flag).
catalog/vulnerable.json All vulnerable branches and fixed releases.
lab/rce/ Disposable single-worker calibration lab.

Autodetect example, no manual offsets:

python3 tools/php2shell_auto.py -u http://127.0.0.1:8088/index.php \
    -c 'id>/out/p1' --repo-dir /tmp/php2shell-rce \
    --proof-file /tmp/php2shell-rce/out/p1

The dispatcher reads zif_system, zval_ptr_dtor and htdata from the matching profile. If the version is patched, or the profile is not verified for RCE, it refuses to run the chain.

4. Pre-RINIT OOM on 8.3.33

The request uses Transfer-Encoding: chunked and omits Content-Length, so post_max_size = 8M does not bound the body. The multipart body contains, in this order:

  1. PHP_SESSION_UPLOAD_PROGRESS (Session captures the cookie PHPSESSID during parsing);
  2. a file part (triggers MULTIPART_EVENT_FILE_START, which publishes PS(id) with zend_string_init);
  3. a big field of 140 MiB, which exhausts memory_limit = 128M inside rfc1867.c, before RINIT.

The local signature is conclusive:

PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted
(tried to allocate 65016420 bytes) in Unknown on line 0

Unknown on line 0 means the failure happens in the input parser, not in the script. The Apache child survives and the pool keeps serving requests (8/8 subsequent 200s).

5. Why 8.3.33 does not reach RCE

The Wiz chain (docs/php2shell-analysis.md, section 7.1) needs, before writing PG(auto_prepend_file):

  1. the stale PS(id) to materialise in Set-Cookie: PHPSESSID=<memory>;
  2. the leak to resolve the libphp.so base and the PG(auto_prepend_file) address;
  3. a write primitive on that global pointer (heap grooming).

On 8.3.33 the read leak (stages 1 and 2) works, but stage 3 does not. Measured state after the stage-3 "A" request:

PS(http_session_vars) = chunk + 0x56180   (bookworm pads)
PS(http_session_vars) = chunk + 0x56440   (lab pads)
55-byte reference buffers land at chunk + 0x5d068 + n*0xa0
32-byte buffers walk upward from chunk + 0x571d8

No reference buffer lands on PS(http_session_vars). The freed reference block is not in the small-bin free lists observed after the bailout: it may still be alive, coalesced or re-paged. Without placing the fake reference, the request dies with SIGSEGV before zif_system.

That is the current blocker, and it is heap research, not a missing debugger.

6. What is proven

  • The version serving requests is PHP 8.3.33, below the first fixed release of the branch (8.3.36).
  • The SAPI is Apache prefork + mod_php, the highest-exposure scenario.
  • session.upload_progress.enabled = On.
  • The parser processes bodies far above post_max_size when sent with Transfer-Encoding: chunked; 140 MiB reached the parser and exhausted the 128 MiB limit. A single HTTP request becomes a repeatable OOM.
  • The Apache worker does not restart after the bailout: process-global state, including the Session module, survives between requests.
  • Stages 1 and 2 are portable across 8.3/8.4: the calibrator discovers the stage-1 index and the stage-2 array index automatically.
  • On PHP 8.4.26 the full chain executes a command, verified 3/3.

7. Reproduction

cd lab/wordpress
docker compose -p wp-711 up -d

cd ..
python3 tools/php2shell_auto.py --list
python3 tools/php2shell_auto.py -u http://127.0.0.1:8080/ \
    -c 'id>/out/p1' --dry-run

Local evidence of the fatal error:

docker logs wp-711-wordpress-1 --tail 40 | grep -E 'Allowed memory size|Unknown on line 0'

8. Remediation

  1. Update to PHP 8.3.36 (or higher) or a vendor package with an accredited backport; rebuild the image and replace the processes.
  2. In the meantime: session.upload_progress.enabled = Off and restart workers; limit the request body at the proxy/Apache (do not rely only on post_max_size, which chunked bypasses) and apply external memory limits.
  3. Moving from mod_php to PHP-FPM with a low pm.max_requests, or ephemeral containers, reduces the cross-request window but does not fix the bug.
  4. Review logs for anomalous multipart bodies followed by memory fatals and Apache child restarts.

9. Limitations and next step

  • SAPI detection is inferred from headers and container inspection; no phpinfo() was exposed on the target.
  • The OOM is detected remotely by HTTP 500 + connection close; the exact signature (Unknown on line 0) was confirmed in the container logs.
  • To finish 8.3.33, instrument the reference lifetime (ASAN/watchpoint on zend_reference_destroy) and reproduce the bookworm re-paging sequence, or find the bin/offset that reclaims the reference.
  • Do not use this tooling against systems without written authorization.