| 失效的假设 | “反序列化只是把字节还原成对象,是个读操作” |
| 所属组 | W1 · 注入 |
| 前置 | 第 3 篇:表达式语言注入。这一篇是它的孪生兄弟——同样是"一个你以为无害的操作,其实会执行代码" |
| 复现环境 | Python 3 标准库,无外部依赖 |
一、“还原对象"这四个字里藏着执行
你在做"记住购物车"的功能。用户的购物车是个对象,你把它序列化成一串字节存进 cookie;下次请求再读回来。
存的时候一行代码,读的时候一行代码。你心里给这两行的定性是:写文件和读文件。
然后有人把 cookie 里那串字节换掉了,你的服务器执行了他的代码。
打个比方
你以为序列化是把家具拆成零件装箱——箱子里是一堆板子和螺丝,收货的人照着标准方式装回去。这个理解里,箱子里装的是东西。
实际上,箱子里附的是一张施工指令单:“取 3 号板,取 5 号螺丝,拧上。“你的工人拿到单子就开工。
那么问题来了:如果单子上多写了一行"顺便把保险柜打开”,工人会不会照做?
会。他没有"哪些指令算装家具、哪些不算"这个概念——他只是在执行单子。
⭐ 这就是全部机制。序列化的产物不是"一堆数据”,是一份重建指令;而反序列化不是"读数据”,是执行那份指令。
第 3 篇讲过一台机器:一个你以为只在填值的地方,接了一个会求值的引擎。反序列化是同一台机器的另一个化身——你以为你只是在"把字节读回对象",可"把字节读回对象"这件事本身就要按字节里的指示去重建,而"重建"可以携带并触发代码。
看标准库 pickle 的一个例子。别被它是 Python 吓到——Java 的原生序列化、PHP 的 unserialize、Ruby、.NET 都有结构完全一样的问题,pickle 只是最容易在标准库里演示的那个。
二、一段"数据",loads 时就跑了代码
import pickle, os
# 一个"把会话对象存进 cookie/缓存,再读回来"的常见模式。
# 看起来 pickle.loads 只是把字节还原成对象——其实它会执行对象自带的构造逻辑。
class Evil:
def __reduce__(self):
# __reduce__ 告诉 pickle:还原我时,去调用这个。这就是执行入口。
return (os.getpid, ()) # 无害示范:只取 pid,证明"代码被调用了"
blob = pickle.dumps(Evil()) # 攻击者构造并发给你的字节串
print("字节看着只是数据:", blob[:20], b"...")
result = pickle.loads(blob) # 你以为只是"还原对象"
print("loads 执行了 os.getpid,和直接调用一致:", result == os.getpid())
字节看着只是数据: b'\x80\x05\x95\x17\x00\x00\x00\x00\x00\x00\x00\x8c\x05posix\x94\x8c' b'...'
loads 执行了 os.getpid,和直接调用一致: True
拆开看发生了什么:
class Evil.__reduce__: 它告诉 pickle "要还原我这个对象,就去调用 os.getpid"
pickle.dumps(Evil()): 把这条指示打包成字节(攻击者那边做的)
pickle.loads(blob): 你这边"还原对象"——于是那条指示被执行了
⭐ 关键在 __reduce__ 这个入口。序列化格式必须有办法表达"这个对象是怎么造出来的",否则复杂对象没法还原。而"怎么造出来的" = “调用什么、传什么参数”——这本质上就是一段可执行的指令。pickle.loads 不是在读数据,是在照着字节里的配方做菜,配方让它调 os.getpid 它就调,让它调 os.system 它也调。
这里只调了 os.getpid(无害,取个进程号),证明"代码确实被调用了"。把它换成别的,就是任意代码执行——和第 3 篇 §二 “够着 os 就等价于 RCE"是同一个结论,同一条边界。
⚠️ 特别注意那句"字节看着只是数据”。攻击载荷就是一串二进制,没有引号、没有分号、没有 <script>——第 1 篇讲过的那些"危险字符"在这里一个都用不上。所以任何"扫一扫反序列化的输入里有没有危险字符"的想法,从起点就是错的。
三、换一种格式,同一个攻击无处落脚
问题不在"数据来自外部",在"你用了一种能表达’构造对象’的格式去接收它"。把格式换成只能表达数据的,攻击就没有了立足点:
import json
# JSON 只能表达数据(字符串/数字/列表/字典),没有"还原成某个类的实例"这种能力,
# 更没有"还原时执行代码"的入口。攻击者能塞进来的,最多是一段普通数据。
malicious = '{"__reduce__": ["os.system", ["rm -rf /"]]}'
obj = json.loads(malicious)
print("json 解析结果的类型:", type(obj).__name__) # dict,就是个字典
print("那个键只是普通字符串键:", obj["__reduce__"]) # 没有任何特殊语义,不会被调用
print("没有代码被执行,因为 JSON 没有'构造对象'这一步")
json 解析结果的类型: dict
那个键只是普通字符串键: ['os.system', ['rm -rf /']]
没有代码被执行,因为 JSON 没有'构造对象'这一步
同样一段 {"__reduce__": [...]},json.loads 把它读成一个普通字典。那个 __reduce__ 键没有任何特殊语义——它就是个字符串键,不会被调用。因为 JSON 的表达能力里根本没有"还原成某个类的实例"这一步,自然也没有"还原时执行代码"的入口。
⭐ 这就是根治的方向,和前面几篇一脉相承:不是把危险格式用得更小心,是换一种在定义上就没有那个能力的格式。 JSON 之于 pickle,就像 argv 数组之于 shell、参数化之于 SQL——都是"拿掉那个会解析/会执行的东西"。
四、为什么它格外阴险
反序列化漏洞有几个别的注入没有的特点,让它特别难防:
① 触发点毫不起眼 "读一个 cookie""从缓存拿个对象""收一个 RPC 消息"——
没人会觉得这些是"执行代码"
② 载荷是二进制 没有可读的危险字符,WAF、日志、人眼都很难看出异常
③ 利用靠"gadget 链" 攻击者不需要你的代码里有恶意函数,
只需把你依赖库里【现成的】方法拼成一条链,
最终拼出"执行命令"的效果 —— 这叫 gadget chain
第 ③ 条是它和第 3 篇最像、也最可怕的地方:你自己没写任何危险代码,危险来自"你引入的库里恰好有可被串起来的方法"。 这也是为什么"我代码里没调 os.system“完全不构成安全——Java 反序列化的历次大事件,用的都是流行库里的 gadget。
⚠️ gadget 链这个概念第一次读会觉得不真实——“把一堆无害的方法拼起来就能执行命令?“能。回到那张施工指令单:单子上每一条单独看都是正当工序(“打开 A 柜"“把 B 挪到 C"“执行 D 里的内容”),是顺序和组合造出了那个后果。攻击者干的活不是写恶意代码,是在你的依赖里找出这样一串工序。这活很费劲,但只需要有人做成一次,之后所有人都能复用。
它出现过的地方
展开在事件分析里:
2015 起 Java 原生反序列化 Apache Commons Collections gadget 链,波及大量 Java 中间件
五、防御
① 首选:不要反序列化不可信数据,换成纯数据格式
接收外部数据 用 JSON / Protobuf 这类【只表达数据】的格式
反序列化后,自己用普通代码把字典映射成对象(显式、可控)
绝不 对来自用户/网络/缓存的字节调用 pickle.loads / Java 原生
readObject / PHP unserialize / 等价物
判断标准:你反序列化的这段字节,是否可能被外部影响? cookie、请求体、消息队列、缓存、上传的文件——全都算"外部”。哪怕它"应该"是你自己写进去的(回想第 2 篇的二阶注入:来自数据库的值也可能是攻击者写的)。
② 必须用富格式时:签名 + 类型白名单
若确实要传对象(内部服务间),至少:
· 对序列化字节做完整性签名(HMAC),验签通过才反序列化 —— 挡住"外人伪造字节"
· 反序列化器配【类型白名单】,只允许还原你预期的那几个类,拒绝一切其它类
⚠️ 顺序很重要:先验签,再反序列化。反过来(先反序列化再验签)等于白设——代码在验签之前就已经执行了。这和第 2 篇"修复点在拼接处不在入口处”、以及所有"检查要在危险动作之前"的道理一致。
③ 纵深
最小权限 反序列化的进程别用高权限,限制它能碰的文件与网络
依赖收敛 已知含 gadget 的库版本要能被依赖扫描盯住(对照 W6 供应链)
六、这些防御的边界
① “只反序列化自己写的数据"不成立。 只要那段字节路过了任何可被外部影响的地方(cookie、缓存、DB),就不能假设它没被动过。参见第 2 篇二阶注入。
② 签名不能替代格式选择。 签名挡住"外人伪造”,但挡不住"有权签名的一方"被攻破,也挡不住实现里"先反序列化后验签"的顺序错误。能用纯数据格式就别依赖签名兜底。
③ 类型白名单要覆盖"可达"而不只是"顶层”。 允许还原类 A,但 A 的字段里能塞进类 B,白名单就被绕过了。要限制的是整条对象图可达的所有类型——这和第 3 篇"对象图导航"是同一个陷阱。
关于时效:机制(富格式反序列化 = 执行)稳定不变。会变的是具体语言/库的默认行为和已知 gadget 清单——哪个版本默认拒绝未知类型、哪个库新增了 gadget,都随版本更新。涉及具体库要查该版本,本篇不给"某库默认安全"的结论。
七、本篇小结
反序列化不是"把字节读成数据",是"按字节的指示重建对象"——
而"重建"可以携带并触发代码。它是第 3 篇"填值处藏求值器"的孪生兄弟。
⭐ pickle.loads 一段"看着只是数据"的字节,就执行了代码(__reduce__ 是入口)
载荷是二进制,没有任何"危险字符"可供过滤
⭐ 根治 = 换成只能表达数据的格式(JSON/Protobuf)
JSON 没有"构造对象"这一步,攻击无处落脚
—— 和 argv 之于 shell、参数化之于 SQL 是同一招
⚠️ gadget 链:你自己没写危险代码,危险来自依赖库里"能被串起来的方法"
=> "我没调 os.system" 不构成安全
必须用富格式时:先验签再反序列化 + 类型白名单(覆盖整条对象图,不只顶层)。
思考题
- §二 的 payload 是纯二进制,不含任何"危险字符”。这对"用 WAF 正则过滤反序列化攻击"意味着什么?和第 1 篇"黑名单必输"是不是同一个结论?
- §五 ② 强调"先验签再反序列化”。如果顺序反了(先 loads 再校验 HMAC),攻击在哪一步就已经得手了?
- gadget 链的前提是"你的依赖里有可被串起来的方法"。为什么"我只反序列化我自己定义的简单类"仍然不安全?(提示:§六 ③ 的对象图。)
- JSON 挡住了 pickle 那类攻击,但把 JSON 反序列化成对象时,如果框架支持"根据 JSON 里的类型字段实例化任意类"(多态反序列化),洞会不会又回来?这说明真正危险的是"格式"还是"能力"?
- 把反序列化和第 3 篇的表达式注入并列:两者"根治"的手法(换纯数据格式 / 换掉求值器)拿掉的是同一样东西吗?用第 1 篇的定义说明。
- 一个"把用户偏好存进 cookie 再读回来"的功能,最省心的安全实现是什么?(提示:既要防伪造,又要防反序列化执行,§五①②如何组合。)
- 为什么说反序列化漏洞的"触发点毫不起眼"比"载荷是二进制"更难防?结合 §四 ① 举一个你系统里"其实在做反序列化、但看着不像"的地方。
相关:第 3 篇:表达式语言注入 · 第 1 篇:注入的一般形式 · Web 安全机制篇 · 事件分析