cookiengineer / actual-php2shell
git clone https://github.com/cookiengineer/actual-php2shell.git
:robot: NO, There is an easy way to exploit this vulnerability! (vibecoded to prove a point)
+47
stars, 24h · #833
+47
stars, 7 days · #4,351
+47
stars, 30 days · #13,874
47
★ total · +47% this week
10
forks · 0 open issues
Star history starts 2026-10-23; the chart appears after the second daily snapshot.
LanguageHTML
License—
Created2026-10-22 (today)
Last pushtoday
Watchers0
OwnerUser
Links
README excerpt
# actual-php2shell
A reproducible, application-independent remote code execution PoC against PHP
8.5.11 (the October 2026 advisories). It turns the two 2026 PHP memory-corruption
advisories into `system("cat /etc/passwd")` with **no application code** — the
victim's `index.php` just echoes `ok`.
- **CVE-2026-109618** — OOM `longjmp` abandons a half-built `HashTable`, leaving
a dangling bucket that shutdown destroys. **This is the RCE primitive.**
- **CVE-2026-109619** — request-owned error state (the fatal-error backtrace)
survives request shutdown and is re-freed by the next request. **This is the
info-leak / ASLR-bypass primitive** (a separate demo here).
Everything is fully reproducible in Podman containers. The confirmed end-to-end
result is `./exploits/cve-2026-109618-oom/rce.py` reading `/etc/passwd`.
---
## 1. The vulnerability (CVE-2026-109618)
When PHP runs out of memory it calls `zend_mm_safe_error()` →
`zend_error_noreturn()` → `zend_bailout()` (`longjmp`). In 8.5.11 the hash-table
insert is **not** allocation-fallible, so an OOM can unwind with a `HashTable`
half-modified.
The corrupting site is `_zend_hash_str_add_or_update_i()`'s `add_to_hash`
(`Zend/zend_hash.c:960`):
[code]
If the key copy `zend_string_init` OOMs, the bucket `p` is left **unwritten**
(`key`/`h`/`val` all stale) but `nNumUsed` is already incremented. At request
shutdown `zend_array_destroy()` walks `nNumUsed` buckets and calls
`i_zval_ptr_dtor(&p->val)` on the unwritten bucket's garbage `val` — an
attacker-shaped value. The 8.5.12 fix replaces every such allocation with
fallible variants (`femalloc`/`fperealloc2` + `HASH_FALLIBLE`) so the insert
returns cleanly instead of bailing out.
## 2. The object-graph / heap-feng-shui balance
The exploit is entirely about getting **one controlled 2560-byte block** to be
the `arData` of the `HashTable` whose last bucket is the OOM bucket. The values
that had to be balanced, and why:
| Type / variable | Size | Role |
|---|---|---|
| `zend_array` (`HashTable`) header | 56 B | `arData`, `nNumUsed`, `nTableSize`, `pDestructor` |
| `Bucket` | 32 B | `val`(16) + `h`(8) + `key`(8); the unwritten one is our target |
| `zval` | 16 B | `value`(8) + `u1.type_info`(4) + `u2`(4) — we set `type_info` |
| `zend_string` | 24 B header + data | the pad value; its length picks the allocator bin |
| `zend_object` | 56 B | fake object: `gc`(8) `handle`(4) `ce`(8) `handlers`(8) `properties`(8) |
| `zend_object_handlers` | `offset`/`free_obj`/`dtor_obj` | `free_obj` is the pivot gadget |
| `zend_mm_heap` / `zend_mm_chunk` | — | `free_slot[]`, `free_tail`, `map[]` (bin 28 = 2560 B) |
The two allocator facts that the whole feng-shui turns on:
1. **The arData of a 64-bucket table is exactly 2560 bytes = bin 28**
(`HT_SIZE_EX(64) = 64*32 buckets + 128*4 hash = 2560`), because
`HT_SIZE_TO_MASK(n) = -(2n)`.
2. **`b150` (walked) and `b0e0` (discarded) are two copies of `$_POST["a"]`**
created by the symtable `SEPARATE_ARRAY`/indirect path; each
`a