PHP2Shell:两个 9.2 分 PHP 远程代码执行漏洞的处置手册
💡 工具推荐: 正则测试器、哈希生成器、cURL 命令生成器
2026 年 10 月 21 日 14:00 UTC,PHP 项目发布了 8.5.12、8.4.27、8.3.36 和 8.2.35 四个版本,修复两个严重程度相同的漏洞:CVE-2026-109618 与 CVE-2026-109619,CVSS v4.0 评分都是 9.2。它们不是某个框架的 bug,也不是某个插件的配置问题,而是 PHP 引擎自身的远程代码执行:在某些条件下,未认证的攻击者可以直接在服务器上执行代码。Wiz 的研究团队把这一对漏洞命名为 PHP2Shell,并指出 PHP 支撑着全球约 70% 的网站,过去十年发布的几乎每一个 PHP 版本——一直回溯到 5.4——都至少受其中一个漏洞影响。这篇文章不讲恐慌,只讲顺序:先确认你在跑什么,再盘点有多少台机器,分批次升级,回头查有没有被用过的痕迹,最后把版本下限写进流水线。
这次不是应用层漏洞,是 PHP 引擎本身的问题
一、两个 CVE,一个共同点
按照 PHP 基金会的说明,CVE-2026-109618 是「OOM 内存破坏导致的 RCE」:如果一个请求恰好在最糟糕的时刻撞上 memory_limit,PHP 可能留下一段未完全初始化的内部数据,之后又把它当成有效数据使用,从而破坏内存。在 Apache 搭配 mod_php 的场景下,未认证请求可以在任何应用代码执行之前就触发它,并且在默认配置下就已经演示出了完整的远程代码执行;在 PHP-FPM 上,也通过应用层代码路径确认了 RCE。CVE-2026-109619 是「陈旧全局状态导致的 RCE」:一次致命错误出现在错误的位置,会留下指向上一个请求内存的内部状态,之后由同一个 worker 处理的请求就可能用到那块已被释放的内存——已确认存在 use-after-free 与 worker 崩溃,报告者还在一个运行于 Apache mod_php 的真实应用上演示了 RCE。两个漏洞的相同点很关键:它们都位于内存管理与错误处理这两块几乎每个请求都会经过的地方。
# 1) Find out exactly what you are running, per branch.
# Patched releases: 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35.
php -v | head -1
php -r 'echo PHP_VERSION, PHP_EOL;'
php -i | grep -E '^Server API|^Loaded Configuration File'
# Debian / Ubuntu: list every installed PHP package and its version
dpkg -l | grep -E '^ii php[0-9.]+-(cli|fpm|common|apache2)' | awk '{print $2, $3}'
# RHEL / Rocky / AlmaLinux
rpm -qa | grep -E '^php' | sort二、为什么这次没有「聪明的缓解措施」
安全事件里最常见的自我安慰是「我们加了 WAF,应该没事」。对这两个漏洞,WAF 值得加,但它是缓解不是修复:CVE-2026-109618 在 mod_php 下可以在应用代码运行前触发,也就是说你的框架防火墙、输入校验、ORM 参数化在这一刻都还没轮到上场。把 mod_php 换成 PHP-FPM 确实抬高了门槛——已确认的 FPM 路径走的是应用层代码——但这仍然不是免疫力,因为引擎里那块坏掉的逻辑还在。真正能消除风险的动作只有一个:升级到修复版本。这不是「厂商让你升级」的说辞,而是修在引擎里的补丁。所以正确的姿势是:把补丁当作主线任务,把 WAF 规则、内存限制、最小权限当作在补丁窗口期内缩小爆炸半径的辅助手段,而不是替代品。
# 2) Inventory every host, not just the box you remember.
import json, subprocess
# The floor for each supported branch, from the October 21 advisory.
PATCHED = {"8.2": (8, 2, 35), "8.3": (8, 3, 36), "8.4": (8, 4, 27), "8.5": (8, 5, 12)}
def parse(v):
return tuple(int(x) for x in v.split("-")[0].split(".")[:3])
def status(v):
branch = ".".join(str(x) for x in parse(v)[:2])
floor = PATCHED.get(branch)
if floor is None:
return "UNSUPPORTED" # older branches have no fix: migrate
return "ok" if parse(v) >= floor else "PATCH NOW"
hosts = json.load(open("hosts.json")) # [{"name": "web-01", "cmd": "ssh web-01"}]
for h in hosts:
v = subprocess.run(h["cmd"] + " php -r 'echo PHP_VERSION;'",
shell=True, capture_output=True, text=True).stdout.strip()
print(h["name"].ljust(20), v.ljust(10), status(v) if v else "unreachable")三、先确认你到底在跑什么
处置的第一步不是执行 apt upgrade,而是把版本号看清楚。这一步听起来无聊,但它决定了后面所有动作:你在 8.1 上跑,修复版本并不存在,需要的是迁移而不是升级;你在一台装了三个 PHP 分支的机器上跑,升级了 8.4 却漏掉 8.3,攻击面一个都没少;你以为生产是 8.4.27,其实镜像里的 base tag 还是 8.4.20,因为 Dockerfile 写的是浮动标签。所以第一段脚本只做一件事:把 php -v、Server API、配置文件路径和已安装的 PHP 包全部打印出来,人眼过一遍。第二段把它变成清单:把每台机器的版本收上来,和一个「分支到修复版本」的映射表比对,直接输出 ok、PATCH NOW 或 UNSUPPORTED 三种状态。前者是确认,后者是规模化。清单出来后,你会发现真正需要紧急处理的机器数量,往往比你以为的少,也往往比你领导的预期多。
# 3) Patch in batches and prove each batch before the next one.
sudo apt-get update
sudo apt-get install -y --only-upgrade php8.4-fpm php8.4-cli php8.4-common
sudo systemctl restart php8.4-fpm
php -v | head -1 # must print 8.4.27 or newer
# Verify the app, not just the binary
curl -fsS -o /dev/null -w '%{http_code}\n' https://canary.example.com/healthz
# Watch error rate and worker restarts for 15 minutes. Only then move on.
journalctl -u php8.4-fpm --since "15 min ago" | grep -ci "restart"
# Never upgrade the whole fleet in one window. A rollback plan you can
# execute in one command beats a fast rollout you cannot undo.四、按批次升级,别一次推全量
补丁窗口期最怕的不是慢,是乱。实操上按批次推进:先升一台 canary,重启 FPM,确认 php -v 打印的是修复版本,再打健康检查端点,然后盯 15 分钟的 worker 重启次数与错误率。这里有个容易忽略的点:二进制升级成功不等于应用升级成功。PHP 的安全修复动的是内存管理和错误处理,理论上兼容,但你依赖的第三方扩展是否编译过、行为是否一致,只有跑一次真实流量才知道。所以「版本号对了」是必要条件不是充分条件。第二批次再推一个可用区或一组低权重实例,稳定后再推全量。同时把回滚预案写好并验证一次:能一条命令回滚的慢路线,永远比先快后慌的路线便宜。
# 4) Assume nothing: look for evidence the flaw was already exercised.
# Both CVEs end in memory corruption, so the trail is in crashes.
grep -riE "segmentation fault|zend_mm_heap corrupted|out of memory" \
/var/log/php*.log /var/log/nginx/error.log 2>/dev/null | tail -20
ls -lt /var/crash/*.crash 2>/dev/null | head
journalctl -u php8.4-fpm --since "30 days ago" | grep -ci segfault
# A crash is not proof of compromise, but it is a reason to rotate every
# secret that worker could read: database passwords, API keys, session keys,
# and any certificate mounted into the process. Wiz notes the disclosed
# memory could contain credentials belonging to other requests.
git log -1 --format=%cd -- .env.example 2>/dev/null五、回头查:这个漏洞是不是已经被用过
打完补丁不等于事情结束。两个 CVE 的落点都是内存损坏,所以它们留下的痕迹和普通注入不太一样:worker 崩溃、段错误、zend_mm_heap corrupted、core dump 里的异常调用栈。第三段脚本就是在日志里找这些线索。这里必须说清楚边界:一次崩溃不是被入侵的证明,它也可能是别的原因。但它是启动取证的理由,尤其是当崩溃发生在补丁发布之前的时间段。更重要的是,即使找不到任何痕迹,只要这台机器跑过这些版本,就该考虑轮换它上面的凭据——Wiz 明确写了这类内存泄露可能包含属于其他请求的密码、认证 token、API 密钥和证书。密钥轮换的成本是可控的,被读走一个数据库口令的成本不是。
# 5) Make the next emergency boring: assert the floor in the pipeline.
# .github/workflows/php-version.yml (the idea ports to any CI)
- name: Assert patched PHP
run: php -r 'exit(version_compare(PHP_VERSION, "8.4.27", ">=") ? 0 : 1);'
- name: Fail on unpinned PHP base images
run: |
if grep -Rn "FROM php:" Dockerfile* | grep -vE "8\.4\.27|8\.5\.12"; then
echo "base image is not on a patched tag"; exit 1
fi
echo "base images pinned"
# Add a weekly dependency-freshness job that opens a PR when a supported
# branch publishes a security release. Patch nights should be scheduled,
# not improvised at 2am.六、把下限写进流水线
每次安全事件之后,团队最常做的事是写一份复盘,然后下次再来一遍。真正有复利的是把这个下限固化:CI 里加一条断言,PHP 版本低于修复版本直接失败;Dockerfile 里禁止浮动标签,base image 必须钉到修复过的 tag;再加一个每周运行的依赖新鲜度任务,当受支持分支发布安全版本时自动开 PR。最后一点值得单独说:PHP 基金会自己在公告里承认,这个 OOM 问题修得慢,是结构性的失败——安全团队的决策流程加上人力不足,导致方案在几个月里反复停滞和重启。他们提到了 2024 年那场由 Sovereign Tech Agency 出资、OSTIF 组织、Quarkslab 执行的首次外部安全审计,也正是在修复审计发现的问题时,核心开发者第一次看到了 OOM 问题的苗头。开源基础设施的维护能力是可以被购买的,赞助一家基金会比在凌晨三点处理一次 RCE 便宜得多。
两个 CVE 都落在内存管理上,痕迹在崩溃日志里
📌 常见问题 FAQ
我应该升级到哪个版本?
官方这次给出了四个分支的修复版本:PHP 8.5.12、8.4.27、8.3.36、8.2.35。请升级到对应分支的该版本或更高。更老的分支已经停止维护,没有官方补丁,只能规划迁移。
两个 CVE 有什么区别?
CVE-2026-109618 是 OOM 内存破坏:请求在最糟糕时刻撞上 memory_limit,PHP 留下未完全初始化的内部数据并当作有效数据处理。CVE-2026-109619 是陈旧全局状态:致命错误留下指向上一个请求内存的内部状态,后续请求复用了已释放的内存。两者都位于内存管理与错误处理路径。
我前面有 WAF / CDN,还需要打补丁吗?
需要。WAF 是缓解措施,不是修复。按 PHP 基金会的说明,在 Apache mod_php 场景下未认证请求可以在任何应用代码运行之前就触发 CVE-2026-109618,默认配置下已演示出完整 RCE,此时你的应用层防护还没有机会介入。补丁修的是引擎本身。
怎么判断漏洞已经被利用了?
看内存损坏留下的痕迹:worker 崩溃、段错误、zend_mm_heap corrupted、core dump、以及 FPM/Web 服务器错误日志里异常的时间聚集。没有痕迹不代表安全,所以对于跑过受影响版本的机器,建议直接轮换它可读到的凭据。
为什么这个漏洞拖了这么久才修?
PHP 基金会在公告里自己给出了答案:修 OOM 问题需要在几个方案之间取舍,每个方案都有代价——破坏向后兼容、带来新的拒绝服务风险,或要求大量第三方扩展配合修改。安全团队按共识运作,难以在方案间定夺,加上具备深层引擎知识的核心开发者人手有限,于是工作反复停滞。他们把这次延误称为结构性的失败。