免责声明
本文由 LLM 参与创作。内容仅供学习研究与授权环境中的安全分析,请勿对未授权系统开展测试。文中利用机制均来自已公开的安全研究与官方通告,代码片段为简化示意。
0x00 引言:一行 echo 的页面,为什么能被打穿?
<?php
echo "OK\n";
2026 年 10 月 21 日,PHP 紧急发布 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35,修复两个 CVSS 4.0 均为 9.2 的未认证远程代码执行漏洞:CVE-2026-109618(OOM corruption)和 CVE-2026-109619(stale global state)。社区将这组漏洞称为 PHP2Shell。
上面这个页面没有数据库、没有上传、没调 session_start(),但在公开的利用实现里,它就是标准的攻击目标——纯 HTTP 打进去,拿到 uid=33(www-data) 的命令执行。
秘密全在 echo 的前后。PHP 处理一个请求,得先把输入解析好、把运行环境初始化好,结束后还要做一遍请求清理,业务代码只占中间一小段。这两个 CVE 恰好埋在准备和收尾这两步里。内存耗尽(OOM)本来是最普通的故障,但在特定的时序下,它变成了攻击者手里的一个开关——这就是整条利用链的起点。
先补一点背景:上传一个文件,PHP 到底做了什么?
平时上传头像、提交附件,页面上看起来只有“选择文件”和“提交”两个动作。服务器实际要先拆开整个请求,分清哪些是普通文字、哪些是文件,再为业务代码准备数据。PHP2Shell 涉及的几个关键环节,就藏在这段准备工作和之后的清理工作里。
第一步:浏览器把表单编码成 multipart。 一个普通的上传表单大致如下。这只是正常上传功能的示例:
<form action="upload.php" method="post" enctype="multipart/form-data">
<input type="text" name="description" value="旅行照片">
<input type="file" name="photo">
<button type="submit">上传</button>
</form>
multipart/form-data 可以理解为“一个包裹里装了多个小包”。浏览器用 boundary 分隔各个 part;每个 part 的头部说明字段名,文件 part 还带文件名等信息,后面接实际内容。上面的请求包含两部分:description 的文字,以及 photo 的文件数据。这里的 part 指请求体中的一个分段,和一次新的 HTTP 请求是两回事。
第二步:Web 服务器把请求交给 PHP。 常见部署有两种。一种是 Apache 的 mod_php,PHP 直接嵌在 Apache 进程里跑;另一种是 PHP-FPM,它自己维护一批 PHP 工作进程,由 Nginx 或 Apache 通过 FastCGI 把请求转过去。
后文反复出现的 SAPI,指的就是 PHP 与外部运行环境之间的这层接口;worker 则是真正处理请求的工作进程,一个 worker 通常要接连处理很多个请求。
Web 服务器还可能先缓冲请求体,再交给 PHP。因此“浏览器正在上传”和“PHP 正在解析上传数据”这两个时间段,具体如何重叠取决于部署配置。
第三步:PHP 解析请求,建立业务代码能直接读取的数据。 在开启自动 POST 解析的常见配置下,PHP 会在执行目标脚本之前处理适用的 POST 请求体。multipart 解析器逐段读取输入:普通字段进入 $_POST;文件内容通常写入上传临时文件;文件的名称、大小、临时路径和上传状态则记录到 $_FILES。
浏览器提交 multipart 请求
└─ Web 服务器交给 PHP
└─ PHP 解析请求体
├─ 普通字段 → $_POST['description']
├─ 文件内容 → 服务器上的上传临时文件
└─ 文件信息 → $_FILES['photo']
└─ 执行 upload.php
└─ 业务校验后,再决定是否保存文件
└─ 请求收尾:清理本次请求的数据和剩余临时文件
$_POST、$_FILES 属于 PHP 的“超全局变量”,脚本可以直接读取。以上面这个表单为例,$_FILES['photo'] 里的关键字段包括:
name:客户端提供的原始文件名。tmp_name:服务器保存上传内容的临时路径。size、error:文件大小和上传结果。type:客户端提供的 MIME 类型,业务端仍需验证实际内容。full_path:客户端提交的路径信息,PHP 8.1 起提供,同样需要按不可信输入处理。
此时文件还只是被临时接收。业务代码接下来检查身份权限、错误码、大小和实际文件类型,再用 move_uploaded_file() 等操作保存到选定位置。正常请求结束时,未移走的上传临时文件会被清理。接收与解析发生在前,应用决定接受或拒绝发生在后。 具体字段与处理流程可以对照 PHP 官方文件上传手册。
文件放在临时目录,为什么还会牵涉内存?
文件内容虽然写进了临时文件,PHP 还是得在内存里维护解析缓冲、字段名、字段值,以及描述这些文件的数组。$_FILES 存的是文件信息,文件正文一般留在临时文件里。
PHP 数组在内核里主要由 HashTable(哈希表) 实现,里面每个元素槽位叫 Bucket,值用 zval 表示。可以先把它们理解成“目录”“目录里的一条记录”“这条记录存的值”。每条记录除了用户提交的文字,还带着类型、长度、引用计数这些管理信息。所以要记住:请求体有多大、文件有多大、PHP 实际占了多少内存,这是三个不同的数字。
几个常见配置各管一摊:upload_max_filesize 管单个上传文件的大小,post_max_size 管整个 POST 请求体,memory_limit 管 PHP 分配器能用多少内存。
后面会说到 OOM(Out Of Memory),指的是内存分配按原有方式继续不下去了。要留意,触发 PHP 的内存上限时,整台服务器往往还有大量空闲内存。
上传进度条,为什么又和 session 有关系?
上传大文件时,页面需要知道“已经处理了多少字节”。PHP 的 Session Upload Progress 功能可以在解析上传数据的过程中,把进度写入 session,让另一个查询请求读取。
session 可以理解为服务器保存的一份会话数据,浏览器通常通过 cookie 中的会话标识找到它。除了登录状态,它也可以存放上传进度等临时信息。当上传进度功能开启、请求携带相应标记并满足处理条件时,PHP 会在上传解析阶段更新这些数据。
这里最该记住的是 官方手册的提醒:上传进度信息会在任何脚本执行之前就写进 session。
所以要把两件事分开看:目标脚本里有没有写 session_start(),是一回事;PHP 内核有没有已经碰过 session 状态,是另一回事。
这也回答了开头那个问题:即使页面只写了一行 echo,只要请求被交给 PHP 且相应解析功能启用,PHP 仍会先完成自己的准备工作。页面上有没有上传按钮,由前端决定;服务端解析哪种请求,由实际收到的请求和运行配置决定。
请求结束了,worker 为什么还在?
为了省下重复启动的开销,worker 通常会被复用,但本次请求分配的内存会在请求收尾时整体释放。于是就出现了两种不同的寿命:工作进程可以活过很多个请求,请求内存却只属于当前这一个请求。
进程里某个字段如果还存着已经释放的内存地址,它就是“悬空指针”。之后再有代码顺着它去访问原来的对象,就是 UAF(Use After Free,释放后使用)。
后文还会反复出现几个词,先在这里集中交代一下:RINIT 是扩展的请求初始化,RSHUTDOWN 是扩展的请求结束处理,析构是销毁一个值、顺便释放它占用的资源。

