这个漏洞在 PHP 里躺了 14 年。
更准确地说:它的起点在 2004 年,2020 年有人报告过它,2024 年一次专业审计又挖到过它 —— 两次都从旁边路过,都没踩下去。
直到今年 8 月,一家叫 Calif Research 的机构用几天时间,把它做成了能打穿 WordPress、Drupal、Joomla、phpMyAdmin 的远程代码执行。
下面这条时间线,来自他们放出的技术复盘。
2004:一切开始的地方

那一年还没有 iPhone。Stefan Esser(就是后来写 Suhosin、常年盯 PHP 安全的那位)在 PHP 里发现了一个问题:当内存分配撞上 memory_limit 时,PHP 会抛出一个"致命错误"跳出去,但它跳出去的时候,没有把中间状态收拾干净。
留下的东西,后来被叫做悬垂指针 —— 内存已经被释放了,但还有变量指着那块地方。
当年这个漏洞被评估为严重到:他本人没有放出 exploit。只有 CORE 和 Immunity 两家商业公司做过不公开的利用。
然后,一躺就是十六年。
2020:一次没人当回事的报告
2020 年,一位叫 jr@coredu 的开发者给 PHP 官方提了 bug #78876,标题很朴素:
Long variables in multipart/form-data cause OOM and temp files
翻译一下:在 multipart 表单里塞超长的变量名,会让 PHP 内存耗尽(OOM),而且临时文件没被清理。
这个报告里没有任何"可被利用"的证据 —— 看起来就是个"内存占用异常 + 临时文件泄漏"的小问题。没修。
2024:审计挖到了,但没证明
2024 年,OSTIF 和 Quarkslab 给 PHP 做了一次正式的安全审计,又挖到了同一个 OOM 问题。
但这次也一样:没能证明它可被利用,所以同样没有修。
一个细节挺有意思:Calif 后来去翻 PHP 仓库时,发现 2020 年那个补丁上挂着一个 git tag 叫 security-audit-2024 —— 说明这两次是同一件事,只是没人把线连起来。
2026:这次为什么成了
今年 7 月 30 日,OpenAI 发起了一个叫 "Patch the Planet" 的项目,邀请外部安全团队审计高影响的开源项目。PHP 被选中了。Stefan Esser 帮忙牵线,把 PHP Foundation 的人拉进了群。
8 月 1 日,Calif 开始干活。几天后就出了第一个结果。
他们自己在复盘里说了一句很实在的话:
我们本来没指望能挖到什么东西 —— 这个目标已经被所有人、所有实验室用 AI 辅助审过很多遍了。
那为什么这次成了?
不是因为模型更强。Calif 写得很直白:没有用特殊的 harness,也没有用别人拿不到的模型。区别在于 ——他们知道这个漏洞的历史。
模型做变体分析有个习惯:从最新、最显眼的 bug 往附近的代码找。它不会主动从"二十年前的冷门利用原语"开始想。
所以他们的做法是:人先知道历史,然后把提示往那个方向推。第一个 bug 出来之后,再把模式归纳成一纸"技能说明",让 AI 拿着这个模式回头扫一遍 —— 才找出了后面两个。
那纸技能说明,核心就两行:
发布或销毁状态 → 执行可能失败的操作 → 初始化 / 提交 / 恢复
非局部 fatal 打断中间步骤
→ 提交或回滚被跳过
→ 后续消费者相信了这个错误状态
三条路,一条一条拆

