在学习URLDNS链以及之后的cc链之前 我们得先掌握java反序列化的基础知识
序列化和反序列化
序列化
把一个java对象转换成字节序列以便存储或者通过网络传输
反序列化
把字节序列还原成java对象
1 2
| 序列化:对象->字符串 反序列化:字符串->对象
|
为什么要进行序列化和反序列化
- 保存对象状态:将对象存入磁盘/缓存 以便之后恢复
- 网络传输:客户端和服务端通信的时候 对象通过socket(套接字)传输
- 对象复制/传递:在不同模块之间传递对象需要序列化
JAVA函数(writeObject/readObject)实现序列化和反序列化
通过下面一个例子就可以理解这两个函数在序列化和反序列化之中的利用
构造恶意类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| package java_Serializable; import java.io.Serializable; public class User implements Serializable{ //注意这个Serialiazble接口 这是序列化标志 private static final long serialVersionUID = 1L; private String username; private int age; public User(String username,int age){ this.username = username; this.age = age; } public String toString(){ return "User{name=" +username + ", age=" + age + "}"; } }
|
构造序列化类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| package java_Serializable; import java.io.FileOutputStream; import java.io.ObjectOutputStream; public class SerializeDemo { public static void main(String[] args)throws Exception{ User user=new User("admin",18); ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.obj")); oos.writeObject(user); oos.close(); System.out.println("序列化成功 user.obj已生成"); } }
|
构造反序列化类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| package java_Serializable; import java.io.FileInputStream; import java.io.ObjectInputStream; public class UnSerializeDemo { public static void main(String[] args)throws Exception{ ObjectInputStream ois=new ObjectInputStream(new FileInputStream("user.obj")); Object obj=ois.readObject(); ois.close(); System.out.println("反序列化成功"); System.out.println(obj); } }
|
实例分析
接下来我们来解释一下导入的这几个包以及代码的运行逻辑
1 2
| import java.io.FileOutputStream; import java.io.ObjectOutputStream;
|
java.io.FileOutputStream:文件字节输出流 负责把数据写入到磁盘文件
java.io.ObjectOutputStream:对象输出流 可以提供把java对象转换成字节流需要的框架 并配合前者写入文件
1 2
| import java.io.FileInputStream; import java.io.ObjectInputStream;
|
java.io.FileInputStream:文件字节输入流 从磁盘文件读取字节数据
java,io,ObjectInputStream:对象输入流 提供将java对象从字节流之中恢复的结构 配合前者使用
重头戏就是这一段代码
1 2 3
| ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.obj")); oos.writeObject(user); oos.close();
|
new FileOutputStream("user.obj")这一段是建立一个和文件user.obj连接的字节流 类似C语言之中的fopen 将其嵌套于new ObjectOutputStream之中 此时这个字节流就可以传递java的序列化内容 但是此时就像开放了两国外交关系 也开放了商道 但是商品还没有生产出来
此时调用writeObject()将对象转换成序列化内容 就好比生产了商品 此时就可以贸易往来 也就是将这一段序列化内容传入user.obj之中
最后需要将这一段字节流关闭
同样的道理 反序列化过程也是如此
1 2 3
| ObjectInputStream ois=new ObjectInputStream(new FileInputStream("user.obj")); Object obj=ois.readObject(); ois.close();
|
此处不再过多解释
值得一提的是readObject的递归性 这也是后续cc链的核心基础:反序列化一个对象的时候 其引用的子对象也会被递归反序列化 并且每个子对象的readObject钩子都会被执行
反序列化漏洞利用
虽然我们前面学习了如何序列化和反序列化 但是实际上如果只是简单的再恶意代码之中加入我们想要执行的恶意代码是无法成功的
像这样
1 2 3 4 5 6
| class EvilObject implements Serializable { private String cmd; public EvilObject(String cmd) { this.cmd = cmd; Runtime.getRuntime().exec(cmd); }
|
这里的exec是无法执行的
这是因为反序列化不是重新执行构造的过程
当你正常new一个对象的时候
1
| EvilObject obj = new EvilObject("calc");
|
JVM会执行构造方法 如果构造方法里面有exec 就会执行 整个逻辑大概像下面这样:
1 2 3 4 5
| 1. JVM 在堆上分配内存空间 2. 调用构造方法 EvilObject(String cmd) └── 执行 this.cmd = cmd └── 执行 Runtime.getRuntime().exec(cmd) ← 这里会执行! 3. 返回对象引用
|
但是反序列化完全绕过构造方法 直接通过JVM内部的底层机制分配一块内存 然后把从字节流之中读出的字段值粘贴到这块内存的对应位置上 写成一个对象 整个过程没有任何java层面的方法被主动调用
1
| Object obj = ois.readObject();
|
整个运行逻辑大概是这样的
1 2 3 4 5 6 7 8
| 1. JVM 从字节流中读取类描述信息(类名 字段名 字段值等) 2. JVM 在堆上分配内存空间(注意此时还没有调用任何构造方法) 3. JVM 通过反射,直接从字节流中读取字段值,写入内存中的对应位置 └── 相当于:field.set(obj, valueFromStream) 4. JVM 检查该类是否定义了 private void readObject(ObjectInputStream) 方法 ├── 如果有:反射调用它 └── 如果没有:什么都不做 直接返回 5. 返回对象引用
|
所以上面我们写的哪个恶意类如果直接反序列化 其中的恶意代码根本不会被执行
那么我们应该通过反序列化执行我们的恶意代码呢
我们来看以下代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| package java_Serializable; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; public class EvilObject implements Serializable { private static final long serialVersionUID = 1L; private String cmd; public EvilObject(String cmd) { this.cmd = cmd; } // 重写readObject,反序列化时自动执行 private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); // 让反序列化按照默认方法执行读取 Runtime.getRuntime().exec(cmd); // 此处就有潜在RCE风险 } }
|
在java之中为了满足一些需求(字段验证等)提供了一个“钩子” :如果类中定义了 private void readObject(ObjectInputStream in) 那么在反序列化恢复字段值之后/之前 就会调用这个钩子 这就是我们说的重写readObject
1
| private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException
|
当你重写的时候 在反序列化类之中再次调用readObject的时候
1 2
| ObjectInputStream ois = new ObjectInputStream(...); Object obj = ois.readObject(); // 假设 obj 的实际类型是 EvilObject
|
代码的执行逻辑是
- 从输入流之中读取一个类的描述信息
- 确认当前JVM之中是否存在这个类 不存在就尝试加载
- 通过反射检查这个类是否定义了一个名未
readObject参数未ObjectInputStream修饰符未private的方法
- 找到之后 框架会在恢复完字段值之后 反射调用这个方法
- 未找到 只执行默认的字段恢复逻辑 不调用任何额外方法
所以
1 2 3 4 5 6 7 8 9 10 11
| private void writeObject(java.io.ObjectOutputStream out) throws IOException { // 自定义序列化逻辑 out.defaultWriteObject(); // 可选,先执行默认序列化 // 然后做点别的事... }
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException { // 自定义反序列化逻辑 in.defaultReadObject(); // 可选,先执行默认反序列化 // 然后做点别的事... }
|
defaultReadObject/defaultWriteObject讲解
在这里我们还要说明一下这两个函数
我们前文之中说到 这两个方法是让JVM以默认的方式将对象存储成字节流或从字节流之中恢复对象 就以我们重写的readObject举例子吧
假设我们不写这个defaultReadObject
1 2 3
| private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { Runtime.getRuntime().exec(cmd); // 此处就有潜在RCE风险 }
|
那么此时的cmd指尚未被恢复 还是字节流 根本没有实际的值让我们将其作为参数执行exec 于是就导致了出错 让AI生成了一个流程图大概就是这样
1 2 3 4 5 6 7 8 9 10
| 1. JVM 分配内存(对象诞生,此时 cmd = null) 2. JVM 反射调用你写的 readObject(ObjectInputStream in) ← 你进入方法了! |-- 此时,cmd 仍然为 null |-- [关键点] 如果你现在直接执行 Runtime.getRuntime().exec(cmd),会报错(空指针) | |-- 执行 in.defaultReadObject(); ← 这一行代码执行完,JVM 才从流里读出数据,赋给 cmd | (此时 cmd 才变成流里保存的 "calc.exe") | |-- 执行 Runtime.getRuntime().exec(cmd); ← 此时 cmd 有值了,成功执行! 3. 方法结束,返回对象
|
在开发者的设计初衷之中 这个方法是用于数据校验和迁移的 比如说下列场景
1 2 3 4 5
| private void readObject(ObjectInputStream in) throws ... { in.defaultReadObject(); //数据恢复后进行校验 如果年龄是负数 强行改成 18 if (this.age < 0) { this.age = 18; } }
|
但是在我们的恶意攻击之中 就可以被我们用来恢复字段
那么现在对于这些知识都了解的差不多了 可以尝试攻击
我们将序列化类改成以下内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| package java_Serializable;
import java.io.FileOutputStream; import java.io.ObjectOutputStream;
public class SerializeDemo { public static void main(String[] args) throws Exception { // 改为创建 EvilObject EvilObject evil = new EvilObject("calc.exe"); // Windows 弹计算器 ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("evil.obj")); oos.writeObject(evil); oos.close();
System.out.println("序列化成功 evil.obj 生成"); } }
|
然后修改一下反序列化类 不用大改
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| package java_Serializable; import java.io.FileInputStream; import java.io.ObjectInputStream; public class UnSerializeDemo { public static void main(String[] args) throws Exception { // 文件名改成 evil.obj ObjectInputStream ois = new ObjectInputStream(new FileInputStream("evil.obj")); Object obj = ois.readObject(); ois.close(); System.out.println("反序列化成功"); } }
|
执行二者之后成功弹出计算器
补充知识点:transient
这里还需要补充一个知识点就是如果一个类的某个字段不想被序列化 可以使用transient修饰 反序列化之后该字段改为默认值(null,0,false)
1
| private transient String password;
|
这个修饰符的设计初衷就是为了保护一些敏感信息
然而在攻击者的视角之中 这个修饰符只能保护JVM默认的序列化行为 却无法影响重写的writeObject/readObject
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public class SecureData implements Serializable { private String data; private transient String encryptedData; // 不会自动序列化
// 我们可以手动写入加密后的数据 private void writeObject(ObjectOutputStream out) throws IOException { out.defaultWriteObject(); // 正常写入 data // 手动把加密后的数据写入流 String encrypted = encrypt(data); out.writeUTF(encrypted); }
// 手动读取并解密 private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); // 正常恢复 data // 手动读取加密数据并解密 String encrypted = in.readUTF(); this.encryptedData = decrypt(encrypted); } }
|
但是由于transient字段不会被自动序列化 所以攻击者无法直接控制transient字段的值 所以构造反序列化利用链的时候 攻击者会优先瞄准非transient字段攻击
小结
至此我们已经初步掌握了java反序列化的一些基本知识 核心性质有这些
- 一个类必须实现
java.io.Serializable标识接口 对象才能被序列化
- 必须重写readObject方法才能植入恶意攻击
还学习了一些细碎的知识点 比如transient可以阻止默认序列化
我都下一篇文章将讲解URLDNS链 这是一个初步实际掌握java反序列化的好链子 也能帮助我们更好的理解后续的cc链 敬请期待
参考文章