对象释放后,旧引用仍需撤销;访问前也要确认对象有效。
PHP 用引用计数来管理很多可共享数据的寿命。可以把它理解成“现在还有几个人在用这块数据”:释放一个值时计数减一,等最后一个使用者退场,底层的这块数据才具备回收条件。
这套机制的前提是计数和记录本身没错。一旦代码把无效内存误当成正常的值,连清理操作都会跟着读错记录——问题就从这里开始扩散。
另外还有两个容易混的词:序列化是把数组、对象这类数据转成便于保存或传输的形式,反序列化是把它们还原回来。
还原复杂数据时,PHP 要维护一份“还原进度”的上下文,里面记着已经处理过哪些值、彼此是什么引用关系、哪些对象还没清理。这份上下文自己也有初始化和销毁的过程。记住这一点,后面就能理解为什么文章既在谈业务数据,又在谈“负责还原数据的内部状态”。
带着这几组关系再看源码,就容易抓住重点了:上传解析会建立数据结构,进度记录会提前接触 session,异常退出会改变清理顺序,而 worker 复用让部分错误状态有机会延续到后续请求。
0x01 漏洞披露:一条二十年长的伏笔
PHP2Shell 的时间线本身就是个好故事:
- 2004:Stefan Esser 发现 PHP 首个 memory-limit 远程漏洞,exploit 从未公开;
- 2020:bug #78876 报告 multipart 长变量导致 OOM 与临时文件残留,当时没深究;
- 2024:OSTIF / Quarkslab 审计 PHP 时重新发现相关问题,利用未证明,未修复;
- 2026-08:安全研究方 Calif 在 OpenAI “Patch the Planet” 项目里重新审视这段历史,几天内找到第一个 OOM-fatal 中断点,随后扩展出针对 mod_php 和 PHP-FPM 的可靠利用;8 月 11 日报告 PHP,8 月 15 日 PHP 官方确认 RCE PoC 有效;
- 2026-10-21:补丁发布,同步披露 GHSA-w8hw-w9fx-j79w 与 GHSA-6wmf-4rv5-p3rp。
官方确认的利用范围:mod_php 在默认输入与内存限制、正常 prefork 多 worker 下,已在业务代码运行前验证完整 RCE,实测目标覆盖 WordPress、Drupal、Joomla、Magento、phpMyAdmin、Nextcloud、Laravel 等;FPM 侧也有官方验证的 session 生命周期 RCE,前提是应用存在可达的解码操作。Calif 将已发现路径最早追溯到 PHP 5.4,修复覆盖 8.2–8.5,另放出 7.4 / 8.0 / 8.1 的最小源码回补分支。
一手研究见 Calif 的 PHP2Shell 研究。
0x02 先看 patch:PHP 怎么改掉“OOM 即跳转”
8.4 分支的修复提交是 506e87fa(8.5 为 31c0ea7d),提交说明只有两行:
Avoid state corruption via bailout
- Replace bailout on OOM with UnwindExit
- Properly reset various globals that may be accessed during startup
完整 diff 的核验结果为:402 个文件、+11112 / −1032 行,横跨 Zend、session、序列化、几乎全部扩展和 OPcache。改动密度本身就说明问题:这是一类生命周期缺陷,散布在所有“发布状态 → 干可能失败的活 → 提交”的代码路径上。
软 OOM:从“立刻跳走”改成“先记账,到安全点再退”
先看问题的根源。旧的 Zend MM 一旦撞到 memory_limit,处理方式是 zend_error_noreturn() → zend_bailout(),一次 longjmp 直接甩出当前执行流。
只要这段 C 代码当时正在改某个内部结构——计数加了、指针还没写、或者反过来——这一跳,就再也没人回来把它改完。
这里的 Zend MM 是 PHP 内核自带的内存管理器。
平时调用函数,是一层层往下走、再一层层返回,每层都有机会收拾自己的烂摊子。longjmp 不讲这个规矩:它直接跳回事先存好的位置,中间那些没执行的语句、没跑的清理代码,统统作废。
打个比方,就是火警响了直接跳窗——人倒是跑了,可手头改到一半的账本还摊在桌上,没人知道它改到了哪一行。后面的代码读到这本账,只会一头雾水。PHP 的 bailout 就是借这套机制中止当前处理的。
补丁的思路很直接:既然“半途跳走”是问题所在,那就别跳了,改成“先记一笔,等手头这活儿干完,再从容退出”。这就是延迟 OOM 状态机:
typedef enum _zend_oom_state {
ZEND_OOM_STATE_NONE = 0,
ZEND_OOM_STATE_PENDING,
ZEND_OOM_STATE_HANDLING,
ZEND_OOM_STATE_HANDLED
} zend_oom_state;
触及软限时先记下错误信息并置 VM 中断标志,等执行器跑到安全检查点再统一处理:
EG(delayed_oom).filename = script_filename;
EG(delayed_oom).lineno = script_lineno;
EG(delayed_oom).state = ZEND_OOM_STATE_PENDING;
zend_atomic_bool_store_ex(&EG(vm_interrupt), true);
这里解释两个词:VM 是执行 PHP 指令的虚拟机;执行帧(frame)则是它为每个正在执行的函数维护的记录,写着“跑到哪条指令了、手上有哪些参数和局部变量”。
补丁在有执行帧时调用 zend_throw_unwind_exit(),让执行器沿着一条受控的路径层层退出;没有执行帧时,处理逻辑仍然会在检查点调用 zend_bailout()。
“安全点”指的就是那些会主动检查延迟 OOM 的位置——通常是在两条 PHP 指令之间,此刻执行器手里没有半截没写完的结构。
把退出动作全部推迟到这些位置,就再不会出现“底层结构改到一半、人已经跑了”的局面。
配套还加了一组 femalloc 族的可失败分配接口:分配不到内存就返回 NULL,由调用方自己处理半成品,取代过去“失败即跳转”的假设。
硬 OOM:放弃常规收尾,直接终止进程
延迟处理也得有底线。内存继续恶化时,zend_mm_check_limit() 会走 zend_mm_hard_limit_error(),往 stderr 报一声,然后 _exit(137) 直接把进程带走,所有依赖内部状态的请求清理一概跳过。
硬限按分段公式算:软限不超过 1 GiB 时取两倍,超过就在软限上再加 1 GiB,两者也都能显式配百分比——别把“两倍”当成固定规律。
有两个地方容易读错。
一是修复后软 OOM 照样会打印 Allowed memory size ... exhausted,这句报错跟补丁有没有打无关,属于正常现象。
二是 _exit(137) 是 PHP 自己带着状态码退出,和被内核 SIGKILL 干掉完全是两回事。
跨请求状态:把“遗留”变成“不可能”
第二类修复针对 109619:请求结束后,进程级结构里指向请求内存的字段必须清空。
读这段代码,要先分清两样东西:保存地址的那个字段,和地址指向的那份数据。
它们的寿命完全不同。字段所在的结构跟着 worker 长期存在,属于“进程级”;它指向的数据却可能只属于当前这一个请求,请求一结束就该被释放。
麻烦的是,进程级的字段不会自动忘记自己指过什么。好比通讯录里还留着旧地址,原住户早搬走了,下一个人照着地址找过去,只会敲错门——更糟的是,门后可能已经住进了别人。
另外这里的 globals 是内核里的全局结构,跟业务脚本里随手写的全局变量不是一回事。
补丁的做法是在 php_request_shutdown() 里,把 superglobals 析构完立刻置成 UNDEF:
for (i = 0; i < NUM_TRACK_VARS; i++) {
zval_ptr_dtor(&PG(http_globals)[i]);
/* May be accessed before php_request_startup() */
ZVAL_UNDEF(&PG(http_globals)[i]);
}
更关键的一手在启动侧:php_request_startup() 里新增 php_basic_early_rinit(),插在 sapi_activate() 之前、请求体解析之前,提前清掉反序列化器等模块的全局状态:
BG(serialize_lock) = 0;
memset(&BG(serialize), 0, sizeof(BG(serialize)));
memset(&BG(unserialize), 0, sizeof(BG(unserialize)));
php_url_scanner_early_rinit();
session 侧还加了个 PS(in_rfc1867_update) 标记:上传进度期间的解码被强制走 incomplete_classes_only 模式,所有对象一律保持 __PHP_Incomplete_Class。这样一来,那些早于业务代码执行的路径上,就不会再触发 autoload 或者 __wakeup() 之类的用户代码了。

