java反序列化的基础就是反射,所以我们先从反射开始
反射
什么是反射
反射允许对封装类的字段,方法和构造函数的信息进行编程访问
获取class对象的三种方式
1.Class.forName(全类名) 最为常用的
2.类名.class 当做参数进行传递
3.对象.getClass();
获取构造方法
先获取class对象,再获取构造方法
1 | Class clazz=Class.forName("全类名"); |
获取成员变量
1 | Class clazz=Class.forName("全类名"); |
获取成员方法
java序列化和反序列化
反射(1)
Java 序列化和反序列化的实质本质上是对象与字节流之间的转换。也就是Java序列化是将对象转换为字节流以便存储或传输,反序列化则是将字节流转换回原对象的过程。
1 | public void execute(String className, String methodName) throws Exception { |
在上面的代码中有几个反射里的重要的方法
forName: 获取类的方法
newInstance:实例化对象的方法
getMethod: 获得函数的方法
invoke: 用来执行函数
获取类的方法不只有forName
,还有obj.getclass()
、Test.class
,他们都是属于java.lang.Class
对象
forName
有两个函数重载:Class<?> forName(String name)
Class<?> forName(String name, boolean initialize, ClassLoader loader)
第一个就是我们最常见的获取class的方式,其实可以理解为第二种方式的一个封装
1
2
3 Class.forName(className)
等于
Class.forName(className,true,currentLoader)
接下来,我们来看看三个初始化方法有什么区别
1 | public class TrainPrint { |
我们在main函数内初始实例化这个类
1 | package src; |
从运行结果能够发现,先调用了static{},其次是{},最后是构造函数
其中, static {} 就是在“类初始化”的时候调⽤的,⽽ {} 中的代码会放在构造函数的 super() 后⾯,
但在当前构造函数内容的前⾯。
所以说, forName
中的 initialize=true 其实就是告诉Java虚拟机是否执⾏”类初始化“。
1 | public class TrainPrint { |
那么,假设我们有如下函数,其中函数的参数name可控:
1 | public void ref(String name) throws Exception { |
我们就可以编写⼀个恶意类,将恶意代码放置在 static {} 中,从⽽执⾏:
1 | import java.lang.Runtime; |
反射(2)
我们知道,java在正常情况下,除了系统类,我们想拿到另一个类要使用import,但使用forName就不需要,我们就可以通过它来加载任意类
获得类以后,我妈就需要继续使用反射来获取这个类的属性、方法,也可以实例化战鼓擂,并调用方法
java有个函数class.newInstance()
用来调用这个类的无参构造方法
不过,有时候在写漏洞利用方面,会发现使用newInstance总是不成功,原因可能有:
1.类没有无参构造函数
2.类的构造函数是私有的
如果构造函数是私有的,我们可以通过class.setAccessible(true)
来取消权限校验
最最最常见的情况就是 java.lang.Runtime
,这个类在我们构造命令执行Payload的时候很常见,但
我们不能直接这样来执行命令
1 | Class clazz = Class.forName("java.lang.Runtime"); |
得到了一个报错,原因是Runtime类的构造方法是私有的
为什么会有类的构造方法是私有的,这涉及到”单例模式”
比如,对于Web应用来说,数据库连接只需要建立一次,而不是每次用到数据库的时候再新建立一个连接,此时作为开发者你就可以将数据库连接使用的类的构造函数设置为私有,然后编写一个静态方法来获取:
1 | public class TrainDB { |
这样,只有类初始化的时候会执行一次构造函数,后面只能通过 getInstance 获取这个对象,避免建立多个数据库连接。
我们只能通过 Runtime.getRuntime()
来获取到 Runtime 对象。我们将上述Payload进行修改即可正常执行命令了:
1 | Class clazz = Class.forName("java.lang.Runtime"); |
在这里,我们用到了getMethod函数和invoke函数
getMethod的作用是通过反射获取一个类的某个特定的成员方法。我们知道在Java中支持类的重载,我们不能仅通过函数名来确定一个函数。所以,在调用 getMethod 的时候,我们需要传给他你需要获取的函数的参数类型列表
我们来举个例子,假设我们有一个类
MyClass
,其中有两个重载的greet
方法,一个接收String
类型参数,另一个接收int
类型参数:
1
2
3
4
5
6
7
8
9
10 public class MyClass {
public void greet(String name) {
System.out.println("Hello, " + name);
}
public void greet(int age) {
System.out.println("You are " + age + " years old.");
}
}如果你想通过反射获取并调用这两个方法之一,你就需要传递正确的参数类型给
getMethod
。下面是如何使用getMethod
的示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 import java.lang.reflect.Method;
public class ReflectionExample {
public static void main(String[] args) throws Exception {
MyClass myClass = new MyClass();
// 获取 greet(String) 方法
Method method1 = MyClass.class.getMethod("greet", String.class);
method1.invoke(myClass, "Alice");
// 获取 greet(int) 方法
Method method2 = MyClass.class.getMethod("greet", int.class);
method2.invoke(myClass, 25);
}
}
invoke 的作用是执行方法,它的第一个参数是:
- 如果这个方法是一个普通方法,那么第一个参数是类对象
- 如果这个方法是一个静态方法,那么第一个参数是类
这也比较好理解了,我们正常执行方法是 [1].method([2], [3], [4]…) ,其实在反射里就是method.invoke([1], [2], [3], [4]…) 。
我们将上述命令执行的payload分解一下得到
1 | Class clazz = Class.forName("java.lang.Runtime"); |
反射(3)
如果一个类没有无参构造方法,也没有类似单例模式里的静态方法,我们怎样通过反射实例化该类?
我们使用一个新的反射方法:getConstructor
它和getMethod
相似,getConstructor
它接收参数是类的构造函数列表,因为构造函数也支持重载,所以我们必须用参数列表类型才能确定一个构造函数
获取到构造方法后,我们使用newInstance
来执行
比如,我们常用的另一种执行命令的方式ProcessBuilder,我们使用反射来获取构造函数,然后调用start()
来执行命令
1 | Class clazz = Class.forName("java.lang.ProcessBuilder"); |
**ProcessBuilder
**有两个构造函数:
1 | public ProcessBuilder(List<String> command) |
上面例子用到了第一个构造函数,所以我在getConstructor
里传入的是List.class
但是,前面那个payload用到了强制类型转换,我们利用漏洞是没有这种语法的,所以我们还需要进行反射
1 | Class clazz = Class.forName("java.lang.ProcessBuilder"); |
RMI(1)
RMI是远程方法调用,它的目标和RPC其实是类似的,是让某个Java虚拟机上的对象调用另一个Java虚拟机中对象上的方法,不过RMI是Java独有的机制
我们来举一个例子
1 | package org.vulhub.RMI; |
一个RMI Sever分为三部分:
1.一个继承了java.rmi.Remote
的接口,其中定义我们要远程调用的函数,比如这里的hello()
2.一个实现了此接口的类
3.一个主类,用来穿件Registry,并将上面的类实例化后绑定到一个地址。这就是我们所谓的Server了
接着我们编写一个RMI Client:
1 | package org.vulhub.Train; |
客户端就简单很多,使用Naming.lookup在Registry中寻找到名字Hello的对象,后面的使用就和在本地使用一样了
虽说执⾏远程⽅法的时候代码是在远程服务器上执⾏的,但实际上我们还是需要知道有哪些⽅法,这时候接⼝的重要性就体现了,这也是为什么我们前⾯要继承 Remote 并将我们需要调⽤的⽅法写在接⼝IRemoteHelloWorld ⾥,因为客户端也需要⽤到这个接⼝。
为了理解RMI的通信过程,我们⽤wireshark抓包看看
这就是完整的通信过程,我们可以发现,整个过程进⾏了两次TCP握⼿,也就是我们实际建⽴了两次TCP连接。
第⼀次建⽴TCP连接是连接远端 192.168.135.142 的1099端⼝,这也是我们在代码⾥看到的端⼝,⼆者进⾏沟通后,我向远端发送了⼀个“Call”消息,远端回复了⼀个“ReturnData”消息,然后我新建了⼀个TCP连接,连到远端的33769端⼝。
那么为什么我会连接33769端⼝呢?
细细阅读数据包我们会发现,在“ReturnData”这个包中,返回了⽬标的IP地址 192.168.135.142 ,其后跟的⼀个字节 \x00\x00\x83\xE9 ,刚好就是整数
33769 的⽹络序列:
1 | 0030 .. .. .. .. .. .. .. ac ed 00 05 77 0f 01 18 35 ......Q....w...5 |
其实这段数据流中从 \xAC\xED 开始往后就是Java序列化数据了,IP和端⼝只是这个对象的⼀部分罢了。
所以捋⼀捋这整个过程,⾸先客户端连接Registry,并在其中寻找Name是Hello的对象,这个对应数据流中的Call消息;然后Registry返回⼀个序列化的数据,这个就是找到的Name=Hello的对象,这个对应数据流中的ReturnData消息;客户端反序列化该对象,发现该对象是⼀个远程对象,地址在 192.168.135.142:33769 ,于是再与这个地址建⽴TCP连接;在这个新的连接中,才执⾏真正远程⽅法调⽤,也就是 hello() 。
我们借⽤下图来说明这些元素间的关系:
RMI Registry就像⼀个⽹关,他⾃⼰是不会执⾏远程⽅法的,但RMI Server可以在上⾯注册⼀个Name
到对象的绑定关系;RMI Client通过Name向RMI Registry查询,得到这个绑定关系,然后再连接RMI
Server;最后,远程⽅法实际上在RMI Server上调⽤。
RMI (2)
在上面我们讲了RMI Server和RMI Client,但是示例代码为什么只有两部分?原因是,通常我们在新建一个RMI Registry时,都会直接绑定一个对象在上面,也就是说我们示例代码中的Server其实包含了Registry和Server两部分
1 | LocateRegistry.createRegistry(1099); |
第一行创建并运行RMI Registry,第二行将RemoteHelloWorld对象绑定在Hello这个名字上
Naming.bind
的第一个参数是一个URL,形如:rmi://host:port/name
.其中,host和port就是RMI Registry的地址和端口,name是远程对象的名字
如果RMI Registry在本地运行,那么host和port是可以省略的,此时host默认是localhost
,port默认是1099
1 | Naming.bind("Hello", new RemoteHelloWorld()); |
以上就是RMI整个的运力和流程。接下来,我们很自然地的想到,RMI会给我们带来哪些安全问题?
我们尝试从两个方向思考这个问题:
1.如果我们能访问RMI Registry服务,如何对其进行攻击?
2.如果我们控制了目标RMI客户端中Naming.lookup
的第一个参数(也就是RMI Registry的地址),能不能进行攻击?
如何攻击RMI Registry?
当我们可以访问目标RMI Registry的时候,会有哪些安全问题呢?
首先,RMI Registry是一个远程对象管理的地方,可以理解为一个远程对象的”后台”。我们可以尝试直接访问”后台“功能,比如修改远程服务器上Hello对应的对象:
1 | RemoteHelloworld h=new RemoteHelloworld(); |
却爆出了这样的错误
原来Java对远程访问RMI Registry做了限制,只有来源地址是localhost的时候,才能调用rebind、bind、unbind等方法
不过list方法和lookup方法可以远程调用
list方法可以列出目标上所有绑定的对象
1 | String[] s=Naming.list("rmi://192.168.135.142:1099"); |
lookup作用就是获得某个远程对象
那么只要目标服务器上存在一些危险方法,我们通过RMI就可以对其进行调用,之前曾有一个工具https://github.com/NickstaDB/BaRMIe,其中一个功能就是进行危险方法的探测。
但是显然,RMI的攻击面绝不仅仅是这样没营养。
RMI利用codebase执行任意代码
曾经有段时间,Java是可以运行在浏览器中的,对,就是Applet这个奇葩。在使用Applet的时候通常需要指定一个codebase属性,比如:
1
2 <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中的类
反序列化(1)
Java反序列化和PHP的反序列化其实有些类似,他们都只将一个对象中的属性按照某种特定的格式生成一段数据流,在反序列化时再按照这个格式将属性拿回来,在赋值给新的对象
但Java相对PHP序列化更深入的地方在于,其提供了更加高级、灵活地方法 writeObject ,允许开发者在序列化流中插入一些自定义数据,进而在反序列化的时候能够使用 readObject 进行读取。
Java在序列化时一个对象,将会调用这个对象中的writeobject方法,参数类型是ObjectOutputStream,开发者可以将任何内容写入这个stream中,反序列化时,会调用readobject,开发者也可以从中读取出前面写入的内容,并进行处理
我们举个例子
Person.java
1 | package org.vulhub.Ser; |
Main.java
1 | package src; |
可见,我这里在执行完默认的 s.defaultWriteObject() 后,我向stream里写入了一个字符串 This is an object 。
我们写入的This is a object 被放在 objectAnnotation 的位置。
在反序列化时,我读取了这个字符串,并将其输出了,这个特性就让Java的开发变得非常灵活
反序列化(2)
ysoserial
在说反序列化漏洞利用链前,我们需要知道一个工具ysoserial
ysoserial可以让用户根据自己选择的利用链,生成反序列化数据,通过讲这些数据发送给目标,从而执行用户的命令
什么是利⽤链?
利⽤链也叫“gadget chains”,我们通常称为gadget。如果你学过PHP反序列化漏洞,那么就可以将gadget理解为⼀种⽅法,它连接的是从触发位置开始到执⾏命令的位置结束,在PHP⾥可能是 __desctruct 到 eval ;如果你没学过其他语⾔的反序列化漏洞,那么gadget就是⼀种⽣成POC的⽅法罢了
ysoserial的使用很简单,虽然我们暂时不理解CommonsCollections利用链,但使用 ysoserial可以很容易生成这个gadget对应的POC:
1 | java -jar ysoserial-master-30099844c6-1.jar CommonsCollections1 "id" 1 |
如上,ysoserial⼤部分的gadget的参数就是⼀条命令,⽐如这⾥是 id 。⽣成好的POC发送给⽬标,如果⽬标存在反序列化漏洞,并满⾜这个gadget对应的条件,则命令 id 将被执⾏。
URLDNS
URLDNS是ysoserial中一个利用链的名字,但准确来说,这个其实不能称作”利用链”。因为其参数不是一个可以利用的命令,而仅作为一个URL,其能触发的结果也不是命令执行,而是一次DNS请求
虽然这个“利用链”实际上是不能利用的,但因为其如下优点,非常适合我们在检测反序列化漏洞时使用
- 使⽤Java内置的类构造,对第三⽅库没有依赖
- 在⽬标没有回显的时候,能够通过DNS请求得知是否存在反序列化漏洞
所以是用来检测是否存在java反序列化?
我们打开https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/payloads/URLDNS.java看看ysoserial是如何⽣成 URLDNS 的代码的:
1 | package ysoserial.payloads; |
利用链分析
我们看到URLDNS类的getObject方法,ysoserial会调用这个方法获得payload。这个方法返回的是一个对象,这个对象就是最后将被序列化的对象,在这里是HashMap
我们前面说了,触发反序列化的方法是readObject,因为Java开发者(包括Java内置库的开发者)经常会在这里面写自己的逻辑,所以导致可以构造利用链
那么,我们可以直奔HashMap类的readObject方法:
1 | /** |
在53行的位置,可以看到将HashMap的键名计算了hash:
1 | putVal(hash(key), key, value, false, false); |
在此处下断电,对这个hash函数进行调试并跟进,这是调用栈:
在没有分析过的情况下,我为何会关注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请求。
另外,如何对Java和ysoserial项⽬进⾏调试,可以参考星球⾥的另⼀篇⽂章:https://t.zsxq.com/ubQRvjq
hash方法调用了key的hashCode()
方法:
1 | stativ final int hash(object key){ |
UELDNS
中使用这个key是一个java.net.URL
对象,我们看看其中的hashCode ⽅法:
此时,handler是URLStreamHandler对象(的某个子类对象),继续跟进其hashCode
方法:
1 | protected int hashCode(URL u) { |
这⾥有调⽤ getHostAddress ⽅法,继续跟进:
1 | protected synchronized InetAddress getHostAddress(URL u) { |
这⾥ InetAddress.getByName(host) 的作⽤是根据主机名,获取其IP地址,在⽹络上其实就是⼀次DNS查询。到这⾥就不必要再跟了。
我们⽤⼀些第三⽅的反连平台就可以查看到这次请求,证明的确存在反序列化漏洞:
所以,⾄此,整个 URLDNS 的Gadget其实清晰⼜简单:
HashMap->readObject()
HashMap->hash()
URL->hashCode()
URLStreamHandler->hashCode()
URLStreamHandler->getHostAddress()
InetAddress->getByName()
从反序列化最开始的 readObject ,到最后触发DNS请求的 getByName ,只经过了6个函数调⽤,这在Java中其实已经算很少了。
要构造这个Gadget,只需要初始化⼀个 java.net.URL 对象,作为 key 放在 java.util.HashMap中;然后,设置这个 URL 对象的 hashCode 为初始值 -1 ,这样反序列化时将会重新计算其 hashCode ,才能触发到后⾯的DNS请求,否则不会调⽤ URL->hashCode() 。
另外,ysoserial为了防⽌在⽣成Payload的时候也执⾏了URL请求和DNS查询,所以重写了⼀个 SilentURLStreamHandler 类,这不是必须的。