Fix UAF exploit to use confirmed two-request OOM approach

The original exploit put the file upload before the field flood,
so the progress handler's serialize completed before OOM fired.
This meant BG(serialize) was not left stale.

The corrected flow:
  Phase 1: Create ~500KB session file via large upload progress value
  Phase 2: Fill memory with fields, then file upload triggers session
           re-init which OOMs during unserialize of the large session
  Phase 3: Verify stale BG(unserialize) state on the surviving worker

Confirmed on PHP 8.3.6, Apache 2.4.58: error log shows
"tried to allocate 503808 bytes" during session handling,
followed by "Session ini settings cannot be changed when a
session is active" on subsequent requests.
This commit is contained in:
neko
2026-10-21 22:17:29 +00:00
parent 76bc2bc2a3
commit a5396a6ab0
3 changed files with 490 additions and 443 deletions
+32 -19
View File
@@ -101,33 +101,46 @@ This is a stable write primitive: the session file persists on disk with
embedded PHP code, accessible at a known path
(`session.save_path/sess_<SID>`).
### The UAF chain
### The UAF chain (two-request approach)
1. **Request 1** (OOM):
- Multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` + field flood
- Progress handler calls `php_var_serialize()`:
- `php_var_serialize_init()` -> `emalloc(64)` -> `BG(serialize).data`
- `BG(serialize).level = 1`
- Field flood triggers OOM -> `zend_bailout()`
- `php_var_serialize_destroy()` never runs
- `BG(serialize).level` stays at 1, `.data` points to freed 64-byte slot
1. **Request 1** (prep):
- Multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` = ~500KB value
- File upload triggers progress handler
- `php_session_rfc1867_update()` writes session file with the ~500KB key
- No OOM; just creates the large session file on disk
2. **Request 2** (OOM):
- Same `PHPSESSID` cookie (references the large session file)
- `PHP_SESSION_UPLOAD_PROGRESS` = small value
- Form fields fill memory to near `memory_limit` (~7.5MB of 8MB)
- File upload triggers progress handler:
- `php_session_initialize()` -> reads ~500KB session file
- `php_session_decode()` -> `php_var_unserialize_init()`:
- `BG(unserialize).level = 1`
- `BG(unserialize).data = emalloc(64)` (64-byte ZendMM bin)
- Unserialize of the ~500KB key string: `emalloc(~500KB)` -> **OOM**
- `zend_bailout()` (longjmp)
- `php_var_unserialize_destroy()` never runs
- Worker survives on Apache prefork
- `BG(unserialize).level` stays at 1, `.data` points to freed 64-byte slot
- Confirmed by error log: "tried to allocate 1003520 bytes" during
session handling
2. **Between requests**:
3. **Between requests**:
- `php_request_shutdown()` calls `zend_deactivate()` -> ZendMM heap destroyed
- `BG(serialize).data` pointer is now dangling (points into freed heap)
- ZendMM reuses chunk cache for the next request's heap
- `BG(unserialize).data` pointer is now dangling (freed heap)
- ZendMM chunk caching preserves mmap'd pages; next request's heap
reuses the same virtual addresses
3. **Request 2** (spray + trigger):
4. **Request 3** (spray + trigger):
- POST fields allocate `zend_string` objects via `emalloc`
- Strings of 39 bytes (24-byte header + 39 + 1 NUL = 64) land in the
same 64-byte bin, potentially reclaiming the stale slot
- Upload progress handler calls `php_var_serialize()`:
- `php_var_serialize_init()` checks `BG(serialize).level > 0`
- Level is 1 (stale), so it increments to 2 and returns the stale
`.data` pointer
- Upload progress handler calls `php_session_decode()`:
- `php_var_unserialize_init()` checks `BG(unserialize).level > 0`
- Level is 1 (stale), so it increments to 2 and returns `.data`
- The pointer now references attacker-controlled spray data
- The serialize code treats it as a `php_serialize_data` struct
- The unserialize code treats it as a `php_unserialize_data` struct
containing a `HashTable`
- `pDestructor` (offset 48) is called during `zend_hash_destroy()`
- If `pDestructor` points to `system()`, `zend_execute_ex()`, or a
@@ -232,4 +245,4 @@ and the stale BG globals persist.
- **Rapid7**: RCE over HTTP with default PHP config; mod_php highest risk; attack complexity HIGH
- **Wiz**: Confirmed RCE against stock WordPress, Drupal, Joomla on affected versions
- **0x4148 lab**: Heap grooming targets ZendMM 320-byte slab bin (note: our analysis finds 64-byte bin for `php_serialize_data`)
- **Sansec**: Disabling `session.upload_progress.enabled` alone is NOT sufficient (stale BG state persists from any OOM during multipart)
- **Sansec**: Analysis of mitigation options and affected versions