补丁机制概括:退出时机移到安全点,严重故障直接终止进程,跨请求引用在两端都被清理。
看懂补丁的思路,漏洞的形状也就出来了。下面按“公共原语 → 三条路径 → 完整武器化”的顺序拆。
0x03 公共原语:让崩溃发生在指定的那一行
三条路径都靠同一个前提:不只是把 PHP 的内存撑爆,还得让它在指定的某一次内存分配上爆。
这个区别很关键。内存撑爆本身没什么用,顶多是个 500 错误;真正有价值的是“爆在某个函数执行到一半的时候”。那时代码已经把一半的状态写进了全局结构,剩下的收尾代码却再也不会执行——进程带着一份没写完的账继续活下去,后面谁去读这份账,谁就中招。
所以第一个问题是:能不能挑?答案是能。Calif 公开了两个手法,本质都是同一个放大器——让“请求体有多少字节”和“PHP 吃掉多少内存”脱钩。一旦两者不成比例,把哪一次分配推到临界点,就变成了调参数的事。
第一,绕过 post_max_size 预检。 这个配置本来是防止单个请求太大的闸门。但 PHP 的做法是:拿请求头里声明的 Content-Length 跟 post_max_size 比一下,比完就开始收 body,之后不再核对真正收了多少字节。于是只要让“声明的长度”和“实际喂进来的数据”不一致,闸门就形同虚设。chunked 传输、不带长度的 HTTP/1.1、HTTP/2,都能做到不报长度;配了输入解压过滤器也一样,压缩前一小段数据,解开后能膨胀成很大一坨。
第二,用一个“空壳文件”换来成倍的堆开销。 multipart 里写成 filename="" 的 part 不带任何文件内容,因此也不占 max_file_uploads 的名额——限流对它无效。但 PHP 不会因为它没内容就跳过,照样会为它建满 6 个 $_FILES 条目:name / full_path / type / tmp_name / error / size。更狠的是把字段名写成嵌套结构:
--BOUNDARY
Content-Disposition: form-data; name="files[a][b][c]...[z]"; filename=""
--BOUNDARY--
这一个 part 送进去的内容几乎是空的,可这 6 个条目每一个都要为 files[a][b][c]... 这条路径单独建一套嵌套的 HashTable、Bucket 和 key 字符串。也就是说,一个几百字节的空壳,换来的是成百上千次内存分配。堆上几百个这样的 part,整个 HTTP body 还没碰到默认的 post_max_size(8 MiB),PHP 的堆却已经吃掉了默认 memory_limit(128 MiB)那么多内存。
而因为总量是可控地堆上去的,在哪一步刚好越线,取决于你把 part 排成什么顺序——想让崩溃落在第几个 part、第几次分配上,都提前算得出来。
到这一步,Allowed memory size exhausted 这句原本只在故障时偶然出现的报错,就成了攻击者能掐着秒表按下的开关。
0x04 路径①:HashTable 桶的半初始化窗口(CVE-2026-109618)
Calif 在 _zend_hash_str_add_or_update_i 里找到第一个中断点。这个函数负责往哈希表里插入新元素,它插元素的顺序有问题:
idx = ht->nNumUsed++;
ht->nNumOfElements++;
p = ht->arData + idx;
p->key = zend_string_init(str, len, persistent); /* may zend_error_noreturn() */
p->h = ZSTR_H(p->key) = h;
ZVAL_COPY_VALUE(&p->val, pData);
问题出在这个顺序上:先把“元素个数”加一,再去分配放 key 的那块内存。
麻烦的是,zend_string_init 一旦分配失败就会触发 OOM bailout,直接跳走。这时候桶里的内容一个字都还没写——key 是空的,值也是空的——可 nNumUsed 已经把它算进了“有效”的范围。
而后面清理的时候,是按这个计数来遍历的:
for (p = ht->arData; p != ht->arData + ht->nNumUsed; p++) {
i_zval_ptr_dtor(&p->val); /* 把 stale 字节当 zval 析构 */
if (p->key) {
zend_string_release(p->key);
}
}
于是出现了错位:清理代码坚信“这里有一个元素”,可这个元素从头到尾都没被填完。它就是一块还没来得及写内容的原始内存,却被当成一个活着的 zval,走了一遍完整的析构流程——析构要读这个值的类型和指针,读到的全是别人留下的旧字节。
一个 UAF 原语,就这么到手了。
为什么只有 mod_php 中招? 关键在于中断发生的时机。这条路径的崩溃落在 php_request_startup() 里——也就是 PHP 还在给这个请求做准备工作的时候,POST 变量正在注册(php_register_variable_ex() → zend_symtable_str_update_ind() → 上面那个函数),业务代码一行都还没跑。
崩溃之后,坏掉的那张表会不会被读一遍,取决于 SAPI 怎么处理“启动失败”。有两种截然不同的做法:
mod_php: php_request_startup() 失败 → 请求清理照常执行
→ zend_array_destroy($_POST/$_FILES) 消费坏表 → worker 存活复用
PHP-FPM: php_request_startup() 失败 → php_module_shutdown() + return
→ worker 直接退出,坏表随进程消失
换句话说,mod_php 选择“清理完再复用这个进程”,FPM 选择“把这个进程直接扔掉”。
前者意味着那张坏表一定会被 zend_array_destroy($_POST/$_FILES) 遍历一遍,半初始化的桶字节于是被当真的析构;后者意味着坏表随着进程一起消失,什么都不会发生。
具体到代码,mod_php 的 sapi_apache2.c 里,请求构造失败只是 zend_bailout() 跳到外层 handler 处理,php_apache_request_dtor() 依然会去调 php_request_shutdown(NULL),一趟清理照做不误。
这也正是官方敢在 mod_php 上直接验证 RCE、对 109618 的 FPM 却只给“源码分析受影响”的原因——不是没找到,是路径根本走不通。

