Files
php2shell/nolfi/FINDINGS.md
T
neko 8147ce0edb 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.
2026-10-21 21:19:25 +00:00

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 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)

  1. Set security.limit_extensions = .php .phar explicitly in every fpm pool (do not leave it empty).
  2. Keep session.save_path outside any web-served tree; audit for alias or location blocks that expose it.
  3. 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.