之前写过 PHP 反序列化的 POP 链基础和 ThinkPHP 5.1.37 的链复现。这次课程把几个”什么时候用哪个”的实战问题补齐了:没有 unserialize 入口怎么办(Session/phar)、过滤拦住 payload 怎么办(正则回溯)、payload 落地文件怎么写(INTO DUMPFILE/恶意 MySQL)。这篇按利用链拼装的顺序整理。
0x01 Session 反序列化:引擎不一致
PHP 的 session 存储和读取各有一套序列化引擎,由 session.serialize_handler 控制:
| handler | 存储格式 |
|---|---|
| php(默认) | 键名|序列化值 |
| php_serialize | 纯 serialize() 结果 |
| php_binary | 长度前缀 + 键名 |
漏洞成立条件:写入和读取用了不同 handler。 经典组合是 php_serialize 写入、php 读取——php 引擎用第一个 | 分隔键名和值,如果攻击者能在 session 值里注入一个 |,后半段就会被整体当序列化串解析:
写入(php_serialize):a:1:{s:4:"test";s:30:"O:4:"User":1:{s:4:"name";s:3:"cmd";}"}
读取(php) :键名 = a:1:{s:4:"test";s:30:"O:4:"User ← 值 = 剩余部分,直接 unserialize
注入口子:session.upload_progress.enabled=On(PHP 默认开)时,上传请求带一个 PHP_SESSION_UPLOAD_PROGRESS 表单字段,它的值会原样写进 session——这是我们可控的字符串来源,不需要应用主动写 session。
触发面:如果应用里有文件包含/include($_GET[...]) 之类的点,包含 session.save_path 下的 session 文件(默认 /var/lib/php/sessions/sess_<PHPSESSID>),构造好的对象链就在解析时执行。
排查清单就三行:两个 handler 是否一致?有没有 upload_progress 可写?有没有包含 session 文件的路径?
0x02 phar 反序列化:不需要 unserialize 函数
这是”没有反序列化入口”时的主力。phar 文件结构里有一段 manifest,其中的 metadata 是 PHP 序列化格式,而且——绝大多数文件操作函数在处理 phar:// 流时都会自动反序列化它:
// 攻击者把恶意对象放进 phar 的 metadata,上传图片马式的 phar 文件
// (文件头可以是任意内容,只要能过图片校验)
file_exists("phar://uploads/avatar.jpg"); // ← 触发 metadata unserialize
// 同样触发的还有:file_get_contents / fopen / is_dir / filesize /
// copy / unlink / stat / filemtime ... 一大批
利用三步:造(用 PHP 生成带恶意 metadata 的 phar,改后缀伪装成图片上传)→ 找(代码里搜”文件操作函数 + 用户可控路径”)→ 触发(路径前加 phar://)。
实战里 phar 的意义在于把”反序列化”从”某个显式 unserialize 调用”扩展成了”任何文件函数”,攻击面直接翻一个量级。
0x03 ThinkPHP 5.1.x 链的关键节点
5.1.3 ~ 5.1.37 的链我之前写过完整复现,这里只记重点的认功能不认类名的骨架,方便在混淆/二开代码里认出来:
入口对象 __destruct
→ 触发 __toString(如 file_exists 的参数被当字符串)
→ 模型序列化路径:toJson → toArray → getAttr
→ getCatch / filterValue($value, $key, $filter)
→ call_user_func($filter, $value) ← $filter=system, $value=命令
→ RCE
两个关键可控点:$filter 来自对象的某个属性(常见 think\request 的 filter 链),$value 来自另一个属性。构造 POP 时就是让这两个属性在链尾相遇于 call_user_func/array_walk。5.2 的链换了中间节点但骨架一样。
0x04 组合拳:正则回溯 → INTO DUMPFILE → 恶意 MySQL
课程给了一条把前面所有零件串起来的完整利用链,场景是”注入点在、payload 过不了 WAF 的正则过滤”:
① 正则回溯绕过过滤
PHP 的 preg_match 有回溯次数上限 pcre.backtrack_limit(默认 100 万)。把 payload 前面垫上大量让正则引擎不断回溯的字符(如连续 a 配合 .*? 类贪婪匹配),回溯超限后 preg_match 返回 false 而不是匹配失败——很多代码把 false 当”没匹配到”处理,过滤就此失效:
payload = "a" * 1000000 + "恶意内容"
preg_match($black_regex, $payload) === false // 代码误判为"安全"
② INTO DUMPFILE 落地 phar
注入点既然活了,就用 SELECT ... INTO DUMPFILE 把带恶意 metadata 的 phar 内容写到 Web 服务器磁盘(注意 DUMPFILE 写单列不转义,OUTFILE 会加换行转义破坏二进制——写文件必须用 DUMPFILE):
SELECT 0x3C3F70687020... INTO DUMPFILE '/var/www/html/uploads/avatar.jpg';
③ 恶意 MySQL 服务器
更进一步的玩法:让目标站的 MySQL 客户端连回攻击者的”假 MySQL 服务端”。恶意服务端在握手后发送伪造的 LOCAL INFILE 请求,客户端会主动把服务器任意路径的文件读过来传给服务端(LOAD DATA LOCAL INFILE 语义被反向利用)——读 Web 根目录源码、配置文件一气呵成。如果客户端应用本身还会对读入内容做反序列化处理,还能直接触发对象注入。
这条链的拼装思路值得记:回溯破防 → 注入落地文件 → 协议级反向利用,每一段都是已知技术,拼起来就是完整 RCE。
0x05 厘清边界:PDO 预编译到底防住了什么
课程把 PDO 预编译分成两种实现,防不住的东西也说得直接:
真假预编译
- 真实预编译(
ATTR_EMULATE_PREPARES=false):SQL 模板先发给 MySQL 编译,参数再单独传输——参数永远只是数据,不进语法树; - 虚假预编译(默认 true):PHP 本地模拟,只是把参数转义后拼进 SQL 再发——安全靠转义函数的覆盖面。
预编译防不住的三个口子
- 未绑定参数:
$pdo->query("SELECT * FROM t WHERE id=" . $_GET['id'])用了 PDO 但没预编译,等于没防; - ORDER BY / 表名列名:绑定参数只会给值加引号,
ORDER BY ?变成ORDER BY 'id'直接语法失效——所以排序字段只能白名单校验,恰恰是实践中最容易漏的地方; - 宽字节:GBK 编码下
%bf%27吃掉转义反斜杠,虚假预编译的转义方案被绕(真实预编译不受影响,这也是强调”开真预编译”的原因)。
一句话总结:预编译只保护”值”,不保护 SQL 结构;结构只能白名单。