SAPI 对失败请求的处理分流:mod_php 清理后复用 worker,FPM 启动失败退进程、运行期异常则复用——后者正是路径③的落脚点。
0x05 路径②:session 全局的悬空指针(CVE-2026-109619,PHP ≥ 5.4)
第二类中断点更漂亮。session.upload_progress 是个默认开启、但几乎没人用的功能——它存在的意义是让页面能显示上传进度条。问题在于,为了让进度条尽早转起来,PHP 在模块初始化还没完成的时候,就去碰 session 了。
先后顺序错了,状态就有了“已经发布、但还没登记”的窗口。
main/main.c 里,一个请求的启动是这样排的:
php_hash_environment(); /* 解析 $_POST/$_FILES —— multipart 在这里 */
zend_activate_modules(); /* 各扩展的 RINIT */
PG(modules_activated) = 1;
注意这个顺序:先解析请求体(multipart 就在这一步),后激活各个模块。而 multipart 解析到第一个文件 part、触发 FILE_START 事件时,upload progress 会立刻调 php_rinit_session(0),把 PS(id)、PS(http_session_vars) 这些指向请求内存的指针,写进 session 模块的全局变量。
可这一刻 PG(modules_activated) 还是 0。 状态已经发布出去了,那个决定“要不要做收尾”的开关却还没立起来。
如果接下来恰好发生 OOM fatal,请求关闭时就会按 modules_activated 分流——它还是 0,所以走的是下面这条路:
if (PG(modules_activated)) {
zend_deactivate_modules(); /* session RSHUTDOWN 被跳过 */
}
shutdown_memory_manager(...); /* 请求堆照常释放 */
后果是:session 的 RSHUTDOWN 根本没跑,没人体面地跟这个请求告别。但下面那行 shutdown_memory_manager() 照常执行——请求占的内存被整体释放了,进程却还活着。
于是 PS(id) 和 PS(http_session_vars) 成了两根悬空指针,指着已经还给分配器的内存。偏偏 mod_php 的 worker 还活着,转身就去服务下一个请求。
消费端同样藏在 upload progress 里,而且设计得相当巧妙。请求 B 只要带上 PHPSESSID 和 PHP_SESSION_UPLOAD_PROGRESS 字段、但不带任何文件 part,就只会走到 MULTIPART_EVENT_END,不会触发 FILE_START。
这一步很关键:FILE_START 里本来有一次“提前把 session 状态重置掉”的操作,现在被绕过去了。于是 END 回调做 cleanup 时,session 以为自己的状态还是好的,重新进到 php_session_initialize():
请求 A: 文件 part → 提前初始化 session → 状态发布 → OOM fatal
→ 跳过 session RSHUTDOWN → 请求堆释放 → worker 存活
请求 B: 无文件 part → FILE_START 重置被绕过 → END cleanup
→ php_session_track_init() → zval_ptr_dtor(悬空的 PS(http_session_vars))
最后这行 zval_ptr_dtor 是整条路径的引爆点:它要析构的对象,指向的是已经被释放的内存。接下来会发生什么,全看请求 B 自己的字段分配有没有先把那块内存重新占住——占住了,析构就是在处理攻击者摆好的数据。
这正是后面 9bie 武器化的主战场。
而这条路径最要命的地方在于,它从 PHP 5.4 起就存在,全程默认配置,应用侧一行代码都不用配合。
0x06 路径③:反序列化器全局悬空(打 FPM 的钥匙)
路径①在 mod_php 上畅通、在 FPM 上走不通,原因是 FPM 启动失败会直接退掉 worker。那就换个思路:不在启动阶段下手,等启动成功之后再制造中断。这时候 worker 死不了,坏状态就能带进下一个请求。
Calif 找到的入口,是 PHP 通用反序列化器维护的一份共享状态:
if (BG(serialize_lock) || !BG(unserialize).level) {
d = emalloc(sizeof(struct php_unserialize_data));
/* 初始化引用表和析构表 */
if (!BG(serialize_lock)) {
BG(unserialize).data = d;
BG(unserialize).level = 1;
}
} else {
d = BG(unserialize).data; /* 复用全局状态 */
++BG(unserialize).level;
}
这段代码的逻辑是:解码开始前,如果全局状态是“空闲”(level 为 0),就新建一份引用表和析构表,把它挂到 BG(unserialize) 上,并标记 level=1;如果已经在解码了,就直接复用那份全局状态,把 level 加一。
正常流程里,解码结束时会把 level 减回去、释放这份表。但如果应用正在执行 unserialize() 或 session_decode()(WordPress 的 maybe_unserialize() 这类封装也算)时发生 OOM fatal,收尾函数 php_var_unserialize_destroy() 就被跳过了。
结果是:BG(unserialize).data 指向的表已经跟着请求内存一起释放,指针却还留着;level 也停在非零,看起来像“还有人在用”。
关键差别在这里——这次 fatal 发生在启动成功之后,所以 FPM 的 worker 只是这一次请求异常,进程照常活下来。悬空的指针,就这么留在了下一个请求里。
下一个请求还是从同一个入口进——upload progress 在 Basic 模块 RINIT 之前就开始解码。因为 level != 0,解码器认定“已经有人在解码了”,于是照规矩复用那份全局状态,也就是那个悬空指针指向的地方。
而请求 B 那些普通字段的解析分配,早就把那块内存重新占住了。 解码器满心以为在操作自己的引用表和析构表,读到的却全是攻击者预先摆进去的数据。
Calif 从这里提炼出的写原语尤其致命,要害在于析构块里的 used_slots 是个有符号数:
slot = destructor_block + 16 + 16 × used_slots
这个下标本来只该是正数,指向块内部的槽位。可既然有符号,攻击者塞一个负数进去,它就会算到块的前面去——落点可以是内存里任意位置。
再在序列化数据里放一组重复的 key,就能触发 var_push_dtor_value(),把一个完整的 zval(指针加类型)写进刚刚选定的那个位置。
往任意地址写任意值——配合前面泄漏出来的堆布局,这就是一个完整的类型混淆原语,稳定性远高于纯粹的堆喷。
mod_php 和 FPM 通吃。官方在 FPM 上验证出的那条 session 生命周期 RCE,走的正是这条路。
0x07 武器化:9bie 公开利用链逐段拆解
机理有了,可从“UAF 原语”到“敲一条命令拿到 uid”,中间还隔着 ASLR、堆布局和触发稳定性。
ASLR(Address Space Layout Randomization,地址空间布局随机化) 是操作系统的一项防护:把程序、共享库、堆、栈这些区域的装载或分配位置随机化,让人没法预先猜到内存地址。具体随机到什么程度,取决于平台和构建配置。
9bie/php2shell-bypass-aslr-exp 把最后这一公里也公开了:目标为 php:8.4.26-apache 官方镜像,加 Apache prefork 单 worker,README 里报的是 3/3 成功。
下面结合 exploit.py 和 NOTES.md 做静态分析。成功次数是仓库作者的说法,本文没有独立跑过它的 RCE 实验。

