本篇主要讲述了,RMI的简单使用和JNDI注入产生的原理,不涉及其他方面,使用的jdk版本是jdk1.7,了解RMI使用,有助于理解漏洞原理。
Java RMI 指的是远程方法调用 (Remote Method Invocation)。它是一种机制,能够让在某个 Java 虚拟机上的对象调用另一个 Java 虚拟机中的对象上的方法。
在Java中,只要一个类继承了java.rmi.Remote接口,即可成为存在于服务器端的远程对象,供客户端访问并提供一定的服务。JavaDoc描述:Remote 接口用于标识其方法可以从非本地虚拟机上调用的接口。任何远程对象都必须直接或间接实现此接口。只有在“远程接口”(扩展 java.rmi.Remote 的接口)中指定的这些方法才可远程使用。
1. 定义一个远程接口,此接口需要继承Remote
2. 开发远程接口的实现类
3. 创建一个server并把远程对象注册到端口
4. 创建一个client查找远程对象,调用远程方法
编写RMI应用的第一步就是先定义远程接口。远程接口必须继承java.rmi.Remote接口,并且声明自己的远程方法。为了处理远程方法发生的各种异常,每一个远程方法必须抛出一个java.rmi.RemoteException异常。
public interface HelloWorld extends Remote {
String sayHello() throws RemoteException;
}
这个远程接口只定义了一个远程方法 sayHello(),远程方法在调用的时候有可能失败比如发生网络问题或者server挂掉,此时远程方法会抛出RemoteException异常。
开发接口的实现类,即具体的远程对象,在远程对象中实现远程接口中定义的方法。
public class HelloWroldImpl implements HelloWorld {
public String sayHello() throws RemoteException {
return "hello world!";
}
}
在server端只需要做两件事:
创建并导出远程对象
用Java RMI registry 注册远程对象
下面是一个server端的程序:
public class RMIServer {
public static void main(String[] args) {
try {
HelloWord hello=new HelloWordImpl();
HelloWord stub=(HelloWord)UnicastRemoteObject.exportObject(hello, 9999);
LocateRegistry.createRegistry(1099);
Registry registry=LocateRegistry.getRegistry();
registry.bind("helloword", stub);
System.out.println("绑定成功!");
} catch (RemoteException e) {
e.printStackTrace();
} catch (AlreadyBoundException e) {
e.printStackTrace();
}
}
}
HelloWord stub=(HelloWord)UnicastRemoteObject.exportObject(hello, 9999);
Server端的main方法在创建一个远程对象来提供服务时,此远程对象必须被导出才能被远程调用者调用。静态方法UnicastRemoteObject.exportObject()负责导出我们定义好的远程对象,并用任意一个tcp端口来接收远程方法调用,同时,它还会返回一个存根,这个存根将会发送给client端进行调用。当exportObject()方法被执行后,运行时会在一个新的Server Socket或共享Server Socket上进行监听,来接收对远程对象的远程调用。返回的存根对象和远程对象继承的是同一套remote接口(为了实现代理模式),并且还它还包含了供client端口访问的主机IP和端口信息。(这里很关键,记住存根这个概念)
Registry registry=LocateRegistry.getRegistry();
registry.bind( "helloword", stub);
为了使client能够调用远程对象的方法,client必须持有远程对象的存根,为此,Java RMI 提供了registry API 可以允许应用程序把一个名称和远程对象的存根绑定在一起,这样client就可以通过这个绑定的名称很方便的查找到需要调用的远程对象了,在这里可以把registry看做是一个名称服务,实现了工厂模式(提供具体的远程对象)和代理模式(代理server端具体处理client端的请求)。
一旦远程对象在server端导出并注册,client就可以通过绑定的名称获得远程对象的引用,然后调用远程方法。
静态方法Registry registry=LocateRegistry.getRegistry()会返回一个实现了java.rmi.registry.Registry接口的存根,并且在服务器本机的端口(默认是1099)上进行注册,返回的registry存根通过调用bind()方法在registry中把一个字符串名称和远程对象存根绑定在一起。
public class RMIClient {
public static void main(String[] args) {
try {
Registry registry = LocateRegistry.getRegistry(1099);
HelloWord hello = (HelloWord) registry.lookup( "helloword");
String ret = hello.sayHello();
System. out.println( ret);
} catch (RemoteException e) {
e.printStackTrace();
} catch (NotBoundException e) {
e.printStackTrace();
}
}
客户端首先通过LocateRegistry.getRegistry("localhost")方法获得registry的存根,然后再执行registry存根的lookup()方法从服务器registry中获得远程对象的存根,最后客户端在远程对象存根上执行sayHello()方法。
这里有个方法,我也研究了下,LocateRegistry.getRegistry(),这个方法有几个重载方法,可以直接传入远程服务器ip,也就是LocateRegistry.getRegistry("127.0.0.1"),这样默认连接的端口就是1099。也可以直接写端口LocateRegistry.getRegistry(1099),这样就是默认查找本地的rmi服务器。最后是两个参数的方法LocateRegistry.getRegistry("127.0.0.1", 1099),可以指定ip和端口获取registry存根。
整个过程可以描述为:
客户端通过远程对象存根中的IP和端口打开一个服务器连接,并且序列化请求数据
服务器端接收请求并且转发请求到远程对象调用服务方法,并且序列化运行结果发送给客户端
客户端接收数据反序列化,把最终结果返回给调用者
启动server,然后在启动client,控制台打印:
Hello Word!
Java RMI中用到的设计模式
Java RMI中用到了经典的工厂模式和代理模式,先介绍下Java RMI应用的一些角色:
1.server:生产各种远程对象,也就是攻击端
2.client:通过命名服务器rmiregistry获取远程对象的存根,也就是受害端
3.rmiregistry:具体处理client与server的交流
现在让我们回到JNDI注入,我也是建议初学者能够去学习下RMI的工作原理,有助于理解JNDI注入漏洞,因为JNDI注入漏洞的核心就是RMI服务,当然也还有其他的一些服务,如LDAP等等,这里我只是针对RMI展开的。
JNDI - Java Naming and Directory Interface 名为 Java命名和目录接口,具体的概念还是比较复杂难懂,具体结构设计细节可以不用了解,简单来说就是 JNDI 提供了一组通用的接口可供应用很方便地去访问不同的后端服务,例如 LDAP、RMI、CORBA 等。
String jndiName = ...;//指定需要查找name名称
Context context = new InitialContext();//初始化默认环境
DataSource ds= (DataSourse)context.lookup(jndiName);//查找该name的数据
Context var1 = new InitialContext();
DataSource var2 = (DataSource)var1.lookup("rmi://127.0.0.1:1099/aa");
所谓的JNDI注入就是当上文代码中jndiName这个变量可控时,引发的漏洞,它将导致远程class文件加载,从而导致远程代码执行。
我们先看payload,分为服务端(攻击端)和客户端(受害端),还有执行命令的class文件
服务端,如图
客户端,如图
编译前的class文件,如图
这里需要注意的是,因为漏洞触发环境是在jdk1.7,所以需要安装jdk1.7,然后将EvilObject.java进行编译,编译时,需要把包名给去除掉,否则会报错。然后将class和java文件都放到搭建好的http服务器上,我是放在本地服务器测试的
主要是需要class文件,我只是顺便都放在一起,如果不把这俩个文件单独拿出来,可能远程资源下载测试就会失败,因为,编译器会读取本地的文件,那样就没有意义了。
现在我们开始测试,我红色标记的地方都是比较重要的步骤
先运行RMIServer
可以看到,程序已经运行起来了,然后我们来启动客户端
可以看到,我们的EvilObject被执行了,弹出了计算器
服务端我们暂且可以理解为,注册了一个RMI服务,然后绑定了一个存根,就是前面讲的远程对象,只不过这个存根是一个Reference对象,后面会讲到。
我们来看一下客户端执行流程
Context ctx = new InitialContext(); 初始化了一个InitialContext对象,这是程序的入口。
然后调用了lookup方法,我们进入到类里看看
public Object lookup(String name) throws NamingException {
return getURLOrDefaultInitCtx(name).lookup(name);
}
可以看到我们传入的字符串先被带入了getURLOrDefaultInitCtx(name),我们进去看看
可以看到,因为NamingManager.hasInitialContextFactoryBuilder()返回的是false,所以不会进入if判断,接着往下走,String scheme = getURLScheme(name);,这里的getURLScheme(name),其实就是对字符串进行截取,截取从第一个字符到:的所有字符,不包括:,如图
说白了就是截取协议头
因为schema不为null,所以进入Context ctx = NamingManager.getURLContext(scheme, myProps);方法,我们进去看看
可以看到schema和environment被传进了getURLObject(scheme, null, null, null, environment);
这里schema的值就是rmi,environment是一个Hashtable集合,size为0,我们继续跟进
可以看到又被带进了,ObjectFactory factory = (ObjectFactory)ResourceManager.getFactory(
Context.URL_PKG_PREFIXES, environment, nameCtx,
"." + scheme + "." + scheme + "URLContextFactory", defaultPkgPrefix);
这里的URL_PKG_PREFIXES = "java.naming.factory.url.pkgs",environment为0,nameCtx=null,schema拼接后=".rmi.rmiURLContextFactory",defaultPkgPrefix = "com.sun.jndi.url"
看到这里大家应该能明白为什么要截取协议头了吧,不同的协议调用不同的工厂类,这也是JNDI的通用性
继续跟进
由于这个方法代码比较多,所以只截取关键的代码,getProperty方法返回值为null,所以facProp=null,跳过if判断,进入else,赋值facProp="com.sun.jndi.url"
这里的key是需要注意的,key=".rmi.rmiURLContextFactory com.sun.jndi.url",再看下一段代码
这里我们只关注三处,className="com.sun.jndi.url.rmi.rmiURLContextFactory",其实就是重新拼接了一下
然后本地加载这个类,实例化返回一个factory对象,最后返回这个对象,返回给ObjectFactory factory = factory对象;,可能需要再倒着往回推,因为不为null,所以进入try代码块,return factory.getObjectInstance(urlInfo, name, nameCtx, environment);
我们先理一下参数,urlInfo=null,name=null,nameCtx=null,environment大小为0,然后我们进入getObjectInstance方法看看
注意,我们进入的是rmiURLContextFactory.class因为,之前返回的是这个factory对象,因为传入的var1为null,进入if判断,返回return new rmiURLContext(var4);,其实就是返回了一个rmiURLContext对象
然后反回到return factory.getObjectInstance(urlInfo, name, nameCtx, environment);调用处,再返回到Object answer = getURLObject(scheme, null, null, null, environment);调用处,往下看
if (answer instanceof Context) {
return (Context)answer;
} else {
return null;
}
因为answer是rmiURLContext对象,所以属于Context类,成立,返回answer到Context ctx = NamingManager.getURLContext(scheme, myProps);
然后接着往下
if (ctx != null) {
return ctx;
}
因为不为null,所以getURLOrDefaultInitCtx方法直接返回rmiURLContext对象,也就相当于调用了rmiURLContext对象的lookup方法,我们进去看看
可以看到,类里面没有这个方法,那应该是在父类里面,继续跟进
可以看到,的确是父类的方法,这里的this.getRootURLContext(var1, this.myEnv)调用的是rmiURLContext的方法,如图
我的理解是,这是一个对我们传入的字符串进行分割的过程,分别取出,ip、端口、还有aa也就是绑定存根时用的名字,如图
然后带入new RegistryContext(var3, var4, var2);,返回一个RegistryContext对象,这个是用于获取RMI注册服务的,也就是用来获取存根的
进入ResolveResult(var10, var11)方法
可以看到将名称和RegistryContext对象封装到了这个类里面,然后返回到ResolveResult var2 = this.getRootURLContext(var1, this.myEnv);这里
往下走,Context var3 = (Context)var2.getResolvedObj();获取RegistryContext对象
var4 = var3.lookup(var2.getRemainingName());,调用RegistryContext对象的lookup方法,查找"aa",也就等同于我们前面rmi的使用方法,registry.lookup( "aa");
进入RegistryContext的lookup方法
可以看到调用了RegistryImpl_Stub去获取服务端registry注册的远程对象,也就是refObjWrapper对象,赋值给var2,继续跟进decodeObject方法
可以看到获取到的就是ReferenceWrapper_Stub对象也就是存根,里面包含了远程服务器的ip和端口等信息
Object var3 = Reference对象
然后带入NamingManager.getObjectInstance(var3, var2, this, this.environment);
这里var3 = Reference对象,var2="aa",this=RegistryContext对象,this.environment大小为0
进入方法,如图
这里的2和3都是可以触发漏洞的,这里我们说的是2
ref的值为Reference对象,通过ref.getFactoryClassName()获取类名EvilObject,然后进入if判断,传入getObjectFactoryFromReference(ref, f),跟进
主要看红色标记的地方,会先尝试从本地查找EvilObject这个类文件,因为本地没有所以clas为null
然后获取codebase为我们写的地址https://127.0.0.1/evil/,然后尝试从远程地址下载EvilObject类文件,此时clas的值为EvilObject类对象,最后实例化类对象,触发构造方法里面的命令,弹出计算器
到这里分析就结束了,以下是借鉴的博客