PHP 8.4 虚拟属性钩子撞上 opcache.jit=1205:读到相邻属性内存,进程报 zend_mm_heap corrupted
一次真实生产事故复盘。现象是进程崩在 zend_mm_heap corrupted,回溯下来问题不在内存分配器,而在 JIT 把虚拟属性(property hook)当普通属性读了。
现场信息
- 环境:PHP 8.4.23(NTS、Alpine、Docker)+ Swoole/Hyperf
- 关键词:Property Hook、Asymmetric Visibility、OPcache、
opcache.jit=1205、FETCH_OBJ_FUNC_ARG、SIMPLE_GET fast path、hook-enter guard、GH-22857、GH-21369
同时满足的 8 个触发条件
缺一不可——这也是为什么小项目一直碰不到:
opcache.jit=1205(function JIT);disable或 tracing 都不触发;- 类位于 namespace 里;
- 调用未加
\前缀,也没有use function,编译期落到INIT_NS_FCALL_BY_NAME后备解析; - 参数是虚拟 property hook(get-only、无 backing store);
- Hook 直接作为实参,即 opcode
FETCH_OBJ_FUNC_ARG;先赋值到局部变量就变成FETCH_OBJ_R,bug 消失; - 调用被
@包围(BEGIN_SILENCE/END_SILENCE); - 类字段布局:hook 前后各有一个 asymmetric-visibility 属性,外加两个 promoted asym 参数;
- Hook 的
get =>里调用静态方法读 promoted asym 属性。
因果链:后备解析 → FETCH_OBJ_FUNC_ARG → 缺 hook-enter guard
编译期。 类在 namespace 中,调用既不写 \ 前缀也没有 use function,函数名无法在编译期解析,走 INIT_NS_FCALL_BY_NAME 后备解析。结果是 callee 的 arginfo 在编译期未知:引擎只能生成 FETCH_OBJ_FUNC_ARG 而不是 FETCH_OBJ_R,优化器此时无法把这次取属性改写掉。第 5 条里「先赋值到局部变量,bug 消失」正是这个原因——那种写法生成的本来就是 FETCH_OBJ_R。
运行期。 JIT 中 FETCH_OBJ_FUNC_ARG 落进了 default: 分支,只做纯 zend_jit_handler() 调用,没有 hook-enter guard。少了这道守卫,SIMPLE_GET fast path 就把 hook 当成普通属性直接读,读到的是相邻 slot 的内容——字段布局恰好命中第 7 条描述的排布,于是读出来的是隔壁属性的内存。
两种触发路径
fast path 的加速依赖 cache slot 上被打的 bit,而 bit 有两种来源:
- Self-primed:同一条 opline 在上一轮走过 slow path,自己把 bit 打上;
- Sibling-primed:另一条
FETCH_OBJ_R读同一个 property 先打上了 bit,compact_literals把两条 opline 的 cache slot 合并成同一个 slot,于是FUNC_ARG读到了别人打的 bit。
这也解释了为什么第一版修复不够。
修复过程
第一版补丁加在引擎侧,在 zend_std_read_property() 里做 opline 检查。reviewer 指出这个改法对 sibling-slot 变体不闭环:bit 不是自己打的,opline 检查拦不住。
第二版改成纯 JIT 层的 hook-enter guard,形状对齐 tracing JIT 里 GH-21369 修 GH-21006 的做法,并为 FETCH_OBJ_FUNC_ARG 拆出专用 helper zend_jit_fetch_obj_func_arg():by-val 走 zend_jit_fetch_obj(),by-ref 才尾调到 FETCH_OBJ_W——传参模式要等到 DO_FCALL_BY_NAME 才能确定。
教训
PHP 8.4 的 property hook 在没有 backing store 时就是虚拟属性,JIT 的 fast path 必须与 hook 语义严格对齐,任何绕过 hook 语义的取属性路径都是隐患。开了 function JIT(1205)、又在 8.4 上大量使用 hook 的项目,升级后值得先在灰度环境压测。