区域基址与堆内对象排列属于不同层次;图中位置与宽度均为示意。
Stage 0:worker 轮换——prefork 白送的地址稳定性
ASLR 让每次启动的地址都不一样,看起来无解。但 Apache prefork 有个特性,把这个问题化解掉了。
A 请求先用一堆 3KB 的存活字段占住堆,再加上一个 12MB 的 OOM 字段,把 child 进程撑到 SIGABRT。prefork master 发现子进程死了,立刻 fork 一个新的补上来。
关键就在这里:fork 出来的子进程,继承的是父进程的内存映射。所以同一个 master 底下,新 worker 的 libphp 基址、Zend MM 那堆 2MB chunk 的布局,跟刚死掉的那个 worker 一模一样。
也就是说,ASLR 只有在 master 启动那一刻随机了一次,之后每个 worker 拿到的都是同一套坐标。于是每个 stage 只对付一次 ASLR 就够了;泄漏失败也不要紧,rotate() 换一个 worker 重来,地址还是那些地址(只有 master 重启才会重新随机)。
这个“轮换原语”,就是整条链的工程地基。
Stage 1:Set-Cookie 堆泄漏——把 zend_string.len 改大
第一阶段的目标:拿到 Zend MM 的 chunk 基址。
做法是沿用路径②的配方,先留下悬空的 session 状态,但需要额外凑两个条件,把数据“偷”出来:
- 凑够 1001 个字段,触发
max_input_vars警告。这个警告如果被display_errors=On打到输出里,就等于提前向浏览器发了内容,headers_sent随之变成 1。有了它,PHP 后面才允许在响应里追加响应头——这是能把数据塞进响应头的前提。 PHPSESSID=\x00这种非法 SID,会逼 PHP 丢掉用户给的 id、重新生成一个,并置上send_cookie=1。响应里于是多出一个Set-Cookie,这个头就成了往外搬数据的信道。
B 请求用 56 字节的字段值缓冲区重占悬空 reference,再补一个 79B 的“伪造字段”,在第 0x10 偏移写入超大的 zend_string.len:
forge = bytearray(b"A" * 79)
struct.pack_into("<Q", forge, 0x10, 0x1000) # 伪 zend_string len=4096
forge[0x18] = ord("L")
parts.append({"name": "1999", "data": bytes(forge)})
接下来,PHP 写 cookie 的时候会对这个 session id 做一次 php_url_encode()。按正常逻辑,就是把这串 id 转成 URL 安全的形式——但这里发生了两件事:这个 id 所在的字符串早就被释放了,现在占着的是 B 请求塞进去的数据;而它的长度字段又被伪造成了 4096。
编码器不知道这些,它只知道“我有一个 4096 字节的字符串要编码”。于是一路越界读下去,把紧跟其后的堆内容一起 urlencode 进 Set-Cookie,4KB 左右的内存就这么带出去了。
看到数据还要能还原出地址。解析端的做法很朴素:在泄漏回来的字节流上滑动窗口,每次取 8 个字节按小端解读,值落在用户态指针区间内的就留下;再把这些候选值按 2MB 对齐折叠,取出现次数最多的那个——众数。
这个思路的妙处在于,堆里会有一大堆指针,它们天然都落在同一个 2MB chunk 里。与其试图精确定位某一个指针,不如数一数大家“最常指向”哪里,后者抗噪得多。
Stage 2:libphp 泄漏——HashTable 恰好对齐 pDestructor
第一阶段拿到的只是堆在哪,还缺 libphp 在哪——代码段的地址。第二阶段要再偷一次。
这次的落点更巧。B 请求构造 983 个数组字段,其中第 406 个是 16 个元素、每个 16B 的值。为什么是 16 个元素?因为 Zend 的 HashTable 是按 16 个槽位一页分配的,这样一份表的大小正好卡在那个位置上。
结果就是:这份精心定尺寸的 HashTable,恰好重新占住了悬空的 PS(id) 那块内存。于是原本该被读成字符串的地方,现在躺着的是哈希表的结构体:
ZSTR_LEN(PS(id)) 读到的是 HashTable 的 nTableSize=16
cookie 数据从 PS(id)+0x18 起 = nNextFreeElement | pDestructor
而 pDestructor = ZVAL_PTR_DTOR,是 libphp 里的固定符号
读法很直白:PS(id) 被当成 zend_string,它的长度字段读到的其实是这个 HashTable 的 nTableSize(也就是 16);而 cookie 数据从 PS(id)+0x18 开始读,读出来的就是 nNextFreeElement 和 pDestructor 两个字段的值。
这里的宝贝是 pDestructor——它是 ZVAL_PTR_DTOR,一个 libphp 里的固定符号。只要拿到它的实际地址,减去它相对 libphp 基址的固定偏移,基址就到手了。
解析端会做双重校验:低 12 位必须和已知偏移里的低 12 位对上(页内偏移不受 ASLR 影响),解出来的基址还得是 4KB 对齐的。两条都满足才采纳。
两次泄漏一拼,代码段和堆的地址就都知道了,ASLR 被彻底绕过。整个过程只靠 HTTP 响应头的读写,一次 libc 都没碰。
Stage 3:fake 析构链——让 PHP 自己调 zif_system
地址都齐了,最后一步是真正执行命令。这一阶段不再往外泄漏什么,而是直接覆写那块悬空的 reference,让它指向攻击者搭好的假结构。
先说怎么稳定地把 reference 摆到固定位置:A 请求用 6 组填充数据,把 reference 推到 chunk 内的某个确定偏移上。
再说怎么改它:B 请求给一个 15 字节的 carrier。为什么是 15 字节?因为 zend_string 分配时会带一个计数头和一个 \0 结尾,bin-40 这一档的字符区正好在 reference 头上——这样写入的数据不会碰到别的结构。
改的时候只动低 7 个字节的指针值,高字节保留原本的规范零。这样既不用关心高位,也不会越界到别的区域。改完,val.value 就指向了某个 167 字节存活字符串里预先摆好的三件套:
之所以要放在一个“还活着”的字符串里,是因为这块内存必须活到最后——一旦被回收,内容会被别的数据覆盖,整套布置就散了。
fake HashTable pDestructor = libphp + zif_system 偏移
fake execute_data num_args=1, arg1 → 指向命令字符串的 IS_STRING zval
fake zend_string 命令本体(≤14 字节,167 − 0x98 − 1)
触发完全走 PHP 自身的析构路径,无需任何注入:
php_session_track_init()
→ zval_ptr_dtor(PS(http_session_vars))
→ zend_reference_destroy(R0)
→ zend_array_destroy(fake HT)
→ zend_hash_destroy(fake HT)
→ pDestructor(&bucket->val) # = zif_system(cmd)
最后这行是关键:zend_hash_destroy 遍历到那唯一一个 bucket 时,会调 pDestructor(&p->val)。
而这个 &p->val 的地址,恰好就是预先摆好的假 execute_data 所在的位置。zif_system 拿到它之后,就按一个标准内部函数该有的规矩去读 call frame——num_args 是几、第一个参数是什么,全都来自攻击者的数据。于是它老老实实把那条命令执行了。
这一手的高明之处在于:不用 ROP、不碰栈、不依赖 libc。它没去劫持任何控制流,只是让 PHP 自己的析构流程把自己坑了——走的完全是合法调用路径。相比之下,NOTES.md 里早期那套靠 pop rdi; ret 加 popen@plt 的写法,要脆弱一个量级。
唯一的限制是命令本身只有 14 字节。绕过办法也简单:用 sh</out/c(8 字节)从挂载目录读一个脚本执行,剩下的就随便写了。
顺手盘点:研究笔记里的可复用部件
NOTES.md 记录了完整的探索过程,几个部件在别的场景同样好使:
stream_bucket_new()320B 裸喷:给 stream filter 喂 payload,得到内容完全可控、且被资源保活的裸缓冲——早期 Defect D 线(session_unset()+__destruct写$_SESSION触发zend_hash_clean重入 → resize 释放旧 arData → 320B 重占 → fake HT)就是靠它拿下的首个 RCE;php_serialize重入泄漏:__serialize()里清空$_SESSION释放正在序列化的数组,zend_reference重占 32B 字符串块后val.type_info被当len,一次 775B 越界读带出堆指针;换 4096B 块则伪造len=32768读 32KB,固定偏移处捞出 libphp 代码指针。注意默认phphandler 已被 GH-16590 堵住,此路要php_serialize;- A/B 请求对本身就是一个通用模式:A 制造悬空+内存布局,B 重占+消费,中间靠 prefork 轮换保证地址连续性。
0x08 防御:在链条上动刀
理解了链条,缓解措施的位置就清楚了:
安装官方修复:升级到官方已发布的修复版本,或安装发行版明确回补这两个 CVE 的包,并重启全部 PHP worker。本次首批修复版本为 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35;实际更新时以官方当前安全公告和发行版维护记录为准。补丁同时调整 OOM 处理、请求结束清理和 early RINIT,分别处理异常退出时机、失效引用和过早访问的问题。
临时缓解按环节选:
session.upload_progress.enabled=0斩断路径②③的消费者——B 请求的提前 session 处理直接消失,官方和 Calif 都点名的首选。这功能默认开启而几乎没人用,关了损失很小;memory_limit=-1移除 OOM 生产者,但引入资源耗尽风险,只配合容器 / cgroup 边界短期用。RLIMIT_AS/RLIMIT_DATA反而会制造底层分配失败,官方明确警告别用;- 关
display_errors能掐掉 9bie 当前的泄漏信道(依赖警告输出置headers_sent),但仓库已声明 off 版在路上,这只争取时间。
检测面(来自利用代码的具体特征):异常巨大的 Set-Cookie(约 4KB urlencoded 乱码,最强单点信号);带 PHP_SESSION_UPLOAD_PROGRESS 却无文件 part 的 multipart 请求;单请求上千字段或单字段十几 MB;worker 反复 SIGABRT 的 502 尖峰;日志里 Session ID is too long or contains illegal characters 与 Failed to read session data 成对出现。

