Java反序列化之JDNI学习
0x00 前言
JNDI内容也不少,原理和方法都要弄清楚。
本文前面概念部分存在大量原话引用,先叠个甲,因为我觉得这些引用表述的非常清晰。
0x01 概述
JNDI概念
JNDI(Java Naming and Directory Interface,Java命名和目录接口)是SUN公司提供的一种标准的Java命名系统接口。JNDI提供统一的客户端API,并由管理者将JNDI API映射为特定的命名服务和目录服务,为开发人员查找和访问各种资源提供了统一的通用接口,可以用来定义用户、网络、机器、对象和服务等各种资源。简单来说,开发人员通过合理的使用JNDI,能够让用户通过统一的方式访问获取网络上的各种资源和服务。如下图:
上文提到了两个名词:命名服务(Naming Server)、目录服务(Directory Server),它们分别是什么,在JNDI中又扮演着什么作用呢?
命名服务(Naming Server)
命名服务,简单来说,就是一种通过名称来查找实际对象的服务。比如RMI协议,可以通过名称来查找并调用具体的远程对象。再比如DNS协议,通过域名来查找具体的IP地址。这些都可以叫做命名服务。
回想上文:
JNDI提供统一的客户端API,并由管理者将JNDI API映射为特定的命名服务和目录服务
JNDI和RMI等协议的关系可见一斑。
在命名服务中,有几个重要的概念。
- Bindings:表示一个名称和对应对象的绑定关系,比如在 DNS 中域名绑定到对应的 IP,在RMI中远程对象绑定到对应的name,文件系统中文件名绑定到对应的文件。
- Context:上下文,一个上下文中对应着一组名称到对象的绑定关系,我们可以在指定上下文中查找名称对应的对象。比如在文件系统中,一个目录就是一个上下文,可以在该目录中查找文件,其中子目录也可以称为子上下文 (SubContext)。
- References:在一个实际的名称服务中,有些对象可能无法直接存储在系统内,这时它们便以引用的形式进行存储,可以理解为 C/C++ 中的指针。引用中包含了获取实际对象所需的信息,甚至对象的实际状态。比如文件系统中实际根据名称打开的文件是一个整数 fd (file descriptor),这就是一个引用,内核根据这个引用值去找到磁盘中的对应位置和读写偏移。
简而言之,一个Context中存在一系列的Bindings,有些Bindings的对象不能直接存储在系统,就用References来表示。
目录服务(Directory Server)
简单来说,目录服务是命名服务的扩展,除了名称服务中已有的名称到对象的关联信息外,还允许对象拥有属性(Attributes)信息。由此,我们不仅可以根据名称去查找(Lookup)对象(并获取其对应属性),还可以根据属性值去搜索(Search)对象。
一些常见的目录服务有:
- LDAP: 轻型目录访问协议
- Active Directory: 为 Windows 域网络设计,包含多个目录服务,比如域名服务、证书服务等;
- 其他基于 X.500 (目录服务的标准) 实现的目录服务;
JNDI SPI
从前文不难知道,JNDI与RMI、DNS等等,是存在关系的,但是具体的关系是怎样的呢?
JNDI 架构上主要包含两个部分,即 Java 的应用层接口和 SPI。SPI(Service Provider Interface),即服务供应接口,主要作用是为底层的具体目录服务提供统一接口,从而实现目录服务的可插拔式安装。如下图:
其中,JDK 中包含了下述内置的命名目录服务:
- RMI: Java Remote Method Invocation,Java 远程方法调用
- LDAP: 轻量级目录访问协议
- CORBA: Common Object Request Broker Architecture,通用对象请求代理架构,用于 COS 名称服务(Common Object Services)
- DNS(域名转换协议)
除此之外,用户还可以在 Java 官网下载其他目录服务实现。由于 SPI 的统一接口,厂商也可以提供自己的私有目录服务实现,用户无需重复修改代码。
em,不讲JNDI代码示例了,讲了也是水字数,看文末的参考文章。
0x02 JNDI调用流程
就JNDI而言,它是如何识别我们调用的是何种服务呢?这就依赖于上文提到的Context(上下文)。
以下以调用RMI服务为例
初始化Context
首先,使用Hashtable类来设置属性INITIAL_CONTEXT_FACTORY和PROVIDER_URL的值。将INITIAL_CONTEXT_FACTORY设置成”com.sun.jndi.rmi.registry.RegistryContextFactory”,JNDI通过这一属性来判断将要调用何种服务。
接着,将属性PROVIDER_URL设置为了”rmi://localhost:1099”,这正是我们RMI服务的地址。JNDI通过该属性来获取服务的路径,进而调用该服务。
最后,向InitialContext类传入我们设置的属性值来初始化一个Context,于是我们就获得了一个与RMI服务相关联的上下文Context。
简而言之,构建Context需要确定两个值,服务调用类型和服务地址。事实上,这段初始代码可以注释掉,系统会自动判断。
再拓展一下,env这个变量往往代表环境变量。通过观察InitialContext类的构造函数,不难发现,我们可以直接通过写入这两个值到环境变量,然后利用默认构造函数直接构造上下文。
Context与服务交互
和RMI类似,Context同样通过以下五种方法来与被调用的服务进行交互
1 | //将名称绑定到对象 |
0x03 JNDI底层实现
上下文的初始化
我们通过JNDI设置不同的上下文,进而使用不同的服务。那么JNDI接口是如何实现该功能的呢?
上下文的初始化有两个步骤:
- 获取工厂类
- 获取服务交互所需资源
获取工厂类(Factory)
从下图开始调试:
显然是要进入getDefaultInitCtx方法,该方法返回一个上下文(Context):
跟进,观察上下文是怎么构建的:
在创建上下文之前,先调用FactoryBuilder,先创建Factory。
至此创建RegistryContextFactory,也就是获取了工厂类。整个流程如图:
获取服务交互所需资源。
获取服务交互所需资源
在获取工厂类这一步骤,JNDI知道了我们要调用哪一种服务,那么它又该如何知道服务地址以及获取服务的各种资源的呢?
不妨先思考一下,从哪里开始调试呢?
当前Factory已经创建,需要进行下一步操作,那么显然在NamingManger#getInitialContext方法:
先关注第一个参数:getInitCtxURL(env)。其实从该方法名字就可以猜到该功能是得到上下文的URL:
再回来观察URLToContext方法:
不难猜到,此处会创建Context。具体流程跟进看看:
rmiURLContext方法没什么需要关注的,直接看下面的lookup方法,此处应该是获取交互所需资源的核心:
此处进行上下文的配置,包括环境变量、注册中心、IP、端口和reference等等。
该步骤的流程如下图:
0x04 JNDI动态协议转换
JNDI动态协议转换概述
前文给出了JNDI初始化代码,我们指定了所需的服务和对应的地址。但实际上,在 Context#lookup()方法的参数中,用户可以指定自己的查找协议。JNDI会通过用户的输入来动态的识别用户要调用的服务以及路径。
给出下图:
JNDI会自己识别服务类型为RMI,RMI地址为localhost:1099。
不难想到,无参的InitialContext方法所返回的上下文并不属于哪一种服务,所以突破口肯定在该上下文所调用的方法:rebind方法。
那么从rebind方法开始调试:
事实上,无论我们调用的是lookup、bind或者是其他initalContext中的方法,都会调用getURLOrDefaultInitCtx方法进行检查。
自然是进入getURLOrDefaultInitCtx方法:
getURLOrDefaultInitCtx方法会通过getURLScheme方法来获取通信协议,然后创建Context。
到NamingManager.getURLContext方法里看看:
最终在getURLObject()方法中,根据defaultPkgPrefix属性动态生成Factory类:
像RMI这样,JNDI默认支持的动态协议转换有哪些:
当我们针对JNDI进行攻击的时候可以优先考虑上面这几种服务。
通过动态协议转换,我们可以仅通过一串特定字符串就可以指定JNDI调用何种服务,十分方便。但是方便是会付出一定代价的。对于一个系统来讲,往往越方便,就越不安全。
假如我们能够控制string字段,那么就可以搭建恶意服务,并控制JNDI接口访问该恶意,于是将导致恶意的远程class文件加载,从而导致远程代码执行。这种攻击手法其实就是JNDI注入,它和RMI服务攻击手法中的”远程加载CodeBase”较为类似,都是通过一些远程通信来引入恶意的class文件,进而导致代码执行。
0x05 JNDI Reference类
RReference类用于表示对不在本地命名或目录系统中的对象的引用。举个例子,当客户端需要访问远程RMI服务上的对象时,如果这个对象是Reference类或其子类的实例,客户端可以通过这个引用从其他服务器加载所需的类文件。
这与RMI中的Codebase功能非常相似。Codebase允许在本地找不到所需类时,从远程服务器动态加载类。这样,Reference类和Codebase机制都能实现远程对象的灵活访问和实例化。
Reference类常用构造函数如下
1 | //className为远程加载时所使用的类名,如果本地找不到这个类名,就去远程加载 |
在RMI中,由于我们远程加载的对象需要继承UnicastRemoteObject类,所以这里我们需要使用ReferenceWrapper类对Reference类或其子类对象进行远程包装成Remote类使其能够被远程访问。
JNDI Reference 的作用
- 存储对象引用
- 在命名服务中存储对象的引用信息,而不是对象本身。
- 延迟加载
- 对象只有在客户端查找时才会被创建。
- 跨网络传递
- 适用于需要跨网络传递对象的情况(如 RMI、LDAP 等)。
0x06 JNDI攻击类型
如非特殊说明,下述攻击皆采用jdk8u65。
JNDI + RMI
不妨先思考一下,下图的rebind方法调用的到底是谁的方法:
rebind方法入手,调试到:
显然,initalContext调用的rebind方法实质上是RMI的rebind方法,所以这里也可以利用RMI对应的攻击方式——虽然这不是 JNDI 传统意义上的漏洞。
再多说几句。这里我们把对象绑定到注册中心,我们就可以利用rebind/bind方法攻击注册中心。至于能否利用unbind/lookup进行攻击,笔者看了一下源码逻辑,应该是可以伪造的。但是,这一切都是在jdk8u65上测试的,前篇文章也讲过,jdk8u121开始便对输入流做了类型检查。
Normal JNDI
该攻击方法被称之为JNDI注入,原理是在服务端调用了一个 Reference对象。该漏洞与所调用服务无关,不论你是 RMI,DNS,LDAP 或者是其他的,都会存在这个问题。
攻击者:(还是这一段代码)
最后rebind上去的,是Reference对象。看看TestRef类:
众所周知,攻击方式放在构造方法里,当该类被实例化的时候就可以自动执行该攻击。不难猜想,该攻击方式是利用客户端实例化远程恶意类进而实现攻击。
给出客户端代码:
具体是如何被攻击的,不妨调试一下。这里笔者就不详细讲述过程,直接给图:
强制步入:
遇到了decodeObject方法,这个方法的作用是用来解码从远程服务器接收到的对象,将其解码为本地的可用对象。
- 如果对象是一个远程引用(RemoteRef),decodeObject 会将其解码为远程代理对象。
- 如果对象是一个普通对象,decodeObject 会直接返回该对象
进去看看:
先尝试本地类加载:
无果,进行远程加载:
这一步走完,如果恶意类的代码是写在静态代码块里的,此时就会执行一次命令。
再往下走:
远程恶意类被实例化,构造方法里的恶意代码执行。需要注意的是,这与上面静态代码块的攻击条件不同,一个是类加载时执行,一个是类实例化时执行。
简而言之,JNDI注入攻击通过远程加载恶意类给客户端,客户端未经检查便加载并实例化该恶意类,进而受到攻击。
JNDI + LDAP
LDAP 概述
LDAP(Lightweight Directory Access Protocol ,轻型目录访问协议)是一种目录服务协议,运行在TCP/IP堆栈之上。LDAP目录服务是由目录数据库和一套访问协议组成的系统,目录服务是一个特殊的数据库,用来保存描述性的、基于属性的详细信息,能进行查询、浏览和搜索,以树状结构组织数据。LDAP目录服务基于客户端-服务器模型,它的功能用于对一个存在目录数据库的访问。 LDAP目录和RMI注册表的区别在于是前者是目录服务,并允许分配存储对象的属性。
也就是说,LDAP 「是一个协议」,约定了 Client 与 Server 之间的信息交互格式、使用的端口号、认证方式等内容。而 「LDAP 协议的实现」,有着众多版本,例如微软的 Active Directory 是 LDAP 在 Windows 上的实现。AD 实现了 LDAP 所需的树形数据库、具体如何解析请求数据并到数据库查询然后返回结果等功能。再例如 OpenLDAP 是可以运行在 Linux 上的 LDAP 协议的开源实现。而我们平常说的 LDAP Server,一般指的是安装并配置了 Active Directory、OpenLDAP 这些程序的服务器。
在LDAP中,我们是通过目录树来访问一条记录的,目录树的结构如下
1 | dn :一条记录的详细位置 |
假设你要树上的一个苹果(一条记录),你怎么告诉园丁它的位置呢?当然首先要说明是哪一棵树(dc,相当于MYSQL的DB),然后是从树根到那个苹果所经过的所有“分叉”(ou),最后就是这个苹果的名字(uid,相当于MySQL表主键id)。
当然,我们也可以使用LDAP服务来存储Java对象,如果我们此时能够控制JNDI去访问存储在LDAP中的Java恶意对象,那么就有可能达到攻击的目的。LDAP能够存储的Java对象如下
- Java 序列化
- JNDI的References
- Marshalled对象
- Remote Location
攻击分析
笔者直接使用现成的LDAP工具:
上述的TestRef以如下方式上传到LDAP中心:
客户端代码:
如此,客户端就会受到攻击。
为什么会这样呢,简单调试一下。问题肯定出现在lookup方法上,打断点进去。
前几步很简单,直接看这里:
熟悉吧,进去看看:
确定了远程加载对象的地址。
往回调,当前的obj还是Reference对象,也就是尚未进行类加载等操作:
再走几步,跟前面JNDI注入的调试几乎一致,最后都会到实例化,更别说类加载:
两种攻击方式的底层逻辑有些类似,因为二者都利用了Reference。
注意一点就是,LDAP+Reference的技巧远程加载Factory类不受RMI+Reference中的com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase等属性的限制,所以适用范围更广。但在JDK 8u191、7u201、6u211之后,com.sun.jndi.ldap.object.trustURLCodebase属性的默认值被设置为false,对LDAP Reference远程工厂类的加载增加了限制。
所以,当JDK版本介于8u191、7u201、6u211与6u141、7u131、8u121之间时,我们就可以利用LDAP+Reference的技巧来进行JNDI注入的利用。
因此,这种利用方式的前提条件就是目标环境的JDK版本在JDK8u191、7u201、6u211以下。
jndi + CORBA
提一嘴,引用一位师傅的话:
一个简单的流程是:resolve_str 最终会调用到 StubFactoryFactoryStaticImpl.createStubFactory 去加载远程 class 并调用 newInstance 创建对象,其内部使用的 ClassLoader 是 RMIClassLoader,在反序列化 stub 的上下文中,默认不允许访问远程文件,因此这种方法在实际场景中比较少用。所以就不深入研究了。
0x07 JDK高版本限制
Codebase 限制
先前进行攻击的时候,存在一个变量codebase,其存储的是远端服务器的地址。如果要根据codebase加载位于远端服务器的类,java.rmi.server.useCodebaseOnly的值必须为false。但是从JDK 6u45、7u21开始,java.rmi.server.useCodebaseOnly的默认值就是true。
该限制近乎通杀,JNDI注入和JNDI+LDAP等攻击手法都会受到影响。
JNDI_RMI_Reference 限制
在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工厂类。
JNDI_LDAP_Reference限制
除此之外,JNDI还可以通过LDAP协议加载远程的Reference工厂类。但是在之后的版本,Java也对LDAP Reference远程加载Factory类进行了限制,在JDK 11.0.1、8u191、7u201、6u211之后 ,com.sun.jndi.ldap.object.trustURLCodebase属性的默认值同样被修改为了false,对应的CVE编号为:CVE-2018-3149。
源码分析
笔者的分析可能会更细节一点。
先以JNDI + RMI为例子。
JNDI + RMI
JDK8u65
JDK8u65里,RegistryContext#decodeObject方法会直接调用到NamingManager#getObjectInstance方法,进而调用getObjectFactoryFromReference方法来获取远程工厂类。
先贴出RegistryContext#decodeObject方法:
JDK8u442
RegistryContext#decodeObject方法:
类似的,JNDI + LDAP会不会也有这样的限制呢?
JNDI + LDAP
问题出在loadClass方法上。
JDK8u65
JDK8u191
分析完毕,开始尝试绕过。
0x08 JDK高版本绕过
细心的读者可能已经发现,笔者并没有讲Codebase限制的底层源码。源码就在那里,不讲也罢;但是不讲不代表该限制不存在,我们先讨论一下如何绕过该限制。
绕过 Codebase 限制
8u191后已经默认不允许加载codebase中的远程类(包括Reference类),但我们可以从本地加载合适Reference Factory。但是,该本地工厂类必须实现javax.naming.spi.ObjectFactory接口,因为在javax.naming.spi.NamingManager#getObjectFactoryFromReference最后的return语句中,对Factory类的实例对象进行了类型转换,并且该工厂类至少存在一个getObjectInstance()方法。如下图:
众所周知,Reference类可以构造factory,factory可以构造危险类。所以我们可以跳过构造这几步,直接寻找本地可利用的Factory类。
笔者采用JDK8u191。
Tomcat8
前置概述
org.apache.naming.factory.BeanFactory就是满足条件之一,并由于该类存在于Tomcat8依赖包中,攻击面和成功率还是比较高的。
org.apache.naming.factory.BeanFactory 在 getObjectInstance() 中会通过反射的方式实例化Reference所指向的任意Bean Class,并且会调用setter方法为所有的属性赋值。而该Bean Class的类名、属性、属性值,全都来自于Reference对象,均是攻击者可控的。
先添加依赖,下面要用:
1 | <dependency> |
攻击概述
服务端作为攻击者,上传恶意对象:
客户端作为受害者,反射调用恶意对象,进而受到攻击:
源码分析
如果有一路看我文章来的读者,或者大部分学到这里的读者,自己调试已经问题不大了,所以只贴一张核心源码:
究其原理,无外乎反射调用eval方法,该命令执行方式的语句如下:
至于中间过程如何,留待读者自行调试。
其实整个过程不难理解。因为高版本的JDK限制Codebase加载远程对象,更细致的说,是限制加载远程的Reference。Reference中的Factory是加载远程对象的核心类,所以剥去Reference这件外衣,我们本质上是需要利用Factory类。
显而易见,我们只能走本地攻击的路线,也就是利用客户端/受害者本地的恶意类来攻击。假设客户端存在对应的Tomcat依赖,我们就可以走调用本地的Factory(org.apache.naming.factory.BeanFactory)这一分支,从而实例化javax.el.ELProcessor类:好巧不巧的是,这个过程存在反射调用,执行任意代码的过程。
换一个角度思考,如果一定要利用受害者本地的类来进行攻击,而且还不能直接调用这个危险类,只能间接的调用。显然,只有Java反射机制的动态调用才能做到。所以我们利用的Factory,其所创建的对象,必须在创建过程中可以执行方法反射,并且反射对应的参数可控。
真的是相当巧合!真的是巧合?
Groovy
在Groovy的官方文档(ASTest)中,可以发现的是,Groovy程序允许我们执行
断言
,也就意味着命令执行
@ASTTest
是一种特殊的AST转换,它会在编译期对AST执行断言,而不是对编译结果执行断言。这意味着此AST转换在生成字节码之前可以访问 AST。@ASTTest
可以放置在任何可注释节点上。因此思路和Tomcat相似,借助BeanFactory的功能,使程序执行
GroovyClassLoader#parseClass
,然后去解析Groovy脚本。
下载依赖:
1 | <dependency> |
服务端/攻击者:
客户端/受害者:
就不打算深究了。有一个问题先记载一下,我本地调试,连弹四个计算器。
javaSerializedData反序列化攻击
这个方法就不属于前面两种攻击方式
Codebase限制的直接影响就是: LDAP + Reference 的路子走不通的。我们先前尝试了本地类攻击,这次将采用远程类攻击。
再复习一下:
在LDAP中,Java有多种方式进行数据存储
- 序列化数据
- JNDI Reference
- Marshalled Object
- Remote Location
同时LDAP也可以为存储的对象指定多种属性
- javaCodeBase
- objectClass
- javaFactory
- javaSerializedData
我们采用:序列化数据 + javaSerializedData 的攻击方式。
LDAP 服务端除了支持 JNDI Reference 这种利用方式外,还支持直接返回一个序列化的对象。如果 Java 对象的 javaSerializedData 属性值不为空,则客户端的
obj.decodeObject()
方法就会对这个字段的内容进行反序列化。此时,如果服务端 ClassPath 中存在反序列化咯多功能利用 Gadget 如 CommonsCollections 库,那么就可以结合该 Gadget 实现反序列化漏洞攻击。这也就是平常 JNDI 漏洞存在最多的形式,通过与其他链子结合,比如当时 2022 蓝帽杯,好像有道题目就是 fastjson 绕过高版本 jdk 攻击。
那个jar包我就找不到正确的,留待后续寻找。
至此,暂时结束JNDI的学习。