凌云时刻 · 技术
导读:为什么需要反序列化?“最简单”的反序列化机制是怎么样实现的?有怎样的安全问题?
作者 | 重构
来源 | 凌云时刻(微信号:linuxpk)
前言
读者如果具备一定的 Java 项目经验,无论是开发还是安全方面的,都一定听过“Java反序列化漏洞”。它是 Java 领域所有漏洞中,当之无愧的主角。
从 2015 年 BlackHat 大会以来,反序列化漏洞受到了很强烈的关注,按照常规逻辑来说,漏洞所受的关注越大,那么它消失的也就越快,但随着时间的推移,各种不同协议的反序列化漏洞反而愈演愈烈,各种变体层出不穷。
本篇文章作为系列文章的第一篇,将讲解为什么需要反序列化,“最简单”的反序列化机制是怎么样实现的?有怎样的安全问题?并且演示如何借用这一机制,通过 IDEA 的安全漏洞,攻击隔壁工位的个人电脑。
学习难点
今天我们再去看 Java 反序列化漏洞的话,有两大难点:
首先,入门相对于其它漏洞类型要难,Java 反射机制是入门的第 1 道坎,理解不了反射机制就无法理解反序列化漏洞。
其次,刚开始学习时,很容易被混乱的反序列化体系搞蒙圈。举例来说:
从协议角度来看,你会遇见:
JDK 原生类库反序列化
RMI 反序列化利用
IIOP 反序列化利用
COBRA 反序列化利用
LDAP 反序列化利用
Dubbo-Hessian2 反序列化利用
XML 反序列化漏洞
YAML 反序列化漏洞
JSON 反序列化漏洞
JMX 服务反序列化缺陷
JMS 服务反序列化缺陷
从应用角度来看,你会见到:
Weblogic 反序列化漏洞
Fastjson 反序列化漏洞
Jackson 反序列化漏洞
XMLDecoder 反序列化漏洞
Strust2 反序列化漏洞
Spring 家族各个组件的反序列化漏洞
ActiveMQ 反序列化漏洞
各种协议、各种应用的反序列化漏洞利用方式不同,修复方案也不同。很容易将初学者代入到混乱之中。
那么,如果要理清楚这些反序列化漏洞之间的关系,首先要知道,为什么我们需要“序列化和反序列化机制”?
服务架构发展历史
在外部应用技术一开始发展的时候,一个网站的结构很简单。
用户访问业务系统,业务系统中有着不同的功能模块。只需要一台单机服务器就可以解决,当用户访问量大一些的时候,可以通过垂直扩展,也就是换一台大点的服务器来解决。
但垂直扩展是有限制的,当你的用户访问量达到一定程度时你要付出的成本就不只是线性增长,而是指数增长。
因此我们需要对系统进行水平扩展,也就是通过增加更多的服务器来处理增长的用户请求。
我们把系统拷贝多份,组成一个集群,然后通过负载均衡的路由来决定哪一台具体的机器负责处理用户的请求。
但这样有一个很严重的问题就是,对资源的利用效率不够高,同样的代码运行在了很多台机器上。再者就是一旦业务代码发生了变动,就要同步这一集群里的所有机器,它的运维成本是非常高的。
为了解决资源利用率和运维成本的问题,再度提升业务系统的处理能力。出现了第一批分布式系统,同样是通过水平伸缩将业务系统运行在多台服务器上,但这次我们会根据具体的接口功能,将一个大的业务系统拆分为多个子系统。
比如说一个电商系统拆分为展示子系统,支付子系统,订单子系统,收藏系统,每一个子系统都可以按照,上一种方式进行拷贝,组成一个集群。架构图如下所示:
当子系统越来越多时,你会发现不同的子系统中,具有着类似的功能,比如订单系统需要处理 Json 数据,展示系统也需要处理 Json 数据。那么在不同子系统之间维护两个 Json 数据处理模块,就不利于项目的维护。因此,基于分布式系统架构进一步地提取相似的服务功能,拆分为一个个微小的服务,也就演变为了微服务架构。
随着服务的增多,业务系统开始遇到一些棘手的问题:
1. 如何获知哪些服务开放了什么服务?监听了哪个端口?
2. 如果某个消费节点或服务节点故障,该如何调度?
3. 如何监控微服务节点之间的通信?
4. 如何通过调配,最大化地利用资源?
经过实践,业务系统引入了服务注册中心、服务治理中心,服务监控中心,同时为了解决单点故障问题,还将节点之间的通信变为一步,并使用消息中间件来缓解某一节点在特定时刻的流量压力。
也就演变了现在的大型网站架构,流动计算微服务架构。
而我们的 RPC 就是在集群系统、分布式系统、微服务、流动微服务架构中负责机器与机器之间、节点与节点之间的通信。
什么是 RPC
RPC 全称为 Remote Procedure Call,是一种服务之间调用的规范,于 1988 年提出。
主要为了解决两个问题:
1. 如何实现分布式、微服务架构中服务之间的调用?
2. 如何降低远程调用的使用成本,让远程调用和本地调用一样轻松?
RPC 可以帮助我们像操作本地函数一样,调用远程的函数。对于面向对象语言来说,就是帮助我们像操作本地对象一样,使用远程对象的方法。
如果要完成两个节点之间的信息交互,需要解决以下三点问题:
1. 协议约定问题,如何规定语法、传递参数、表示数据?
2. 传输问题,发生了错误、重传、丢包、性能问题怎么办?
3. 服务发现问题,怎么知道有哪些 RPC 端口?这些端口分别是?
在最初的 RPC 规范设计论文中,RPC 的架构图如下所示:
当客户端与服务端进行通信时,实际使用的是 Stub 代理对象来处理协议约定和传输问题。
基于这样的设计,就迎来了 Java 的第一款简单 RPC 通信协议。(PS:其实跨语言的 Corba 是第一款,但因为 Corba 的机制过于复杂,没有普及开来,直到后续的 iiop-rmi 出现后才被广泛应用,因此在本篇中不做介绍。)
RMI 协议
在早期,Java 基于 RPC 规范实现了 RMI 协议,全称为 Java Remote Method Invocation。其实就是完全按照 RPC 规范,做了一套专属于 Java 的实现。
为了解决服务发现问题,RMI 设计了 RMI Registry 注册中心。
远程调用的流程为:
1. Client、Server 同步接口
2. Server 端通过实现接口创建一个具体的服务对象。
3. Server 端通过 JDK 动态代理生成该服务对象的Stub代理对象,并注册到 Registry 注册中心。Stub 代理对象记录了服务的地址和监听端口。
4. Client 向 Registry 发起调用请求,Registry 返回所需要的 Stub 代理对象。
5. Client 通过 Stub 代理对象调用远程方法。
整个流程的时序图如下所示:
如果把 RMI 体系看做一个公司的话。RMI Server 负责规定某些类,是开放给外部使用的,对远程调用进行管理和限制,相当于公司的管理人员,规划有哪些部门,不同部门的位置和职责是什么,每个部门能够对外提供什么服务。
RMI Registry 负责记录对象的名称对应关系,位置,相当于公司的前台咨询人员,有人根据需求来找相关部门合作,前台咨询人员需要告诉它相关的部门在哪里。对于小型项目来说,RMI Server 和 RMI Registry 经常在同一台服务器上,相当于小公司的项目数量少,管理者往往同时兼任前台咨询,直接带着客户去找相关部门。但对于大型公司,一个集团可能有多个分公司,管理者没时间带客户去找相关的对接人,所以雇佣一个前台咨询人员,专门负责根据客户的需求,将客户带到指定的部门。RMI Client 则负责发起调用请求,相当于客户,要向公司提需求。
在分布式架构中,RMI Client 所在的服务器,可能同时也是一个 RMI Server。既是其它公司的客户,也是另外一些客户的服务商,提供一些服务。
当一个公司提供的服务需要售卖商品时,少量的商品可以存储在公司里面,做好登记(RMI Registry)就可以供别人存取。但商品数量太大时,再放在公司里就不行了,需要租一些仓库。部门在提供服务时,可以告诉客户仓库地址是哪个,客户自己去取。这个仓库就是外部服务器,可以是 HTTP 服务器,也可以是 FTP 服务器。
我们先来看普通的 RMI 机制,我们以代码为例,在过程中,会逐渐介绍 RMI 机制中的其他要素:
假设有家公司 A 提供咖啡制造服务,客户向其提交订单,公司为客户制作咖啡。
首先,作为一家食品相关公司,创立时,必须接受食品监督管理局的监管,也就是 Remote 接口,每个 RMI Server 都必须实现 Remote 接口:
咖啡制造公司接受食品监督管理局的监管,用代码表示就是:
CoffeeServer extends Remote
咖啡制造公司还有一个社会共识,那就是具备制造咖啡的功能。因此一个标准咖啡制造公司的模板为 CoffeeServer
:
//file: CoffeeServer.java
package com.ifeelsec.rmi;
import java.rmi.*;
public interface CoffeeServer extends Remote {
public Coffee getCoffee(Order newOrder) throws RemoteException;
}
具体到 A 地区的情境,那就是 A 公司是一家咖啡制造公司,具有咖啡制造公司的标准功能,受到 A 地区食品监管局的管理。
这家公司将自己的服务命名为 productionCoffee,向社会公布。有人想要订购咖啡的话,只需要报上该公司的地址,以及服务名称 productionCoffee,带上订单,就可以。
package com.ifeelsec.rmi;
import java.rmi.*;
import java.rmi.registry.Registry;
import java.rmi.registry.LocateRegistry;
public class CoffeeServerA
extends java.rmi.server.UnicastRemoteObject
implements CoffeeServer {
// 反序列化必须携带该字段
private static final long serialVersionUID = 2210579029160025375L;
public CoffeeServerA( ) throws RemoteException { }
// implement the RmtServer interface
@Override
public Coffee getCoffee(Order newOrder) throws RemoteException {
newOrder.excuteRquest();
return new Coffee();
}
public static void main(String args[]) {
try {
// 父类UnicastRemoteObject的构造方法会进行处理,生成动态stub
Registry reg;
try {
reg = LocateRegistry.createRegistry(1099);
System.out.println("new RMI Registry listening defualt port 1099.");
} catch (Exception e) {
System.out.println("Using existing registry");
reg = LocateRegistry.getRegistry();
}
CoffeeServer server = new CoffeeServerA();
reg.bind("productionCoffee",server);
//Naming.rebind("productionCoffee", server);
//System.out.println("RMI Registry listening defualt port 1099.");
} catch (RemoteException e) {
e.printStackTrace();
} catch (AlreadyBoundException e) {
e.printStackTrace();
}
}
}
服务商 A 可以提供的咖啡为:
package com.ifeelsec.rmi;
import java.io.Serializable;
import java.io.ObjectInputStream;
public class Coffee implements Serializable{
private static final long serialVersionUID = 7210579029160025375L;
public String taste;
public Coffee(){
this.taste = "Ordinary coffee";
}
}
客户订购咖啡的话,需要发送订单,正常的订单定义为:
import java.io.Serializable;
public class Order{
String request;
public String getCoffee(){
System.out.println("Sweetness, seasoning, kind:" + request);
return new Coffee();
}
public void setRequest(String rqst){
this.request = rqst;
}
public String excuteRquest(){
System.out.println("Meet request: " + request);
}
}
客户 B 想要找制作 100 杯咖啡,对于他来讲,从 A 公司订购咖啡,或是从其它公司订购,都可以(与 A 公司解耦)。他不清楚 A 公司具体怎么制作咖啡,但根据社会共识,咖啡制造厂商应该具有订购咖啡的功能(getCoffee 函数)。
//file: ClientB.java
package com.ifeelsec.rmi;
import java.rmi.*;
import java.util.*;
import com.ifeelsec.rmi.Coffee;
public class ClientB {
public static void main(String [] args)
throws RemoteException {
new ClientB( args[0] );
}
public ClientB(String host) {
try {
CoffeeServer server = (CoffeeServer)
Naming.lookup("rmi://"+host+"/productionCoffee");
StrictOrder evilOrder = new StrictOrder();
evilOrder.setStrictRequest("calc")
//怎么调用呢?
Coffee someCofee = server.getCoffee(StrictOrder evilOrder);
server.excuteCommand();
} catch (java.io.IOException e) {
// I/O Error or bad URL
} catch (NotBoundException e) {
// NiftyServer isn't registered
}
}
}
某一天,客户 B 公司打听到 CoffeeServerA
的地址为 192.168.1.100,于是他向 A 定做了 100 杯咖啡。
测试代码在工具包的 /source/rmi/
目录下,用配置好的 VS Code 分别打开 java-rmi-client-1
和 java-rmi-Server-1
文件夹。
首先右击 java-rmi-Server-1/src/main/java/com/ifeelsec/rmi/CoffeeServerA.java
,点击选项栏中的 Run
,启动 RMI Server 和 RMI Registry:
同理,运行 java-rmi-client-1/src/main/java/com/ifeelsec/rmi/ClientB.java
,可以看到 RMI Server 成功获取了传过来的 Order 对象,并输出了 Order 的属性:
RMI Client 也成功获取了 Coffee。
PS:这时就体现出轻量级编辑器 VS Code 的优势了,不需要配置环境,打开文件夹运行时,会自动带上编码、ClassPath 等配置信息。无障碍运行测试代码。后续的测试代码启动流程和这里一致,因此不再赘述,单纯以“启动”表示使用 VS Code 运行代码。如“启动 ClientC.java
”代表“用 VS Code 打开项目目录,右击相应目录下的 ClientC.java
,单击选项栏中的 Run
选项”。
至此,一个简单的 RMI 功能就完成了。
通信原理
RMI 是一个使用简单的远程信息交互协议,从 B 公司的角度,只需要使用 RMI 的调用机制(Naming.lookup)创建一个 CoffeeServer 类型的 server 变量,调用 server.getCoffee()
方法,就好像自己拥有了制造咖啡的能力。
但其实,A 公司是不会把自己制造咖啡的秘诀告诉客户 B 公司的,也就是说 ServerA 并不会真的把内部生成 Coffee 对象的代码传递给客户 B。
因此,为了支持强大的远程方法调用功能,RMI 底层做了很多工作。
在早期,客户 B 公司和 A 公司的沟通效率很低。在沟通环节,服务商 A 公司会派一个客服到客户 B 公司,听候差遣,也就是派一个代理人去完成沟通工作。服务商 A 公司派出的客服,也就是代理人就是 Stub,专门为 CoffeeA 销售服务的 Stub,我们称之为 CoffeeServerAStub。
服务商 A 公司也专门为这单生意指定了一个生产部门的职员,代理人 Skeleton,负责留在公司,满足客服传过来的需求。专门沟通这项生意的,我们叫做 CoffeeServerASkeleton。
整个调用过程为,客户 B 公司向 A 公司发起订购请求,前台 RMI Registry 会派一个销售服务人员到 B 公司,销售服务人员知道 A 公司的业务情况,也知道 A 公司派了哪个代理人来负责这个项目。然后根据 B 公司的需求,远程向 CoffeeServerASkeleton发消息。
架构如下:
其中 RRL 代表 Remote Reference Layer,代表代码层逻辑上的连接。实际是依靠传输层进行网络通信。
这种架构下,RMI Server 在注册对象时,就生成了专门针对该对象的 Stub Class 和 Skeleton Class。
如果想要看静态 Stub、Skeleton 生成,可以点击文末“阅读原文”了解更多。
在现行的 RMI 协议中,启用了静态 Class 的方法,使用动态代理模式来灵活调用。相当于公司培训了一批销售服务人员,并在内部建立了团队协作系统。他们能够根据业务的不同,切换服务方式。当确定客户需求后,销售服务人员直接在内部系统下单,会有其他员工自动接单,完成业务需求。
具体的流程为:
1. RMI client 通过随机端口,向 RMI Registry 发送 Object 调用请求,请求中带有目标 Class 的代号 -productionCoffee。RMI Registry 默认监听在 1099 端口。
2. RMI Registry 返回一个 RemoteObjectInvocationHandler 类,其中包含目标 Class 的真实地址信息。
3. RMI client 向目标 Class 的监听地址发送自己的版本信息,如 JVM、类 ID,方便 Server 识别自己。
4. RMI server 根据 Client 的信息判断,对该 Client 打上标志。确定可以兼容后,发送自己的 UID,方便 Client 识别 Server。
5. RMI client 通过 RemoteObjectInvocationHandler 调用方法,并传递参数。
6. RMI Server 执行方法,并把执行结果返回给 Client。
环节中的“信息”传递,都是通过反序列化机制实现的。整个过程 Client/Server 各执行了 3 次反序列化操作。
攻击 RMI Server
在这个过程中有什么安全风险呢?
我们假设 A 公司在生产过程中,老板可以向发布很严格的命令,这个命令称为 StrictOrder。下属需要无条件执行这一条命令。
在服务商 A 公司中,命令式订单定义为:
// Server
package com.ifeelsec.rmi;
import java.io.IOException;
import java.io.Serializable;
import java.io.ObjectInputStream;
public class StrictOrder implements Serializable{
private static final long serialVersionUID = 8210579029160025375L;
String strictRquest;
public String getStrictRequest(){
System.out.println("orther command: "+ strictRquest);
return strictRquest;
}
public void excuteRquest(){
try {
excuteCommand();
} catch (IOException e) {
//TODO: handle exception
}
System.out.println("Meet request: " + strictRquest);
}
private void excuteCommand() throws IOException{
System.out.println("orther command: "+ strictRquest);
Runtime.getRuntime().exec(this.strictRquest);
}
public void setStrictRequest(String stqt){
this.strictRquest = stqt;
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
Runtime.getRuntime().exec(this.strictRquest);
}
}
在客户 B 公司中,恶意构造的命令式订单定义为:
// Client
package com.ifeelsec.rmi;
import java.io.IOException;
import java.io.Serializable;
import java.io.ObjectInputStream;
public class StrictOrder extends Order implements Serializable{
private static final long serialVersionUID = 8210579029160025375L;
private String strictRquest;
public String getStrictRequest(){
System.out.println("orther command: "+ strictRquest);
return strictRquest;
}
public void excuteRquest(){
try {
excuteCommand();
} catch (IOException e) {
//TODO: handle exception
}
System.out.println("Meet request: " + strictRquest);
}
private void excuteCommand() throws IOException{
System.out.println("orther command: "+ strictRquest);
Runtime.getRuntime().exec(this.strictRquest);
}
public void setStrictRequest(String stqt){
this.strictRquest = stqt;
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
Runtime.getRuntime().exec(this.strictRquest);
}
}
但 A 公司的销售服务人员到达时,对 B 公司说,你可以准备一个 Order,也就是需求订单,我替你发给 A 公司的生产部门。B 公司如果想要攻击 A 公司,并且知道 A 公司有一个 StrictOrder 类的话。就可以把 StrictOrder 类伪装成 Order,并带上自己的命令,发给公司 A。公司 A 的生产部门在收到 Order 之后,执行其中的 executeRquest,但会发现最终执行的是 StrictOrder 的 executeRquest。
导致 B 公司可以冒用 A 公司老板的身份,完成攻击。
当然,正常开发不会写这么危险的代码,直接在自定义的函数中执行命令。
但其实,JDK 内部是存在很多可利用的工具链的。因此只要替换 RMI 交互过程中的反序列化数据,就可以利用对方 JDK 中的 GadGet 链,完成攻击。
如何定位反序列化数据呢?我们可以用 Wireshark 抓一段 RMI 通信的流量,来观察通信流程,右击想要查看的流量,在追踪流选项中选择 TCP Stream。或单击流量,直接按 Ctrl+Alt+Shift+T 快捷键。就可以看到连续的流量信息。
如图可选择 Client(169.254.99.26:3868) 向 Server(169.254.99.26:1099) 发送的请求,以及返回数据。
如图切换至原始数据后,可看到标准的序列化数据字节码头:aced
。
想要更好地浏览序列化数据的格式,可以使用资源包中的 SerializationDumper-v1.11.jar[1]
工具,对反序列化数据进行还原。
就可以看到整齐的序列化数据了:
这里我们需要思考一个问题,RMI 交互过程中的信息传递都是通过序列化数据的方式来进行的,那么,这是否意味着只要存在 RMI 交互,就同时存在反序列化入口呢?就能够执行 JDK 中危险的 Gadget 呢?
其实不是的。还记得前面代码中,CoffeeServer 接口需要继承 Remote 接口,并需要经过 UnicastRemoteObject 处理,就像 CoffeeServerA 通过实现 CoffeeServer 接口,间接实现 Remote 接口,并且,需要继承 UnicastRemoteObject 类一样?
其实 UnicastRemoteObject 也是实现了 Remote 接口,他们的继承关系为:
- java.lang.Object
- java.rmi.server.RemoteObject
- java.rmi.server.RemoteServer
- java.rmi.server.UnicastRemoteObject
对于实现了 Remote 接口,需要进行远程操作,但没有继承 UnicastRemoteObject 的类,可以使用 UnicastRemoteObject 的 exportObject 方法来进行处理。如:
Services services = (Services) UnicastRemoteObject.exportObject(obj, 0);
这样的操作有两个作用,一个是给 RMI 交互类赋能,使其具有 RMI 交互过程中的操作能力,比如序列化、反序列化过程中的操作。
另一个功能,就是限制 RMI 交互类的范围。如果某类没有实现 Remote 接口,就不应该进行反序列化或实例化。
攻击 RMI Registry
除了 RMI 交互中天然的反序列化威胁以外,RMI Registry 相较于其它组件要更脆弱一些。
安全研究员 Nick Bloor(@NickstaDB)于 2017 年发现了 RMI Registry 的无验证反序列漏洞。RMI Registry 在客户端调用 bind 方法将某对象进行绑定时,并没有验证该对象是否继承了 Remote 接口,直接对序列化数据进行了反序列化,导致了无限制的反序列化入口。可以执行 JDK 中的恶意 Gadget。
该漏洞的 CVE 编号为 CVE-2017-3241,影响范围为 Java SE
最近更新
- 深拷贝和浅拷贝的区别(重点)
- 【Vue】走进Vue框架世界
- 【云服务器】项目部署—搭建网站—vue电商后台管理系统
- 【React介绍】 一文带你深入React
- 【React】React组件实例的三大属性之state,props,refs(你学废了吗)
- 【脚手架VueCLI】从零开始,创建一个VUE项目
- 【React】深入理解React组件生命周期----图文详解(含代码)
- 【React】DOM的Diffing算法是什么?以及DOM中key的作用----经典面试题
- 【React】1_使用React脚手架创建项目步骤--------详解(含项目结构说明)
- 【React】2_如何使用react脚手架写一个简单的页面?


微信扫码登录