Back Sploitus Exploit for Improperly Controlled Sequential Memory Allocation in Php
A small HTTP/1.1 reverse proxy using Tokio + Hyper that applies the request-side mitigations recommended by the PHP2Shell advisories (CVE-2026-109618 / CVE-2026-109619). **This reduces exposure; it does not make a vulnerable PHP safe. Updating to PHP 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 is the only complete fix**, and the advisories' `php.ini` mitigations (`session.upload_progress.enabled=0`, `memory_limit`, `post_max_size`) should still be applied.
Requires Rust, Docker Compose, and Python 3 for the integration tests.
# Defaults: LISTEN=127.0.0.1:8080, UPSTREAM=
curl '
curl -X POST --data-binary 'hello'
`UPSTREAM` must be a fixed HTTP origin without credentials, a path prefix, or query. Client Host and absolute-form request targets cannot change the TCP destination. Original Host is preserved for virtual hosting.
The public image is `ghcr.io/centillex/aegis` (Dockerfile at the repository root; production defaults, unprivileged user, fully env-configured). A complete public-demo stack — Caddy (TLS) → Aegis → an affected stock PHP 8.3.35 — is in [`deploy/`](deploy/), sized for the cheapest Lightsail instance and documented end to end in [`deploy/README.md`](deploy/README.md).
`compose.yaml` runs three official `php:*-apache` images, pinned by digest, all bound to 127.0.0.1 only:
| `php` | 18082 | 8.3.35, stock config | Pre-fix release (deliberately vulnerable) |
| `php-patched` | 18083 | 8.3.36, stock config | Patched-runtime control |
| `php-hardened` | 18084 | 8.3.35 + `lab/php-hardening.ini` + `lab/apache-hardening.conf` | The advisory's interim origin mitigations |
Tests verify each runtime's SAPI (`apache2handler`), version and effective INI values. They do **not** establish exploitability or reproduce either vulnerability.
- Fixture: `lab/index.php`, read-only mount, no command execution.
- Container CPU, memory, and process limits are configured.
- Docker bridge networking permits outbound traffic; this is **not an air-gapped sandbox**. Use an egress-isolated disposable VM before running untrusted exploit payloads.
- Do not put credentials, sensitive data, or production mounts in this container.
- Stop the vulnerable runtime after testing: `docker compose down`.
- (CVE-2026-109618)
- (CVE-2026-109619)
-
-
-
-
-
-
- (detection opportunities)
-
-
Both CVEs are reached through input PHP parses during request startup, before application code runs (php.net advisory 2026-10-21, GHSA-w8hw-w9fx-j79w, GHSA-6wmf-4rv5-p3rp). The proxy bounds that input and fails closed where it cannot interpret a request the way PHP would.
| Check | Default | Env var | Advisory link |
| Decoded body size (declared and streamed) | 8 MiB → 413 | `MAX_BODY_BYTES` | 109618: `post_max_size`, limits must cover requests without `Content-Length` |
| Form/multipart bodies buffered, inspected, forwarded with a definite `Content-Length` | always | — | 109618: multipart without usable content length |
| Headers per multipart part / header bytes per part | 8 / 4096 → 403 | `MAX_PART_HEADERS`, `MAX_PART_HEADER_BYTES` | 109618: unbounded part headers |
| Multipart parts | 256 → 403 | `MAX_MULTIPART_PARTS` | 109618 |
| File controls, **including empty ones** | 20 → 403 | `MAX_FILE_UPLOADS` | 109618: empty file controls bypass `max_file_uploads` |
| Variable nesting depth (query, cookie, urlencoded, multipart; `%5B` counted) | 8 → 403 | `MAX_INPUT_NESTING` | 109618: nested names, `max_input_nesting_level` |
| Variables per query / cookie / urlencoded body | 1000 → 403 | `MAX_INPUT_VARS` | 109618: input field count |
| Field-name length (query, cookie, urlencoded, multipart; raw bytes) | 1024 → 403 | `MAX_NAME_BYTES` | 109618: published exploit uses a ~3.5 MB urlencoded field name (Datadog) |
| Multipart filename length (`filename=`) | 1024 → 403 | `MAX_NAME_BYTES` | 109618: long repeated-character filenames groom memory; the name is also written to the session unvalidated (Datadog, lilting.ch) |
| Raw control bytes (other than TAB/CR/LF) in urlencoded bodies | 403 | — | 109618: published exploit carries raw NUL/binary bytes (Datadog) |
| **Serialized-PHP grammar** in any request input value (query, cookie, urlencoded, multipart) or multipart filename: `O::"Class"`, `C::"class"`, `R:;`/`r:;` back-references, `a::{` beyond the variable limit | 403 | — | A grammar gate, not a signature: `unserialize()` can only consume this grammar, so every POP chain (any gadget class, any framework) and the Chain B var_hash-overflow trigger must produce these tokens in some decoded input. Values are scanned after percent-decoding, mirroring PHP's own input parsing (a raw `;` in a cookie is the cookie separator — PHP drops it — so real deliveries encode it). Not seen through: application-level encoding (base64/encrypted cookies) and non-request delivery channels — origin governance. Multipart file contents are not scanned: any binary upload may legitimately contain these bytes |
| Known tool signatures: User-Agent containing `php2shell`, multipart boundary starting `----p2s` | 403 | — | Observed scanners (Ellio, VulnCheck). Trivially changed, so this only sheds known tooling |
| Multipart field named the session upload-progress key (case-, quote-, backslash- and fold-insensitive) | blocked → 403 | `UPLOAD_PROGRESS_NAME` (default `PHP_SESSION_UPLOAD_PROGRESS`) | 109619: confirmed session path |
| Block all multipart | off | `BLOCK_MULTIPART=1` | 109618: block multipart if the app doesn't use it |
| Request `Content-Encoding` other than `identity` | 415 | — | inspected size must equal decoded size |
| Duplicate `Content-Type`, missing/ambiguous boundary, unterminated quotes, duplicate `Content-Disposition` or `name`/`filename` | 400 | — | fail closed on parser differentials |
Content-Type classification mirrors PHP (`sapi_read_post_data`: lower-cased, truncated at `;`, `,` or space), and boundary extraction mirrors `rfc1867.c`. Multipart delimiters are matched at every line start, including the closing delimiter, which over-counts relative to PHP rather than under-counting. Blocked requests are logged as `blocked : ` (query strings are not logged).
| Check | Default | Env var | Rationale |
| `Set-Cookie` for the session cookie whose value isn't `[0-9A-Za-z,-]{0,256}` → upstream response replaced with 502 and its connection discarded | on | `SESSION_NAME` (default `PHPSESSID`) | Corrupted session state leaking memory (Wiz indicator: large/malformed `PHPSESSID` response cookies) |
| Per-client strikes: each blocked request (except 408) and each upstream 500 is a strike; reaching the limit within the window bans the IP (429) | 10 per 60 s → 300 s ban | `STRIKE_LIMIT` (0 disables), `STRIKE_WINDOW_SECS`, `BAN_SECS` | 109619 needs a fatal-error producer request; exploitation needs repeated attempts (Wiz: repeated 500s) |
| One request per upstream connection | off | `UPSTREAM_REUSE=0` | Lets origin-side one-request-per-worker settings actually take effect (see below) |
| Report-only mode: 403/502 policy decisions are logged as `report-only ...` and forwarded; strikes are not recorded; 400/408/413/415 framing rules are still enforced | off | `POLICY_MODE=report` | Tune limits against real traffic before enforcing. **Not a protection mode.** |
Strikes are keyed on the TCP peer address. Behind a CDN or NAT many users one address, so raise `STRIKE_LIMIT` or disable it there. The table is capped at 65,536 addresses. When it is full and nothing has expired, new addresses are not tracked, so tracking fails open instead of evicting active bans.
### Origin hardening (required alongside the proxy)
Some attack paths are server configuration rather than request content, so no proxy can filter them. Apply these at the origin. `php-hardened` in the lab demonstrates them, and `tests/defense.py` verifies them.
- **`lab/php-hardening.ini`**: `session.upload_progress.enabled=0`, `memory_limit=-1` (with a cgroup memory limit, not `RLIMIT_AS`/`RLIMIT_DATA`), and `post_max_size`, `max_input_vars`, `max_input_nesting_level` and `max_file_uploads` matching the proxy. Load it from `php.ini`/`conf.d` or `php_admin_value`, never from `ini_set()` or `.user.ini`, and restart every worker.
- **No per-request INI overrides** (the 109619 early-diagnostic path): `AllowOverride None` so `.htaccess` `php_value` is ignored. For FPM, make sure the web server never sets `PHP_VALUE`/`PHP_ADMIN_VALUE` from anything request-derived, and remove invalid or deprecated INI settings.
- **Fresh worker per request**: 109619 needs stale state to survive into a later request on the *same* worker. `lab/apache-hardening.conf` sets `MaxConnectionsPerChild 1` (FPM: `pm.max_requests=1`). The proxy pools upstream connections by default, so many requests still one Apache child. `tests/defense.py` shows a repeated PID with pooling and distinct PIDs with `UPSTREAM_REUSE=0`. Use both together, and expect a fork per request.
- On PHP 8.5, also set `fatal_error_backtraces=0`.
**Still not covered:** OOMs, fatal errors and execution timeouts reachable from application code after startup, other than through the 500 strike signal. Limits are heuristics derived from the advisory text, not from a reproduced exploit, so passing the test suites is not evidence of exploit prevention.
`tests/prod_check.py` confirms a deployment is in the request path and enforcing. It sends one benign request per trigger class through the real URL, with no exploit payloads, and requires each one to be rejected *by Centillex Aegis*: the response must carry Centillex Aegis's block message, not another layer's.
python3 tests/prod_check.py # 6 blocked probes
python3 tests/prod_check.py --size # + 9 MiB chunked upload
It exits non-zero if any probe reaches PHP. That happens in report-only mode, or when traffic can bypass the proxy. Also run it against the origin's own address from outside: if the origin is reachable directly, every probe will reach PHP, and Centillex Aegis protects nothing. Firewall the origin so only Centillex Aegis can connect to it. The probes stay under the default strike limit; wait 60 s between runs from the same address.
The checks prove that Centillex Aegis is in the path and rejects every trigger class the advisories and published analyses describe. They **do not prove that an unpatched PHP cannot be exploited.** php.net: "There are no complete mitigations, updating is the only safe choice." Datadog notes a successful exploit can return HTTP 200 and leave the worker running, and Ellio notes source-IP blocking has limited value against rotating infrastructure. Pair the proxy with origin-side detection, because Centillex Aegis cannot see these signals:
- PHP `Fatal error: Allowed memory size ... exhausted ... in Unknown on line 0`: the OOM happened during request startup, before any script ran. `in Unknown on line 0` is the discriminator; a genuine script OOM cites a file and line.
- `Failed to decode session object` in the PHP log, and especially one session ID accumulating repeated decode failures: the stale-state path for CVE-2026-109619 (Blevene gist).
- Worker crashes: Apache `AH00052: child pid ... exit signal Segmentation fault (11)`, or FPM master `exited on signal 11 (SIGSEGV)` — a failed exploit attempt (Datadog, Blevene gist). Correlate with an OOM/decode error on the same worker within ~15 min.
- The web server user (`www-data`, `apache`) spawning `sh`, interpreters, or network tools (`curl`/`wget`/`nc`), or a worker running from a `memfd`: the clearest sign of compromise.
- New PHP files in web roots, or files in `/tmp`, `/dev/shm`, `/var/tmp`.
Because a successful exploit can return 200, treat these origin-side receipts as the real alarm; Centillex Aegis's block count is only a measure of attempts turned away.
The strongest evidence short of patching is to replay the public proof of concept against the lab through Centillex Aegis. Do this in an egress-isolated VM, not on this Docker bridge network.
The full public toolkit ([ikow/php2shell-poc]( was replayed against the lab's stock, unhardened `php:8.3.35-apache` origin, and against the PoC's own FPM lab (php 8.2.35, `session.upload_progress` on, `cleanup=Off`, an LFI sink, per-directory serialize handlers), with Centillex Aegis in front at production defaults. Benign commands only (`id`).
**Chain A (session injection + LFI → pre-auth RCE), the conclusive pair:**
| Direct at origin | `[+] RCE CONFIRMED: uid=33(www-data)` — first attempt; the raw `` payload sits verbatim in the session file via the upload-progress filename, then the LFI sink includes it |
| Through Centillex Aegis | Every plant request 403 `session upload progress field`; the follow-up LFI hits only a nonexistent session file (harmless PHP warning); no session file was ever created for the exploit's SID; RCE not confirmed |
**Chain C (handler mismatch → `__destruct` → RCE):** unguarded, the gadget (`x|O:4:"Evil":1:{...}`) was planted verbatim into the session file, but the stale-state trigger did not complete in this lab (CVE-2026-109619's producer/consumer timing), so the full chain was not demonstrated either way. Through Centillex Aegis, every injection upload was 403'd, the strike ban (429) then stopped the remaining steps, and no gadget session file was ever created.
**CVE-2026-109618 OOM verifiers and the Chain D trigger (reconstructed):** the public verifiers' three strategies are blocked at the correct policy line (403 part-headers / 403 nesting / 413 body size). The public verifiers never crashed any origin tested (they use the wrong shapes), but the Chain D trigger — fully specified in the repo's `docs/ANALYSIS.md` ("300+ form fields with **distinct** names × ~100KB values, totaling > `memory_limit`, OOM during rfc1867 parsing") — was reconstructed and fired, unguarded, on PHP 8.2.35 mod_php with the PoC's crash-config: `Fatal error: Allowed memory size of 33554432 bytes exhausted ... in Unknown on line 0` — the documented `Unknown on line 0` receipt (OOM in SAPI, before any script). Through Centillex Aegis the same trigger is 413'd (`declared body size` / `body exceeds size limit`, both with and without `Content-Length`), and nothing reaches the origin. Two reconstruction notes: parts must use **distinct** names (same-named parts overwrite each other in `$_POST` and never grow the peak allocation), and on a *truly stock* config (`post_max_size=8M`, `memory_limit=128M`) the trigger cannot fire at all — the exposure is hosts that raised `post_max_size` (or lowered `memory_limit`) so parsed input can approach `memory_limit`; Centillex Aegis's 8 MiB cap enforces that margin at the proxy regardless.
**Chain E (FPM session-decode OOM → stale `BG(unserialize)`):** every wire-crossing step — the producer's session-building uploads *and* the consumer POST — carries `PHP_SESSION_UPLOAD_PROGRESS`, so Centillex Aegis 403s both and the chain's session file is never created (verified: no session file ever written through the proxy). Direct, both steps pass unguarded. The end-to-end stale-`BG` crash could not be reproduced unguarded in this lab (PHP's `max_file_uploads=20` caps per-upload progress entries, and session writes above ~2 MB silently fail, so the ~30 MB producer session file could not be built) — that stage remains untested.
The RCE-conversion halves of Chains D/E (heap spray to turn the crash into controlled execution) remain paywalled and untested — but each chain dies at its first request through Centillex Aegis, before any stage of the exploit can begin.
The replay also found a real Centillex Aegis bug (now fixed, with regression tests): a boundary whose value contains the word "boundary" (the PoC's `OOM_BOUNDARY_TEST`, Chrome's `----WebKitFormBoundary...`) was rejected as an ambiguous parameter, which would have 400'd every Chrome upload.
The policy suite (36 checks) passes unchanged in front of the latest affected release of every line, EOL or supported, each in its stock configuration (`upload_progress` on by default, `use_strict_mode` off):
| Image | Running | EOL | upload_progress | Policy suite |
| `php:5.6.40-apache` | 5.6.40 | Jan 2019 | on | 36/36 |
| `php:7.4.33-apache` | 7.4.33 | Nov 2022 | on | 36/36 |
| `php:8.1-apache` | 8.1.34 | Dec 2025 | on | 36/36 |
| `php:8.2.34-apache` | 8.2.34 | — | on | 36/36 |
| `php:8.3.35-apache` | 8.3.35 | — | on | 36/36 |
| `php:8.4.26-apache` | 8.4.26 | — | on | 36/36 |
| `php:8.5.11-apache` | 8.5.11 | — | on | 36/36 |
The CVE-2026-109619 mechanism was additionally verified per version: direct, the session-fixation plant (POST-body `PHPSESSID` plus an upload-progress field) writes a session file at an attacker-chosen ID on every version from 5.6.40 to 8.5.11 — the vulnerable code really is identical across fourteen years of releases; through Aegis, every plant is refused (403) and no session file is created. The `unserialize()` sink check (Chain B's delivery) likewise consumes the payload direct on every version and is unreachable through the proxy on every version. The lab fixture is written in PHP 5.4-compatible syntax so the same file tests the whole matrix.
Cloudflare has not published its rule logic. Its changelog lists seven "Generic Rules - Anomalous Execution" rules (2026-10-21, action Block) that "detect anomalous request patterns". It also runs a *full-transaction* response rule, "Default block for High confidence Signatures", which can replace an origin response that shows exploit confirmation. The layering is the same as Centillex Aegis's: structural request limits plus a response check. Centillex Aegis's response check (malformed session cookie) is narrower than Cloudflare's undisclosed signatures. Cloud WAFs inspect only the first part of a request body (Abstract Security), while Centillex Aegis parses the whole body up to `MAX_BODY_BYTES`, which matters for the size-based trigger classes.
- Streaming request/response bodies with Hyper backpressure. Two kinds of request body are buffered instead, bounded by `MAX_BODY_BYTES` and forwarded with a definite `Content-Length`: form bodies (`application/x-www-form-urlencoded`, `multipart/form-data`) and any body without a declared length (chunked). An oversized body therefore never partially reaches PHP.
- Shared upstream connection pool (disable with `UPSTREAM_REUSE=0`), HTTP keep-alive, TCP_NODELAY.
- Hop-by-hop headers removed, including Connection-nominated headers.
- Client-supplied standard forwarding identity headers replaced for a directly exposed HTTP edge.
- Maximum 1,024 concurrent downstream connections; excess connections get a best-effort 503 and are closed. Accept errors are logged and retried, never fatal.
- 10-second request-header timeout (64 KiB header buffer, 100 headers), 5-second upstream connect timeout, 30-second deadline through upstream response headers.
- 15-second idle timeout on request and response bodies; 30-second deadline to receive a buffered form body (408).
- Upstream connection failures return 502; response-header deadline returns 504.
HTTP/1.1 only. No TLS, WebSocket/CONNECT tunnels, HTTP/2, trailer preservation, graceful shutdown, or production observability. Request methods, paths, bodies, status codes, and end-to-end headers pass through subject to HTTP framing and the documented header policy; this is not a byte-transparent TCP proxy. Errors after response headers have been sent terminate the stream rather than changing its status. Slow-but-not-idle clients can still occupy connection slots. There is no per-IP connection or rate limit apart from strike bans.
This is a performance-oriented baseline, **not a benchmarked high-throughput claim**. Forwarding headers currently assume plaintext direct-edge operation; review this before placing it behind a TLS terminator or another trusted proxy.
cargo clippy --all-targets --locked -- -D warnings
for origin in "18082 8.3.35" "18083 8.3.36" "18084 8.3.35"; do
ORIGIN_PORT=$1 EXPECTED_VERSION=$2 python3 tests/passthrough.py
ORIGIN_PORT=$1 EXPECTED_VERSION=$2 python3 tests/policy.py
Each Python suite starts its own temporary proxy on `127.0.0.1:18081` and stops it afterward. Ports 18081–18084 must be free. `ORIGIN_PORT` and `EXPECTED_VERSION` choose the lab origin (default: 18082 / 8.3.35). All requests are benign shapes; there are no exploit payloads.
- `tests/passthrough.py` (12 checks): runtime identity, encoded/duplicate query parameters, Host/custom headers, methods and bodies, 2 MiB binary transfer, chunked uploads, status/duplicate cookies, HEAD, hop-by-hop stripping, forwarding identity, unsupported tunnels/upgrades, keep-alive, concurrent clients, and upstream failure handling.
- `tests/policy.py` (26 checks): ordinary and chunked forms still reach PHP with a definite `Content-Length`, and each request-policy row above is blocked with the expected status. One check confirms the unguarded origin accepts a 3 MB field name that Centillex Aegis blocks; another confirms the multipart policy applies to `PUT`, not only `POST`, since PHP 8.4+ parses multipart bodies on other methods.
- `tests/defense.py` (7 checks): strike bans from policy violations and from upstream 500s, the malformed session-cookie response block, report-only mode, chunked non-form bodies, the hardened origin's effective INI, and worker isolation with and without `UPSTREAM_REUSE=0`.
Running passthrough and policy against the patched and hardened origins confirms the proxy behaves the same in front of each. Unit tests in `src/` cover parser edge cases, the body guard, and strike accounting.
- Run `POLICY_MODE=report` against real application traffic, review the `report-only` log lines, and tune the limits before enforcing.
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.
