请求结束了,指针还活着。这是PHP运行时内部一个寿命错配问题最直白的描述:一个进程级变量保存了指向请求内存的指针,请求结束后内存被释放,变量却留着那个地址。下一个请求进来,读到的是已经释放的内存。
这个缺陷被归入代码坏味道,编号322。它的后果清单不短:释放后使用、远程代码执行、隐藏副作用、陈旧状态跨请求传递、像坏缓存一样的过期值,以及测试很难覆盖的跨请求故障。
关键在于两种寿命被悄悄绑在了一起。服务器进程可以活几个小时、连续处理很多请求;单个请求可能只持续几毫秒。有些变量属于进程,有些只属于某一次请求。把请求的指针存进进程级变量,等于让短命的东西挂在了长命的架子上。
正常路径会重置这个变量。问题出在非正常路径:致命错误可能在重置状态之前中断操作,超时、未捕获错误、内存耗尽都能触发这条路。还有一种情况是延迟写入——关闭流程已经清空了状态,一条迟到的警告又把全局变量填了回去。下一个请求在正常初始化替换它之前,先消费了这份陈旧状态。
攻击者需要两个请求落在同一个worker上:一个留下陈旧值,一个读取它。在合适的内存布局下,这个序列就把释放后使用变成了远程代码执行。错误路径的测试覆盖通常少于正常路径,这让缺陷更容易藏住。
这类坏味道存在于原生代码里,比如语言运行时或扩展,内存由人手动管理。检测办法包括:找出持有请求内存指针、却活在进程作用域的变量;读全局变量附近的注释,承认缺少重置或存在提前读取的注释就是线索;把每个启动初始化和对应的关闭重置做比对,只出现在一边的变量值得怀疑。对称性本身不够,因为延迟写入能在重置之后把旧值带回来。
运行时或原生扩展代码还需要带AddressSanitizer或Valgrind的插桩构建,可能还要关掉运行时自己的内存分配器,让工具看见每一次分配。测试时让连续请求走同一个worker,并故意让第一个失败。报告释放后使用(CWE-416)的静态分析器能抓到一部分。短命的命令行脚本会掩盖问题,因为后面没有请求来读这个陈旧值。
真正属于进程的数据,比如配置或编译缓存,可以活到进程结束。坏味道只在它指向请求拥有的内存或状态时出现。在持久化worker的运行时里,这个问题最要紧——不能指望进程死掉来替你清理。
修复思路是让状态按请求划分作用域、集中所有权、在早期消费者之前初始化、释放前清空引用、覆盖每条退出路径、阻止延迟写入,并测试连续的worker请求。PHP的修复提交31c0ea7d7ff名为“Avoid state corruption via bailout”,同时处理两个CVE:CVE-2026-109619是持久全局变量保留请求状态、代码在正常初始化前消费它;CVE-2026-109618是内存耗尽退出在请求状态只初始化了一部分时中断执行。前者与这个坏味道对应,后者是相关的提前退出问题,只部分吻合。
已修复的正式版本是PHP 8.5.12、8.4.27、8.3.36和8.2.35。PHP 8.1及更早版本不再获得更新。PHP团队为PHP 7.4、8.0和8.1发布了仅源码的回移补丁,部分厂商也提供自己的修复。无法升级到修复版本时,可以查所在发行版的安全公告。
已确认的利用路径取决于SAPI、配置和受影响的状态。Apache mod_php有已演示的路径,PHP-FPM在特定条件下也有记录的暴露路径。换一个SAPI不等于消除风险。配置改动可以挡住特定攻击路径,但不会让存在漏洞的安装变安全。
一个全局变量的寿命可以超过它指针所引用的内存,这个错配本身就是坏味道的定义。
热门跟贴