CatchAdmin PHP 后台管理框架 Logo CatchAdmin

PHP 8.5.10 与 8.4.25 补齐多项数组与 DOM 操作的栈溢出防护

PHP 8.5.10 与 8.4.25 补齐多项数组与 DOM 操作的栈溢出防护

当 PHP-FPM 工作进程突然消失,唯一痕迹只是 FPM 日志中的一条 WARNING: child exited on signal 11 (SIGSEGV),且没有异常或致命错误时,深度嵌套数组很可能牵涉其中。PHP 8.3 引入了一套机制,专门用于阻止这类崩溃,但此后核心函数对该机制的覆盖一直并不完整。PHP 项目于 2026 年 8 月 11 日标记的候选版本,一次性补齐了其中的大量缺口。

PHP 8.4.25 RC1 与 PHP 8.5.10 RC1 都合入了一组提交。这些提交在大约十几个位置完成了同一项朴素却重要的工作:检查 C 栈是否即将耗尽,并抛出 Error,以免进程直接终止。受影响的函数几乎每个 PHP 应用都会用到。

先来看一种在真实应用中反复出现的数据结构:根据用户输入构建的嵌套树。

php
<?php
// 构造一个合法但嵌套极深的数组,恶意客户端可以
// 通过单个 JSON 请求体提交这类数据。
$deep = [];
$node = &$deep;
for ($i = 0; $i < 100_000; $i++) {
    $node['child'] = [];
    $node = &$node['child'];
}
unset($node);

array_walk_recursive($deep, static fn ($v) => $v);

应用这些补丁前,在 PHP 8.4.24 或 8.5.9 上执行上述代码块的倒数第二行会直接导致进程发生段错误,异常和警告机制均来不及介入。ext/standard/array.c 中的 php_array_walk() 每深入一层嵌套便递归一次;由于缺少栈检查,它会持续向 C 栈压入帧,直至操作系统拒绝继续执行。

这段代码中已有的 GC_IS_RECURSIVE 防护只能捕获自引用数组,也就是经典的 $a['self'] = &$a; 循环。非循环数组的嵌套深度此前可以无限增长。php_array_replace_recursive()php_compact_var() 也存在同样的问题:它们逐层递归,递归过程同样缺少栈空间监控。

这一区别正是问题的关键,值得牢牢记住:循环检测与深度限制是两个不同的问题,而 PHP 在历史上对前者的处理要完善得多。

这些提交于 8 月 8 日至 10 日合入 PHP-8.4,随后向前合并至 PHP-8.5 和 master 分支,具体覆盖以下函数和路径:

  • array_walk()array_walk_recursive(),通过 PR #23125 修复 GH-23111
  • array_replace_recursive(),通过 PR #23124 修复 GH-23113
  • compact(),通过 PR #23126 修复 GH-23115
  • zend_hash_compare() 完成的数组比较,经 PR #23090 修复 GH-23088
  • DOMNode::normalize()Dom\Node::normalize(),通过 PR #23127 修复 GH-23116 和 GH-23117

其中多项修复由 Lazizbek Ergashev 提交,Arnaud Le Blanc 负责合并。早在 PHP 8.3 开发周期,正是 Arnaud 加入了栈限制机制

数组比较的修复尤其值得关注,因为这一问题很容易在无意间触发。使用 == 比较两个数组时,每深入一层嵌套,调用链都会再次依次经过 zend_compare_arrays()zend_compare_symbol_tables()zend_hash_compare()。该路径此前只防范循环引用。一个没有循环、嵌套深度达到数万层的数组就足以导致进程崩溃。对象比较早已通过 zend_std_compare_objects() 正确处理这一问题,此次修复让数组与对象的行为保持了一致。

DOM 修复中有一处巧妙的实现细节。两个 normalize() 方法都返回 void,因此仅在 EG(exception) 为空时抛出 Error。缺少这一防护时,若树结构在触及栈上限的深度处恰好很宽,栈展开过程会在每个节点抛出一个 Error 并将其串联起来,最终形成二次方级别的复杂度。用于修复拒绝服务漏洞的机制本身也必须避免引发拒绝服务。

