java反序列化之jndi
java反序列化之jndi
Sherlock引用
JNDI
概述
JNDI(Java Naming and Directory Interface,Java命名和目录接口)是SUN公司提供的一种标准的Java命名系统接口。JNDI提供统一的客户端API,并由管理者将JNDI API映射为特定的命名服务和目录服务,为开发人员查找和访问各种资源提供了统一的通用接口,可以用来定义用户、网络、机器、对象和服务等各种资源。简单来说,开发人员通过合理的使用JNDI,能够让用户通过统一的方式访问获取网络上的各种资源和服务。如下图所示
命名服务(Naming Server)
命名服务,简单来说,就是一种通过名称来查找实际对象的服务。比如我们的RMI协议,可以通过名称来查找并调用具体的远程对象。再比如我们的DNS协议,通过域名来查找具体的IP地址。这些都可以叫做命名服务。
例子:DNS(域名系统)
- 场景:当你在浏览器中输入
www.google.com
时,浏览器需要知道这个域名对应的 IP 地址才能访问网站。 - 命名服务的作用:
- DNS 将域名(名称)映射到 IP 地址(对象)。
- 例如:
- 名称:
www.google.com
- 对象:
142.250.190.78
(Google 的 IP 地址)
- 名称:
- 类比:
- 就像你通过电话簿查找“张三”的名字,找到他的电话号码一样。
在命名服务中,有几个重要的概念。
- Bindings:表示一个名称和对应对象的绑定关系,比如在在 DNS 中域名绑定到对应的 IP,在RMI中远程对象绑定到对应的name,文件系统中文件名绑定到对应的文件。
- Context:上下文,一个上下文中对应着一组名称到对象的绑定关系,我们可以在指定上下文中查找名称对应的对象。比如在文件系统中,一个目录就是一个上下文,可以在该目录中查找文件,其中子目录也可以称为子上下文 (SubContext)。
- References:在一个实际的名称服务中,有些对象可能无法直接存储在系统内,这时它们便以引用的形式进行存储,可以理解为 C/C++ 中的指针。引用中包含了获取实际对象所需的信息,甚至对象的实际状态。比如文件系统中实际根据名称打开的文件是一个整数 fd (file descriptor),这就是一个引用,内核根据这个引用值去找到磁盘中的对应位置和读写偏移。
目录服务(Directory Service)
简单来说,目录服务是命名服务的扩展,除了名称服务中已有的名称到对象的关联信息外,还允许对象拥有属性(Attributes)信息。由此,我们不仅可以根据名称去查找(Lookup)对象(并获取其对应属性),还可以根据属性值去搜索(Search)对象。
一些常见的目录服务有:
- LDAP: 轻型目录访问协议
- Active Directory: 为 Windows 域网络设计,包含多个目录服务,比如域名服务、证书服务等;
- 其他基于 X.500 (目录服务的标准) 实现的目录服务;
例子:LDAP(轻量级目录访问协议)
- 场景:公司内部有一个员工信息目录,你需要查找某个员工的详细信息。
- 目录服务的作用:
- LDAP 不仅可以通过员工姓名找到对应的员工对象,还可以查询该员工的属性(如职位、邮箱、电话等)。
- 例如:
- 名称:
cn=张三,ou=研发部,dc=公司,dc=com
- 对象:员工“张三”的信息。
- 属性:
- 职位:
职位=软件工程师
- 邮箱:
邮箱=zhangsan@company.com
- 电话:
电话=123456789
- 职位:
- 名称:
- 类比:
- 就像你通过员工管理系统查找“张三”,不仅能找到他的联系方式,还能看到他的职位、部门等详细信息。
JNDI SPI
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 的统一接口,厂商也可以提供自己的私有目录服务实现,用户无需重复修改代码。
JNDI代码示例
JNDI 接口主要分为下述 5 个包:
javax.naming
:主要用于命名操作,它包含了命名服务的类和接口,该包定义了Context接口和InitialContext类javax.naming.directory
:主要用于目录操作,它定义了DirContext接口和InitialDir-Context类javax.naming.event
:在命名目录服务器中请求事件通知javax.naming.ldap
:提供LDAP服务支持javax.naming.spi
:允许动态插入不同实现,为不同命名目录服务供应商的开发人员提供开发和实现的途径,以便应用程序通过JNDI可以访问相关服务
JNDI_RMI
JDK版本为JDK8u_65
首先在本地起一个RMI服务(我用的还是自己学习rmi时候的例子,感兴趣的可以自己去翻一下前面的文章)
上面的目的是为了创建一个注册表
在这之后,文件JNDIRMIServer的代码如下
1 | import javax.naming.InitialContext; |
文件JNDIRMIClient代码如下:
1 | import javax.naming.InitialContext; |
两文件都运行后,客户端成功执行了sayHello方法
JNDI_DNS
1 | import javax.naming.Context; |
运行结果如下:
JNDI的工作流程
在上文,我们通过JNDI成功地调用了RMI和DNS服务。那么对于JNDI来讲,它是如何识别我们调用的是何种服务呢?这就依赖于我们上面提到的Context(上下文)了。
初始化Context
我以RMI服务为例
1 | //设置JNDI环境变量 |
首先使用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的方法多种多样,我们来看一下InitialContext
类的构造函数
1 | //构建一个默认的初始上下文 |
所以我们还可以用如下方式来初始化一个Context
1 | //设置JNDI环境变量 |
通过Context与服务交互
和RMI类似,Context同样通过以下五种方法来与被调用的服务进行交互
1 | //将名称绑定到对象 |
JNDI底层实现
上下文的初始化
我们通过JNDI来设置不同的上下文,就可以调用不同的服务。那么JNDI接口是如何实现这一功能的呢?这里我们仍以JNDI_RMI为例,我们从上下文的初始化开始。
JNDI_RMI代码如下:
1 | import javax.naming.Context; |
获取工厂类
我们来调试一下
在InitalContext#InitalContext()
中,通过我们传入的HashTable
进行init
跟进该方法中,可以发现就是根据我们输入的环境变量进行的初始化
继续跟进init方法中,步入getDefaultInitCtx()方法
继续跟进,最终跟到NamingManager#getInitialContext()
中
这里首先通过getInitialContextFactoryBuilder()
初始化了一个InitialContextFactoryBuilder
类。如果该类为空,则将className
设置为*INITIAL_CONTEXT_FACTORY*
属性。还记得该属性是什么吗?就是我们手动设置的RMI上下文工厂类com.sun.jndi.rmi.registry.RegistryContextFactory
。
继续往下走
这里通过loadClass()
来动态加载我们设置的工厂类。最终调用的其实是RegistryContextFactory#getInitialContext()
方法,通过我们的设置工厂类来初始化上下文Context
现在我们知道了,JNDI是通过我们设置的*INITIAL_CONTEXT_FACTORY*
工厂类来判断将上下文初始化为何种类型,进而调用该类型上下文所对应的服务。调用链如下
获取服务交互所需资源
现在JNDI知道了我们想要调用何种服务,那么它又是如何知道服务地址以及获取服务的各种资源的呢?我们接着上文,跟到RegistryContextFactory#getInitialContext()
中
其中的参数var1其实就是我们自己设的环境变量,跟进getInitCtxURL()
JNDI通过我们设置的*PROVIDER_URL*
环境变量来获取服务的路径,接着在URLToContext()
方法中初始化了一个rmiURLContextFactory
类,并根据服务路径来获取实例。
回去之后我们跟进URLToContext方法
然后继续跟进getObjectInstance方法
再跟进到getUsingURL方法中
调用了lookup()
方法,跟进去
继续跟进lookup方法,跟到RegistryContext#lookup()
中,根据上述过程中获取的信息初始化了一个新的RegistryContext
可见,在最终初始化的时候获取了一系列RMI通信过程中所需的资源,包括RegistryImpl_Stub
类、path
、port
等信息。如下图
JNDI在初始化上下文的时候获取了与服务交互所需的各种资源,所以下一步就是通过获取的资源和服务愉快地进行交互了。
各种调用链如下
JNDI动态协议转换
上面两个例子中,我们手动设置了属性*INITIAL_CONTEXT_FACTORY*
和*PROVIDER_URL*
的值来对Context进行初始化。通过对Context的初始化,JNDI能够识别我们想调用何种服务,以及服务的路径。
但实际上,在 Context#lookup()方法的参数中,用户可以指定自己的查找协议。JNDI会通过用户的输入来动态的识别用户要调用的服务以及路径
我们来进行调试
要注意的是我们不管调用的是lookup、bind或者是其他initalContext
中的方法,都会调用getURLOrDefaultInitCtx()
方法进行检查
跟进getURLOrDefaultInitCtx()
方法,会通过getURLScheme()
方法来获取通信协议,比如这里获取到的是rmi
协议
接着跟据获取到的协议,通过NamingManager#getURLContext()
来调用getURLObject()
方法
最终在getURLObject()
方法中,根据*defaultPkgPrefix
*属性动态生成Factory
类
我们看一下JNDI默认支持那些动态协议转换。当我们针对JNDI进行攻击的时候可以优先考虑以下几种服务
通过动态协议转换,我们可以仅通过一串特定字符串就可以指定JNDI调用何种服务,十分方便。但是方便是会付出一定代价的。对于一个系统来讲,往往越方便,就越不安全。
假如我们能够控制string
字段,那么就可以搭建恶意服务,并控制JNDI接口访问该恶意,于是将导致恶意的远程class文件加载,从而导致远程代码执行。这种攻击手法其实就是JNDI注入,它和RMI服务攻击手法中的”远程加载CodeBase”较为类似,都是通过一些远程通信来引入恶意的class文件,进而导致代码执行。
JNDI Reference类
Reference类表示对存在于命名/目录系统以外的对象的引用。比如远程获取 RMI 服务上的对象是 Reference 类或者其子类,则在客户端获取到远程对象存根实例时,可以从其他服务器上加载class文件来进行实例化。
这听起来是不是有点像RMI中Codebase的功能?当在本地找不到所调用的类时,我们可以通过Reference类来调用位于远程服务器的类。
Reference类常用构造函数如下
1 | //className为远程加载时所使用的类名,如果本地找不到这个类名,就去远程加载 |
在RMI中,由于我们远程加载的对象需要继承UnicastRemoteObject
类,所以这里我们需要使用ReferenceWrapper类对Reference类或其子类对象进行远程包装成Remote类使其能够被远程访问。
JNDI Reference 的作用
- 存储对象引用:
- 在命名服务中存储对象的引用信息,而不是对象本身。
- 延迟加载:
- 对象只有在客户端查找时才会被创建。
- 跨网络传递:
- 适用于需要跨网络传递对象的情况(如 RMI、LDAP 等)。
JNDI注入
JNDI+RMI
第一种
上面客户端调用远程方法的时候感觉和原本的RMI是一样的,那么它是不是借助了原生的RMI来实现,如果是的话,那么整个过程也是通过序列化反序列化来实现的,那么我们之前说的攻击RMI的那些攻击方法是不是同样也可以用在这里了
我们下面来调试看看
强制步入lookup方法
继续步入lookup方法
继续步入lookup方法
可以发现我们调用的就是原生RMI的lookup方法,这是在文件RegistryContext中的,但我们最开始创建的是InitialContext,它会根据我们传进去的参数中协议的不同来调用不同的Context来处理
也就是说只要代码IRemoteObj remoteObj = (IRemoteObj)initialContext.lookup("rmi://localhost:1099/remoteObj");
中lookup方法的参数是可控的话,那么我们就可以让其直接加载我们的恶意类来成功实现攻击
当然这不是传统的JNDI注入,下面我们来讲讲传统的注入方法
第二种
传统的是利用Reference类来实现的
JNDIRMIServer代码如下:
1 | import javax.naming.InitialContext; |
其中的类T内容如下:
1 | import java.io.IOException; |
将其编译后的文件移到不同文件夹下,删除T.java文件,然后用python起一个服务
这个时候服务端就可以正常运行,rebind方法可以正常进行绑定,客户端再运行的话就会弹出计算器
JNDIRMIClient代码如下:
1 | import javax.naming.InitialContext; |
当然我们可以来调试一下,看看这个过程是怎么样的
前面几步都是一样的,走到lookup函数后继续往下走
下面有一个decodeObject方法,这个方法的作用是用来解码从远程服务器接收到的对象,将其解码为本地的可用对象
- 如果对象是一个远程引用(
RemoteRef
),decodeObject
会将其解码为远程代理对象。 - 如果对象是一个普通对象,
decodeObject
会直接返回该对象
我们跟进该方法中
这里的重点是getObjectInstance方法,我们跟进去,一直往下走到这里
我们也跟进去,可以看到首先是进行了本地类加载,在本地查找是否存在类T,但是我们知道当然是没有的,所以这里肯定还是null
没事,我们继续往下走
可以自己跟进去看看codebase的值是什么–是我们之前起的远程服务器的地址
出去后继续往下走,又开始了通过loadCLass方法进行类加载,不过这次是远程类加载,从我们的远程服务器上面来获取相关类
走完这一步后就成功弹窗了,loadClass方法谈的计算器是静态代码块中的
再往下走,走完这一步后也会弹一个计算器出来,newInstance方法是通过反射动态创建对象实例,这个时候会调用构造方法,会执行其中的弹计算器的命令
要注意,两个计算器弹出来的原因是不一样的
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
在8u121之后上面的方法便行不通了
在8u191之前可以使用ldap来进行绕过
注入
首先我们先下载一个软件来专门起LDAP服务:https://directory.apache.org/studio/download/download-windows.html
该软件所需的JDK版本是11,但是要在11.0.25以下(亲测11.0.25不行)
在软件中新起一个服务并新建一个连接后如下所示:
JNDILDAPServer文件代码如下:
1 | import javax.naming.InitialContext; |
代码中的cn值任取
至于为什么端口号是10389主要是因为配置中LDAP服务端口的默认设置
运行后会更新成如下所示:
可以看到我们的类绑上去了,所以我们就可以直接在客户端上面进行查询了
JNDILDAPClient文件代码如下:
1 | import javax.naming.InitialContext; |
运行后便会弹出计算器
接下来我们可以调试一下是怎么导致的,同样是调lookup函数
前面一部分自己去调试,然后会走到这个比较重要的文件这里
该函数比较种重要的地方也是下面的decodeObject函数
var4就是从LDAP服务器上面获取到的属性,那么下一步就是要解析这些属性,所以我们跟进去
从函数的具体内容里面我们也可以看到如果说取回来的字段代表的是序列化的,那就调用第一个if中的方法,如果是远程对象的话就调用第二个if中的,如果是引用的话,就是调用else中的decodeReference方法,通过该方法就把引用给解出来了,也就相当于获取到了我们绑定的reference
下一步就和上面的一样,要根据该reference来查找远程地址里面的恶意类
往下走就到了这里
很熟悉了吧,我们接着跟进去,走到这里
剩下的和之前的是一样的,感兴趣继续跟进去,这里就不一一赘述了
高版本绕过
在我们利用Codebase攻击RMI服务的时候,如果想要根据Codebase加载位于远端服务器的类时,java.rmi.server.useCodebaseOnly
的值必须为false
。但是从JDK 6u45
、7u21
开始,java.rmi.server.useCodebaseOnly
的默认值就是true
JNDI同样有类似的限制,在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
工厂类。如下所示
1 | Exception in thread "main" javax.naming.ConfigurationException: The object factory is untrusted. Set the system property 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'. |
JNDI_LDAP_Reference限制
但是需要注意的是JNDI不仅可以从通过RMI加载远程的Reference
工厂类,也可以通过LDAP协议加载远程的Reference工厂类,但是在之后的版本Java也对LDAP Reference远程加载Factory
类进行了限制,在JDK 11.0.1
、8u191
、7u201
、6u211
之后 com.sun.jndi.ldap.object.trustURLCodebase
属性的默认值同样被修改为了false
,对应的CVE编号为:CVE-2018-3149
。
源码分析
这里我们以RMI服务为例
这里用的版本是8u251
可以发现当我们运行客户端之后既没有报错也没有弹计算器,我们可以调试着看一下是修改了哪些地方,同样是调lookup方法
前面的流程都是一样的,我们直接快进到下面这一步
跟进该方法中,该方法之前说过会先进行本地类加载,加载不到之后再到codebase中进行加载,所以我们现在就看一下在codebase中时怎么进行加载的
我们可以看到这里面多加了一个判断:"true".equalsIgnoreCase(trustURLCodebase)
,只有当这个为true的时候才能够从远端进行类加载
而trustURLCodebase默认为false,所以显然是进入不到if中的,会return null,也不会报错
因此类也就加载不出来,自然也就弹不了计算器了
回顾一下这个过程,我们知道引用还是可以正常加载出来的,但是通过引用来远程加载工厂类的(也就是factory)时候就行不通了
那么这个时候我们的思路就是要找找可以本地加载出来的工厂类,也要比较经常用的
绕过方法
使用本地的Reference Factory类
8u191后已经默认不允许加载codebase
中的远程类,但我们可以从本地加载合适Reference Factory
。
需要注意是,该本地工厂类必须实现javax.naming.spi.ObjectFactory
接口,因为在javax.naming.spi.NamingManager#getObjectFactoryFromReference
最后的return
语句对Factory
类的实例对象进行了类型转换,并且该工厂类至少存在一个getObjectInstance()
方法
Tomcat8
org.apache.naming.factory.BeanFactory
就是满足条件之一,并由于该类存在于Tomcat8依赖包中,攻击面和成功率还是比较高的。
org.apache.naming.factory.BeanFactory
在 getObjectInstance()
中会通过反射的方式实例化Reference所指向的任意Bean Class,并且会调用setter方法为所有的属性赋值。而该Bean Class的类名、属性、属性值,全都来自于Reference对象,均是攻击者可控的。
服务端和客户端加载依赖
1 | <dependency> |
在该类中也有一个getObjectInstance方法,因此在上面进行到:factory.getObjectInstance
一行时就会走到该文件中
该方法重点就在于有一个反射调用
由于是从根源上绕过,所以无论是起一个rmi还是ldap服务都是可以的,下面起的是rmi服务
先开一个注册中心
服务端代码:
1 | import org.apache.naming.ResourceRef; |
客户端代码:
1 | import javax.naming.InitialContext; |
执行了之后会弹出计算器
也就是说如果高版本有依赖Tmocat8的环境来的话,我们就可以通过这种方法来进行绕过