从spring到fastjson,许多反序列化漏洞里面都出现了JNDI注入的身影,详细分析下它的原理以及在攻击中的意义。
首先是JNDI的概念,来自官网:
JNDI
Java 命名和目录接口 (JNDI) 为用 Java 编程语言编写的应用程序提供命名和目录功能。它旨在独立于任何特定的命名或目录服务实现。因此,可以通过一种通用方式访问各种服务——新的、新兴的和已经部署的服务。
JNDI 架构由 API(应用程序编程接口)和 SPI(服务提供者接口)组成。Java 应用程序使用此 API 来访问各种命名和目录服务。SPI 可以透明地插入各种命名和目录服务,允许使用 JNDI 技术的 API 的 Java 应用程序访问它们的服务。
大概可以明白是类似通过名字查找对象的功能,感觉和RMI有点类似。在官方文档中用Linux文件系统以及DNS系统来类比JNDI,都是一种查找功能。还是先看个demo,原生JNDI支持RMI、LDAP、COS、DNS,官网都有教程,这里以RMI为例。官网的用法有三部分,RMI服务端、JNDI服务端、JNDI客户端
RMI服务端:
1 | public class RMIServer { |
JNDI服务端
1 | public class JNDIRMIServer { |
JNDI客户端
1 | public class JNDIRMIClient |
运行后在JNDI客户端和JNDI服务端输出结果。
如果没有JNDI服务端而是直接把远程对象绑定到RMI服务端上,JNDI客户端一样是可以获取的。此时在RMI服务端输出结果。
实际和RMI的使用很类似,看上去只是一种封装。
RMI动态类加载
说回RMI,之前的RMI使用中,都是在客户端和服务端同时定义了相同的远程接口。那么如果客户端没有服务端那个类的定义怎么办呢?为了解决这种需求Java很贴心的设计了一个叫codebase的东西,codebase实际上就是一种URL,客户端可以从URL里面动态加载类。这样一来客户端就不需要定义和实现远程接口,非常方便。
但方便和安全总是对立的,显然这东西有很大的安全风险,所以在JDK7u21后,RMI默认配置java.rmi.server.useCodebaseOnly为true,也就是规定客户端不再接收服务端的codebase,而是必须只能从客户端自己从命令行或者代码里配置的codebase里面加载远程类,这样就避免了恶意服务端通过让客户端远程加载恶意类的方式来攻击客户端。
跟一下具体的实现,实际上在RMI的反序列化流程就会调用,比如Registry_Stub#lookup反序列化远程对象的时候,就会调用到MarshalInputStream#resolveClass
1 | protected Class<?> resolveClass(ObjectStreamClass classDesc) |
到一个RMIClassLoader的内部类
1 | private static RMIClassLoaderSpi newDefaultProviderInstance() { |
LoaderHandler#loadClass
1 | public static Class<?> loadClass(String codebase, String name, |
这里就看到了,如果没配置codebase就调用普通的Class.forName加载,配置了codebase的话也会先用本地类加载器去加载,找不到就调用下面的接收URL数组的loadClass:
1 | private static Class<?> loadClass(URL[] urls, String name) |
里面还先得配置SecurityManager,不然还是用本地类加载器加载。基本是非常极端的环境,除非客户端主动开了这个功能,不然很难从默认配置进行攻击。
JNDI+RMI动态类加载
说了这么多RMI动态加载,和JNDI有啥关系呢?其实JNDI也提供了一种动态加载类的方式,就是使用Reference。所谓Reference,就是指不直接把对象绑定到上下文目录里,而是提供一个工厂类,使用时从这个工厂类里面创建出要调用的对象,并且这个工厂类是可以从codebase动态加载的。官网教程https://docs.oracle.com/javase/jndi/tutorial/objects/factory/interface.html
从这个描述就可以知道实际上这个过程会导致任意的类加载并实例化,进而导致任意代码执行。比如这样:
服务端
1 | public class JNDIRMIServer { |
使用JNDI需要创建一个InitialContext,然后配置属性来确定用什么服务,比如目前是RMI服务。
客户端
1 | public class JNDIRMIClient |
在客户端的例子看出也可以不传配置,直接传一个带着协议的URL,JNDI可以自己分析协议确定对应的服务。有的文章说只有lookup和search可以,实际上服务端bind的时候也可以。
TestRef
1 | public class TestRef { |
客户端lookup的时候就会触发类的加载和实例化,导致代码执行。分析下具体调用流程,首先在InitialContext#lookup:
1 | public Object lookup(String name) throws NamingException { |
先看看getURLOrDefaultInitCtx
1 | protected Context getURLOrDefaultInitCtx(String name) |
获取协议也就是rmi,然后调用NamingManager.getURLContext,返回的是一个rmiURLContext
那么实际调用的就是rmiURLContext#lookup,但是rmiURLContext并没有重写lookup,所以调用到GenericURLContext#lookup
1 | public Object lookup(String name) throws NamingException { |
具体流程不重要,之后调用了RegistryContext#lookup
1 | public Object lookup(Name name) throws NamingException { |
可以看到调用了RegistryImpl_Stub#lookup,那么这里一样会有JRMP攻击和注册中心攻击客户端的问题。
之后调用decodeObject
1 | private Object decodeObject(Remote r, Name name) throws NamingException { |
首先做一个类型判断,如果r是RemoteReference类型,就把obj赋值为((RemoteReference)r).getReference(),r就是从注册中心获取到的stub对象,之前在JNDI服务端绑定的是一个Reference对象,但现在这里获取到的r是一个ReferenceWrapper_Stub对象,很明显在绑定时JNDI做了些默认处理,简单看一下,在RegistryContext#bind
1 | public void bind(Name name, Object obj) throws NamingException { |
可以看到绑定的时候会自动套上一层ReferenceWrapper,网上很多例子抄来抄去都是手动写了一个,多此一举。ReferenceWrapper是继承了UnicastRemoteObject的RemoteReference对象,调用getReference方法时可以返回一个Reference,经过它的封装相当于把Reference对象转化为远程对象。注册中心正常进行绑定,客户端获取的时候一样是拿到stub对象。
看看这个stub,ReferenceWrapper_Stub这个类看来很不常用,整个openjdk里也没有源码。
1 | public final class ReferenceWrapper_Stub extends RemoteStub implements RemoteReference, Remote { |
getReference调用了UnicastRef#invoke,相当于一次RMI客户端请求RMI服务端,反序列化获取调用结果。所以这里实际上也可以用RMI中服务端攻击客户端的攻击方式。
反序列化获取到这个Reference后,调用NamingManager#getObjectInstance
1 | public static Object |
这个函数的功能就是从Reference里生成真正要调用的对象,生成对象的逻辑在getObjectFactoryFromReference
1 | static ObjectFactory getObjectFactoryFromReference( |
这里是重点了。首先会调用helper.loadClass,是com.sun.Naming.Internal.VersionHelper12#loadClass
1 | public Class<?> loadClass(String className) throws ClassNotFoundException { |
这里有三个重载的loadClass,第一个相当于包装,主要看第二个和第三个。第二个其实是把Class.forName包装成loadClass,而这两者的区别是forName会触发类初始化,也就是会调用静态代码块,实际上是有安全隐患的。而第三个loadClass就更直接了,直接用参数codebase,也就是Reference里的classFactoryLocation参数,里面的路径创建一个URLClassLoader,用它来加载该类。这样实际上就允许了远程类加载,相当于允许了远程任意代码执行。
而getObjectFactoryFromReference中的逻辑就是首先用本地AppClassLoader去加载factoryName类,找不到就用URLClassLoader去codeBase里的路径加载,最后实例化,当然这里就算不实例化也能执行代码,因为调用了forName。
这里执行完会报错,因为
1 | factory = getObjectFactoryFromReference(ref, f); |
恶意类不能转化为ObjectFactory,想不报错就继承这个类就行了。也可以正常返回然后重写getObjectInstance在里面执行命令。
调试完就可以明白JNDI使用Reference代替远程对象的本意应该就是不通过反序列化直接传递对象,而是仅传递几个字符串,客户端用这几个名称去加载工厂类自己组装一个远程对象。把反序列化变成了远程类加载,如果不是为了加载客户端不存在的类的话,好像没有太大的意义。这里也没找到什么好的例子。
分析到这就可以结束了,如果能控制客户端加载一个恶意的Reference,就能在客户端执行任意代码。
和RMI的动态类加载一样,JAVA开发者意识到了其中的安全隐患,所以在jdk8u121之后,添加了一个com.sun.jndi.rmi.object.trustURLCodebase属性,对应RegistryContext#trustURLCodebase,如果这个属性为false就不能进行远程类加载。对应的代码变成了这样
1 | static final boolean trustURLCodebase; |
可以看到是在RegistryContext里对com.sun.jndi.rmi.object.trustURLCodebase这个属性做了校验,但真正进行远程类加载的的NamingManager.getObjectInstance实际上没有做限制。也就是说虽然JNDI+RMI的方式进行远程加载就走不通了,但JNDI和其他服务的联动也许还有机会,oracle官网说JNDI内置支持LDAP、CORBA、RMI、DNS,挨个看一下哪些能动态类加载,并且看看有没有不受限制的。调试时JDK版本为8u141。
JNDI+LDAP动态类加载
先看LDAP,服务端用Apache Directory Studio起了个LDAP。翻了翻官方文档,和RMI类似,LDAP也是可以绑定java对象的,列举了以下几种对象都可以:
Java serializable objects
Referenceable objects and JNDI References
Objects with attributes (DirContext)
RMI (Java Remote Method Invocation) objects (including those that use IIOP)
CORBA objects
Java里LDAP好像必须结合JNDI去使用。原理其实很简单,但是不会写这玩意的查询,鼓捣了半天。先来个传序列化对象的,比如HashMap:
服务端,就是绑定一下。
1 | public class JNDILDAPServer { |
1 | public class JNDILDAPClient { |
跟一下调用流程,还是到GenericURLContext#lookup,然后到PartialCompositeContext#lookup
1 | public Object lookup(Name name) throws NamingException { |
然后到ComponentContext#p_lookup
1 | protected Object p_lookup(Name name, Continuation cont) throws NamingException { |
又到LdapCtx#c_lookup
1 | protected Object c_lookup(Name name, Continuation cont) |
这个类可以类比RegistryContext,是JNDI基于LDAP实现的核心逻辑类,那么重点看这个函数是否触发了远程类加载。很明显看到了两处敏感调用,一处Obj.decodeObject和一处DirectoryManager.getObjectInstance。当前满足if语句,会进入第一处,那么看一下:
Obj#decodeObject
1 | /* |
注释里写了,从LDAP查询返回值里解码出一个Object或者Reference。上来先获取codebase,可以在绑定的时候new BasicAttributes(“javaCodebase”, codebase)添加。然后后续有三种还原对象的方法,都是用这个codebase加载。看一下这三种方法,先看第一个if,走正常Java反序列化:
首先用codebase创建一个URLClassLoader
com.sun.jndi.ldap.VersionHelper12#getURLClassLoader
1 | private static final String TRUST_URL_CODEBASE_PROPERTY = |
这个类有同名类,不在一个包底下,容易看错。这是jdk8u141的代码,注意到这里实际上也限制了系统属性,但不是之前RMI的那个trustURLCodebase属性,而是需要com.sun.jndi.ldap.object.trustURLCodebase为true。这个类里默认是false,也就是这个点默认是不能远程类加载的。
回来看下一步Obj#deserializeObject
1 | /* |
如果设置了codebase并且trustURLCodebase为true,就会用codebase来进行反序列化,否则就是原生反序列化。具体实现是重写了resolveClass,不细说了。
那么现在知道RMI+JNDI修复后,默认情况下LDAP+JNDI是不能直接通过反序列化实现远程类加载的。再看其他两个case
Obj#decodeRmiObject
1 | /* |
这个点存储的是RMI的Reference,会在getObjectInstance的时候创建,和前面Reference+JNDI类似的工厂模式。
那还要找后面调了getObjectInstance的地方,先放着,看第三个:
Obj#decodeReference
1 | /* |
太长了省略了一些,大意就是用查询到的信息创建一个Reference,如果能进if,过程里也调用了deserializeObject,但一样是受限的。
那么实际上后两种情况都返回的是Reference,看看接下来是怎么从这个Reference创建对象的,其实这部分和JNDI+RMI很类似。
不管获取到的obj是什么,都会调用到DirectoryManager#getObjectInstance
1 | public static Object |
实际上这个函数和NamingManager#getObjectInstance基本是一样的,如果是传一个Reference就会调getObjectFactoryFromReference进行远程类加载,并且这个函数里面没看见有做限制的地方。
那么只要LDAP服务端返回构造好的Reference就能绕过RMI+JNDI的限制继续攻击客户端了,简单写个poc:
绑定恶意Reference:
1 | public class JNDILDAPServer { |
客户端不变,lookup的时候导致RCE。
其实Obj#decodeObject里第二个if和第三个是差不多的,都是返回一个Reference。但第二个if用到的属性值是废弃的,LDAP服务端好像不支持,只能自己构造恶意服务端,也没太大意义,就不复现了。
分析完感觉上Java开发者是想修复所有JNDI调用的远程类加载问题的,但可能是因为有两个VersionHelper12,修了一个忘了另一个,导致getObjectFactoryFromReference里面没有修复。
JNDI+CORBA
第三个JNDI支持的服务是CORBA,关于CORBA的细节放在了之前单独的文章里,这里主要讲CORBA和JNDI结合使用的场景。常用的基于RMI+IIOP的实现和RMI实现很像。
远程接口:
1 | public interface IRemoteObj extends java.rmi.Remote { |
远程类:
1 | public class RemoteObjImpl extends PortableRemoteObject implements IRemoteObj { |
开启ORBD
1 | orbd -ORBInitialPort 1050 -ORBInitialHost localhost |
服务端:
1 | public class JNDICORBAServer |
客户端:
1 | public class JNDICORBAClient |
服务端需要先手动创建stub,切换到class的目录执行:
1 | rmic -classpath . -iiop org.example.RemoteObjImpl |
执行后成功在服务端执行。
这个过程和RMI类似会有反序列化以及一些动态类加载的风险。但和RMI一样也是很受限的,调试一下
直接看CNCtx#lookup
1 | public java.lang.Object lookup(Name name) |
第一个关键点是_NamingContextStub#callResolve
1 | public org.omg.CORBA.Object resolve (org.omg.CosNaming.NameComponent[] n) throws org.omg.CosNaming.NamingContextPackage.NotFound, org.omg.CosNaming.NamingContextPackage.CannotProceed, org.omg.CosNaming.NamingContextPackage.InvalidName |
之前分析CORBA的时候分析过,这里的ObjectHelper.read最后是调用RMIClassLoader.loadClass进行类加载的,和RMI一样,基本是没有办法进行远程类加载的。
回到lookup,获取到answer后,看到了熟悉的NamingManager#getObjectInstance。但这里有个if判断,看一下:
CorbaUtils#isObjectFactoryTrusted
1 | public static boolean isObjectFactoryTrusted(Object obj) |
明显是和RMI+JNDI的利用一起修复的,看下CNCtx.trustURLCodebase
1 | public static final boolean trustURLCodebase; |
事实上CNCtx和RegistryContext是在同一个版本修复的(openjdk版本557d133dbc61),添加了各自的trustURLCodebase属性。所以基于CORBA的JNDI利用没有太大意义,完全可以用RMI替代,除非是应对某些黑名单的场景。
JNDI+DNS
最后看一下DNS,因为这是官网写的Jdk最后一个JNDI支持的服务,直接跟到DnsContext#c_lookup
1 | public Object c_lookup(Name name, Continuation cont) |
可以看到虽然调用了DirectoryManager.getObjectInstance,但第一个参数是不可控的,也就没办法把第一个参数改成Reference进行远程类加载了。
LDAP+JNDI的修复
前面说了RMI+JNDI的修复方式是片面的,导致使用LDAP依然可以进行远程类加载,而在Jdk8u191也对LDAP+JNDI导致的远程类加载进行了修复,看具体的代码改动:
com.sun.naming.internal.VersionHelper12在openjdk的28d4d67065ab版本改成了如下
1 | final class VersionHelper12 extends VersionHelper { |
在之前分析的动态加载工厂类的地方修改了com.sun.jndi.ldap.object.trustURLCodebase为false,这样JNDI的远程类加载真正的漏洞点终于修复了。之前的修复都往上了一层,可能因为这是所有JNDI服务通用的工具类,没办法用通用的方法修复。
大概整理下几处修复对应的版本,在这能看见各版本和时间的对应https://www.java.com/releases/
RMI动态类加载的修复在2013.2.28对应6u45、7u21(openjdk版本96890625ebdf),当时Jdk8还没发行
RMI/CORBA+JNDI的修复在2016.10.21,对应Jdk6u141、7u131、8u121(openjdk版本557d133dbc61)
LDAP+JNDI的修复在2018.7.10,对应Jdk8u191、7u201、6u211(openjdk版本28d4d67065ab)
很多帖子说第RMI+JNDI修复的版本是8u113,但根本没有113这个版本。可能是因为8u121的前一个版本是8u112,但Jdk的版本号本来也不是连续的,不知道谁第一个写的,抄来抄去。
那么在这些修复后JNDI注入就没用了吗?当然不是,还是有操作空间的。
JNDI+反序列化
在前面分析的时候,注意到实际上某些过程里是有原生反序列化点的,那么就算不能直接远程类加载RCE,有反序列化点来打本地gadget也是有机会的。整理一下有以下几处触发了反序列化:
1、RMI调用流程里的RegistryImpl_Stub#lookup
2、RMI调用流程里的ReferenceWrapper_Stub#getReference
3、LDAP调用流程里的Obj#deserializeObject
RMI的就不说了,之前整理过,看下LDAP触发原生反序列化的写法。其实很简单,就是绑定一个恶意对象:
服务端:
1 | public class JNDILDAPServer { |
客户端和之前一样直接lookup就行了,这就是一个普通的反序列化攻击,需要被攻击的客户端上有利用链,比如这里手动加的cc。
在21年4月7号,openjdk版本f396f4a7ee5d对LDAP触发的反序列化进行了一次更新,修改了Obj#decodeObject
1 | static Object decodeObject(Attributes attrs) |
实际上是在反序列化前加了一个if,判断VersionHelper12.isSerialDataAllowed()。跟进去发现是添加了一个新属性com.sun.jndi.ldap.object.trustSerialData
com.sun.jndi.ldap.VersionHelper12
1 | private static final String TRUST_SERIAL_DATA_PROPERTY = |
但看到这个属性默认是开启的,也就是只是提供了一个开关,依然是可以攻击的。这次更新还把原来的Class.forName改成了不触发初始化的方式,但应该没有具体的利用链用到,可能只是未雨绸缪。
前面分析Obj#decodeObject时候看到过底下的case里的decodeReference实际上也触发了反序列化,这个更新实际上又给漏了。不过不知道是不是有人提醒,21年9月8号,openjdk版本8c553f12bece又更新了一下Obj#decodeReference
1 | private static Reference decodeReference(Attributes attrs, |
在反序列化前检查了trustSerialData属性,又修了一处潜在漏洞,教教教还教。
不过直到当前最新版Jdk8u311,依然是可以用JNDI触发反序列化的,LDAP和RMI都可以。
JNDI+本地工厂类
除了触发反序列化进行攻击,还有一种攻击思路,就是依然走Reference加载工厂类那一条路,只不过不加载远程类,而是加载本地工厂类。也是和找gadget有点像,这个工厂类需要满足一些条件:
1、需要实现javax.naming.spi.ObjectFactory
2、getObjectInstance中可以触发危险方法
Jdk里面那几个类都不满足了,所以只能到其他通用的包里去找,在tomcat中有这么一个类满足条件
org.apache.naming.factory.BeanFactory#getObjectInstance
1 | public Object getObjectInstance(Object obj, Name name, Context nameCtx, |
又有loadClass又有newInstance又有invoke,要素齐全。代码太长了,删了一部分,分析下关键逻辑。核心自然是method.invoke(bean, valueArray)这里,在bean上调用了method方法,参数是valueArray。跟踪逻辑后发现这三个变量都是可控的,只是bean需要能调用getConstructor.newInstance(),method需要是public的。
那么需要一个有无参构造方法的类,里面的某个public方法能触发任意代码执行,其实满足这个条件最先想到的是TemplatesImpl,但这个类需要修改私有属性值,只靠传参是不行的。Jdk里应该是没有满足这个条件的类了,一般用的是tmocat种的ELProcessor#eval
1 | public Object eval(String expression) { |
这里有个细节,实际上没有构造方法的类可以用无参的newInstance创建,而只有有参数构造方法的不行,ELProcessor是没有构造函数的,所以满足条件。
Payload长这样:
1 | public class JNDIRMIServer { |
把这个对象绑到JNDI上就行了,客户端lookup的时候触发代码执行。
还有种利用groovy的,可能相对不那么常用
1 | ResourceRef ref = new ResourceRef("groovy.lang.GroovyShell", null, "", "", true,"org.apache.naming.factory.BeanFactory",null); |
参考链接
https://docs.oracle.com/javase/tutorial/jndi/index.html
https://docs.oracle.com/javase/7/docs/technotes/guides/rmi/codebase.html
https://docs.oracle.com/javase/jndi/tutorial/objects/index.html
https://docs.oracle.com/javase/8/docs/technotes/guides/jndi/index.html
https://www.cs.binghamton.edu/~steflik/cs328/jndi/
https://www.infoworld.com/article/2076073/ldap-and-jndi--together-forever.html
https://docs.oracle.com/javase/8/docs/technotes/guides/idl/jidlExample.html
https://github.com/welk1n/JNDI-Injection-Bypass