Files
php2shell/README.md
T
neko 9945223910 Strip repo to UAF RCE exploit only
Removed all exploits that require LFI or FPM access:
- cve-2026-109618/ (OOM + LFI)
- cve-2026-109619/ (session poisoning + LFI)
- session-deser/ (handler mismatch + deserialization chains)
- bypass/ (patch bypasses, all LFI-dependent)
- nolfi/ (served save_path + FPM pathinfo)
- fcgi/ (FastCGI auto_prepend_file)
- wordpress-exploit/ (LFI wrapper)
- wordpress/ (vulnerable plugin)

Moved the UAF RCE exploit (full ASLR bypass, no LFI, no FPM, no
framework) to the repository root. Rewrote README with exploit
details, usage, patch analysis, and test results. Cleaned .gitignore.
2026-10-23 12:24:55 +00:00

11 KiB

CVE-2026-109618 / CVE-2026-109619: Full RCE via OOM Bailout + Heap Spray

Remote code execution in PHP's session upload progress handler, exploitable over plain HTTP against Apache mod_php with completely stock configuration. No LFI, no framework dependency, no non-default PHP settings required.

Affected versions

PHP 5.4.0 through 8.3.35, 8.4.0 through 8.4.26, 8.5.0 through 8.5.11, running under Apache mod_php with prefork MPM.

Patched in PHP 8.3.36, 8.4.27, 8.5.12.

PHP-FPM is not vulnerable: FPM workers exit on bailout (code 70), so stale state never persists across requests.

What this exploit achieves

Full remote code execution (arbitrary command) from a single unauthenticated HTTP client, against any PHP-handled URL, with full ASLR bypass. The target page does not need session_start() or any specific code.

Tested: 5/5 successful RCE against stock php:8.4.26-apache Docker image with zero configuration changes (memory_limit=128M, post_max_size=8M, display_errors=On, MaxRequestWorkers=150).

Prerequisites

All of these are default on stock PHP Docker images and common Debian/Ubuntu Apache + mod_php installations:

  • Apache mod_php with prefork MPM
  • session.upload_progress.enabled = 1 (default: on)
  • session.upload_progress.cleanup = 1 (default: on)
  • Any PHP-handled URL (even a 404 error page)
  • display_errors = On or STDOUT (default when no php.ini is deployed)

The exploit bypasses PHP's post_max_size limit via HTTP chunked Transfer-Encoding (see below), so the post_max_size=8M default is not an obstacle despite needing to send ~140MB to exhaust memory_limit=128M.

For multi-worker Apache (the default), the --workers N spray mode sends the OOM trigger to all workers before probing for the leak.

How it works

The vulnerability chain

CVE-2026-109618: When memory_limit is exceeded during multipart POST parsing, PHP calls zend_bailout() which executes a longjmp back to the request shutdown handler. This skips session cleanup code in php_session_rfc1867_update(), leaving PS(id) as a dangling pointer and PS(send_cookie) = 1.

CVE-2026-109619: On the next request to the same Apache worker, sapi_activate() runs the RFC1867 callback BEFORE session state is properly reinitialized. The callback sees the stale PS(id) and PS(send_cookie) = 1, and calls php_session_send_cookie() with the dangling pointer. The php_url_encode(PS(id)) call reads heap memory through the stale pointer and emits it as a Set-Cookie header.

post_max_size bypass via chunked Transfer-Encoding

PHP checks Content-Length against post_max_size before invoking the multipart parser. If Content-Length exceeds the limit, the body is rejected entirely. However, when the request uses Transfer-Encoding: chunked instead of Content-Length, no upfront size check occurs. PHP's sapi_read_post_block() reads chunked data from Apache's input filter chain without enforcing post_max_size, allowing the full body to be processed regardless of the configured limit.

This means the exploit works against memory_limit=128M with post_max_size=8M by sending ~140MB of chunked POST data.

Three-stage exploit (full ASLR bypass)

All traffic is plain HTTP multipart POST requests:

Stage 1: Heap ASLR bypass (Zend MM chunk base)

Request A sends 1001+ fields (triggering max_input_vars warning which sets SG(headers_sent) = 1) plus PHPSESSID=\x00 and PHP_SESSION_UPLOAD_PROGRESS=pwn (to activate session handling) plus a large field via chunked TE to trigger OOM. The warning output prevents php_session_send_cookie() from sending the cookie, but PS(send_cookie) remains 1 and PS(id) becomes a dangling pointer.

Request B sends PHPSESSID + progress fields plus ~841 filler fields with a forge field containing a fake zend_string header (len = 0x1000). The filler fields' heap allocations overlap the dangling PS(id) address. When php_session_send_cookie() runs with the stale pointer, it reads 4096 bytes of heap data and emits it as a Set-Cookie value. The leaked bytes contain Zend MM free-list pointers, which reveal the chunk base address (2MB aligned).

Stage 2: Code ASLR bypass (libphp.so base)

Similar to stage 1, but request B's field layout places a real PHP HashTable structure at the dangling pointer offset. The pDestructor field of the HashTable is a function pointer into libphp.so (zval_ptr_dtor). Reading this via Set-Cookie leaks the libphp.so load address.

Stage 3: RCE (zif_system)

Request A poisons the worker with stale session state. Request B sprays the heap with a fake IS_REFERENCE zval pointing to a fake HashTable whose pDestructor = zif_system. When session cleanup calls zend_hash_destroy() on this fake table, it invokes zif_system() with attacker-controlled arguments, executing an arbitrary shell command as the web server user (www-data).

Usage

# 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/c' for longer)
python3 rce_exploit.py -u http://target/ -c 'id>/tmp/pwn'

