Java反序列化漏洞-基础篇
Java原生序列化与反序列化
概述
序列化与反序列化
Java序列化是指把Java对象转换为字节序列的过程;而Java反序列化是指把字节序列恢复为Java对象的过程。
序列化分为两大部分:序列化和反序列化。序列化是这个过程的第一部分,将数据分解成字节流,以便存储在文件中或在网络上传输。反序列化就是打开字节流并重构对象。对象序列化不仅要将基本数据类型转换成字节表示,有时还要恢复数据。恢复数据要求有恢复数据的对象实例。
为什么需要序列化与反序列化
① 想把内存中的对象保存到一个文件中或者数据库中时候;
② 想用套接字在网络上传送对象的时候;
③ 想通过RMI传输对象的时候
一些应用场景,涉及到将对象转化成二进制,序列化保证了能够成功读取到保存的对象。
常见的序列化和反序列化协议
- XML&SOAP
XML 是一种常用的序列化和反序列化协议,具有跨机器,跨语言等优点,SOAP(Simple Object Access protocol) 是一种被广泛应用的,基于 XML 为序列化和反序列化协议的结构化消息传递协议
- JSON(Javascript Object Notation)
- Protobuf
序列化实现
只有实现了Serializable或者Externalizable接口的类的对象才能被序列化为字节序列。(不是则会抛出异常)
Serializable 接口是 Java 提供的序列化接口,它是一个空接口
public interface Serializable {
}
Serializable 用来标识当前类可以被 ObjectOutputStream 序列化,以及被 ObjectInputStream 反序列化。
Serializable 接口的基本使用
通过 ObjectOutputStream 将需要序列化数据写入到流中,因为 Java IO 是一种装饰者模式,因此可以通过 ObjectOutStream 包装 FileOutStream 将数据写入到文件中或者包装 ByteArrayOutStream 将数据写入到内存中。同理,可以通过 ObjectInputStream 将数据从磁盘 FileInputStream 或者内存 ByteArrayInputStream 读取出来然后转化为指定的对象即可。
Serializable 接口的特点
- 序列化类的属性没有实现 Serializable 那么在序列化就会报错
public class Student implements Serializable {
private String name;
private int age;
/**
* Color 类也是需要实现序列化接口的。
*/
private Color color;//这里如果没有实现序列化接口,那么在 Student 对象序列化时将会报错
}
Exception in thread "main" java.io.NotSerializableException: com.example.seriable.Color
- 在反序列化过程中,它的父类如果没有实现序列化接口,那么将需要提供无参构造函数来重新创建对象。
Animal 是父类,它没有实现 Serilizable 接口
public class Animal {
private String color;
public Animal() {//没有无参构造将会报错
System.out.println("调用 Animal 无参构造");
}
public Animal(String color) {
this.color = color;
System.out.println("调用 Animal 有 color 参数的构造");
}
@Override
public String toString() {
return "Animal{" +
"color='" + color + '\'' +
'}';
}
}
BlackCat 是 Animal 的子类
public class BlackCat extends Animal implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
public BlackCat() {
super();
System.out.println("调用黑猫的无参构造");
}
public BlackCat(String color, String name) {
super(color);
this.name = name;
System.out.println("调用黑猫有 color 参数的构造");
}
@Override
public String toString() {
return "BlackCat{" +
"name='" + name + '\'' +super.toString() +'\'' +
'}';
}
}
SuperMain 测试类
public class SuperMain {
private static final String FILE_PATH = "./super.bin";
public static void main(String[] args) throws Exception {
serializeAnimal();
deserializeAnimal();
}
private static void serializeAnimal() throws Exception {
BlackCat black = new BlackCat("black", "我是黑猫");
System.out.println("序列化前:"+black.toString());
System.out.println("=================开始序列化================");
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_PATH));
oos.writeObject(black);
oos.flush();
oos.close();
}
private static void deserializeAnimal() throws Exception {
System.out.println("=================开始反序列化================");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(FILE_PATH));
BlackCat black = (BlackCat) ois.readObject();
ois.close();
System.out.println(black);
}
}
输出结果
调用 Animal 有 color 参数的构造
调用黑猫有 color 参数的构造
序列化前:BlackCat{name='我是黑猫'Animal{color='black'}'}
=================开始序列化================
=================开始反序列化================
调用 Animal 无参构造
BlackCat{name='我是黑猫'Animal{color='null'}'}
从上面的执行结果来看,如果要序列化的对象的父类 Animal 没有实现序列化接口,那么在反序列化时是会调用对应的无参构造方法的,这样做的目的是重新初始化父类的属性,例如 Animal 因为没有实现序列化接口,因此对应的 color 属性就不会被序列化,因此反序列得到的 color 值就为 null。
对上面的2个操作文件流的类的简单说明
ObjectOutputStream代表对象输出流:
它的writeObject(Object obj)方法可对参数指定的obj对象进行序列化,把得到的字节序列写到一个目标输出流中。
ObjectInputStream代表对象输入流:
它的readObject()方法从一个源输入流中读取字节序列,再把它们反序列化为一个对象,并将其返回。
- 一个实现 Serializable 接口的子类也是可以被序列化的。
- 静态成员变量是不能被序列化
序列化是针对对象属性的,而静态成员变量是属于类的。
- transient 标识的对象成员变量不参与序列化
在下面这个栗子中,MyList 这个类定义了一个 arr 数组属性,初始化的数组长度为 100。在实际序列化时如果让 arr 属性参与序列化的话,那么长度为 100 的数组都会被序列化下来,但是我在数组中可能只存放 30 个数组而已,这明显是不可理的,所以这里就要自定义序列化过程啦,具体的做法是写以下两个 private 方法:
private void writeObject(java.io.ObjectOutputStream s)throws java.io.IOException
private void readObject(java.io.ObjectInputStream s)throws java.io.IOException, ClassNotFoundException
从这两个方法的名字就可以看出分别是序列化写入数据和反序列化读取数据用的,那么这两个方法是在哪里使用呢?其实在序列化和反序列化过程中会通过反射调用的,具体下面会分析这个过程哦。
public class MyList implements Serializable {
private String name;
/*
transient 表示该成员 arr 不需要被序列化
*/
private transient Object[] arr;
public MyList() {
}
public MyList(String name) {
this.name = name;
this.arr = new Object[100];
/*
给前面30个元素进行初始化
*/
for (int i = 0; i < 30; i++) {
this.arr[i] = i;
}
}
@Override
public String toString() {
return "MyList{" +
"name='" + name + '\'' +
", arr=" + Arrays.toString(arr) +
'}';
}
//-------------------------- 自定义序列化反序列化 arr 元素 ------------------
/**
* Save the state of the <tt>ArrayList</tt> instance to a stream (that
* is, serialize it).
*
* @serialData The length of the array backing the <tt>ArrayList</tt>
* instance is emitted (int), followed by all of its elements
* (each an <tt>Object</tt>) in the proper order.
*/
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
//执行 JVM 默认的序列化操作
s.defaultWriteObject();
//手动序列化 arr 前面30个元素
for (int i = 0; i < 30; i++) {
s.writeObject(arr[i]);
}
}
/**
* Reconstitute the <tt>ArrayList</tt> instance from a stream (that is,
* deserialize it).
*/
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
s.defaultReadObject();
arr = new Object[30];
// Read in all elements in the proper order.
for (int i = 0; i < 30; i++) {
arr[i] = s.readObject();
}
}
}
测试
public class TransientMain {
private static final String FILE_PATH = "./transient.bin";
public static void main(String[] args) throws Exception {
serializeMyList();
deserializeMyList();
}
private static void serializeMyList() throws Exception {
System.out.println("序列化...");
MyList myList = new MyList("ArrayList");
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_PATH));
oos.writeObject(myList);
oos.flush();
oos.close();
}
/*
1.如果 private Object[] arr; 没有使用 transient ,那么整个数组都会被保存,而不是保存实际存储的数据
输出结果:MyList{name='ArrayList', arr=[0, 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, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null]}
2.private transient Object[] arr;设置了 transient,表示 arr 元素不进行序列化
输出结果:MyList{name='ArrayList', arr=null}
3.参考 ArrayList 处理内部的 transient Object[] elementData; 数组是通过 writeObject 和 readObject 实现的
我们的 MyList 内部也可以借鉴这种方式实现transient元素的手动序列化和反序列化。
*/
private static void deserializeMyList() throws Exception {
System.out.println("反序列化...");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(FILE_PATH));
MyList myList = (MyList) ois.readObject();
ois.close();
System.out.println(myList);
}
}
输出
序列化...
writeObject...
反序列化...
readObject...
MyList{name='ArrayList', arr=[0, 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]}
- Serializable 在序列化和反序列化过程中大量使用了反射,因此其过程会产生的大量的内存碎片
反序列化漏洞
为什么会产生安全问题?
只要服务端反序列化数据,客户端传递类的 readObject 中代码会自动执行,基于攻击者在服务器上运行代码的能力。
可能的形式
- 入口类的 readObject 直接调用危险方法
- 入口参数中包含可控类,该类有危险方法,readObject 时调用
- 入口类参数中包含可控类,该类又调用其他有危险方法的类,readObject 时调用
- 构造函数/静态代码块等类加载时隐式执行
产生漏洞的条件
共同条件:继承 Serializable
入口类:source (重写 readObject 调用常见的函数,参数类型宽泛 最好 jdk 自带)
调用链 gadget chain 相同名称、相同类型
执行类 sink (RCE SSRF 写文件等等)
Java反射利用
反射理解
Java 的反射机制是指在运行状态中,对于任意一个类都能够知道这个类所有的属性和方法; 并且对于任意一个对象,都能够调用它的任意一个方法;这种动态获取信息以及动态调用对象方法的功能成为Java语言的反射机制。
正射
我们在编写代码时,当需要使用到某一个类的时候,都会先了解这个类是做什么的。然后实例化这个类,接着用实例化好的对象进行操作,这就是正射。
Student student = new Student();
student.doHomework("数学");
反射
反射就是,一开始并不知道我们要初始化的类对象是什么,自然也无法使用 new 关键字来创建对象了。
Class clazz = Class.forName("reflection.Student");
Method method = clazz.getMethod("doHomework", String.class);
Constructor constructor = clazz.getConstructor();
Object object = constructor.newInstance();
method.invoke(object, "语文");
Class 对象理解
要理解Class对象,我们先来了解一下RTTI吧。 RTTI(Run-Time Type Identification)运行时类型识别,其作用是在运行时识别一个对象的类型和类的信息。
Java是如何让我们在运行时识别对象和类的信息的?主要有两种方式: 一种是传统的RRTI,它假定我们在编译期已知道了所有类型。 另一种是反射机制,它允许我们在运行时发现和使用类的信息。
每个类都有一个Class对象,每当编译一个新类就产生一个Class对象(更恰当地说,是被保存在一个同名的.class文件中)。比如创建一个Student类,那么,JVM就会创建一个Student对应Class类的Class对象,该Class对象保存了Student类相关的类型信息。
Class类的对象作用是运行时提供或获得某个对象的类型信息。
反射的基本使用
获取 Class 类对象
获取反射中的Class对象有三种方法。
第一种,使用 Class.forName 静态方法。
Class class1 = Class.forName("reflection.TestReflection");
第二种,使用类的.class 方法
Class class2 = TestReflection.class;
第三种,使用实例对象的 getClass() 方法。
TestReflection testReflection = new TestReflection();
Class class3 = testReflection.getClass();
反射创造对象
通过反射创建类对象主要有两种方式:
- 通过 Class 的
newInstance()
方法
Class class1 = Class.forName("reflection.Student");
Student student = (Student) class1.newInstance();
System.out.println(student);
- 通过 Constructor 的
newInstance()
方法
Constructor personConstructor = c.getConstructor(String.class, int.class);
Person p = (Person) personConstructor.newInstance("abc", 22);
System.out.println(p);
通过 Constructor 方法能够使用有参构造器
反射获取类的构造器
Class class1 = Class.forName("reflection.Student");
Constructor[] constructors = class1.getDeclaredConstructors();
for (int i = 0; i < constructors.length; i++) {
System.out.println(constructors[i]);
}
反射获取类的成员变量
Field[] personFields = c.getDeclaredFields();
for (Field f:personFields) {
System.out.println(f);
}
Field nameField = c.getDeclaredField("age");
// age是私有字段
nameField.setAccessible(true);
nameField.set(p,23);
System.out.println(p);
getField(String name)
根据参数变量名,返回一个具体的具有public属性的成员变量,如果该变量不是public属性,则报异常,此时应使用getDeclaredField(String name)
反射获取类的方法
Method[] personMethods = c.getMethods();
for(Method m:personMethods) {
System.out.println(m);
}
Method actionMethod = c.getMethod("action",String.class);
actionMethod.invoke(p,"Hello");
反射在反序列化中的利用
- 需要定制对象
- 通过
innoke
调用同名函数以外的函数 - 通过 Class 类创建对象,引入不能序列化的类
JDK动态代理利用
代理模式
定义:为其他对象提供一种代理以控制对这个对象的访问
上图中,Subject是一个抽象类或者接口,RealSubject是实现方法类,具体的业务执行,Proxy则是RealSubject的代理,直接和client接触的。
代理模式可以在不修改被代理对象的基础上,通过扩展代理类,进行一些功能的附加与增强。值得注意的是,代理类和被代理类应该共同实现一个接口,或者是共同继承某个类。
代理模式优点
- 职责清晰
- 高扩展,只要实现了接口,都可以用代理。
- 智能化,动态代理。
分类
静态代理
以租房为例,我们一般用租房软件、找中介或者找房东。这里的中介就是代理者。
首先定义一个提供了租房方法的接口。
public interface IRentHouse {
void rentHouse();
}
定义租房的实现类
public class RentHouse implements IRentHouse {
@Override
public void rentHouse() {
System.out.println("租了一间房子。。。");
}
}
我要租房,房源都在中介手中,所以找中介
public class IntermediaryProxy implements IRentHouse {
private IRentHouse rentHouse;
public IntermediaryProxy(IRentHouse irentHouse){
rentHouse = irentHouse;
}
@Override
public void rentHouse() {
System.out.println("交中介费");
rentHouse.rentHouse();
System.out.println("中介负责维修管理");
}
}
这里中介也实现了租房的接口。
再main方法中测试
public class Main {
public static void main(String[] args){
//定义租房
IRentHouse rentHouse = new RentHouse();
//定义中介
IRentHouse intermediary = new IntermediaryProxy(rentHouse);
//中介租房
intermediary.rentHouse();
}
}
返回信息
交中介费
租了一间房子。。。
中介负责维修管理
这就是静态代理,因为中介这个代理类已经事先写好了,只负责代理租房业务
强制代理
如果我们直接找房东要租房,房东会说我把房子委托给中介了,你找中介去租吧。这样我们就又要交一部分中介费了,真坑。
来看代码如何实现,定义一个租房接口,增加一个方法。
public interface IRentHouse {
void rentHouse();
IRentHouse getProxy();
}
这时中介的方法也稍微做一下修改
public class IntermediaryProxy implements IRentHouse {
private IRentHouse rentHouse;
public IntermediaryProxy(IRentHouse irentHouse){
rentHouse = irentHouse;
}
@Override
public void rentHouse() {
rentHouse.rentHouse();
}
@Override
public IRentHouse getProxy() {
return this;
}
}
我们再来看房东是如何实现租房:
public class LandLord implements IRentHouse {
private IRentHouse iRentHouse = null;
@Override
public void rentHouse() {
if (isProxy()){
System.out.println("租了一间房子。。。");
}else {
System.out.println("请找中介");
}
}
@Override
public IRentHouse getProxy() {
iRentHouse = new IntermediaryProxy(this);
return iRentHouse;
}
/**
* 校验是否是代理访问
* @return
*/
private boolean isProxy(){
if(this.iRentHouse == null){
return false;
}else{
return true;
}
}
}
房东的getProxy方法返回的是代理类,然后判断租房方法的调用者是否是中介,不是中介就不租房。
main方法测试:
public static void main(String[] args){
IRentHouse iRentHosue = new LandLord();
//租客找房东租房
iRentHouse.rentHouse();
//找中介租房
IRentHouse rentHouse = iRentHouse.getProxy();
rentHouse.rentHouse();
}
}
请找中介
租了一间房子。。。
看,这样就是强制你使用代理,如果不是代理就没法访问。
动态代理
我们知道现在的中介不仅仅是有租房业务,同时还有卖房、家政、维修等得业务,只是我们就不能对每一个业务都增加一个代理,就要提供通用的代理方法,这就要通过动态代理来实现了。
中介的代理方法做了一下修改
public class IntermediaryProxy implements InvocationHandler {
private Object obj;
public IntermediaryProxy(Object object){
obj = object;
}
/**
* 调用被代理的方法
* @param proxy
* @param method
* @param args
* @return
* @throws Throwable
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object result = method.invoke(this.obj, args);
return result;
}
}
在这里实现InvocationHandler接口,此接口是JDK提供的动态代理接口,对被代理的方法提供代理。其中invoke方法是接口InvocationHandler定义必须实现的, 它完成对真实方法的调用。动态代理是根据被代理的接口生成所有的方法,也就是说给定一个接口,动态代理就会实现接口下所有的方法。通过 InvocationHandler接口, 所有方法都由该Handler来进行处理, 即所有被代理的方法都由 InvocationHandler接管实际的处理任务。
这里增加一个卖房的业务,代码和租房代码类似。
main方法测试:
public static void main(String[] args){
IRentHouse rentHouse = new RentHouse();
//定义一个handler
InvocationHandler handler = new IntermediaryProxy(rentHouse);
//获得类的class loader
ClassLoader cl = rentHouse.getClass().getClassLoader();
//动态产生一个代理者
IRentHouse proxy = (IRentHouse) Proxy.newProxyInstance(cl, new Class[]{IRentHouse.class}, handler);
proxy.rentHouse();
ISellHouse sellHouse = new SellHouse();
InvocationHandler handler1 = new IntermediaryProxy(sellHouse);
ClassLoader classLoader = sellHouse.getClass().getClassLoader();
ISellHouse proxy1 = (ISellHouse) Proxy.newProxyInstance(classLoader, new Class[]{ISellHouse.class}, handler1);
proxy1.sellHouse();
}
租了一间房子。。。
买了一间房子。。。
在main方法中我们用到了Proxy这个类的方法,
public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)
loder:类加载器,interfaces:代码要用来代理的接口, h:一个 InvocationHandler 对象 。
InvocationHandler 是一个接口,每个代理的实例都有一个与之关联的 InvocationHandler 实现类,如果代理的方法被调用,那么代理便会通知和转发给内部的 InvocationHandler 实现类,由它决定处理。
public interface InvocationHandler {
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}
InvocationHandler 内部只是一个 invoke() 方法,正是这个方法决定了怎么样处理代理传递过来的方法调用。
因为,Proxy 动态产生的代理会调用 InvocationHandler 实现类,所以 InvocationHandler 是实际执行者。
动态代理在反序列化中的利用
当在反序列化入口类中找不到跟利用类的里面的同名函数时,可以尝试使用传入一个动态代理类,这个动态代理类代理的类是利用类,而这个动态代理类的invoke()
函数调用了利用类的同名函数,因为动态代理类必然会调用invoke()
函数,从而能够调用利用类中的危险函数。
- 拼接两条链:任意 -> 固定
类的动态加载
类加载
类加载的时机
类加载,即虚拟机加载.class文件。什么时候虚拟机需要开始加载一个类呢?虚拟机对此没有规范约束,交给虚拟机把握。
Javac原理
javac是用于将源码文件.java编译成对应的字节码文件.class。
其步骤是:源码——>词法分析器组件(生成token流)——>语法分析器组件(语法树)——>语义分析器组件(注解语法树)——>代码生成器组件(字节码)。
类加载过程
先在方法区找class信息,有的话直接调用,没有的话则使用类加载器加载到方法区(静态成员放在静态区,非静态成功放在非静态区),静态代码块在类加载时自动执行代码,非静态的不执行;先父类后子类,先静态后非静态;静态方法和非静态方法都是被动调用,即不调用就不执行。
类加载的流程如下图:
类的初始化
类实例化和初始化概念
类实例化:是指创建一个类的实例(对象)的过程,由类创建的对象,在构造一个实例时,需要在内存中开辟空间,即new Object()
。
类初始化:是指类中各个类成员(被static修饰的成员变量)赋初始值的过程,是类生命周期中的一个阶段;即实例化基础上对对象赋初始值。
类初始化的时机
有且只有5中情况下必须立即对类进行初始化:
- 遇到new、getstatic、putstaic、invokestatic 四条字节码指令,如果没有初始化则需要先进行初始化。
注:数组类型初始化只会初始化数组类型本身,不会初始化相关类型,例如:new String[],此时只会初始化String[]即Ljava.lang.String,但是不会触发初始化String。
常见场景:
1、使用new关键字实例化对象。
2、读取或者设置一个类的静态字段(被final修饰,已在编译器把结果放在常量池的静态字段除外)的时候。
3、调用一个类的静态方法时。
- 使用java.lang.reflect进行反射调用,如果未初始化,触发初始化
- 初始化一个类时,若父类未初始化,则先触发父类初始化
- 虚拟机启动时,用户需要指定一个执行main方法的主类,虚拟机会先初始化这个类
- 如果一个java.lang.invoke.MethodHandle 实例最后的解析结果的REF-getstatic、REF-pubstatic、REF-invokestatic方法句柄,并且这个方法句柄所在对应的类没有进行初始化,则需要先触发其初始化
以上初始化称为主动引用,除此之外所有引用类的方式都不会触发初始,称为被动引用。
小结:
在类加载过程中会自动调用的代码有
初始化:静态代码块
实例化:构造代码块、无参构造函数
类加载在反序化中的利用
由于类在加载的时候会执行代码,因此这样能够让攻击者有利可图
URLClassLoader 任意类加载
- file
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{new URL("file:///C:\\Users\\John\\Desktop\\Java\\")});
- http
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{new URL("http://127.0.0.1:9999/")});
- jar
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{new URL("jar:http://127.0.0.1:9999/Hello.jar!/")});
ClassLoader.defineClass 字节码加载任意类
私有
ClassLoader cl = ClassLoader.getSystemClassLoader();
Method defindClassMethod = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class);
defindClassMethod.setAccessible(true);
byte[] code = Files.readAllBytes(Paths.get("C:\\Users\\John\\Desktop\\Java\\Test.class"));
Class c = (Class) defindClassMethod.invoke(cl,"Test",code,0,code.length);
c.newInstance();
Unsafe.defineClass 字节码加载任意类:
public 类不能直接生成,Spring 里面可以直接生成
Class c = Unsafe.class;
Field theUnsafeField = c.getDeclaredField("theUnsafe");
theUnsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafeField.get(null);
byte[] code = Files.readAllBytes(Paths.get("C:\\Users\\John\\Desktop\\Java\\Test.class"));
Class c2 = (Class) unsafe.defineClass("Test", code, 0, code.length, cl, null);
c2.newInstance();