最终找到的是两个"能让内存爆掉"的触发器,加上三个"被 fatal 打断后会留下垃圾"的代码点。三三组合,出了三条不同的攻击路径。
第一条:先加计数,再分配 key
PHP 往数组里插一个新元素时,代码是这样的(简化):
idx = ht->nNumUsed++;
ht->nNumOfElements++;
p = ht->arData + idx;
p->key = zend_string_init(str, len, persistent); /* 这里可能会抛 fatal */
p->h = ZSTR_H(p->key) = h;
ZVAL_COPY_VALUE(&p->val, pData);
看出来了吗:计数先加,key 后分配。如果分配 key 的时候抛了 fatal,后面的赋值全都被跳过 —— 但计数已经加过了。
于是清理阶段会认为"这里有一个有效元素",去释放一个根本没初始化完的槽位。
这条在 Apache + mod_php 上能打出 RCE:因为 Apache 在启动失败后,仍然会执行请求清理,清理时就把那个坏掉的表当真了。PHP-FPM 不一样,它启动失败直接干掉 worker,垃圾跟着进程一起没了。
第二条:session 在上岗前就被初始化了
这条更妙。PHP 处理 multipart 表单时,如果开启上传进度(session.upload_progress),会在解析请求体的时候就去初始化 session。
而这个时候,PHP 还没走完"模块激活"这一步:
PG(modules_activated) = 0;
php_hash_environment(); /* 解析请求变量和 multipart(这里会碰 session)*/
zend_activate_modules(); /* 正常模块初始化 */
PG(modules_activated) = 1;
于是 session 模块的全局变量里,存了一堆指向当前请求内存的指针。如果解析中途炸了,正常收尾被跳过,请求内存被释放 —— 但那些指针还留着。
然后攻击者发第二个请求:一个不带文件的 multipart 表单。因为没有文件,前面那条"初始化"的分支不会执行;但结尾的清理会执行,于是它拿着上一个请求留下的悬垂指针,去操作已经释放的内存。
同样只打 mod_php。
第三条:老朋友 unserialize 帮了忙
前两条都只打 mod_php,而现实中大部分站点跑的是 PHP-FPM。区别在 fatal 发生的时机:
- 如果 fatal 发生在请求启动阶段 → FPM 直接杀掉 worker;
- 如果发生在启动成功之后(脚本已经在跑了)→ FPM 走正常收尾,worker 活着,等下一个请求。
所以第三条要找的是"应用跑起来之后能留下垃圾的状态"。他们找到了反序列化器。
PHP 反序列化时,会申请一块记账用的内存(记录引用和待析构的值),指针存在全局里。正常结束会清掉它,但中途 fatal 就清不掉了。
于是下一个 multipart 请求进来时,上传进度会在正常初始化之前碰到 session → session 反过来调用反序列化 → 反序列化器看到全局里那个"嵌套层级还没归零"的旧指针,就把悬垂内存当成有效的记账结构来用。
再配合序列化数据里的 R: / r: 引用和一份析构列表,攻击者能控制 PHP 把指针写到哪里。这就成了 application-dependent 的 RCE —— 只要应用里有 session_start()、unserialize() 或者 WordPress 那种 maybe_unserialize()。
两个很巧的绕过

光有漏洞点还不够,得先让内存爆掉。PHP 有两个限制挡在前面:post_max_size(默认 8M)和 memory_limit(默认 128M)。他们各绕了一次。
绕过 post_max_size:说一套,做一套
PHP 在解析 POST 之前,会拿 Content-Length 跟 post_max_size 比一下。但它比的是"声明的长度",不是"实际消费的字节数"。
于是有三种方式让它说一套做一套:
- HTTP/1.1 的 chunked 传输 —— 根本没有 Content-Length;
- HTTP/2 不带长度 —— 同理;
- 输入解压过滤器 —— 压缩后的长度很小,解压后的流很大。
绕过内存限制:几百个"空盒子"
这个思路特别省力。RFC1867 的文件上传里,如果 filename="",那就是个没有数据的空文件。
它没有数据,所以不计入 max_file_uploads。但 PHP 还是会老老实实给它建 6 个 $_FILES 条目:name、full_path、type、tmp_name、error、size。
更狠的是嵌套字段名。如果字段叫 files[a][b][c]...,PHP 得为这 6 个条目各自把这条嵌套链建出来 —— 一个短的"空上传"记录,会膨胀成一堆 HashTable、Bucket 和字符串键。
几百条这样的记录,就能吃掉默认的 128MB,而整个 HTTP 请求体还不到 8MB。
然后,让后面某一次精心选择的分配撞上 memory_limit,fatal 就准时来了。
哪些应用被打穿了
Calif 确认了这些应用的未认证 RCE:
- WordPress 7.1.2、Drupal 11.4.3、Joomla 6.1.4、MediaWiki 1.46.2
- phpMyAdmin 5.2.3、Nextcloud 35.0.1、Roundcube 1.7.4
- Magento 2.4.9、Laravel 13.11、Symfony 8.1、WHMCS
以及,某些情况下的任意 PHP 脚本。
现在该做什么
一句话:升级。8.2.35 / 8.3.36 / 8.4.27 / 8.5.12。
Calif 也给了临时缓解,但说得比官方更直白:
- PHP-FPM:
session.upload_progress.enabled=0+memory_limit=-1; - mod_php:
memory_limit=-1;
然后自己补了一句:这些都不能替代升级,而且 memory_limit=-1 是把 PHP 的内存上限拿掉,反而会提高内存耗尽的风险。
(怎么升、升哪些版本,见我另一篇的处置记录 —— 那台机器从检查到升完总共 20 秒。)
一个很讽刺的细节
Calif 顺手去查了 Hack/HHVM 有没有同样的问题。
结果:HHVM 没事。
原因是 —— 2020 年那个没人当回事的补丁(bug #78876),被人提交到了 HHVM 里。HHVM 顺手把这条路堵死了。
十四年里,唯一的"修复",发生在一个当时没人认为需要修的地方。
技术细节与时间线来自 Calif Research 的复盘文章(calif.io/php2shell,作者 Ryan Austin);补丁版本与官方缓解建议来自 PHP 官方安全公告。

F12笔记






