之前写过 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 用第一个 | 分隔键名和值,值里注入 | 就能让后半段被整体当序列化串解析:
写入(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_SESSION_UPLOAD_PROGRESS 字段的上传请求会把值原样写进 session,无需应用主动写入。
触发面:应用有文件包含(include($_GET[...]))能包含 session.save_path 下的 session 文件(默认 /var/lib/php/sessions/sess_<PHPSESSID>),对象链在解析时执行。
排查三行:两个 handler 是否一致?有没有 upload_progress 可写?有没有包含 session 文件的路径?
0x02 phar 反序列化:不需要 unserialize 函数
没有 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://)。意义是把反序列化从”显式 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、$value 各来自对象某属性(常见 think\request 的 filter 链),构造 POP 就是让二者在链尾相遇于 call_user_func/array_walk;5.2 换了中间节点但骨架一样。
0x04 组合拳:正则回溯 → INTO DUMPFILE → 恶意 MySQL
把前面零件串起来的完整链,场景:注入点在,但 payload 过不了 WAF 正则。
① 正则回溯绕过过滤
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 会加换行转义破坏二进制。
SELECT 0x3C3F70687020... INTO DUMPFILE '/var/www/html/uploads/avatar.jpg';
③ 恶意 MySQL 服务器
让目标站的 MySQL 客户端连回攻击者的”假 MySQL 服务端”:服务端握手后发伪造的 LOCAL INFILE 请求,客户端会主动把服务器任意路径的文件读回给服务端(LOAD DATA LOCAL INFILE 语义被反向利用),读源码/配置一气呵成;客户端若还对读入内容反序列化,直接触发对象注入。
拼装思路:回溯破防 → 注入落地文件 → 协议级反向利用,每段都是已知技术,拼起来就是完整 RCE。
0x05 厘清边界:PDO 预编译到底防住了什么
预编译实现分两种:
- 真实预编译(
ATTR_EMULATE_PREPARES=false):模板先给 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 结构;结构只能白名单。