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.
8.7 KiB
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 throughphp_var_serializeescaping ("→\"), so a smuggledO:8:\"...cannot parse; read-back is a single alignedunserialize().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:
-
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. -
If
session.save_pathis 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.fromsecurity.limit_extensions(verified). But withsecurity.limit_extensionsempty — a very common production setting — the classic nginxcgi.fix_pathinforoute executes it:GET /sessions/sess_<SID>/x.php?c=idnginx matches
location ~ \.php$on the URI, resolves the longest existing prefix as the script, and fpm executes the extensionless session file withPATH_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 returneduid=1000(...). Both limit_extensions states tested. -
Persistence vs the race. With
session.upload_progress.cleanup=1(default) the file is truncated atMULTIPART_EVENT_END. Persistence options:cleanup=0— file persists withdone=1; no race needed.- Streaming request bodies (Apache mod_php, nginx
fastcgi_request_buffering off, lighttpd, direct fpm exposure): the write lands atMULTIPART_EVENT_FILE_STARTand 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_bailoutlongjmps pastMULTIPART_EVENT_END, cleanup never runs, and the poisoned file persists. Requires the OOM body to fit underpost_max_sizewhile exceedingmemory_limitheadroom (i.e.memory_limit <flood volume< post_max_size). Verified: 17 MB flood againstmemory_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:
--probeclassifies 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. --oomswitches 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)
- Set
security.limit_extensions = .php .pharexplicitly in every fpm pool (do not leave it empty). - Keep
session.save_pathoutside any web-served tree; audit for alias orlocationblocks that expose it. - Keep
cgi.fix_pathinfo=0where 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.