# 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.