-
-
[原创]PHP2Shell 复现连载(二):从 session.c 源码定位根因,到 ZendMM 的 320 字节 bin
-
发表于: 2天前 74
-
上一篇(第一篇):https://bbs.kanxue.com/thread-293373-1-1.htm
PHP2Shell 复现连载(二):从 session.c 源码定位根因,到 ZendMM 的 320 字节 bin
【测试声明】本文内容为源码静态分析与本地环境观察,未对任何第三方系统发起请求;文中不包含可直接运行的利用代码。
0x00 本篇要回答什么
第一篇给了结论:能触发,但打不穿。这一篇把"为什么"补上。
具体回答五个问题:
一、那两个失效指针到底存在哪、活多久;
二、为什么"停顿时序"是硬条件,不是玄学;
三、官方补丁是从哪个方向修的,你怎么自己验证;
四、320 字节这个数字从哪来,为什么它决定了可利用性;
五、从"写到已释放内存"到"控制执行流",中间隔着什么。
做完这一篇,你手上应该有一套可复用的方法:给一个 CVE 编号和受影响版本,自己能挖到根因、自己能看到官方改了什么。这比"复现成功"有用得多,也是后面第三篇实操的基础。
0x01 先看清参与的三方
PHP 的 multipart 上传进度特性,涉及三个彼此独立的角色。分开看,后面才不会绕晕。
角色一:multipart 解析器。位于 main/rfc1867.c。它按客户端发来的字节流切分,遇到文件开始、文件数据、文件结束这些边界时向外抛事件。它只关心"流到哪里了",不关心 session。
角色二:session 数据。位于 ext/session/session.c。它把"上传进度"这类临时信息写进当前请求的 session 数组,格式是 upload_progress_<字段值> 对应的一个 zval 数组。
角色三:session 存储后端。这里是 files handler,把 session 数组序列化后落到 /var/lib/php/sessions/sess_<sid>。
一次上传里,解析器每前进一段,就会回调 session 侧更新进度。问题出在角色二和角色三的边界上。
0x02 关键路径:从 FILE_DATA 到 track_init
把调用链摊开(行号基于 PHP 8.4.4 源码,不同小版本会有偏移):
客户端发送 multipart 数据
↓
main/rfc1867.c 检测到 FILE_DATA 事件
↓
ext/session/session.c php_session_rfc1867_update()
↓
需要写进度 → php_session_initialize()
↓
php_session_track_init() ← 重建 session 数组,旧 HashTable 在这里被释放
关键在于"缓存"这一步。
为了让后续事件不用每次重新查找,update 路径会把两个 zval 槽位的地址存下来,供下一次事件直接写入:
session.c:3292 → post_bytes_processed
session.c:3318 → current_file_bytes_processed
注意存的是 zval*,也就是裸地址,不是值,也不是"sid + key"这种可以重新解析的引用。
于是时序变成:
第一次 FILE_DATA:缓存两个 zval*,指向当前 session HashTable 的 arData 槽位。
↓
(间隔超过 min_freq,默认 1 秒)
↓
第二次 FILE_DATA:重新初始化 session,旧 HashTable 被释放,但缓存的两个指针没有失效。
↓
session.c:3331-3332:通过这两个失效指针写入字节数。
↓
写入落在已释放的内存上。
这就是 UAF 写。
0x03 同一个 bug 的三种说法
这三个说法是同一件事,但只有第三个能解释"为什么难修"。
说法一(现象层):写操作落在一块已经释放的内存上。
说法二(引用层):缓存的是裸指针,对象销毁时没有任何机制通知缓存失效。
说法三(所有权层,也是最有用的):session 数组有明确的所有者——当前请求的 session 生命周期;而缓存的指针存在函数外的静态存储里,生命周期跨事件延续。两边的生命周期没有对齐。
第三种说法为什么有用?因为它能直接推出修复方向,也解释了为什么这类 bug 在 C 项目里反复出现:只要有一处"为了性能把地址存下来"的优化,就埋了一个所有权错配的隐患。堆喷、引用计数、GC 都只能降低概率,不能消除。
再看这个 bug 倒霉的地方:被缓存的是 Zend 引擎内部的 HashTable 槽位地址,而这个 HashTable 会在 session 重新初始化时被整体换掉。换掉的动作很常规,缓存失效却是无声的。
0x04 触发条件拆解:四个必要条件
条件一:PHP 版本落在受影响区间。低于修复版本,代码里就存在这段逻辑。
条件二:session.upload_progress.enabled = On。这是 PHP 的默认值,绝大多数安装都满足。
条件三:session.save_handler = files。文件 handler 下进度数据会真实写盘,后续才能在磁盘上观察到落地内容。
条件四:两次 FILE_DATA 事件之间的间隔大于 session.upload_progress.min_freq(默认 1 秒)。
第四条最容易被忽略,也最值得展开。
min_freq 是"最小更新频率",本质是节流阀:如果每次收到数据都更新 session,一次大文件上传会产生上万次写,性能直接崩掉。所以它规定——两次更新间隔小于 min_freq 时,本次跳过。
跳过意味着:第二次事件的 update 逻辑不会被调用,php_session_initialize() 不重新执行,track_init() 不释放旧 HashTable,UAF 也就不会发生。
反过来,只有当第二次事件"真的触发了 update",重建才发生,指针才失效。
所以这个漏洞的第一硬条件不是"上传大文件",而是"让 PHP 认为该更新进度了"。这也是为什么很多人在浏览器里手动传文件永远打不中——浏览器发得太快,触发不到节流之后的第二次更新。
0x05 为什么 nginx 的请求缓冲是必要条件
这一条常被当成"靶场配置技巧",其实它决定条件四能不能成立。
nginx 的 fastcgi_request_buffering 默认 on。开启时 nginx 会把客户端发来的整个请求体全部收完,再一次性转发给 FPM。对 FPM 来说,收到请求体是一个连续动作,中间没有停顿。
关闭时(off),nginx 边收边转。客户端发一段,nginx 转一段,FPM 处理一段。客户端节奏被忠实传导到 FPM 读取侧。
于是:
buffering on → 客户端停顿被 nginx 吸收 → FPM 侧看到连续数据流 → 第二次事件不晚于 min_freq → 不触发重建 → 无 UAF
buffering off → 客户端停顿被传导 → FPM 侧两次读取之间空闲超过 1 秒 → 第二次事件触发 update → 重建 → UAF
这里有个容易混淆的点:很多人以为"关缓冲"是为了让攻击更容易,其实它是让"暂停"这件事在协议层面变得可见。缓冲把时间维度抹平了,而漏洞恰恰活在这个时间维度里。
只看源码会以为漏洞无条件触发,实际它的触发条件一半写在 PHP 配置里,一半写在部署拓扑里。这个认识对防守方的意义是:你可以不改代码,只改拓扑就消掉触发条件(见第四篇)。
0x06 补丁差分:自己找官方改了什么
这部分是我认为比复现更有价值的能力。任何人拿到一个 CVE,都可以从"官方怎么修的"反推根因,过程可验证,不需要相信任何二手分析。
第一步,拿到 PHP 源码仓库:
git clone --filter=blob:none d2bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6H3K9s2m8Q4x3V1k6H3K9s2m8Q4x3X3c8K6M7X3y4Q4x3X3g2Y4K9i4b7`.
cd php-src
--filter=blob:none 是部分克隆,只拉提交历史不拉全部文件内容,速度快很多。
第二步,确认两个 tag。tag 名形如 php-8.4.26 与 php-8.4.27,以仓库实际 tag 为准,可以先跑:
git tag -l "php-8.4*"
第三步,只看这一个文件在两个 tag 之间的提交:
git log --oneline --no-merges php-8.4.26..php-8.4.27 -- ext/session/session.c
第四步,看具体改了什么:
git diff php-8.4.26..php-8.4.27 -- ext/session/session.c
看的时候不要从头读,按下面顺序定位:
一、先找 php_session_rfc1867_update 这个函数有没有改动;
二、再找那两个缓存变量(post_bytes_processed、current_file_bytes_processed)的赋值与使用位置;
三、最后看 php_session_track_init 或它附近,有没有新增"清理缓存"的语句。
这三处只要有一处变了,基本能锁定修复点。
第五步,验证理解:把 diff 里改动的行,和 0x02 的调用链对一遍,问自己一句——这一行改动,是否让"缓存指针"和"session 生命周期"重新对齐了?
如果答案是"是",说明你读懂了。如果对不上,说明前面的调用链理解有偏差,回去重读源码,不要硬套。
关于本篇的诚实说明:我在这里给的是方法,不是"官方 commit 的具体内容"。原因有两个——不同小版本的修复方式可能分多次提交,且我这边的观察只能证明漏洞机制,不能替代你自己跑一遍 diff。你按上面五步做完,得到的结论比任何文章都可信。
0x07 从差分反推:三种可能的修复思路
跑完 diff 之后,改动大概率落在下面三类的某一类。
思路一:让缓存失效。在 session 重建(track_init)的地方顺手把缓存的两个指针清成 NULL,后续写入前判断是否为空。
特点是"最小改动",只堵这一条路径。缺点是缓存机制本身还在,下次有人加新字段,同样的坑还会踩。
思路二:不再缓存裸指针。改成缓存 key(字段名),每次写入前重新查找槽位。查找有开销,但地址永远有效。
这一类更彻底,代价是每次更新多一次哈希查找。对低频的进度更新来说,这个代价可以接受。
思路三:把进度数据移出 session。不再复用 session 数组存临时进度,改用独立结构,生命周期跟着请求走。
这一类是重构,改动面最大,但根除问题。历史上 PHP 不少 session 相关的怪问题,最后都是靠"把不该放 session 的东西挪出去"解决的。
判断官方走的是哪一类,方法很简单:看 diff 的新增行数。只加了几行判空,是思路一;把指针变量换成字符串 key,是思路二;动了数据结构定义,是思路三。
这也解释了为什么同一个小版本号里的安全修复,有时需要连升好几个补丁版本才彻底。
0x08 为什么这个 bug 特别难被发现
原因一:它不产生错误。写穿一块已释放但尚未复用的内存,ZendMM 不报错,PHP 不 warn,请求返回 200。所有监控指标都是绿的。
原因二:它依赖时序。同一份代码,压力小的时候不触发,压力大的时候可能触发。常规功能测试永远测不出来,因为你不会在上传中间故意停 1.5 秒。
原因三:日志里没有痕迹。唯一痕迹在 session 文件里(内容异常或出现不该有的字符),而 session 目录通常没人看。
原因四:触发和影响之间隔着堆布局。触发了未必崩,崩了未必是这里导致的。排查的人很容易把"容器偶发退出"归因到资源不足。
四条加起来,意味着发现路径几乎只有两条:静态审计(有人认真读了这段缓存逻辑),或者模糊测试恰好撞上(概率极低,因为需要客户端主动停顿)。
给防守方一个可用的判断:如果 PHP 服务历史上出现过"上传大文件时偶发 502 / 进程重启",且一直归因为超时或内存不足,值得回头查触发时间点附近有没有异常 multipart 请求。这类归因错误是漏洞长期潜伏的主要原因。
0x09 ZendMM 的分层结构
从这一节开始,进入"为什么打不穿"。
先认识 ZendMM。它是 PHP 自己的内存管理器,不直接用 malloc,而是从系统批发大块内存再切碎分配。三个层级:
第一层,chunk。从系统申请的大块,默认 2 MB 一个。所有小块都从 chunk 里切。
第二层,page。chunk 被切成 4 KB 的页。
第三层,slot(小内存块)。page 再按固定尺寸切成等长小块。
关键在于"固定尺寸"四个字。ZendMM 不给任意长度的块,它维护一组预设尺寸(size class),申请时向上取整到最近的一档。
这套尺寸大致是:
8, 16, 24, 32, 40, 48, 56, 64,
80, 96, 112, 128, 160, 192, 224, 256,
320, 384, 448, 512, 640, 768, 896, 1024,
1280, 1536, 1792, 2048, 2560, 3072
规律:64 字节以下每 8 字节一档(对齐粒度),超过 64 之后按 1.25 到 1.5 倍递增。超过 3072 走大块路径,另行处理。
每一档对应一个空闲链表(free list),同尺寸的已释放块串在里面。每次申请该尺寸就从链头摘一个,每次释放就挂回链头。
这套设计的目的是减少碎片、加快分配。副作用是:同尺寸的释放与分配,几乎总是复用同一批块。这句话是理解整个 UAF 可利用性的钥匙。
0x0A 320 字节这个数字从哪来
官方 README 说需要"spraying fake structures into ZendMM's 320-byte slab bin"。
320 是上面那张表里的一档。为什么被释放的那块内存刚好落在这一档?
因为它装的是 session 数组的底层存储:Zend 的 HashTable(就是 PHP 数组的底层实现)。一个装了 5 个键的进度数组,其 arData(存放 Bucket 的连续数组)加上头部结构,整体大小落在 320 这一档。
换个说法:这个 UAF 的目标块尺寸不是攻击者选的,是数据结构定的。你能利用它,前提是能造出同样落在 320 档的伪造结构去占据它。
这就是堆喷要达成的效果——在同一档位的空闲链表上连续释放再分配,让目标块被替换成你控制的内容。喷的东西尺寸必须刚好落在 320 档,大一点小一点都会落到别的档,完全无效。
0x0B 释放之后,那块内存去了哪
执行 track_init 时,旧 HashTable 被销毁,它的 arData 数组被 zend_mm_free 还给分配器。
"还给分配器"这个动作只做三件事:
一、把这块内存挂到 320 档的空闲链表头;
二、更新几个统计计数;
三、把块头标记成空闲。
它不清零,不取消映射,也不让原来那个指针变得不可写。
所以紧接着的那次写操作,物理上完全合法:地址有效、页有效、写权限有效。CPU 不报错,内核不杀进程。你写进去的字节,安安静静躺在那里。
这就是 UAF"静默"的物理原因——它不违反任何硬件规则,违反的只是程序逻辑里的所有权约定。
而这块内存什么时候变成"别人的",取决于后续有没有同尺寸分配来摘走它。没有,它一直是死存的旧数据;有,它就变成别人结构的一部分。
0x0C 三种可能的写入结果
从这里开始,"能不能利用"变成概率问题。三种结果对应三种局面。
结果一:崩溃。
写入破坏了相邻结构的关键字段(比如下一个 page 的管理头、或别的请求的元数据),进程在下次访问时崩掉。
这是最差的结果,不但没利用成功,还留下明显异常,暴露了攻击行为。很多人误以为"打崩了说明打得深",恰恰相反——随机崩溃是布局没对上的典型表现。
结果二:静默。
写入落在 320 档空闲链上的一个块里,没有别的东西依赖这块内存。请求正常返回 200。
这就是我复现时看到的情况:5 轮全部 Armed,0 次崩溃。它证明写入穿透成功,但写入内容没有被任何逻辑读取。
结果三:可控。
写入落在了一块已被"有解释权的结构"占据的内存上,写入的字节改变了那个结构的字段,而这个字段后续会被解引用或调用。
只有第三种能走向代码执行。
从结果一到结果三,中间需要的是:让目标块在写入之前,被一块同尺寸、内容由你决定、且会被解释执行的结构占据。这就是堆喷的全部目标。
0x0D 从静默到可控,具体难在哪
难点一:窗口极窄。
顺序必须是"释放 → 被伪造结构占据 → 写入",三步之间不能插入别的同尺寸分配。而 PHP 在一个请求里会做大量分配,你无法预知哪个内部结构会在你喷的时候挤进来。
难点二:占据者必须"能被解释"。
随便喷一堆同尺寸垃圾字节确实能占住位置,但写进去没人读,还是静默。必须让占据者是 Zend 引擎后续会解引用、且解引用会产生控制流转移的结构——比如 zend_function、对象 handler 表、或可触发回调的结构。这要求精确知道目标结构在每个小版本里的字段偏移。
难点三:写入内容是受约束的。
这个 UAF 写的不是任意字节,而是两个整数字段:已处理字节数和当前文件已处理字节数。你能控制的是这两个数字的数值,不是任意一段缓冲区。
这一点非常致命。很多漏洞的利用价值来自"能写入受控长度的受控数据",而这个漏洞只能写两个数值。要用两个数值完成从数据到控制的跨越,通常需要一个中间结构把"数值"解释成"指针"或"长度",而这需要非常具体的堆布局知识。
难点四:只写一次。
每个请求只能触发有限的几次写,写错就是错了,没有重试机会。而调堆布局往往需要成百上千次尝试。
把这四条放在一起,就理解了为什么官方仓库止步于"证明可达"——继续往下走,工作量级等同于从一个 bug 做一个完整 exploit,而且高度依赖具体构建版本。
0x0E 请求隔离:一个被低估的障碍
PHP 是"每请求一世界"的模型。请求结束时,属于该请求的内存会被整体回收(request shutdown),下一个请求从干净的堆开始。
这对利用意味着什么?
意味着整个利用必须在同一个请求生命周期内完成。你没法"第一个请求负责释放,第二个请求负责占据",因为请求结束的那一刻堆就被清了。
而在这个单一请求内部,你要同时完成:制造释放、喷满 320 档、让伪造结构就位、触发写入、让写入结果被解引用。全部挤在一个 HTTP 请求里,中间还要符合 min_freq 的时序约束。
这是路径 A 最不直观的困难点。写堆利用的人习惯了"多次交互逐步布局",而这里只有一次机会。
顺带这也解释了为什么"多打几次总能中"的思路不成立——每个请求都是独立世界,成功率不会因为重复而累积。
0x0F 版本适配的代价
即使上面全搞定,还有一个变量:具体构建。三个层面:
一、编译选项。是否开启某些扩展、优化等级不同,会导致结构体布局和函数内联情况不同。
二、小版本差异。8.3.x 和 8.4.x 的 Zend 结构体字段顺序可能已经改过。
三、发行版打包差异。Debian、Alpine、官方 Docker 镜像的编译参数不同,甚至补丁集不同。
堆利用里所有偏移都必须精确匹配目标构建。这意味着"通用 exploit"从一开始就是伪命题——能做的是"针对某个具体镜像的 exploit",换个环境就得重做。
这也是为什么这个漏洞爆发后,公开流传的都是"验证可达"的脚本,没有一个声称"通用 getshell"。不是没人研究,是投入产出比不支持通用化。
0x10 这件事对防守方意味着什么
路径 A 极难,这对防守方是好消息,但好消息的保质期很短。
短期(几周内):能真正利用路径 A 的人很少,实际攻击以配置类路径为主(fpm 端口暴露那类,任意版本都能打)。所以处置优先级应先给配置:把 9000 关进本机、关掉不用的 upload_progress。这两条当天能做完,能挡住绝大多数实际攻击。
中期(几个月):随着补丁 diff 公开、结构体布局被反复讨论,路径 A 的门槛会下降。历史经验是,一个"看起来不可利用"的 UAF,在公开半年后出现可用 exploit 的比例并不低。所以不能因为"现在打不穿"就缓一缓升级。
长期:这个漏洞真正的教训不是"PHP 有个 UAF",而是"默认开启的特性会扩大攻击面"。session.upload_progress 从引入到现在,绝大多数站点从没用过,却一直开着。这类"默认开启、无人使用"的特性,是服务端软件里最容易被忽视的风险来源。
给一个可操作的习惯:定期列出所有"默认开启但你并不使用"的配置项,逐项关掉。这比追 CVE 更有效。
0x11 小结与下一篇
这一篇做了两件事:把根因从"UAF 写"还原成可逐行对照的调用链,并给出自己验证官方修复的方法;然后把"为什么打不穿"拆成分配器机制、堆布局、请求隔离、版本适配四层。
结论四条:
一、根因是缓存裸指针,而它指向的 HashTable 会在 session 重建时整体释放,两边生命周期没对齐;
二、最关键触发条件是"两次 FILE_DATA 间隔超过 min_freq",能否成立取决于部署拓扑(nginx 缓冲);
三、官方修复方向可以从 diff 里读出来,读法比结论更重要;
四、从静默到可控需要的是一整套堆布局工程,不是改几个参数。
下一篇进入实操:从环境搭起,到把 UAF 原语真正打通,再到辨析"我拿到的 shell 到底来自哪条路径"。那一篇会解释为什么"复现成功"不能写成"漏洞可利用"。
免责声明:本文仅用于安全研究与防御加固讨论。任何测试须确保目标系统为自有资产或已获书面授权,测试环境与生产环境网络隔离。
[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)