Document five patch defense layers and display_errors=Off analysis
- Expand patch section: five independent defenses, not three - Defense 1 (rinit_session_globals at MULTIPART_EVENT_START) alone is sufficient to block the entire exploit - Document post_max_size enforcement that blocks chunked TE bypass - Add display_errors=Off impossibility analysis with technical detail - Confirm PHP 8.3.35 RCE working (not just stages 1+2) - Note 8.4.27 tested and confirmed blocked
This commit is contained in:
+46
-14
@@ -159,23 +159,39 @@ python3 rce_exploit.py calibrate --docker-image php:8.3.35-apache \
|
||||
|
||||
## The patch
|
||||
|
||||
The fix (PHP 8.3.36, 8.4.27, 8.5.12) adds three independent defense
|
||||
layers. Any one of them blocks the attack:
|
||||
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_basic_early_rinit()`**: Called from `php_request_startup()`
|
||||
AFTER `zend_activate()` but BEFORE `sapi_activate()`. Zeros
|
||||
`BG(serialize_lock)`, `BG(serialize)`, and `BG(unserialize)` with
|
||||
`memset`. Eliminates the stale state window entirely.
|
||||
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_bailout()` on OOM is removed. The allocator
|
||||
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)`.
|
||||
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. **Session handler hardening**: RFC1867 session decode runs in
|
||||
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`).
|
||||
`php_rinit_session_globals()` at `MULTIPART_EVENT_START`.
|
||||
`$_SESSION` cleanup at `MULTIPART_EVENT_END`.
|
||||
`$_SESSION` is deleted at `MULTIPART_EVENT_END`. A `PG(modules_activated)`
|
||||
guard prevents `request_parse_body()` from corrupting session state.
|
||||
|
||||
## Test environment
|
||||
|
||||
@@ -190,7 +206,8 @@ 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 (stages 1+2 confirmed, stage 3 calibrating)
|
||||
- `php:8.3.35-apache` stock Docker image, RCE confirmed (first attempt)
|
||||
- `php:8.4.27-apache` patched: all three exploit primitives blocked (see below)
|
||||
|
||||
## display_errors and WordPress
|
||||
|
||||
@@ -219,6 +236,21 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user