什么是 JNDI
英文全写为:Java Naming and Directory Interface
,翻译叫做 JAVA 命名和目录接口,是 sun 公司提供的一种标准的 java 命令系统接口,JNDI 提供统一的客户端 API,并由管理者将服务端映射为 JNDI 上的特定服务和对象,简单来说就是: JNDI,能够让用户通过统一的方式访问获取网络上的各种资源和服务 (键值对找东西)
Naming 和 Directroy
0x01 Naming 命名服务
提供一种由键值对构成的通过名称来查找实际对象的服务,比如 RMI 协议,就是客户端通过注册中心上的对象名称得到远程对象的引用,有几个名词需要解释一下
- Bindings:表示一个名称和对应对象的绑定关系,也就是 DNS 域名绑定到对应 IP 上,RMI 中一个远程对象去绑定一个名称
- Context: 表示一组名称和对应对象的绑定,比如 spring 中的 IOC 容器,里面就存在 id 和各种 javabean 的对应关系,javaweb 中的 standardContext 等
- References:在一个实际的对象存储键值对中,有些对象他并不能直接存储在系统中,这个时候就需要
References
(引用)来代表这个对象,而一个引用中必须要包含该实际对象的信息,运作状态等等。最形象的是 linux 文件系统中的 fd( file descriptor ),我们实际对文件的操作是内容通过这个 fd 找到磁盘中对应位置和读写偏移的。
0x02 Directory 目录服务
目录服务相当于命名服务的一个扩展,一个键值对的信息服务中,实际对象还能够拥有对应的属性(attributes)信息,那我们进行 lookup 查找操作的时候,就不止通过名称去查找,还可以通过属性值去查找
0x03 JNDI SPI
JNDI 架构如下:
层次由上往下,我们最终接触到的 JDNI 部分是 SPI,SPI 主要是为底层的具体目录服务提供统一的接口,从而实现目录服务的可插拔式的安装
从这种图就能看出,JDK 原生的 JNDI 有如下服务:
- RMI: Java Remote Method Invocation,Java 远程方法调用
- LDAP: 轻量级目录访问协议
- CORBA: Common Object Request Broker Architecture,通用对象请求代理架构,用于 COS 名称服务(Common Object Services)
- DNS(域名转换协议)
JNDI 代码实现
JNDI 主要分为如下几个包:
- javax.naming:主要用于命名操作,它包含了命名服务的类和接口,该包定义了Context接口和InitialContext类
- javax.naming.directory:主要用于目录操作,它定义了DirContext接口和InitialDir-Context类
- javax.naming.event:在命名目录服务器中请求事件通知
- javax.naming.ldap:提供LDAP服务支持
- javax.naming.spi:允许动态插入不同实现,为不同命名目录服务供应商的开发人员提供开发和实现的途径,以便应用程序通过JNDI可以访问相关服务
开始代码的具体实现
0x01 JNDI_RMI 实现
先写 RMI 的服务端
远程对象接口:
1 |
|
远程对象:
1 |
|
服务端逻辑:
1 | package com.stoocea; |
启动 RMI 服务,然后我们用 JNDI 的接口去调远程对象
1 |
|
调用结果如下
稍微分析一下 JNDI 实现端的逻辑:
我们通过 设置环境变量,指定了要从哪获取对象,也就是 1099 端口上的 registry 注册中心,然后就调用创建出来的 Context 的 lookup 即可
那么 JNDI 是如何识别我们指定的服务,以及如何定位到服务中心的?
JNDI 工作流程
0x01 context 初始化和对应属性设置
我们之前提到过 JNDI 的整体架构,最近的一层是 JNDI 的 spi 层,他是我们能够接触到的 JNDI 最直接的,然后我们看 InitalContext 的导包结构,他导入了 naming 下的 spi 服务,而 SPI 本身的作用主要是为底层的具体目录服务提供统一的接口,从而能够使得目录服务的可插拔安装,这正好对应 InitialContext 为什么要获取 INITIAL_CONTEXT_FACTORY
与PROVIDER_URL
,正是为了满足这一条件
1 | Hashtable<String,String> env=new Hashtable<String,String>(); |
INITIAL_CONTEXT_FACTORY
能够让 initalContext 能够正常获取到我们想要指定的服务名称PROVIDER_URL
能够让初始化出来的 context 定位到服务位置
这其实跟之前 RMI 获取到服务中心的流程很像,而 RMI 其实是隶属于 JNDI 的,所以操作步骤也是差不多的
除了这么一个通过 hashtable 传值初始化 initcontext 的方法,还有其他两种方法
第一个是选择不初始化 initcontext
直接调用 init 方法,从环境变量中获取属性值
实现如下:
1 | //设置JNDI环境变量 |
JNDI 的功能点其实和 RMI 差不多
0x02 JNDI 底层实现
1x01 获取 contect 的 factor 构造类
我们把断点打在initcontext
初始化的地方
然后继续跟进
在 init 方法中,首先是获取环境变量,也就是当前系统,以及我们刚才设置的 hashtable 的环境变量
然后跟进 getDefaultInitCtx()方法,最终跟进到 NamingManager 的 getInitialContext
方法
首先getInitialContextFactoryBuilder();
获取到 Context 的工厂 builder 类,如果 builder 为空,就直接获取INITIAL_CONTEXT_FACTORY
属性,作为我们的 Context 工厂 builer 类,那这里就是我们刚才通过 hashtable 设置的com.sun.jndi.rmi.registry.RegistryContextFactory
了
然后继续往下
通过 loadClass 动态加载工厂类,最后调用 RegistryContextFactory的getInitialContext方法
整理一下调用类
1 | InitialContext#init() |
事后继续翻阅流程的时候也在最终返回的 getInitialContext 方法中,发现实现了这个方法的类就那么几个,也就是 JNDI 服务种类的那些
1x02 获取 context 内容
继续跟进 factrory 的 getInitialContext 的方法,这里的 var1 就是我们自己传的那个 hashtable 变量
var1 作为 getInitCtxURL 的参数值跟进
分析逻辑,这里是类 RegistryContextFactory,所以如果 var2 为空,他默认是返回 rmi 协议头,但是没有 URL 解析,这里我们 var2 肯定不为空,所以就直接返回 var2
继续跟进外层的 URLToContext
开始调用 rmiURLContextFactory 的 getObjectInstance 方法
这里会判断我们的 RMIURL 是否为空之后,跟进 getUsingURL
很奇怪,这里会直接调用 rmiURLContext 的 lookup 方法,但是此时 RMI 等相关配置还未装配,继续跟进看看逻辑
由于 rmiURLContext 本身是不带 lookup 方法,所以直接到父类GenericURLContext
的 lookup 方法
这里第一部逻辑就是执行 getRootURLContext()方法获取解析结果,这里的结果 var 如下,结果封装成了 ResolveResult 对象,通过 getResolvedObj 方法取出 RegistryContext
之后调用RegistryContext 的 lookup 方法,这里会因为我们传值的 ResolveResult 通过getRemainingName 方法返回的结果为空,所以不会继续下面的直接调用 registryImpl_stub 的 lookup 方法,而是返回当前的RegistryContext
上述逻辑比较重要,因为可能刚才流程师傅们在学习的时候会很好奇为什么会进 lookup 逻辑,但是却没有返回查询结果(我也不知道为什么在创建 RegistryContext 的时候会调 lookup)
那么这里返回之后会一路返回上去
所以我们当前初始化的InitialContext 就是 RegistryContext 实例化对象,再次调用其 lookup 方法就是调用刚才的 lookup 了
总结一下调用栈:
下面代码部分的获取 context 不涉及getInitCtxURL 获取服务路径的部分
1 | RegistryContextFactory#getInitialContext()-> |
其实能调 RMI 的远程 lookup,就存在攻击可能
JNDI 动态协议转化
这次我们不往InitialContext 初始化里面丢自己写好的环境变量 hashtable
1 | String string="rmi://localhost:1099/Hello"; |
直接就往initialContext 的 lookup 里面写我们的 RMI 定位 URL
发现依然能够查找到远程对象
0x01 动态协议转化流程分析
在 lookup 处下个断点,跟进
发现在 initialContext 中不论是调哪种方法都是默认要走一层getURLOrDefaultInitCtx,也就是我们最开始获取 Context 的 factory 构造类的流程相似
跟进到具体内容
由于这里我们没有InitialContextFactoryBuilder,所以不能够直接去调用getDefaultInitCtx(不然就和上面的流程一样了),往下先获取到 scheme,也就是 rmi 协议头
带着 scheme 进入NamingManager.getURLContext 方法
继续跟进到 getURLObject 方法
这边根据_defaultPkgPrefix_属性动态生成Factory类
也就是说这个包下的类都是可以通过 JNDI 的动态协议转化生成 factory 的
直到这里,我相信师傅们应该能理解为什么要分别分析 JNDI 的正常通过获取环境变量设置去实例化获取 context,和 JNDI 动态协议转化了
JNDI 动态协议转化使我们的攻击扩大了可能性以及攻击范围,虽说 JNDI 的这种动态转化的功能很方便,但是有方便,并且参数可控,就是我们可以攻击点,假如我们可以控制字符串的输入,就能够搭建恶意服务,并控制JNDI接口访问该恶意,于是将导致恶意的远程class文件加载,从而导致远程代码执行(工厂类的 loadclass 动态加载)
JNDI-Refernce 类
定义在最开始的提到过,但是这里还要对其有一定的补充:
**Reference类表示对存在于命名/目录系统以外的对象的引用 **,什么意思呢?就是当我们查询远程对象在服务中找不到时,能够从其他远程端获取到 class 文件加载并实例化
Reference 类结构如下:
这里只列出一种构造方法,还有其他三种构造方法,依次递减构造参数
由于 RMI 的限制,如果想要远程对象被访问到就必须继承 UnicastRemoteObject。 所以这里我们需要使用ReferenceWrapper类对Reference类或其子类对象进行远程包装成Remote类使其能够被远程访问。
JNDI 注入
先来张流程图
0x01 JNDI+RMI
和 RMI 中远程从 codebase 中获取恶意类加载的思路是差不多的,我们这里只不过不是把 codebase 换成了 Reference 对象
写一个简单的恶意 RMI 客户端
1 | package com.stoocea; |
这里 Reference 必须用ReferenceWrapper
包装一下,不然无法通过 RMI 访问到
然后写一下恶意工厂类,必须继承ObjectFactory 类。同样也是由于必须继承远程对象类才能被 RMI 访问到,所以这里必须继承UnicastRemoteObject
1 | package com.stoocea; |
之后写上受害者的信息
1 | import javax.naming.InitialContext; |
成功弹出计算机
1x01 流程分析
其实上面对于 JNDI 的工作流程分析时就已经提到过了,我们这里先确定一下大概的方向:JNDI 的动态协议转化导致我们能够从 Reference 类中读取地址,从恶意地址中获取恶意工厂类(其实不用工厂类也行)字节码内容,然后本地实例化之后触发构造方法 RCE
首先 lookup 处下个断点
来到 lookup 的具体内容
继续跟进 getURLOrDefaultInitCtx 方法
由于我们 URL 传值中制定了 RMI 协议,所以这里的 scheme 取出来是”RMI”,进入 if 判断完毕之后的内容,通过 NamingManager 的 getURLContext 的方法来获取 context
getURLContext** **内容里面只有一个 getURLObject 方法
继续跟进,这里根据 scheme 的值构造出了 rmiURLContext 的构造实体 factory
那么之后就是根据工厂类来获取 rmiURLContext 的内容了
这里就直接返回 rmiURLContext,到调用 lookup 中去
还是由于 rmiURLContext 本身没有 lookup 方法的定义,跟进到 GenericURLContext 的 lookup 方法
这里的getRootURLContext 方法和getResolvedObj 方法都都是为了获取 RegistryContext
此时的 var3 就是RegistryContext,继续调用其 lookup 方法
到这里就已经获取到了 RMI 中熟悉的 Stub,从远程客户端上请求字节码了,最后调用 decodeObeject 进行实例化
这里持续跟进到 getObjectFactoryFromReference
调用 loadClass 进行动态类加载
与之前 JNDI 实例化 initialContext 时不同,iinitialContext 的 factory 类不是在这里实例化(虽然长得挺像的),而是在NamingManager 的 getInitialContext,至于这里的逻辑就是区别在getURLOrDefaultInitCtx 里面了,看是走 URLCtx 还是 DefaultCtx
0x02 踩坑实录
愚蠢的 stoocea 又在这里踩坑了,目录结构尽量是直接在 java 目录下,不要在子包下写服务
不然客户端启动的时候找不到 RMIHello 这个恶意类(我也不知道为什么,可能寻找的地址 String 要加点什么吧)
0x03 JNDI+LDAP
0x01 什么是 LDAP
首先回顾一下 LDAP 的定义( Lightweight Directory Access Protocol ,轻型目录访问协议 ),是一种目录服务协议,运行在 TCP/IP 堆栈上。它本身是由目录数据库和一套的目录访问协议构成的,目录服务本身是一个特殊的数据库,用来保存描述性的,基于属性的详细信息,拥有最基本的查询,浏览,以树状结构组织数据
简单点说,LDAP 就是一个用来存储协议的数据库
在 LDAP 中我们通过目录树来访问一条记录,基本名词和结构如下:
1 | dn :一条记录的详细位置 |
访问的深度依次从上往下
JNDI 的工作流程是不会变的,如果我们可以控制 JNDI 去访问 LDAP 服务器上的恶意对象,就能够达到和 恶意 RMI 对象同样的效果
0x02 JNDI+LDAP 攻击实现
先用 windows 上常用的 LDAP 服务器把 LDAP 服务搭上,这里我用是 apache LDAP ,首先 new 一个新的 LDAP 服务,然后在这个 LDAP 服务的基础上加上 LDAP 连接,目录结构如下
之后开始 JNDI 作用 LDAP 的代码实现
先写一个服务端(针对 LDAP 了,所以不用去加 ReferenceWrapper 去包装满足 RMI 的要求了)
1 | import com.sun.jndi.rmi.registry.ReferenceWrapper; |
然后要注意恶意类的编写有所改变,不是针对 RMI 形式的攻击了,这里的是 LDAP,所以不用去继承 UnicastRef
1 | import javax.naming.Context; |
然后是客户端:
1 | import javax.naming.InitialContext; |
实验没问题
1x01 攻击流程分析
其实整体和 RMI 很相似,只不过初步获取 Context 不同
这里我们依然断点打在 lookup 处
直接进 initialContext 的 lookup,先获取 context
之后返回出去开始调用 ldapURLContext 的 lookup 方法,跟进之后发现还是跟 RMIURLcontext 差不多,调用父类 GenericContext 的 lookup 方法
前面获取解析结果 ldapCtx 就不跟进了
直接进它的 lookup
但是 ldapCtx 本身是没有 lookup 方法,还是得调它的父类(这里直接掉到了顶级父类)PartialCompositeContext 的 lookup 方法
获取一堆信息,然后直接进 try,跟进 p_lookup
最后又一直向下调用 ComponentContext 的 c_lookup
跟进到最后熟悉的 DirectoryManager 的 getObjectInstance 了
getObjectInstance 中跟进到 getObjectFactoryFromReference
之后就是动态类加载了
JNDI 注入高版本绕过
0x01 JNDI-RMI 限制
JDK 6u132, JDK 7u122, JDK 8u113之后Java限制了通过RMI远程加载Reference工厂类。
估计师傅们自己在测试的时候应该也遇到了这个问题,也就是报错
com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase 的默认值变为了false,即默认不允许通过RMI从远程的Codebase加载Reference工厂类。
1x01 源码分析
相信经过上面的攻击流程分析,师傅们应该可以确定,JNDI-RMI 的远程攻击进入开始阶段是在RegistryContext#decodeObject()
中
对比 8u65 和 8u202 的源码不同
多了一层对于类型的限制以及远程 trustURLCodebase 的检查,基本上远程 codebase 都不会被允许加载了,所以我们得另辟蹊径,首先加载本地的 factory,然后再利用这个本地的 factory 去加载远程类
0x02 JNDI-LDAP 限制
同样包下的 com.sun.jndi.rmi.object.trustURLCodebase
也被默认值设置为了 false,限制相同,绕过方式也相同
0x03 绕过实现分析
我们以 RMI 的绕过为前期绕过分析对象,实际上两者共用一套绕过逻辑
首先这个本地工厂类必须实现 ObjectFactory,这是一直从 JNDI 注入开始利用类就满足的一个条件,在写利用类的时候应该也发现如果实现ObjectFactory 就必须要实现 getObjectInstance 方法。然后这个工厂类实例化我们的恶意类的方法参数必须可控
综合上面的条件,在 Tomcat8 的依赖包中有一个 BeanFactory
满足前置的两个条件
观察他的 getObjectInstance 方法,发现有一个限制:我们实例化生产的类必须是 ResouceRef 类型,不然无法进入方法的 trycatch 块处理逻辑
什么是 ResourceRef 呢?在 javaweb 或者 springmvc 项目中,我们在 web.xml 中见的很多,用来引入外部资源的,而 EL 表达式其实就是 ELEMENT 元素表达式,在 web.xml 中用来加载外部资源
我们看看 ResourceRef 的具体定义和内容
简单来说它也是一种引用 wrapper,也就是用来包装指定类的各类信息,还包括了用来加载该类的 factory 工厂地址(为什么是 factory 呢?具体思考 javaweb 和 SSM 这一套管理类的逻辑),而且都是我们可控的,这就很巧,我们就是需要一个从本地加载 factory 类—>beanfactory
总结一下就是:我们用 ResourceRef 包装一下 ELProcessor 和指定加载它的beanfactory,之后让 beanfactory 去实例化 ELProcessor,调用 EL 的 eval 方法,触发 RCE
1x03 代码实现
可以先写一个恶意服务端
1 | import com.sun.jndi.rmi.registry.ReferenceWrapper; |
然后写一个受害者
1 | import javax.naming.InitialContext; |
执行起来应该没有问题,复现时要注意 java 中的 eval 能不能直接加载 runtime 类进行命令执行,以及 EL 的依赖是否打上了
1x04 流程分析
断点依然是打在 lookup 中,但是这里我们就直接进 lookup 了,getURLOrDefaultInitCtx 返回出来依然是 rmiURLContext
逻辑照旧
由于是 RMI 的 context ,所以直接就跟进到 RegistryContext 的 lookup
通过 RMI 原生获取到字节码之后,再调用 decodeObject 方法
关键性逻辑在第一句,这里会判断我们传进来的要实例化的对象是否是 RemoteRef 类,如果是的话,那我们这个流程就肯定没有后续了,因为它会将实例化对象强转为 RemoteReference 类型,会自带 classFactoryLocation 属性字段,后面的 if 判断就过不去了,这就是为什么我们要选择 ResourceRef 的原因
继续往下
来到最为关键的 if 判断,由于我们的指定实例化类是 ResourceRef,不带 classFactoryLocation 属性,所以三个判断条件中,第二个判断条件不符合要求,与运算不通过,可以开开心心进行类加载了
那么直接跟进 NamingManager 的 getObjectInstance 方法
首先对其强转为 Reference 类,然后直接通过getFactoryClassName 获取到工厂类的全类名(此时是org.apache.naming.factory.BeanFactory
),调用getObjectFactoryFromReference
先对它进行类加载
然后开始调用工厂类的getObjectInstance
对类实现实例化加载
这里稍微看一下变量表
自然跟进到BeanFactory
的getObjectInstance
方法
调用ResourceRef.getClassName()
,获取到 ELProcessor 的全类名,开一个当前线程的 context 类加载器,对 ELProcessor 进行类加载
然后的内容就是对 ResourceRef 这个类中的设定的信息进行处理,比如说获取到 forceString,然后对其字符串处理满足表达式,然后获取要执行方法的参数等等,最后的最后,获取到方法以及参数之后反射调用,触发 RCE
最终触发如下
EL 表达式其实是一种很常用的 RCE 媒介,初次完整分析很过瘾
JNDI 复习 over,除了思路开拓和基础部分是跟文章,分析部分都是自己去走逻辑,去解决自己遇到的问题,感觉很爽,不会再去傻傻的跟着别人的步骤走了
总结
稍微总结一下
JNDI 的工作流程,不论是不是动态协议转化,都是一个总的逻辑:为了获取到 context 实例而去构造 factory 工厂类,然后通过 factory 去实例化 context,然后再去通过 context 去调用各种方法
如果是指定 initialcontext 的属性,调用 initialcontext 的各种功能方法,那么逻辑大致为
如果是动态协议转化(漏洞所在)
initialContext 直接调方法,比如 lookup,那么他会先判断是哪种协议,然后通过获取该协议相关的 factory,去构造该协议的 Context,再去调该协议 Context 的方法,我们的漏洞分析也就是从这里开始的
- getURLOrDefaultInitCtx 获取到相关协议 Context
- 相关协议 context 调用相关方法,如 lookup
- 通过 RMI 原生获取到远程服务器上的恶意字节码
- decodeObject 开始实例化,本质还是通过工厂类去实例化
- 调用工厂类的getObjectInstance 去完成(高版本与低版本的区别就在此是否有对类型的限制,以及trustURLCodebase 默认是否为 false)