# CVE-2026-109618 / CVE-2026-109619: Full RCE via OOM Bailout + Heap Spray Remote code execution in PHP's session upload progress handler, exploitable over plain HTTP against Apache mod_php with **completely stock configuration**. No LFI, no framework dependency, no non-default PHP settings required. ## Affected versions PHP 5.4.0 through 8.3.35, 8.4.0 through 8.4.26, 8.5.0 through 8.5.11, running under Apache mod_php with prefork MPM. Patched in PHP 8.3.36, 8.4.27, 8.5.12. PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70), so stale state never persists across requests. ## What this exploit achieves Full remote code execution (arbitrary command) from a single unauthenticated HTTP client, against any PHP-handled URL, with full ASLR bypass. The target page does not need `session_start()` or any specific code. Tested: 5/5 successful RCE against stock `php:8.4.26-apache` Docker image with zero configuration changes (`memory_limit=128M`, `post_max_size=8M`, `display_errors=On`, `MaxRequestWorkers=150`). ## Prerequisites All of these are default on stock PHP Docker images and common Debian/Ubuntu Apache + mod_php installations: - Apache mod_php with prefork MPM - `session.upload_progress.enabled = 1` (default: on) - `session.upload_progress.cleanup = 1` (default: on) - Any PHP-handled URL (even a 404 error page) - `display_errors = On` or `STDOUT` (default when no php.ini is deployed) The exploit bypasses PHP's `post_max_size` limit via HTTP chunked Transfer-Encoding (see below), so the `post_max_size=8M` default is not an obstacle despite needing to send ~140MB to exhaust `memory_limit=128M`. For multi-worker Apache (the default), the `--workers N` spray mode sends the OOM trigger to all workers before probing for the leak. ## How it works ### The vulnerability chain **CVE-2026-109618**: When `memory_limit` is exceeded during multipart POST parsing, PHP calls `zend_bailout()` which executes a `longjmp` back to the request shutdown handler. This skips session cleanup code in `php_session_rfc1867_update()`, leaving `PS(id)` as a dangling pointer and `PS(send_cookie) = 1`. **CVE-2026-109619**: On the next request to the same Apache worker, `sapi_activate()` runs the RFC1867 callback BEFORE session state is properly reinitialized. The callback sees the stale `PS(id)` and `PS(send_cookie) = 1`, and calls `php_session_send_cookie()` with the dangling pointer. The `php_url_encode(PS(id))` call reads heap memory through the stale pointer and emits it as a `Set-Cookie` header. ### post_max_size bypass via chunked Transfer-Encoding PHP checks `Content-Length` against `post_max_size` before invoking the multipart parser. If `Content-Length` exceeds the limit, the body is rejected entirely. However, when the request uses `Transfer-Encoding: chunked` instead of `Content-Length`, no upfront size check occurs. PHP's `sapi_read_post_block()` reads chunked data from Apache's input filter chain without enforcing `post_max_size`, allowing the full body to be processed regardless of the configured limit. This means the exploit works against `memory_limit=128M` with `post_max_size=8M` by sending ~140MB of chunked POST data. ### Three-stage exploit (full ASLR bypass) All traffic is plain HTTP multipart POST requests: **Stage 1: Heap ASLR bypass (Zend MM chunk base)** Request A sends 1001+ fields (triggering `max_input_vars` warning which sets `SG(headers_sent) = 1`) plus `PHPSESSID=\x00` and `PHP_SESSION_UPLOAD_PROGRESS=pwn` (to activate session handling) plus a large field via chunked TE to trigger OOM. The warning output prevents `php_session_send_cookie()` from sending the cookie, but `PS(send_cookie)` remains 1 and `PS(id)` becomes a dangling pointer. Request B sends `PHPSESSID` + progress fields plus ~841 filler fields with a forge field containing a fake `zend_string` header (`len = 0x1000`). The filler fields' heap allocations overlap the dangling `PS(id)` address. When `php_session_send_cookie()` runs with the stale pointer, it reads 4096 bytes of heap data and emits it as a `Set-Cookie` value. The leaked bytes contain Zend MM free-list pointers, which reveal the chunk base address (2MB aligned). **Stage 2: Code ASLR bypass (libphp.so base)** Similar to stage 1, but request B's field layout places a real PHP `HashTable` structure at the dangling pointer offset. The `pDestructor` field of the HashTable is a function pointer into libphp.so (`zval_ptr_dtor`). Reading this via `Set-Cookie` leaks the libphp.so load address. **Stage 3: RCE (zif_system)** Request A poisons the worker with stale session state. Request B sprays the heap with a fake `IS_REFERENCE` zval pointing to a fake `HashTable` whose `pDestructor = zif_system`. When session cleanup calls `zend_hash_destroy()` on this fake table, it invokes `zif_system()` with attacker-controlled arguments, executing an arbitrary shell command as the web server user (`www-data`). ## Usage ```bash # Auto-detect PHP version, look up offsets, exploit python3 rce_exploit.py -u http://target/index.php # Auto-pull matching Docker image for offset extraction python3 rce_exploit.py -u http://target/index.php --auto-pull # Verify RCE via docker exec (for testing) python3 rce_exploit.py -u http://target/index.php \ --docker-proof CONTAINER /tmp/pwn # Multi-worker Apache spray mode python3 rce_exploit.py -u http://target/ --workers 150 # Custom command (max 14 bytes, use 'sh/tmp/pwn' # Extract offsets for a new PHP build python3 rce_exploit.py calibrate --docker-image php:8.3.35-apache \ --php-version 8.3.35 ``` ### Options | Flag | Default | Description | |---|---|---| | `-u URL` | (required) | Target URL (any PHP endpoint) | | `-c CMD` | `id>/tmp/pwn` | Shell command (max 14 bytes) | | `--workers N` | 1 | Apache workers to spray | | `--huge-mb N` | 140 | OOM field size in MB | | `--docker-proof C P` | | Verify via `docker exec C cat P` | | `--auto-pull` | off | Pull Docker image for offset extraction | | `--retries N` | 5 | Attempts per stage | | `--profile` | auto | Heap profile (`bookworm` or `lab`) | | `--timeout` | 30.0 | Socket timeout in seconds | ### Built-in offset database | PHP version | Distro | Status | |---|---|---| | 8.4.26 | bookworm | Last vulnerable 8.4.x | | 8.3.35 | bookworm | Last vulnerable 8.3.x | | 8.4.27 | bookworm | Patched (offsets extracted, bypass not implemented) | | 8.3.36 | bookworm | Patched (offsets extracted, bypass not implemented) | ## The patch The fix (PHP 8.3.36, 8.4.27, 8.5.12) adds five independent defense layers. Any one of the first three alone is sufficient to block the attack: 1. **`php_rinit_session_globals()` at `MULTIPART_EVENT_START`**: The session RFC1867 callback now calls `php_rinit_session_globals()` at the very start of multipart parsing, unconditionally zeroing `PS(id)`, `PS(session_status)`, and `PS(send_cookie)`. This destroys the dangling `PS(id)` from the previous request before B's parsing begins. This single defense is sufficient to kill the entire exploit chain. 2. **Delayed OOM**: `zend_mm_safe_error` (which called `zend_bailout` / longjmp) is replaced by `zend_mm_delayed_verror`. OOM no longer triggers a longjmp during allocation. Instead, a soft limit records the error and sets `vm_interrupt`. Allocations return NULL in fallible mode or succeed up to a hard limit (default 200% of `memory_limit`). A hard limit calls `_exit(137)`. No longjmp means all cleanup code runs normally, so stale state never persists. 3. **`post_max_size` enforcement in `multipart_buffer_fill()`**: A new check in `fill_buffer()` enforces `post_max_size` against `SG(read_post_bytes)` regardless of Transfer-Encoding. The chunked TE bypass (which previously allowed sending 140MB despite 8M limit) is blocked. OOM cannot be triggered via multipart data alone. 4. **`php_basic_early_rinit()`**: Called from `php_request_startup()` before `sapi_activate()`. Zeros `BG(serialize_lock)`, `BG(serialize)`, and `BG(unserialize)`, eliminating the stale serialization state window from CVE-2026-109618. 5. **Session deserialization hardening**: RFC1867 session decode runs in `incomplete_classes_only` mode (no `__wakeup`/`__unserialize`). `$_SESSION` is deleted at `MULTIPART_EVENT_END`. A `PG(modules_activated)` guard prevents `request_parse_body()` from corrupting session state. ## Test environment The `docker/` directory contains a minimal test setup: ```bash cd docker && docker compose up -d # Stock settings variant (no custom php.ini): docker run -d --name stock -p 8093:80 php:8.4.26-apache ``` The exploit has been tested on: - `php:8.4.26-apache` stock Docker image, 5/5 RCE (default settings) - `php:8.4.26-apache` with single worker + `memory_limit=8M`, 9/9 RCE - `php:8.3.35-apache` stock Docker image, RCE confirmed (first attempt) - `php:8.4.26-apache` + WordPress 6.8.1 (no php.ini), 5/5 RCE (first attempt each) - `php:8.4.27-apache` patched: all three exploit primitives blocked (see below) ## display_errors and WordPress The exploit's info leak (stages 1 and 2) relies on `display_errors = On`. The `max_input_vars` warning must produce visible output to set `SG(headers_sent) = 1` before the session cookie attempt, which causes `php_session_send_cookie()` to fail and leaves `PS(send_cookie) = 1` for the next request's leak. With `display_errors = Off`, the warning is logged but produces no output. The session cookie succeeds, `PS(send_cookie)` resets to 0, and no leak cookie is emitted on the subsequent request. **Why stock PHP is still vulnerable**: PHP's compile-time default for `display_errors` is `On`. Stock Docker images (`php:*-apache`) ship without a `php.ini`, so the default applies. Only when a system administrator deploys `php.ini-production` (which sets `display_errors = Off`) or adds explicit INI configuration is the target hardened against this leak. **WordPress**: WordPress calls `ini_set('display_errors', 0)` at runtime in `wp-settings.php`. This `ini_set` executes during script startup, which is AFTER multipart POST parsing (where the OOM and session leak occur). Therefore, WordPress installed on a stock PHP image (no `php.ini`) is vulnerable, because `display_errors` is still `On` at the time the exploit code path runs. The official `wordpress:*` Docker images ship a custom `error-logging.ini` that sets `display_errors = Off` at the INI level, which does block the leak. **Why display_errors=Off cannot be bypassed**: The info leak requires `SG(headers_sent) = 1` before `php_session_send_cookie()` runs during `MULTIPART_EVENT_FILE_START`. The only code path that sets `SG(headers_sent)` during multipart parsing is the error display path (`php_error_cb` -> `zend_write` -> `sapi_module.ub_write`), which is gated on `display_errors`. With `display_errors = Off`, warnings go to the error log only, producing no output. There is no alternative mechanism to force HTTP output during multipart parsing: the SAPI write function is the only path, and it requires display_errors or explicit script output (which has not started yet at this stage of request processing). Precision OOM calibration (attempting to trigger OOM inside the ~500-byte `php_session_send_cookie` window) was tested and fails due to Zend MM bin-size boundaries causing ~1MB allocation jumps that cannot be calibrated to the required precision. ## References - CVE-2026-109618: OOM during multipart parsing triggers longjmp past session cleanup - CVE-2026-109619: Session globals (PS(id), PS(send_cookie)) survive across requests - CVSS: 9.2 (both CVEs, per Rapid7)