Java 反序列化漏洞基础:从 readObject 到命令执行
Java 反序列化漏洞基础:从 readObject 到命令执行
写在前面
前面我们复现了 Log4j2 和 fastjson 的漏洞,它们都属于"JNDI 注入"那一挂–触发点是组件自己的特性(Log4j2 的 lookup、fastjson 的 autoType),最终都靠 JNDI 去加载远程类。
从这篇开始,我们要进入 Java 安全里另一个更底层、也更经典的话题:反序列化漏洞。
如果说 JNDI 注入是"借组件的刀杀人",那反序列化漏洞就是"借 Java 自己的机制杀人"–它不依赖某个特定组件的 bug,而是利用 Java 反序列化机制本身的"过度信任"。CommonsCollections的 CC1-CC6 六条利用链,就是这条路上的里程碑,几乎所有 Java 安全面试、所有反序列化漏洞分析,都绕不开它们。
在拆解六条链之前,得先把基础打牢:反序列化到底是什么、为什么它能 RCE、CC 库又给我们提供了哪些"积木"。这篇就是那块地基。
一、序列化与反序列化是什么
序列化(serialization):把一个 Java 对象"拍扁"成一串字节流,方便存到文件、塞进网络传输。
反序列化(deserialization):把这串字节流再"还原"成一个活生生的 Java 对象。
// 序列化:对象 -> 字节流ObjectOutputStreamoos=newObjectOutputStream(newFileOutputStream("obj.ser"));oos.writeObject(someObject);// 反序列化:字节流 -> 对象ObjectInputStreamois=newObjectInputStream(newFileInputStream("obj.ser"));Objectobj=ois.readObject();不是所有对象都能序列化–它的类必须实现java.io.Serializable接口(只是个标记接口,没有方法)。实现了它,JVM 就允许这个类的对象被"拍扁"和"还原"。
这功能本身很正常:RPC 通信、Session 持久化、缓存存储都在用。问题出在下一步。
二、readObject:那个危险的"魔术方法"
Java 的反序列化有个特殊设计:当一个对象被反序列化时,如果它的类自定义了readObject方法,JVM 会调用这个方法,而不是走默认的还原逻辑。
publicclassSomeClassimplementsSerializable{privatevoidreadObject(ObjectInputStreamin)throwsIOException,ClassNotFoundException{in.defaultReadObject();// 默认还原字段// ... 然后这个类可以"顺便干点别的"}}注意readObject的签名是private void readObject(ObjectInputStream),它不是你主动调的,而是反序列化时由 JVM 回调的。这就是所谓的"魔术方法"。
这个设计本意是让类在反序列化时做些自定义恢复(比如校验数据、重建临时字段)。但副作用是:只要一个对象被反序列化,它所在类的readObject里的代码就会执行。
而"被反序列化"这个动作,在很多场景下是由外部数据触发的–比如服务端读取客户端传来的序列化字节流。于是攻击面就出现了:
攻击者递交一段精心构造的字节流,服务端一调用
readObject,字节流里指定的那个类的readObject方法就会执行–而那个类的readObject里可能藏着危险操作。
这就是反序列化漏洞的本质:反序列化 = 攻击者能触发任意(可序列化)类的 readObject 执行。
三、漏洞利用的两步法
光让某个类的readObject执行还不够–那个类的readObject里得有"能通往命令执行"的逻辑。但显然 JDK 不会蠢到在readObject里直接Runtime.exec。
真实的利用是"搭桥":找一个readObject里会调用某个对象方法的类,再把这个方法调用一步步导到Runtime.exec或"加载字节码"上。这就是经典的两步法:
┌─────────────────────────────┐ ┌──────────────────────────┐ │ ① 入口类(反序列化入口) │ │ ② 执行体(最终干坏事) │ │ │ │ │ │ 它的 readObject 会在反序列化 │ ────> │ Runtime.exec("calc") │ │ 时调用某个对象的方法 │ 桥 │ 或 加载恶意字节码 │ │ (get / hashCode / toString / │ │ │ │ compare / setValue ...) │ │ │ └─────────────────────────────┘ └──────────────────────────┘- 入口类:
readObject里调用了某个对象的方法(get、hashCode、toString、compare、setValue等)。因为被调用的对象是攻击者可控的(在字节流里指定),所以这个方法调用可以被"引向"攻击者放好的对象。 - 执行体:一段能把"方法被调用"转换成"命令执行"的逻辑。
中间用各种触发器(Map、Comparator 等)把方法调用一层层传到执行体。CC 链里的各种类,要么是入口,要么是触发器,要么是执行体。
四、CommonsCollections 的核心积木
org.apache.commons.collections(CC 库)是 Apache 的集合工具库,曾经几乎是 Java 项目的标配。它恰好提供了一组完美的"积木"来拼反序列化攻击。下面这些类是 CC1-CC6 反复出场的角色,先认个脸熟。
4.1 Transformer 三件套(执行体的核心)
Transformer是一个"变换器"接口:输入一个对象,输出一个对象。
publicinterfaceTransformer{Objecttransform(Objectinput);}CC 库提供了三个关键实现,能拼出"执行任意命令"的逻辑:
①ConstantTransformer–无论输入啥,恒返回构造时传入的常量。
newConstantTransformer(Runtime.class).transform(任意输入)// 永远返回 Runtime.class②InvokerTransformer–反射调用输入对象的任意方法(最危险的一个)。
// transform 时等价于:input.methodName(paramTypes, args)newInvokerTransformer("exec",newClass[]{String.class},newObject[]{"calc"}).transform(runtimeInstance)// -> runtimeInstance.exec("calc")③ChainedTransformer–把多个 Transformer 串起来,前一个的输出当后一个的输入。
newChainedTransformer(newTransformer[]{newConstantTransformer(Runtime.class),// -> Runtime.classnewInvokerTransformer("getMethod",...),// -> Method getRuntimenewInvokerTransformer("invoke",...),// -> Runtime 实例newInvokerTransformer("exec",...)// -> runtime.exec("calc")})为什么这么绕?因为
InvokerTransformer.transform是对"输入对象"调方法,而Runtime.getRuntime()是静态方法、没法当实例方法调。所以要先拿Runtime.class,再反射getMethod("getRuntime"),再invoke(null)拿到实例,最后exec。一条绕弯的反射链。
这三件套串起来 = 任意命令执行。现在只差"让ChainedTransformer.transform被调用"。
4.2 触发器:LazyMap 与 TransformedMap
CC 库有两种 Map,能在正常操作时触发Transformer:
LazyMap–get(key)时,若 key 不存在,用factory.transform(key)生成 value 并缓存。factory就是上面那个ChainedTransformer。
MaplazyMap=LazyMap.decorate(innerMap,chainedTransformer);lazyMap.get("不存在的key");// -> chainedTransformer.transform("不存在的key") -> Runtime.execTransformedMap–put/setValue时,对 value 跑valueTransformer.transform。
CC1/CC3/CC5/CC6 都用LazyMap作为触发器。所以剩下的问题就是:怎么让LazyMap.get()在反序列化时被调用?
4.3 桥接类:TiedMapEntry
TiedMapEntry持有一个Map和一个key,它的三个方法都指向getValue():
TiedMapEntry.hashCode()->getValue()->map.get(key)TiedMapEntry.toString()->getValue()->map.get(key)如果map是LazyMap,那么TiedMapEntry.hashCode()或toString()一被调用,就会触发LazyMap.get()->ChainedTransformer.transform()。
这一下把"入口"拓宽了:任何在反序列化时会调用hashCode()或toString()的类,都能借TiedMapEntry桥接到LazyMap。CC5(借toString)、CC6(借hashCode)就是走这条路。
4.4 字节码执行体:TemplatesImpl
ChainedTransformer+Runtime.exec这条路有个软肋:很多防护(黑名单、RASP)会盯着Runtime、InvokerTransformer。于是有了更隐蔽的执行体:直接加载恶意字节码。
com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl是 JDK 内置的 XSLT 模板类。它有个_bytecodes字段,存着一段 Java class 字节码。调用它的newTransformer()/getOutputProperties()时,会走:
newTransformer() -> getTransletInstance() -> defineTransletClasses() // defineClass 加载 _bytecodes -> newInstance() // 实例化那个恶意类恶意类继承AbstractTranslet,在静态初始化块里执行命令:
publicclassEvilextendsAbstractTranslet{static{Runtime.getRuntime().exec("calc");// 类被加载时执行}// 两个 transform 空实现(父类抽象方法)}这条路的好处:它加载的是任意字节码,不是固定的Runtime.exec,绕过针对 Runtime 的黑名单。CC2/CC3/CC4 都用它当执行体。
五、六条链的全景预告
把上面的积木组合起来,就得到了 CC1-CC6。它们本质上是两个维度的组合:
| 入口线 | 执行体线 | |
|---|---|---|
| CC1 / CC3 | AnnotationInvocationHandler(受 JDK 8u71 限制) | CC1=Runtime,CC3=TemplatesImpl |
| CC2 / CC4 | PriorityQueue(需 CC4 库) | CC2=TemplatesImpl,CC4=TemplatesImpl |
| CC5 / CC6 | BadAttributeValueExpException/HashSet(通用,不受 8u71 限制) | 都是 Runtime |
- 入口线演进:从依赖 JDK 内部类(CC1/3,被 8u71 修掉)-> 借
PriorityQueue(CC2/4,要 CC4 库)-> 借BadAttributeValueExpException/HashSet(CC5/6,通用)。 - 执行体线演进:
ChainedTransformer+Runtime(CC1/5/6)->TemplatesImpl字节码(CC2/3/4,绕黑名单)。
记住这两个维度,后面六条链就不是六个孤立的东西,而是 2×3 的组合。接下来我们就一条一条拆开看,每条链都配真实复现(calc 弹出 = RCE)。
参考
- ysoserial(CC 链的出处):https://github.com/frohoff/ysoserial
- Apache Commons Collections:https://commons.apache.org/proper/commons-collections/
- Java 反序列化漏洞科普:https://github.com/GrrrDog/Java-Deserialization-Cheat-Sheet