# Extract offsets for a new PHP build
python3 rce_exploit.py calibrate --docker-image php:8.3.35-apache \
  --php-version 8.3.35

Options

Flag Default Description
-u URL (required) Target URL (any PHP endpoint)
-c CMD id>/tmp/pwn Shell command (max 14 bytes)
--workers N 1 Apache workers to spray
--huge-mb N 140 OOM field size in MB
--docker-proof C P Verify via docker exec C cat P
--auto-pull off Pull Docker image for offset extraction
--retries N 5 Attempts per stage
--profile auto Heap profile (bookworm or lab)
--timeout 30.0 Socket timeout in seconds

Built-in offset database

PHP version Distro Status
8.4.26 bookworm Last vulnerable 8.4.x
8.3.35 bookworm Last vulnerable 8.3.x
8.4.27 bookworm Patched (offsets extracted, bypass not implemented)
8.3.36 bookworm Patched (offsets extracted, bypass not implemented)

The patch

The fix (PHP 8.3.36, 8.4.27, 8.5.12) adds five independent defense layers. Any one of the first three alone is sufficient to block the attack:

  1. php_rinit_session_globals() at MULTIPART_EVENT_START: The session RFC1867 callback now calls php_rinit_session_globals() at the very start of multipart parsing, unconditionally zeroing PS(id), PS(session_status), and PS(send_cookie). This destroys the dangling PS(id) from the previous request before B's parsing begins. This single defense is sufficient to kill the entire exploit chain.

  2. Delayed OOM: zend_mm_safe_error (which called zend_bailout / longjmp) is replaced by zend_mm_delayed_verror. OOM no longer triggers a longjmp during allocation. Instead, a soft limit records the error and sets vm_interrupt. Allocations return NULL in fallible mode or succeed up to a hard limit (default 200% of memory_limit). A hard limit calls _exit(137). No longjmp means all cleanup code runs normally, so stale state never persists.

  3. post_max_size enforcement in multipart_buffer_fill(): A new check in fill_buffer() enforces post_max_size against SG(read_post_bytes) regardless of Transfer-Encoding. The chunked TE bypass (which previously allowed sending 140MB despite 8M limit) is blocked. OOM cannot be triggered via multipart data alone.

  4. php_basic_early_rinit(): Called from php_request_startup() before sapi_activate(). Zeros BG(serialize_lock), BG(serialize), and BG(unserialize), eliminating the stale serialization state window from CVE-2026-109618.

  5. Session deserialization hardening: RFC1867 session decode runs in incomplete_classes_only mode (no __wakeup/__unserialize). $_SESSION is deleted at MULTIPART_EVENT_END. A PG(modules_activated) guard prevents request_parse_body() from corrupting session state.

Test environment

The docker/ directory contains a minimal test setup:

cd docker && docker compose up -d
# Stock settings variant (no custom php.ini):
docker run -d --name stock -p 8093:80 php:8.4.26-apache

The exploit has been tested on:

  • php:8.4.26-apache stock Docker image, 5/5 RCE (default settings)
  • php:8.4.26-apache with single worker + memory_limit=8M, 9/9 RCE
  • php:8.3.35-apache stock Docker image, RCE confirmed (first attempt)
  • php:8.4.26-apache + WordPress 6.8.1 (no php.ini), 5/5 RCE (first attempt each)
  • php:8.4.27-apache patched: all three exploit primitives blocked (see below)

display_errors and WordPress

The exploit's info leak (stages 1 and 2) relies on display_errors = On. The max_input_vars warning must produce visible output to set SG(headers_sent) = 1 before the session cookie attempt, which causes php_session_send_cookie() to fail and leaves PS(send_cookie) = 1 for the next request's leak.

With display_errors = Off, the warning is logged but produces no output. The session cookie succeeds, PS(send_cookie) resets to 0, and no leak cookie is emitted on the subsequent request.

Why stock PHP is still vulnerable: PHP's compile-time default for display_errors is On. Stock Docker images (php:*-apache) ship without a php.ini, so the default applies. Only when a system administrator deploys php.ini-production (which sets display_errors = Off) or adds explicit INI configuration is the target hardened against this leak.

WordPress: WordPress calls ini_set('display_errors', 0) at runtime in wp-settings.php. This ini_set executes during script startup, which is AFTER multipart POST parsing (where the OOM and session leak occur). Therefore, WordPress installed on a stock PHP image (no php.ini) is vulnerable, because display_errors is still On at the time the exploit code path runs. The official wordpress:* Docker images ship a custom error-logging.ini that sets display_errors = Off at the INI level, which does block the leak.

Why display_errors=Off cannot be bypassed: The info leak requires SG(headers_sent) = 1 before php_session_send_cookie() runs during MULTIPART_EVENT_FILE_START. The only code path that sets SG(headers_sent) during multipart parsing is the error display path (php_error_cb -> zend_write -> sapi_module.ub_write), which is gated on display_errors. With display_errors = Off, warnings go to the error log only, producing no output. There is no alternative mechanism to force HTTP output during multipart parsing: the SAPI write function is the only path, and it requires display_errors or explicit script output (which has not started yet at this stage of request processing). Precision OOM calibration (attempting to trigger OOM inside the ~500-byte php_session_send_cookie window) was tested and fails due to Zend MM bin-size boundaries causing ~1MB allocation jumps that cannot be calibrated to the required precision.

References

  • CVE-2026-109618: OOM during multipart parsing triggers longjmp past session cleanup
  • CVE-2026-109619: Session globals (PS(id), PS(send_cookie)) survive across requests
  • CVSS: 9.2 (both CVEs, per Rapid7)