三类问题涉及不同状态,补丁分别处理元素可见性、请求内存引用和共享解码上下文。
0x09 小结
PHP2Shell 的价值在于,它把一条埋了二十年的线走通了:内存耗尽从“故障”变成了“可控中断”,而中断又恰好落在内部状态发布与提交之间的那道缝里。
三条路径共用同一个生产者原语,消费端则分别击中 HashTable 计数、session 全局和反序列化器全局。SAPI 对启动失败的处理差异,决定了 mod_php 先倒下;而应用层的解码操作,让 FPM 同样守不住。
补丁的修法也值得回味:把软 OOM 的处理挪到受控检查点,用硬限约束还能分配多少余量,再把请求两端的状态清理补齐。
对写 C 的工程师来说,这提醒我们重新审视“先更新记录、再执行可能失败的操作”这种很常见的顺序:一旦操作中断,后面的代码究竟会把这条记录当成什么状态?
IceSolst 在 X 上的这段调侃,倒很贴切:
如果你先发布补丁,它会得到一个 PoC。
如果你不发布补丁,人们会在你身上烧掉数百万个 token,并得到更多 PoC,包含你尚未修补的新漏洞。
这轮修复也值得继续跟踪:同类问题可能仍有遗漏,后续研究还可能找到新的异常路径或变体。 PHP 官方当前将这些版本列为两项已披露漏洞的修复版本;“这类问题是否已经全部找完”,则是另一个需要持续研究的问题。本文尚未验证针对完整官方修复版本的新绕过。先更新,再关注 PHP 官方安全公告、版本更新记录、watchTowr 的 PHP2Shell 跟踪分析 和发行版后续通告,两件事应当一起做。
对维护者来说,修复、回归测试和后续审计需要持续推进;对使用者来说,及时更新并确认运行中的 worker 已切换,仍然是当下最实际的行动。
0xFF 参考资料
- PHP 官方紧急安全公告与缓解措施
- CVE-2026-109618:RCE via OOM corruption(GHSA-w8hw-w9fx-j79w)
- CVE-2026-109619:RCE via stale global state(GHSA-6wmf-4rv5-p3rp)
- PHP 8.4 安全提交:Avoid state corruption via bailout
- PHP 8.5 安全提交:Avoid state corruption via bailout
- Calif:PHP2Shell 一手研究
- 9bie:php2shell-bypass-aslr-exp 公开利用实现
- Wiz:PHP2Shell 技术分析
- PHP Bug #78876:multipart 长变量 OOM(2020)
- PHP:Session Upload Progress 手册
资料与代码阅读截止于 2026 年 10 月 22 日。