这些修复在内部统一使用 ZEND_CHECK_STACK_LIMIText/standard/var.chttp.c 使用这一宏已有一段时间。这是一套现有机制,如今终于得到了一致应用。

实际效果是,这些调用现在能够像 PHP 的其他部分一样妥善处理栈溢出。下面展示了接受任意 JSON 的请求处理器会发生怎样的变化:

php
<?php

namespace PhpArch\Ingest;

final class PayloadNormalizer
{
    public function normalize(string $json): array
    {
        // json_decode 一直支持深度限制,应当充分利用它。
        $data = json_decode($json, true, 64, JSON_THROW_ON_ERROR);

        try {
            array_walk_recursive($data, $this->coerce(...));
        } catch (\Error $e) {
            // 在 8.4.25+ / 8.5.10+ 中可以执行到这里。
            // 在更早版本中,工作进程到这一步早已终止。
            throw new PayloadTooDeep($e->getMessage(), previous: $e);
        }

        return $data;
    }

    private function coerce(mixed &$value): void
    {
        if (is_string($value)) {
            $value = trim($value);
        }
    }
}

Error 消息沿用了 PHP 8.3 确立的格式:

plaintext
Maximum call stack size of 8339456 bytes
(zend.max_allowed_stack_size - zend.reserved_stack_size) reached.
Infinite recursion?

这段措辞仍将原因推测为无限递归。无限递归是最常见的情况,合法的深度嵌套输入等其他原因也会触及同一限制。

有两点需要明确说明。第一,在此处捕获 \Error 是合理的;捕获后应将其封装为应用自定义类型并重新抛出。直接吞掉它可能掩盖程序其他位置真正失控的递归。第二,根本解决方案仍然是在输入到达这些函数前限制其深度。json_decode() 接受一个深度参数,其默认值 512 对大多数 API 来说已经相当宽裕。将该值设置为应用数据模式(schema)实际需要的深度,成本低于依赖后续的栈溢出防护。

zend.max_allowed_stack_sizezend.reserved_stack_size 都在 PHP 8.3 中引入。前者的默认值为 0,表示 PHP 会在运行时检测线程栈大小并采用该值。将其设置为 -1 会彻底禁用检查,使程序重新面临段错误风险,因此只应作为最后手段。

以下三种场景最需要关注这些配置:在线程式 SAPI 或 Swoole 这类运行时中,检测到的栈大小偏离预期;容器采用特殊的 ulimit -s 设置;递归下降解析器等程序确实需要深度递归。若因合理需求触及限制,应提高 zend.max_allowed_stack_size,并保留栈检查。

ini
; 仅在实际测量确认确有需要时设置。
zend.max_allowed_stack_size = 16M
zend.reserved_stack_size = 64K

两个候选版本在大约 200 个文件中包含约 5,000 行变更。对于补丁版本而言,这组补丁规模相当可观。除栈防护工作外,其中还包括 implode()、XSL、用户定义的流过滤器和套接字中的释放后使用问题修复,以及一项 JIT 去优化器的寄存器保存修复。

当前稳定版本是 PHP 8.5.9 和 PHP 8.4.24,两者均发布于 2026 年 7 月 30 日。对于任何需要解析不受信任 XML 或接受嵌套 JSON 的系统,都值得立即将这些候选版本部署到预发布环境中进行验证。此次行为变化范围很窄:此前会崩溃的代码现在会抛出 Error。如果测试套件中存在曾悄无声息地终止 CLI 进程的用例,升级后会在原本悄无声息的位置看到异常。这是一次改进,也仍属于行为变更。

与此同时,PHP 8.6.0 Beta 1 已于 2026 年 8 月 13 日发布,并通过 master 分支的合并继承了上述全部修复。Beta 2 计划于 8 月 27 日发布。

本作品采用《CC 协议》,转载必须注明作者和本文链接