JavaSec系列 - 2. JNDI注入
本章源码: https://github.com/hey3e/JavaSec-Code/tree/main/javasec2
命名与目录系统 (Naming and Directory services),如RMI (Remote Method Invocation)、LDAP (Lightweight Directory Access Protocol) 等,能够以类似字典key-value
的name-object
形式对对象进行存储,使得我们可以通过名称来查询并访问对象。JNDI (Java Naming and Directory Interface) 便是该过程的接口。
结合上一章的知识,我们考虑在系统中存储对象序列化后的字节流,当用户进行查询时,系统返回对应的字节流,用户再进行反序列化获取对象。但在实际场景中,如果对象过大,采用该方式往往会给系统带来一定的负担。
于是,JNDI使用Naming References
的方式进行存储,此时,name-object
中的object
并非对象本身,而是对象的引用Reference
,其中包含对象名及其真正被存放的地址codebase。当用户进行查询时,系统返回Reference
,用户解析后再从codebase获取对象。不过这里说的不太严谨,后面会进行补充。
不过,上述从远程codebase加载对象的方式存在许多安全问题,随着jdk版本的迭代,系统对codebase已逐渐不再信任,JNDI受到越来越多的限制。我们下面看一下各版本下JNDI如何实现。
(1)jdk<8u121 (RMI+JNDI)
首先,构造要查询的目标类TargetClass
:
1 | public class TargetClass { |
javac编译,并挂到python的简易服务器上:
下面编写RMI服务器的代码:
1 | public class RMIServer { |
其中:
- a处,创建RMI服务,监听1099端口。
- b处,构造
Reference
,codebase为前文的python服务器,对象为TargetClass
类。 - c处,进行存储,
name
为Target,object
为Reference
。
接下来,编写Client查询代码:
1 | public class RMIClient { |
注意,Client与TargetClass
需要在不同的project中,后面会同本文开头的“不严谨”一起给出解释。这里我们使用InitialContext
类的lookup
方法向RMI系统进行查询。
运行Server后运行Client,发现TargetClass
类的两段代码都被执行,同时服务器收到了一条GET请求:
开始跟进lookup
。InitialContext
类的lookup
方法首先调用了getURLOrDefaultInitCtx
获取到基于URL scheme的Context
,此处是基于rmi的RegistryContext
:
继续调用两个lookup
后,来到RegistryContext
类的decodeObject
方法,此时Client已经获取到了目标对象的Reference
:
接着,在NamingManager
类的getObjectInstance
方法中,对Reference
进行了处理,传入getObjectFactoryFromReference
方法:
此处处理后的Reference
如下,与RMI系统中设置的一致:
其中,className
为自定的类名,classFactory
为目标类名,classFactoryLocation
为codebase。
在getObjectFactoryFromReference
方法中,程序分别尝试了从本地和codebase两处加载TargetClass
:
最终,程序选择了codebase的loadClass
,并在其中调用了我们熟悉的forName
:
不仅如此,getObjectFactoryFromReference
还调用了newInstance
方法完成了对TargetClass
的初始化,至此,RMI上的JNDI完成:
在jdk>8u121的环境下使用上述RMI+JNDI时,我们发现Client报错:
可见高版本RMI默认已不信任codebase。为绕过该限制,我们可以转向LDAP+JNDI。
(2)jdk<8u191 (LDAP+JNDI)
这里,我们方便的用marshalsec启动一个LDAP服务器:
修改Client代码中的URL scheme为ldap,端口为1389:
1 | public class RMIClient { |
运行,成功加载并初始化TargetClass
,LDAP上的JNDI完成:
跟进lookup
方法,流程与RMI类似。
(3)JNDI注入
同样,marshalsec也可以启动RMI服务器:
其中,JRMP (Java Remote Method Protocol) 远程方法协议,是用于RMI过程中的协议。
运行Client,可以得到与前文同样的结果。
marshalsec与我们自己编写的RMI服务器之间的不同之处在于,如果你向我们编写的RMI服务器查询一个不存在的对象Foo:
1 | ctx.lookup("rmi://127.0.0.1:1097/Foo"); |
程序会报NameNotFound
错误:
这是因为我们的RMI服务器上并未存储名为Foo的Reference
。
若向marshalsec的RMI服务器查询,成功:
这是因为marshalsec实际上是利用socket自定义了一个RMI服务器,当收到rmi的lookup请求时,服务器会调用handleRMI
方法进行处理:
其中,我们提供的参数 http://127.0.0.1:4444/#TargetClass 会被直接包装成Reference
返回。即无论Client查询谁,服务器返回的都是我们设计的Reference
。因此JNDI一定成功。
marshalsec的LDAP服务器同理,当收到ldap的lookup请求时,调用sendResult
方法,返回我们设计的Reference
:
上面所讲均是JNDI的使用。试想如果我们能够控制目标lookup
方法的参数,使其向由我们启动的服务器发起查询,以返回一个指定的恶意Reference
,就可以达到远程代码执行的效果。这类攻击就叫做JNDI注入。
下面是来自 https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE-wp.pdf 的两类JNDI注入示意图:
(4)jdk>8u191
在jdk8u191+的环境中,LDAP默认也已不再信任codebase。至此,已难以通过远程加载codebase的方式实现JNDI注入。
下面我们先来填一下前文“不严谨”和“不同project”的坑。
我们切回到jdk<8u121的环境下,并在Client的目录下也构造一个TargetClass
,使其实现ObjectFactory
接口和getObjectInstance
方法:
1 | import javax.naming.Context; |
还有一点不同,这里我们打印的是”Client…”,而codebase下的TargetClass
打印的是”Server…”。
复现RMI+JNDI,查看结果:
可知Client这次拿到的是本地的TargetClass
。跟进,再次来到getObjectFactoryFromReference
方法,发现这次程序选择了从本地进行加载:
于是,我们可以补充一下文章开头的JNDI知识点:当用户进行查询时,系统返回Reference
,用户会先在本地ClassPath中查找该对象,若没有,再去codebase处获取。
综上,我们考虑:既然高版本jdk限制了从codebase获取类,我们就尝试通过利用本地ClassPath中已有的类来实现JNDI注入。
首先,这个类需要实现了ObjectFactory
接口和getObjectInstance
方法,在Apache Tomcat中,我们找到了符合条件的org.apache.naming.factory.BeanFactory
类:
回头debug本地类的JNDI,我们看到该类实现的getObjectInstance
方法会被调用:
于是我们下面来看org.apache.naming.factory.BeanFactory
类的getObjectInstance
方法有什么值得利用的地方。
可见,它会处理ResourceRef
类的Reference
,加载并初始化名为Reference
中className
字段的类。由此我们得知,className
指定的类必须本地存在且有无参构造方法。
继续向下:
这一段,我们了解到方法获取Reference
中”forceString”属性的值并用’=’进行分隔,等号左边成为哈希表forced
的一个key,其value是className
指定的类中名为等号右边的方法。如”forceString”为”x=y”,则”x”将存为key,value为y
方法。注意,此方法的参数paramTypes
为一个String
类型,所以我们要找的className
指定的类中需要有这样一个方法。
继续向下:
这里,方法遍历Reference
的所有属性,对type不为if
中指定的值的属性,获取其content,并利用反射invoke
了className
类中名为type在forced
中对应的value的方法,且参数为content。沿续上文的举例,若在Reference
中添加type为”x”的属性,则y
方法最终将被反射调用。
综上所述,可见如果我们能找到一个符合要求的className
类并精心构造一个Reference
,通过Client从本地加载org.apache.naming.factory.BeanFactory
类并执行getObjectInstance
方法,有希望借助反射实现JNDI注入。
最终,我们找到了javax.el.ELProcessor
类,它满足我们之前提出的要求:
- 存在于Tomcat依赖包中。
- 有无参构造方法。
- 有
eval
方法,其参数为一个String类型。
同时,在设计Reference
时,我们需要注意,在jdk8u121+的环境中,是这样检查的codebase的信任问题的:
在RegistryContext
类的decodeObject
方法中,对trustURLCodebase
的true
orfalse
进行了检查。观察判断条件,我们可以通过设置Reference
的factoryLocation
,也就是codebase,为null
的方式进行绕过,从而成功进入getObjectInstance
方法。
接下来,根据已上思路,我们编写RMI服务器来返回恶意Reference
:
1 | public class RMIServer191 { |
其中,”x”属性的content,作为参数传入eval
方法后,会被解析成EL表达式从而达到命令执行的效果。
运行Client,弹出计算器,JNDI注入成功:
跟进lookup
,可以对org.apache.naming.factory.BeanFactory
类的getObjectInstance
方法是如何处理Reference
的有一个更深的理解。
至此,我们了解了不同环境下JNDI注入的绕过与实现:
- jdk<8u121:RMI+codebase。
- jdk<8u191:LDAP+codebase。
- jdk>8u191:
BeanFactory
+本地ClassPath。
JavaSec系列 - 2. JNDI注入