JAVA反序列化
JAVA反序列化
CH0icoJava反序列化漏洞:从背景到利用
01.历史背景与漏洞起源
2015年:反序列化漏洞的元年
在2015年1月28日,名为“Marshalling Pickles”的PPT被分享在slideshare网站上,由Gabriel Lawrence(@gebl)和Chris Frohoff(@frohoff)在AppSecCali大会上进行了演讲。但遗憾的是,这次演讲在当时并没有激起太大的波澜。
PPT中详细描述了序列化(Serializing Objects),也可以称作“marshaling”、“pickling”、“freezing”、“flattening”。其核心作用是将内存中的对象进行“快照”和“拉平”,转化为平面化的、串行的数据流,以便进行存储、传输,或在不同位置进行重构和使用。PPT中还演示了多种攻击路径,包括在Java和Python环境中修改反序列化数据操纵应用程序状态、在PHP环境中操纵应用程序逻辑读取非预期文件,以及使用EL表达式在ViewState的序列化数据中执行代码。
真正将反序列化漏洞引入大众视野的,是2015年11月6日Fox Glove Security安全团队的Steve Breen(推特@breenmachine)在其团队博客上发布的长文,标题为:“Weblogic,WebSphere,JBoss,Jenkins,OpenNMS以及你的应用有什么共同点?漏洞!”这篇文章横扫了大部分主流Java中间件,正式拉开了反序列化漏洞的帷幕。
Steve Breen及其团队在研读了“Marshalling Pickles”的PPT后意识到,如果能在Commons-Collections这类公共库或流行框架中找到反序列化漏洞,带来的危害将是极大的。而令人惊讶的是,从1月PPT发布到11月,Commons-Collections框架都没有对该漏洞进行修复。于是他们从寻找commons-collections库反向寻找调用点,或者直接抓网络包查找有序列化数据特征的访问路径,使用frohoff公开的ysoserial工具生成payload,成功攻击了Weblogic的T3协议、OpenNMS的RMI、Jenkins的Jenkins-CLI、JBoss的JMXInvokerServlet、WebSphere的管理端口等主流Java中间件。
PPT中对漏洞机理的阐述
PPT作者将上述攻击行为延伸和扩展,使其成为了远程代码执行漏洞。虽然现在习惯根据漏洞利用方式称其为反序列化漏洞,但实际上PPT中对这种漏洞类型的叫法是:Property-Oriented Programming / Object Injection(面向属性编程/对象注入)。这个叫法可以追溯到Stefan Esser在Blackhat 2010上的PPT。
面向属性编程的原理与二进制利用中的面向返回编程(Return-Oriented Programming)相似,都是从现有运行环境中寻找一系列的代码或指令调用,然后根据需求构成一组连续的调用链。
PPT中由此引出了“gadget”的概念,描述了一个完整的反序列化攻击需要包含以下元素:
- “kick-off” gadget(入口点):在反序列化过程中或反序列化之后会执行
- “sink” gadget(终点):执行任意代码或命令的类
- 多个chain gadget:将“kick-off”和“sink”连接起来,形成链形调用
攻击流程为:形成的序列化chain发送到有脆弱性的应用程序中,chain在序列化过程中或序列化之后在应用程序中执行。
同时,PPT也指出了这种攻击在Java环境中的局限性:
- 只能使用应用程序中的类
- 漏洞代码和gadgets中使用类的ClassLoader问题
- gadgets类必须实现Serializable/Externalizable接口
- 库/类版本差异
- static类型的常量
Java中反序列化数据的常见位置
Java喜欢在多个位置对对象进行序列化,这基本覆盖了大部分的触发点:
- HTTP请求中:Parameters、ViewState、Cookies等位置
- RMI(远程方法调用):RMI协议百分百基于序列化
- RMI over HTTP:很多胖客户端Web应用程序使用
- JMX(Java管理扩展):同样依赖于反序列化
- 自定义协议:为发送/接收Java对象制定的新协议规范
02.序列化与反序列化基础
概述
Java序列化是指把Java对象转换为字节序列的过程,而Java反序列化是指把字节序列恢复为Java对象的过程。简单来说,就是将Java对象当前状态以字节序列的形式描述出来,这串字节可能被储存或发送到任何需要的位置,在适当的时候再将它转回原本的Java对象。
这中间需要一个规则,描述了序列化和反序列化时究竟该如何把一个对象处理成字节序列,又如何把字节序列变回对象,这一过程必须是可逆的。
实现条件
只有实现了Serializable或Externalizable接口的类的对象才能被序列化为字节序列,否则会抛出异常。
Serializable接口
Serializable接口是Java提供的序列化接口,它是一个空接口,用来标识当前类可以被ObjectOutputStream序列化,以及被ObjectInputStream反序列化:
1 | public interface Serializable { |
Externalizable接口
Externalizable接口是一个更高级别的序列化机制,允许类对序列化和反序列化过程进行更多的控制和自定义。与Serializable不同,Externalizable接口的序列化和反序列化方法对对象的状态完全负责,包括对象的所有成员变量。
实现Externalizable的要求:
- 类必须显式实现
Externalizable接口 - 必须实现
writeExternal和readExternal方法来手动指定对象的序列化和反序列化过程 - 必须提供一个公共的无参数构造函数,因为反序列化过程需要调用该构造函数来创建对象实例
代码示例:
1 | import java.io.*; |
其他序列化条件
- 对象的所有成员都可序列化:如果一个类实现了
Serializable接口,但其成员中有某些成员变量不可序列化,则序列化操作会失败 - 静态成员变量不参与序列化:静态成员变量属于类级别的数据,不包含在序列化的过程中
- transient关键字:如果某个成员变量被声明为
transient,则在序列化过程中会被忽略,不会被持久化 - 序列化版本号serialVersionUID:序列化版本号不一致反序列化会失败。建议显式声明一个名为
serialVersionUID的静态变量,用于控制序列化的版本。若不声明,Java会根据类的结构自动生成一个版本号,若类的结构发生变化则版本号不同,无法反序列化
ObjectOutputStream:实现序列化
ObjectOutputStream用于将Java对象的原始数据类型和图形写入OutputStream,实现序列化功能。其继承关系如下:
- 父类OutputStream:所有字节输出流的顶级父类,用来接收输出的字节并发送到某些接收器
- 接口ObjectOutput:扩展了
DataOutput接口,提供了将数据从任何Java基本类型转换为字节序列并写入二进制流的功能,并在其基础上增加了writeObject方法 - 接口ObjectStreamConstants:定义了一些在对象序列化时写入的常量,如
STREAM_MAGIC、STREAM_VERSION等
关键方法
writeObject:核心方法,用来将一个对象写入输出流中。负责为指定的类编写其对象的状态,以便后续使用对应的readObject方法来恢复它。
writeUnshared:将非共享对象写入ObjectOutputStream,会使用BlockDataOutputStream的新实例进行序列化操作,不会使用原来OutputStream的引用对象。
writeObject0:writeObject和writeUnshared实际上都调用writeObject0方法,它是上述两个方法的基础实现。
writeObjectOverride:如果ObjectOutputStream中的enableOverride属性为true,writeObject方法将会调用writeObjectOverride,这个方法由ObjectOutputStream的子类实现,用于完全重新实现序列化功能。
ObjectInputStream:实现反序列化
ObjectInputStream用于恢复那些已经被序列化的对象,实现反序列化功能。其继承关系如下:
- 父类InputStream:所有字节输入流的顶级父类
- 接口ObjectInput:扩展了
DataInput接口,提供了从二进制流读取字节并将其重新转换为Java基础类型的功能,额外提供了readObject方法 - 接口ObjectStreamConstants:同上
关键方法
readObject:从ObjectInputStream读取一个对象,将读取对象的类、类的签名、类的非transient和非static字段的值,以及其所有父类类型。该方法会“传递性”地执行,即在反序列化过程中会调用反序列化类的readObject方法,以完整地重新生成这个类的对象。
readUnshared:从ObjectInputStream读取一个非共享对象,与readObject类似,但不同点在于不允许后续的readObject和readUnshared调用引用这次调用反序列化得到的对象。
readObject0:readObject和readUnshared实际上调用readObject0方法,是上面两个方法的基础实现。
readObjectOverride:由ObjectInputStream子类调用,与writeObjectOverride一致。
通过以上分析可以看出,ObjectOutputStream和ObjectInputStream的实现几乎是一种对称的、双生的方式。
常见的输入输出流
除了FileInputStream/FileOutputStream之外,根据条件的不同,还可以使用其他的输入输出流来处理数据:
- BufferedInputStream/BufferedOutputStream:当需要对读取或写入的数据进行缓冲以提高性能时使用,特别是对大文件或网络数据流
- ByteArrayInputStream/ByteArrayOutputStream:当需要从字节数组中读取数据,或将数据写入到字节数组中时使用
- PipedInputStream/PipedOutputStream:当需要通过管道与另一个线程进行数据交换时,可用于线程间通信
- DataInputStream/DataOutputStream:当需要从输入流中以Java基本数据类型的格式读取数据,或以Java基本数据类型的格式将数据写入输出流时使用
- FileInputStream/FileOutputStream:当需要从文件中读取字节数据,或将数据写入文件时使用
序列化版本号serialVersionUID
Java的序列化机制是通过判断运行时类的serialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把传进来的字节流中的serialVersionUID与本地实体类中的serialVersionUID进行比较,如果相同则认为是一致的,便可以进行反序列化,否则就会报序列化版本不一致的异常。
如果没有显式指定serialVersionUID,Java会根据类的结构自动生成一个,这种情况下只有同一次编译生成的class才会生成相同的serialVersionUID。
为了解决兼容性问题,建议在要序列化的类中显式声明:
1 | private static final long serialVersionUID = 1L; |
03.反序列化漏洞原理
漏洞触发机制
前面提到过,一个类想要实现序列化和反序列化,必须要实现java.io.Serializable或java.io.Externalizable接口。其中,如果被序列化的类重写了writeObject和readObject方法,Java将会委托使用这两个方法来进行序列化和反序列化的操作。
正是因为这个特性,导致反序列化漏洞的出现:在反序列化一个类时,如果其重写了readObject方法,程序将会调用它,如果这个方法中存在一些恶意的调用,则会对应用程序造成危害。
漏洞代码示例
以下代码创建了Person类,实现了Serializable接口,并重写了readObject方法,在方法中使用Runtime执行命令弹出计算器:
1 | public class Person implements Serializable { |
将Person类序列化并写入文件,随后对其进行反序列化,就触发了命令执行:
1 | public class SerializableTest { |
底层调用过程分析
下面来详细分析java.io.ObjectInputStream#readObject()方法的底层实现,理解为什么重写了readObject就会被执行。
readObject方法实际调用
readObject0方法反序列化字符串。readObject0方法以字节的方式去读,如果读到
0x73,则代表这是一个对象的序列化数据,将会调用readOrdinaryObject方法进行处理。readOrdinaryObject方法会调用
readClassDesc方法读取类描述符,并根据其中的内容判断类是否实现了Externalizable接口:- 如果是,则调用
readExternalData方法去执行反序列化类中的readExternal - 如果不是,则调用
readSerialData方法去执行类中的readObject方法
- 如果是,则调用
readSerialData方法首先通过类描述符获得了序列化对象的数据布局,通过布局的
hasReadObjectMethod方法判断对象是否有重写readObject方法,如果有,则使用invokeReadObject方法调用对象中的readObject。
通过上述分析,可以清晰了解反序列化漏洞的触发原因。与反序列化漏洞的触发方式相同,在序列化时,如果一个类重写了writeObject方法且其中产生恶意调用,也将会导致漏洞,不过在实际环境中,序列化的数据来自不可信源的情况相对少见。
接下来需要找到那些重写了readObject方法的类,并找到相关的调用链来触发漏洞。
04.URLDNS利用链分析
为什么从URLDNS开始学习
网上很多学习Java反序列化漏洞的文章,都是从CommonsCollections这条利用链开始学起的。但CommonsCollections是一条相对复杂的利用链,对于新手来说理解难度较高。
因此,建议学习Java反序列化从URLDNS开始看起,因为它有非常突出的优点:
- 使用Java内置的类构造,对第三方库没有依赖
- 在目标没有回显的时候,能够通过DNS请求得知是否存在反序列化漏洞
- 只经过了6个函数调用,在Java中已经算很少了
- 没有JDK版本限制
URLDNS虽然被收录在ysoserial中,但准确来说不能称作真正意义上的“利用链”,因为其参数不是一个可以“利用”的命令,而仅为URL,触发的结果也不是命令执行,而是一次DNS请求。通常用来探测是否存在反序列化漏洞。
ysoserial:反序列化漏洞利用的里程碑工具
ysoserial是Gabriel Lawrence和Chris Frohoff在AppSecCali议题中释出的工具,它可以让用户根据自己选择的利用链生成反序列化利用数据,通过将这些数据发送给目标,从而执行用户预先定义的命令。
利用链也叫“gadget chains”,通常简称为gadget。如果类比PHP反序列化漏洞,gadget就是从触发位置开始到执行命令位置结束的一条方法链,比如从__destruct到eval。
ysoserial的使用很简单,大部分gadget的参数就是一条命令:
1 | java -jar ysoserial-master-30099844c6-1.jar CommonsCollections1 "id" |
生成的POC发送给目标,如果目标存在反序列化漏洞并满足该gadget对应的条件,则命令将被执行。
漏洞触发点:URL类的特性
这个漏洞的关键点是Java内置的java.net.URL类,它的equals和hashCode方法具有一个有趣的特性:在对URL对象进行比较时(使用equals或hashCode),会触发一次DNS解析,因为对于URL来说,如果两个主机名都可以解析为相同的IP地址,则这两个主机会被认为是相同的。
从equals方法看触发过程
URL#equals方法重写了Object的判断,调用URLStreamHandler#equals方法进行判断,该方法会调用sameFile方法比较两个URL是否引用了相同的protocol(协议)、host(主机)、port(端口)、path(路径)。
sameFile方法在比较host时,调用hostsEqual方法进行比较,而hostsEqual方法调用getHostAddress方法对要比较的两个URL进行请求解析IP地址。
getHostAddress方法使用InetAddress.getByName()方法对host进行解析,触发了DNS请求。
从hashCode方法看触发过程
URL的hashCode方法也进行了重写,调用了URLStreamHandler#hashCode方法,其中同样是调用了getHostAddress方法对URL的host进行解析。
URLStreamHandler#hashCode方法的实现依次获取传入URL链接的Protocol(协议)、HostAddress(主机地址)、File(文件路径)、Port(端口)、Ref(锚点,即#后面的部分),对每部分调用它们的hashCode方法将结果累加返回。其中getHostAddress方法就是触发DNS解析的关键点。
测试代码验证:
1 | URL url = new URL("http://su18.dnslog.cn"); |
无论是使用equals方法还是hashCode方法,应用程序都会触发访问。
入口类:HashMap
重写了readObject的类java.util.HashMap是URLDNS gadget的主角。HashMap是Java中最常用的Map实现类,以键值对的方式存储数据,为提升操作效率,根据键的hashCode值存储数据。
HashMap的readObject方法在反序列化时,会从序列化数据中读取键值对,进行for循环处理:
1 | for (int i = 0; i < mappings; i++) { |
其中hash方法的实现为:
1 | static final int hash(Object key) { |
可以看到,HashMap在反序列化时会调用其中key对象的hashCode方法来计算hash值。如果key是一个URL对象,就会触发DNS解析查询。
选择HashMap作为入口类的原因:
- 实现了
Serializable接口,可以被反序列化 - 重写了
readObject方法 - 参数类型宽泛,只要是Object都可以
- JDK自带
构造Payload及常见问题
序列化过程中的坑
在使用HashMap的put方法时,同样会调用putVal方法对key进行hash,从而触发DNS解析。如果在生成payload时就触发了DNS查询,会带来两个问题:
- 本地存在了解析记录,第二次解析就不会去请求DNS服务器,导致反序列化时看不到解析记录
- URL对象的
hashCode属性被缓存,后续不再触发URLStreamHandler的hashCode方法
URL对象的hashCode属性默认为-1,当hashCode方法被调用后,会在这个属性中缓存已经计算过的值:
1 | public synchronized int hashCode() { |
如果再次计算将直接返回值,不会触发URLStreamHandler的hashCode方法。
解决方案一:反射修改hashCode值
先调用put方法,然后用反射将URL对象的hashCode改回-1:
1 | HashMap<URL, Integer> hashMap = new HashMap<>(); |
解决方案二:反射调用putVal方法
直接反射调用HashMap的putVal方法绕过hash计算:
1 | HashMap<URL, Integer> hashMap = new HashMap<>(); |
但由于JDK版本差异(1.7和1.8方法名不一样),这种方法不具有通用性。
解决方案三(ysoserial的实现):自定义SilentURLStreamHandler
ysoserial采用了更优雅的方式,自定义URLStreamHandler的子类SilentURLStreamHandler,重写getHostAddress方法直接返回null:
1 | static class SilentURLStreamHandler extends URLStreamHandler { |
在初始化URL对象时传入这个自定义handler,这样在put方法触发hash计算时,调用的是自定义的getHostAddress,不会触发真正的DNS查询。然后在put之后通过反射将hashCode改回-1。
完整实现:
1 | URLStreamHandler handler = new SilentURLStreamHandler(); |
调用链总结
整个URLDNS的gadget链非常清晰简单:
1 | HashMap.readObject() |
从反序列化最开始的readObject到最后触发DNS请求的getByName,总共只经过了6个函数调用。要构造这个Gadget,只需要:
- 初始化一个
java.net.URL对象作为key放在java.util.HashMap中 - 设置这个URL对象的
hashCode为初始值-1,这样反序列化时将会重新计算其hashCode,才能触发后续的DNS请求
这就是为什么URLDNS是学习Java反序列化漏洞的最佳入门案例——它足够简单,却完整展示了反序列化利用链的核心思路:从一个重写了readObject的入口类出发,经过中间方法的层层调用,最终到达一个能执行敏感操作的sink点。 这种寻找和构造调用链的思路,是理解所有更复杂的反序列化gadget的基础。











