VPS优惠推送
分享好玩教程

一个漏洞的二十二年:PHP2Shell 是怎么活到今天才被抓住的

这个漏洞在 PHP 里躺了 14 年。

更准确地说:它的起点在 2004 年,2020 年有人报告过它,2024 年一次专业审计又挖到过它 —— 两次都从旁边路过,都没踩下去。

直到今年 8 月,一家叫 Calif Research 的机构用几天时间,把它做成了能打穿 WordPress、Drupal、Joomla、phpMyAdmin 的远程代码执行。

下面这条时间线,来自他们放出的技术复盘。

2004:一切开始的地方

二十二年时间线:2004 被发现,2020 和 2024 两次被路过,2026 才被真正利用

那一年还没有 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 打断中间步骤
  → 提交或回滚被跳过
  → 后续消费者相信了这个错误状态

三条路,一条一条拆

两个 OOM 触发器 × 三个可被打断的代码点 = 三条链

最终找到的是两个"能让内存爆掉"的触发器,加上三个"被 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()。

两个很巧的绕过

两个绕过:post_max_size 说一套做一套,空文件撑爆 memory_limit

光有漏洞点还不够,得先让内存爆掉。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 官方安全公告。

赞(0)
未经允许不得转载:F12笔记 » 一个漏洞的二十二年:PHP2Shell 是怎么活到今天才被抓住的

评论 抢沙发