FastCGI auto_prepend_file exploit: RCE without LFI on default PHP config
New exploit at fcgi/exploit_fcgi.py combines the session upload progress write primitive (CVE-2026-109618/109619) with direct FastCGI access to PHP-FPM. A slow-drip multipart upload holds one FPM worker in the multipart parser while a second FastCGI request includes the poisoned session file via auto_prepend_file. Works on default PHP configuration, no LFI, no framework, no libraries. security.limit_extensions does not block auto_prepend_file inclusion. php_admin_value in pool config does not block PHP_VALUE from raw FastCGI. Tested and confirmed RCE on: PHP 8.3.6 (local, pre-patch) PHP 8.3.35 (Docker, pre-patch) PHP 8.3.36 (Docker, patched) PHP 8.4.27 (Docker, patched) Mitigated by session.upload_progress.enabled=0 or blocking FPM access. Also includes nolfi/ exploit (served save_path + pathinfo routing) from previous work, and updated README with mitigation matrix for all vectors.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
# RCE Without LFI — Findings
|
||||
|
||||
Question: can CVE-2026-109618 / CVE-2026-109619 (the `PHP_SESSION_UPLOAD_PROGRESS`
|
||||
write primitive) be turned into RCE **without any include() primitive in the
|
||||
application**?
|
||||
|
||||
Short answer: not through the session decoder — PHP never unserializes attacker
|
||||
bytes off the session file, and four independent guards close that route. But
|
||||
yes through a different consumer: **PHP-FPM's own request routing**. When
|
||||
`session.save_path` is web-served, the poisoned session file is directly
|
||||
requestable, and php-fpm executes it through the standard `PATH_INFO` route as
|
||||
soon as `security.limit_extensions` is empty. No application code runs at any
|
||||
point; the poison fires even on 404 URLs.
|
||||
|
||||
Everything below was verified against a local php-fpm 8.5.11 build plus the
|
||||
PHP 8.3/8.5 sources (`session_83.c`, php-src `ext/session/session.c`,
|
||||
`ext/session/mod_files.c`, `ext/session/rfc1867_83.c`).
|
||||
|
||||
## Part 1: why session deserialization is a dead end
|
||||
|
||||
The obvious no-LFI consumer is `session_start()` itself: it decodes the
|
||||
poisoned file on the next request. If the decoded stream could be made to
|
||||
unserialize an attacker-chosen `O:...` object, any autoloadable gadget chain
|
||||
(phpggc) would give RCE with zero LFI. It cannot, for four reasons:
|
||||
|
||||
**1. The `|` guard aborts the whole write** (php handler, the default).
|
||||
|
||||
`PS_SERIALIZER_ENCODE_FUNC(php)` checks every key:
|
||||
|
||||
smart_str_appendl(&buf, ZSTR_VAL(key), ZSTR_LEN(key));
|
||||
if (memchr(ZSTR_VAL(key), PS_DELIMITER, ZSTR_LEN(key))) {
|
||||
... return NULL; /* session.c:993-998 in 8.3 */
|
||||
|
||||
and `php_session_save_current_state()` then writes the **empty string** instead
|
||||
of the session (session.c:529-531). The progress key is `prefix + $_POST value`
|
||||
appended verbatim (session.c:3144-3145, no per-character filtering — the README
|
||||
of CVE-2026-109619 slightly overstates this as "filtering"), so a `|` in the
|
||||
value kills the write entirely.
|
||||
|
||||
Verified: POST with progress value `AAA|O:13:"ExploitCanary"...` → session file
|
||||
exists with **0 bytes**. No object, no data.
|
||||
|
||||
**2. The key is never unserialized.** With `serialize_handler=php`, the decode
|
||||
loop splits at the first `|` and unserializes only the value side
|
||||
(session.c:1024-1048). The value side is always the well-formed progress array
|
||||
written by `php_var_serialize` — length-prefixed, aligned. The raw attacker
|
||||
bytes land in the key, which becomes a plain `$_SESSION` array key.
|
||||
|
||||
**3. Other serializers are aligned or escaped.**
|
||||
- `php_serialize`: key passes through `php_var_serialize` escaping
|
||||
(`"` → `\"`), so a smuggled `O:8:\"...` cannot parse; read-back is a single
|
||||
aligned `unserialize()`.
|
||||
- `php_binary`: encode skips keys longer than 127 bytes (`session.c:928`,
|
||||
`continue`), decode is length-prefixed.
|
||||
- `wddx`: XML, can only produce scalars/arrays.
|
||||
|
||||
**4. The SID cannot carry the payload into a filename.** The rfc1867 flow
|
||||
skips `session.c`'s own SID validation — `PS(id) = zend_string_copy(...)`
|
||||
straight from the cookie/GET/POST-form-field (session.c FILE_START handler) —
|
||||
but `mod_files.c:ps_files_open()` re-validates with `php_session_valid_key()`
|
||||
(`[A-Za-z0-9,-]`, length ≤ 256) before every open, and opens with `O_NOFOLLOW`.
|
||||
The path is always `<save_path>/sess_<SID>` (`ps_files_path_create`), so no
|
||||
traversal, no `sess_x.php`, no symlink. Verified: a dotted SID produces
|
||||
"Session ID is too long or contains illegal characters" and no file.
|
||||
|
||||
Handler-specific corollary: the rfc1867 path hands the raw SID to whichever
|
||||
save handler is configured. `mod_files` is the only built-in that validates.
|
||||
Third-party handlers (phpredis, memcached) receive the SID bytes unvalidated —
|
||||
whether they sanitize before building store keys is deployment-dependent and
|
||||
was **not verified** here; for redis-style handlers a CRLF-capable SID is
|
||||
protocol injection, which is a separate research thread.
|
||||
|
||||
## Part 2: what works — php-fpm consumes the file itself
|
||||
|
||||
The no-LFI consumer is not PHP code, it is the **request router**:
|
||||
|
||||
1. **The poison does not need the application.** The rfc1867 callback runs
|
||||
inside the SAPI POST reader, before script dispatch. A multipart POST to a
|
||||
URL whose script does not exist (fpm answers 404) still writes the poisoned
|
||||
session file. Verified: POST to `/nonexistent.php` → file written. This
|
||||
means *any* PHP-handled vhost URL is a valid injection endpoint.
|
||||
|
||||
2. **If `session.save_path` is web-served, the file is fetchable over HTTP.**
|
||||
`sess_<SID>` has no extension (SID charset has no dot), so a default php-fpm
|
||||
refuses it: `Access denied.` from `security.limit_extensions` (verified).
|
||||
But with **`security.limit_extensions` empty** — a very common production
|
||||
setting — the classic nginx `cgi.fix_pathinfo` route executes it:
|
||||
|
||||
GET /sessions/sess_<SID>/x.php?c=id
|
||||
|
||||
nginx matches `location ~ \.php$` on the URI, resolves the longest existing
|
||||
prefix as the script, and fpm executes the extensionless session file with
|
||||
`PATH_INFO=/x.php`.
|
||||
|
||||
Verified end-to-end against php-fpm 8.5.11: the session file
|
||||
`upload_progress_<?php system($_GET["c"]); ?>|a:5:{...}` executed and
|
||||
returned `uid=1000(...)`. Both limit_extensions states tested.
|
||||
|
||||
3. **Persistence vs the race.** With `session.upload_progress.cleanup=1`
|
||||
(default) the file is truncated at `MULTIPART_EVENT_END`. Persistence
|
||||
options:
|
||||
- `cleanup=0` — file persists with `done=1`; no race needed.
|
||||
- **Streaming request bodies** (Apache mod_php, nginx
|
||||
`fastcgi_request_buffering off`, lighttpd, direct fpm exposure): the
|
||||
write lands at `MULTIPART_EVENT_FILE_START` and the window lasts as long
|
||||
as the upload is dripped. Verified: slow FCGI drip, execution request hit
|
||||
during the window on the first poll.
|
||||
- **Buffering stacks** (nginx default): the whole body arrives, then fpm
|
||||
parses; the window is only the parse time — measured ~16 ms for a 7 MB
|
||||
body. Detection may land, follow-up commands usually miss. Not reliable.
|
||||
- **CVE-2026-109618 OOM bailout** — deterministic: the field flood OOMs the
|
||||
parser, `zend_bailout` longjmps past `MULTIPART_EVENT_END`, cleanup never
|
||||
runs, and the poisoned file persists. Requires the OOM body to fit under
|
||||
`post_max_size` while exceeding `memory_limit` headroom (i.e.
|
||||
`memory_limit <` flood volume `< post_max_size`). Verified: 17 MB flood
|
||||
against `memory_limit=16M`, `post_max_size=64M` → worker died, file
|
||||
persisted, pathinfo execution returned command output.
|
||||
|
||||
## Exploit
|
||||
|
||||
`exploit_nolfi.py` implements the chain with auto-detection:
|
||||
|
||||
- `--probe` classifies each candidate URL prefix: not served / served only
|
||||
(source disclosure) / fully executable.
|
||||
- Default mode poisons + races the upload window while polling execution
|
||||
vectors (`/x.php`, `/index.php`, `/a.phar`, `/x.phtml`), then falls back to
|
||||
calm execution if the file persisted.
|
||||
- `--oom` switches persistence to the CVE-2026-109618 bailout (tune with
|
||||
`--fields` / `--fsize`).
|
||||
|
||||
## Updated mitigation matrix
|
||||
|
||||
| Mitigation | Race | RLIMIT | /proc/fd | served-path (this) |
|
||||
|---|---|---|---|---|
|
||||
| PHP 8.4.27 patch alone | FAIL | FAIL | FAIL | FAIL |
|
||||
| + `upload_progress=0` | OK | OK | FAIL | FAIL (poison gone, but /proc/fd-style upload temp files unaffected) |
|
||||
| + `hidepid=2` on /proc | FAIL | FAIL | OK | FAIL |
|
||||
| + limit_extensions=`.php .phar` | — | — | — | OK |
|
||||
| + save_path outside docroot / not served | — | — | — | OK |
|
||||
| Fix the LFI | OK | OK | OK | **FAIL** |
|
||||
|
||||
"Fix the LFI" no longer closes the bug class: the served-path chain needs no
|
||||
application file-inclusion at all.
|
||||
|
||||
## Recommendations (additions to the existing list)
|
||||
|
||||
5. Set `security.limit_extensions = .php .phar` explicitly in every fpm pool
|
||||
(do not leave it empty).
|
||||
6. Keep `session.save_path` outside any web-served tree; audit for alias or
|
||||
`location` blocks that expose it.
|
||||
7. Keep `cgi.fix_pathinfo=0` where the application does not need PATH_INFO.
|
||||
|
||||
## Test log (local rig)
|
||||
|
||||
- php-fpm 8.5.11 (source build), session.save_path under a docroot served by
|
||||
an nginx-equivalent gateway (static files + `location ~ \.php$` +
|
||||
fix_pathinfo semantics).
|
||||
- `cleanup=0` + empty limit_extensions: poison → served detection → pathinfo
|
||||
exec → interactive command loop. Confirmed RCE.
|
||||
- `cleanup=1` + empty limit_extensions + streaming FCGI drip: execution hit
|
||||
during upload window on first poll. Confirmed RCE.
|
||||
- `cleanup=1` + OOM flood (17 MB, memory_limit=16M): worker bailout, file
|
||||
persisted, exec confirmed. Confirmed RCE.
|
||||
- `cleanup=1` + nginx-style buffered body: ~16 ms window at 7 MB; detection
|
||||
frequently succeeds, sustained command execution unreliable. Documented.
|
||||
- `security.limit_extensions=.php .phar` (default): all execution attempts
|
||||
blocked with "Access denied". Confirmed mitigation.
|
||||
- Session deserialization injection (`|` in progress key): 0-byte file,
|
||||
no unserialization. Confirmed dead end.
|
||||
Reference in New Issue
Block a user