# 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 `/sess_` (`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_` 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_/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_|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.