1515> triggers pre-RINIT out-of-memory conditions and kills Apache children.
1616
1717PHP2Shell is a research workspace for the ` session.upload_progress ` OOM /
18- stale-pointer chain in PHP's Apache module. It packages a version-aware
19- dispatcher, a per-build profile engine, reproducible laboratories, a calibration
20- factory and runtime instrumentation around the public research exploit by
21- [ 9bie] ( https://github.com/9bie/php2shell-bypass-aslr-exp ) .
18+ stale-pointer chain in PHP's Apache module, plus the PHP-FPM variant that
19+ exploits the same CVEs through the stale ` BG(unserialize) ` state. It packages a
20+ version-aware dispatcher, a per-build profile engine, reproducible laboratories,
21+ a calibration factory and runtime instrumentation around the public research
22+ exploit by [ 9bie] ( https://github.com/9bie/php2shell-bypass-aslr-exp ) .
2223
2324The goal is reproducible research and defensive validation, not mass
2425exploitation. The dispatcher refuses to guess: it stops when the build has no
@@ -35,6 +36,7 @@ not match the profile.
3536| PHP 8.5.11 amd64 | ` php:8.5.11-apache ` | ` php-8.5.11-bookworm-amd64 ` | yes | yes | ** yes** | separate offsets, same ` htdata ` |
3637| PHP 8.5.10 aarch64 | ` php:8.5.10-apache ` | ` php-8.5.10-bookworm-aarch64 ` | yes | yes | ** yes** | ` uid=33(www-data) ` |
3738| PHP 8.4.26 amd64 | ` php:8.4.26-apache ` | ` php-8.4.26-bookworm-amd64 ` | yes | yes | ** yes** | reference build |
39+ | PHP 8.4.26 FPM | ` php:8.4.26-fpm ` (nginx) | ` 8.4.26-fpm ` | n/a | n/a | ** yes** | ` BG(unserialize) ` chain; worker runs ` uid=1000 ` proof |
3840| PHP 8.4.25 aarch64 | ` php:8.4.25-apache ` | ` php-8.4.25-bookworm-aarch64 ` | yes | yes | ** yes** | probabilistic stage 3; retries |
3941| PHP 8.5.0 aarch64 | ` php:8.5.0-apache ` | ` php-8.5.0-bookworm ` | yes | yes | no | stage-3 layout not found |
4042| PHP 8.3.35 aarch64 | ` php:8.3.35-apache ` | ` php-8.3.35-bookworm-aarch64 ` | yes | yes | no | stage-3 reference alive |
@@ -47,6 +49,9 @@ not match the profile.
4749| PHP 8.2.0 aarch64 | ` php:8.2.0-apache ` | — (offsets only) | n/a | n/a | no | no heap profile |
4850
4951** 13 heap profiles, 5 with verified RCE. Offsets extracted for 12 builds.**
52+ Plus one FPM profile (` 8.4.26-fpm ` ) with verified RCE through the
53+ ` BG(unserialize) ` stale-state chain (no heap-leak stages; the stale struct is
54+ located with the oracle each run).
5055
5156` Heap leak ` is stage 1, ` libphp leak ` is stage 2 and ` RCE ` is a real command
5257proof on an isolated lab. ` no ` means not verified, not necessarily impossible;
@@ -75,6 +80,13 @@ default retries because its stage 3 is probabilistic.
7580- ** Command staging** — on local labs ` -c ` accepts commands of any length,
7681 stages them in ` /out/c ` and captures the output. ` --script ` and ` --interactive `
7782 provide batch and REPL modes.
83+ - ** FPM exploitation chain** — ` tools/php2shell_fpm.py ` exploits the same
84+ CVEs against PHP-FPM behind nginx: an out-of-memory request leaves the
85+ ` BG(unserialize) ` pointer stale, the next request reclaims the struct, and a
86+ forged session variable turns the session shutdown path into ` zif_system ` .
87+ Verified command execution on 8.4.26; ` patchcheck ` proves 8.4.27 is clean.
88+ Requires a ` /proc/<pid>/mem ` oracle (` ptrace_scope=0 ` ), so it runs in the
89+ local laboratory mode (` --builder ` endpoint for the 4 MB arm phase).
7890- ** Calibration factory** — ` php2shell_calibrate.py ` discovers stages 1-2 and
7991 verifies stage 3 with a real proof before writing ` rce: true ` ;
8092 ` php2shell_matrix.py ` runs the factory over a matrix of images.
@@ -139,6 +151,25 @@ python3 tools/php2shell_labs.py status
139151python3 tools/php2shell_inventory.py
140152```
141153
154+ ### PHP-FPM laboratory (no Docker)
155+
156+ The FPM chain runs against a user-prefix PHP build with nginx, using the
157+ ` /proc/<pid>/mem ` oracle (` ptrace_scope=0 ` ):
158+
159+ ``` sh
160+ python3 tools/php2shell_fpm_labs.py up --version 8.4.26
161+ python3 tools/php2shell_auto.py -u http://127.0.0.1:8110/index.php --sapi fpm \
162+ --builder http://127.0.0.1:8111
163+ # or drive the chain directly:
164+ python3 tools/php2shell_fpm.py endgame \
165+ --base http://127.0.0.1:8110 --builder http://127.0.0.1:8111 --version 8.4.26
166+ python3 tools/php2shell_fpm_labs.py down
167+ ```
168+
169+ The consumer request exits with HTTP 502 (the worker dies after firing
170+ ` zif_system ` ); the proof is written by the command itself and served statically
171+ from ` /out/ ` . See [ docs/fpm-chain.md] ( docs/fpm-chain.md ) .
172+
142173## How it works
143174
144175The chain abuses the ` session.upload_progress ` callback that runs ** before**
@@ -158,6 +189,30 @@ the request startup (pre-RINIT) and therefore before the target script:
158189The full technical write-up, including the session lifecycle and the heap
159190grooming, is in [ docs/php2shell-analysis.md] ( docs/php2shell-analysis.md ) .
160191
192+ ### PHP-FPM chain
193+
194+ The FPM lifecycle differs from mod_php: a pre-RINIT OOM kills the worker
195+ outright (` FPM_EXIT_SOFTWARE ` ), so the Apache stage-1/2/3 carrier does not
196+ transfer. The FPM chain instead abuses the stale ` BG(unserialize) ` state:
197+
198+ 1 . ** Arm.** An out-of-memory multipart request aborts between
199+ ` php_var_unserialize_init ` and ` php_var_unserialize_destroy ` , leaving
200+ ` BG(unserialize).data ` dangling and ` level=1 ` . The next request that reaches
201+ RINIT memsets ` BG ` — so the stale window is one request wide.
202+ 2 . ** Locate and reclaim.** The stale 8216-byte ` php_unserialize_data ` struct is
203+ found with the ` /proc/<pid>/mem ` oracle (two candidate heap slots; a mismatch
204+ self-corrects on retry). A 4 MB body posted to a second endpoint (the
205+ builder pool) reclaims the chunk.
206+ 3 . ** Forge and fire.** The reclaimed buffer carries a fake ` zend_reference `
207+ whose ` val ` is a fake array whose ` pDestructor ` is ` zif_system ` . The consume
208+ request attaches it as session variable ` r ` ; at session shutdown
209+ ` zval_ptr_dtor(&PS(http_session_vars)) ` destroys the reference, the array
210+ destructor loop calls ` zif_system(frame) ` with the argument at `execute_data
211+ + 0x50` — a fake ` zend_string` holding the command.
212+
213+ ` cmd_patchcheck ` verifies the stale arm on 8.4.26 and that 8.4.27 (` level=0 ` )
214+ is clean. Details in [ docs/fpm-chain.md] ( docs/fpm-chain.md ) .
215+
161216A profile is per build:
162217
163218``` json
@@ -191,9 +246,12 @@ The shipped calibration targets:
191246 ` session.use_strict_mode=0 ` .
192247
193248A hardened production PHP (128M+, ` display_errors=Off ` , multiple workers, FPM)
194- does not match the shipped calibration. Multi-worker targets are handled with
195- ` --pin-workers ` ; ` display_errors=Off ` and FPM remain open research items (see
196- Limitations).
249+ does not match the shipped Apache calibration. Multi-worker Apache targets are
250+ handled with ` --pin-workers ` ; ` display_errors=Off ` remains an open research
251+ item. The FPM chain has its own requirements: nginx in front of FPM, a
252+ ` /proc/<pid>/mem ` oracle (` yama ptrace_scope=0 ` ), a small consumer pool
253+ (` memory_limit=8M ` , cleanup on) and a larger builder pool (64M, cleanup off)
254+ for the 4 MB arm phase — see [ docs/fpm-chain.md] ( docs/fpm-chain.md ) .
197255
198256## Multi-worker targets
199257
@@ -277,7 +335,9 @@ profiles/*.json heap profiles (offsets, heap params, verified fla
277335profiles/offsets/*.json ELF symbol offsets per image
278336vendor/ generated upstream checkout (git-ignored)
279337tools/
280- php2shell_auto.py version-aware dispatcher
338+ php2shell_auto.py version-aware dispatcher (Apache + FPM)
339+ php2shell_fpm.py PHP-FPM chain client (arm/reclaim/fire)
340+ php2shell_fpm_labs.py user-prefix FPM laboratories (no Docker)
281341 php2shell_calibrate.py profile generator with proof verification
282342 php2shell_matrix.py profile factory over image tags
283343 php2shell_rce.py manual upstream launcher
@@ -292,8 +352,10 @@ tools/
292352lab/
293353 wordpress/ WordPress 8.3.33 + mod_php laboratory
294354 rce/ single-worker calibration lab and configs
355+ fpm/ nginx + PHP-FPM laboratory (8.4.26 / 8.4.27)
295356docs/
296357 php2shell-analysis.md technical analysis
358+ fpm-chain.md PHP-FPM chain and laboratory
297359 rce-guide.md full RCE guide and profile schema
298360 exploit-report.md laboratory results
299361 labs.md laboratory reference
@@ -368,8 +430,10 @@ OOM, which is why the 8.4/8.5 stage-3 carrier does not transfer.
368430
369431## Limitations
370432
371- - ** mod_php prefork only.** PHP-FPM has a different lifecycle; the chain does
372- not transfer as-is and FPM is not supported.
433+ - ** mod_php prefork and FPM.** The Apache chain needs prefork + ` mod_php ` ;
434+ the FPM chain needs nginx + FPM with a ` /proc/<pid>/mem ` oracle
435+ (` ptrace_scope=0 ` ) and two endpoints (consumer + builder pool). Other SAPIs
436+ are not supported.
373437- ** One worker / pinning.** Without pinning, the per-worker session state breaks
374438 stage 1.
375439- ** Per-build offsets.** A profile is tied to a ` libphp.so ` build; the same
@@ -392,6 +456,7 @@ OOM, which is why the 8.4/8.5 stage-3 carrier does not transfer.
392456## Documentation
393457
394458- [ Technical analysis] ( docs/php2shell-analysis.md )
459+ - [ PHP-FPM chain] ( docs/fpm-chain.md )
395460- [ RCE guide and profile schema] ( docs/rce-guide.md )
396461- [ Laboratory results] ( docs/exploit-report.md )
397462- [ Laboratories] ( docs/labs.md )
0 commit comments