1. 项目概述:一次对经典安全漏洞的深度“考古”
最近在整理内部安全审计的案例库,翻到了一个老项目里关于Apache Shiro的漏洞利用记录。虽然Shiro-550、Shiro-721这些编号现在听起来像是“上古”漏洞,很多新入行的兄弟可能都没听说过,但直到今天,我依然能在一些企业的老旧系统里找到它们的影子。安全领域有个特点,漏洞本身会过时,但漏洞背后的设计缺陷和编码逻辑,却像一面镜子,能持续映照出我们在开发中容易犯的共性错误。这次,我们不搞简单的漏洞复现,那太“脚本小子”了。我想带大家做一次彻底的“代码考古”,亲手把Shiro那个著名的反序列化漏洞(CVE-2016-4437)从源码层面扒开,看看它究竟是怎么“炼成”的。这不仅仅是满足好奇心,更重要的是,通过理解一个经典漏洞的完整诞生过程,我们能建立起一套分析同类问题的思维框架——下次再遇到其他框架的“rememberMe”功能,或者其他加密参数,你就能条件反射般地想到:“这里会不会有类似的坑?”
这个分析过程,本质上是一次针对特定漏洞的“根本原因分析”。它要求我们跳出单纯使用工具进行黑盒测试的舒适区,深入到Java代码、加密解密、序列化协议乃至框架设计哲学的层面。对于开发者而言,这能帮你写出更健壮的代码,避免踩进同样的陷阱;对于安全研究员,这能极大提升你的漏洞挖掘深度和武器化能力,不再停留在“知其然”的层面。整个分析之旅,我们会从Shiro的默认配置出发,追踪一个加密Cookie的生成、传递到解析的全链路,最终定位到那一行决定性的、使用了硬编码密钥的代码。相信我,当你亲眼在源码里找到那个Base64.decode(“kPH+bIxk5D2deZiIxcaaaA==”)时,那种“原来如此”的顿悟感,是任何漏洞扫描报告都无法给予的。
2. 漏洞原理与架构缺陷深度剖析
2.1 Shiro身份认证的核心流程与“记住我”机制
要打蛇,得先知道七寸在哪。Shiro作为一个强大的安全框架,其核心职责之一是管理用户的会话和认证状态。在Web应用中,为了提升用户体验,“记住我”(RememberMe)是一个常见功能。Shiro的实现方式是:当用户勾选“记住我”登录成功后,服务器端会生成一个包含用户身份信息的令牌,加密后发送给浏览器,保存在名为rememberMe的Cookie中。下次用户访问时,即使会话过期,浏览器也会自动带上这个Cookie,Shiro会尝试解密并反序列化其中的内容,自动重建登录状态。
这个流程听起来很合理,但魔鬼藏在细节里。整个安全链条的强度,取决于最薄弱的那一环。在这里,链条包括:序列化算法、加密算法、加密密钥和反序列化逻辑。Shiro在早期版本中,为开发者“贴心”地提供了默认实现,却也埋下了祸根。
- 序列化与加密对象:Shiro使用Java原生的序列化机制,将用户身份主体(比如用户名)转换成二进制流。然后,它使用AES加密算法对这个二进制流进行加密。AES是一种对称加密算法,意味着加密和解密使用同一把密钥。
- 密钥的生成与管理:这是整个漏洞的命门。在
AbstractRememberMeManager类中,Shiro提供了一个默认的加密密钥。问题在于,这个密钥是硬编码在代码库中的固定值。所有使用Shiro默认配置的应用,只要开启了“记住我”功能,使用的都是同一把密钥。 - 攻击入口:由于密钥是公开的(因为开源),攻击者就可以伪造一个恶意的
rememberMeCookie。他可以使用公开的密钥,加密一段精心构造的、能够导致远程代码执行的恶意序列化数据(例如使用 CommonsCollections 库利用链生成的数据),然后将这个加密后的字符串作为Cookie值发送给服务器。 - 致命的反序列化:Shiro服务器在接收到Cookie后,会用它那固定的密钥进行解密。解密成功后,它会毫无戒备地对解密出的二进制数据执行
ObjectInputStream.readObject()操作。这个过程,就是反序列化。一旦反序列化的数据包含恶意利用链,就会触发远程代码执行,服务器就沦陷了。
注意:这里的关键不是AES被破解,也不是Java序列化本身有漏洞(虽然它有问题),而是**“密钥可控”**这一根本性设计缺陷。将安全依赖于一个默认的、公开的静态密钥,违背了密码学最基本的原则。
2.2 硬编码密钥:一个不可饶恕的设计失误
让我们把目光聚焦到漏洞的核心——那个硬编码的密钥。在Shiro 1.2.4及之前版本的源码中,你可以在org.apache.shiro.mgt.AbstractRememberMeManager类里找到如下代码:
public abstract class AbstractRememberMeManager implements RememberMeManager { private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA=="); private Serializer<PrincipalCollection> serializer = new DefaultSerializer<PrincipalCollection>(); private CipherService cipherService = new AesCipherService(); private byte[] encryptionCipherKey; private byte[] decryptionCipherKey; public AbstractRememberMeManager() { setCipherKey(DEFAULT_CIPHER_KEY_BYTES); // 构造函数中使用了默认密钥 } // ... 其他方法 }这段代码清晰得令人窒息。DEFAULT_CIPHER_KEY_BYTES是一个静态常量,经过Base64解码后,成为AES加密的密钥。每一个没有在配置中显式覆盖cipherKey属性的Shiro应用,都在使用这个相同的密钥。
为什么这是灾难性的?
- 无差异性:所有使用默认配置的应用,其加密“门锁”的钥匙都是一模一样的。攻击者只需要制作一把“万能钥匙”(即利用这个公开密钥加密的恶意payload),就可以打开所有没换锁的门。
- 可预测性:密钥不是随机生成的,而是固定的。这使得攻击变得极其简单和自动化。漏洞利用工具(如ShiroAttack2、shiro-exploit)可以内置这个密钥,实现一键攻击。
- 责任转嫁失效:框架提供默认配置本意是降低开发者的使用门槛,但在安全领域,默认配置必须是安全的。这个设计将安全责任错误地转嫁给了开发者,假设他们“一定会去修改密钥”,而实际情况是,很多开发者甚至不知道这个配置的存在。
这个案例给我们的深刻教训是:任何安全相关的配置,尤其是加密密钥、盐值、初始向量等,绝对不能在代码中硬编码默认值。必须强制要求应用在部署时进行配置。后来Shiro在修复版本中,移除了这个默认密钥,如果开发者不主动配置,启动时会直接抛出异常,这才是正确的做法。
3. 漏洞利用链的构造与关键代码分析
理解了原理,我们来看看攻击者是如何将理论转化为实践的。漏洞利用的核心是构造一个恶意的序列化对象。Shiro漏洞之所以危害巨大,是因为它完美衔接了另一个经典的Java漏洞:Apache Commons Collections库的反序列化利用链。
3.1 从Java反序列化到命令执行
Java反序列化漏洞本身不是一个新鲜事。当ObjectInputStream读取一个序列化对象时,它会根据对象中的类描述符,尝试在当前的类路径下找到对应的类,并调用其特定的方法(如readObject、readResolve等)来重建对象。一些类在readObject方法中的实现存在安全隐患,可能会执行某些危险操作。
Apache Commons Collections 3.2.1及之前版本中,提供了一些用于对象转换和回调的工具类,例如InvokerTransformer、ChainedTransformer、ConstantTransformer和LazyMap。攻击者可以像搭积木一样,将这些对象以特定的顺序组合起来,形成一个“利用链”(Gadget Chain)。当这个组合对象被反序列化时,其readObject方法会触发一系列的变换和回调,最终可以执行任意Java代码,例如通过Runtime.getRuntime().exec(“calc”)来弹出计算器。
Shiro漏洞的“助攻”在于,它提供了一个稳定、可靠的触发入口(RememberMe Cookie的解密与反序列化),并且默认密钥公开,使得攻击者可以轻松地将构造好的CommonsCollections利用链,加密后送入这个入口。
3.2 构造恶意RememberMe Cookie的步骤拆解
假设攻击者已经知道了目标系统使用Shiro且存在默认密钥漏洞,他的攻击流程如下:
生成恶意序列化数据:使用ysoserial等工具,指定CommonsCollections利用链和要执行的命令(如
touch /tmp/hacked),生成一个恶意的Java序列化字节数组。java -jar ysoserial.jar CommonsCollections5 "touch /tmp/hacked" > payload.ser使用固定密钥进行AES加密:Shiro使用的AES模式是CBC,并需要IV(初始化向量)。攻击者需要模拟Shiro的加密过程:先序列化,然后使用
AesCipherService和硬编码的密钥kPH+bIxk5D2deZiIxcaaaA==进行加密。加密时,Shiro会生成一个随机的IV,并将其拼接到加密后的数据前面。所以最终的密文结构是:IV + 加密后的序列化数据。进行Base64编码:将上一步得到的密文(IV+加密数据)进行Base64编码,得到一个字符串。
发送HTTP请求:将这个Base64字符串作为
rememberMeCookie的值,发送给目标服务器的任意一个需要Shiro鉴权的端点。服务器端触发:服务器端的Shiro接收到Cookie,进行Base64解码,提取出前16个字节作为IV,后面的部分作为密文,用同样的硬编码密钥进行AES解密。解密成功后,将得到的字节数组交给
DefaultSerializer进行反序列化(即ObjectInputStream.readObject())。此时,恶意的CommonsCollections对象被还原,其readObject方法被自动调用,利用链执行,最终运行了touch /tmp/hacked命令。
关键代码定位(攻击者视角):攻击者需要精确复现Shiro的加密逻辑。核心是找到org.apache.shiro.crypto.AesCipherService这个类,查看其encrypt方法。他会发现,Shiro使用的是AES/CBC/PKCS5Padding模式。在加密时,AesCipherService会生成一个随机IV,并调用JcaCipherService的crypt方法。加密后的字节数组,就是IV拼接上真正的密文。攻击者编写的漏洞利用工具,本质上就是实现了这个加密过程的反向工程。
4. 漏洞复现环境搭建与深度调试
“纸上得来终觉浅,绝知此事要躬行。” 要真正吃透这个漏洞,最好的办法就是亲手搭建环境,并用调试器跟踪代码的每一步执行。这里我分享一个用IDEA进行源码级调试的实战过程。
4.1 靶场环境搭建与配置
我们不使用现成的Docker靶场,而是自己创建一个最简单的Spring Boot + Shiro 1.2.4的Web应用,这样对流程的控制更彻底。
- 创建项目:使用Spring Initializr创建一个基础的Spring Boot Web项目。
- 引入依赖:在
pom.xml中引入存在漏洞的Shiro版本和Web依赖。<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <version>1.2.4</version> <!-- 漏洞版本 --> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-web</artifactId> <version>1.2.4</version> </dependency> - 配置Shiro:创建一个
ShiroConfig配置类,配置一个简单的内存Realm和启用RememberMe功能。关键点:不要手动设置rememberMeManager的cipherKey,让它使用默认值。@Bean public RememberMeManager rememberMeManager() { CookieRememberMeManager rememberMeManager = new CookieRememberMeManager(); // 故意不设置 cipherKey,让其使用默认的硬编码密钥 // rememberMeManager.setCipherKey(Base64.decode("你自己的密钥")); return rememberMeManager; } - 创建登录页面和控制器:实现一个简单的
/login页面,表单中包含“记住我”复选框。后端控制器处理登录逻辑,调用subject.login(token)。
启动这个应用,你就拥有了一个最纯净的、存在Shiro-550漏洞的靶场。
4.2 使用IDEA进行动态调试与代码追踪
接下来是重头戏,我们启动调试模式,在关键位置打上断点,亲眼看看漏洞是如何被触发的。
- 定位入口断点:在
CookieRememberMeManager的getRememberedPrincipals方法上打上断点。这是处理rememberMeCookie的入口。 - 构造并发送攻击请求:使用Burp Suite、Postman或者写一个简单的Python脚本,按照前面章节描述的步骤,生成一个恶意的RememberMe Cookie,发送给靶场的任意一个受保护接口(比如
/home)。 - 跟踪解密过程:当请求命中断点后,一步步Step Into。
- 首先会进入
getRememberedSerializedIdentity方法,它从Cookie中读取字节数组。 - 然后进入
convertBytesToPrincipals方法,这里调用了decrypt方法。 - 跟进
decrypt,你会进入AbstractRememberMeManager的decrypt方法,它最终调用AesCipherService.decrypt。 - 仔细观察:在解密时,查看
cipherService使用的encryptionCipherKey变量,它的值正是我们“熟悉”的kPH+bIxk5D2deZiIxcaaaA==解码后的字节数组。这就是铁证!
- 首先会进入
- 跟踪反序列化过程:解密成功后,代码会返回字节数组,接着调用
deserialize方法。- 跟进
deserialize,你会进入DefaultSerializer的deserialize方法。 - 这里创建了一个
ByteArrayInputStream和ObjectInputStream,然后直接调用了ois.readObject()。 - 就在这一刻,恶意payload被反序列化。如果你的payload是弹计算器(Windows)或者创建文件(Linux),此时命令就会被执行。你可以在调试器的变量窗口,看到
ois.readObject()返回的对象,是一个复杂的、包含Transformer链的LazyMap或TiedMapEntry等对象。
- 跟进
实操心得:在调试反序列化触发命令执行时,建议payload先使用无害命令,如
echo test > /tmp/shiro_test或calc(Windows GUI程序在无头服务器上可能不工作)。同时,确保CommonsCollections库在靶场的类路径中。调试过程可能会因为利用链的复杂性而抛出各种异常,需要耐心跟踪。看到Runtime.getRuntime().exec()被调用栈触发时,那种“抓现行”的感觉非常棒。
5. 漏洞修复方案与安全编码启示
分析漏洞是为了更好地防御。Shiro官方早已修复了此漏洞,修复方案也给我们上了生动的一课。
5.1 官方修复方案解读
Shiro的修复主要从两个版本体现:
Shiro 1.2.5:在这个版本中,官方移除了
AbstractRememberMeManager中的默认硬编码密钥DEFAULT_CIPHER_KEY_BYTES。查看源码,构造函数变成了这样:public AbstractRememberMeManager() { // 不再设置默认密钥 // setCipherKey(DEFAULT_CIPHER_KEY_BYTES); }同时,在
setCipherService等方法中增加了校验,如果发现密钥为空或强度不够,会抛出异常。这意味着,开发者必须显式地在配置中提供一个强密钥,否则应用无法启动。这属于“强制安全”的修复思路。后续版本的最佳实践:官方文档和社区强烈建议:
- 使用随机生成的、足够长度的密钥(如AES-128需要16字节,AES-256需要32字节)。
- 将密钥作为配置项,放在配置文件(如
application.yml)或环境变量中,绝对不要写入源码。 - 定期更换密钥(虽然对RememberMe功能来说,更换会使所有已发出的“记住我”Cookie失效,需权衡)。
5.2 对开发者与架构师的安全启示
Shiro-550漏洞远远不止于一个CVE编号,它是一系列安全问题的集中体现:
- 默认配置必须安全:这是框架设计者的金科玉律。任何涉及加密、认证、授权的默认配置,其安全强度应该等同于生产环境要求。如果无法做到,宁可让应用启动失败,也不能提供一个“方便但不安全”的默认值。
- 密钥管理是生命线:加密的有效性完全取决于密钥的保密性。硬编码、弱密钥、密钥共享是三大致命伤。必须建立完善的密钥管理系统,使用密钥管理服务(KMS)或硬件安全模块(HSM)是大型应用的必选项。
- 慎用Java反序列化:Java原生序列化机制 (
ObjectInputStream/ObjectOutputStream) 已被证明是极度危险的。在传输或存储不可信数据时,应优先考虑更安全的替代方案,如JSON、Protocol Buffers、Kryo(需正确配置)等。如果必须使用,要严格进行白名单过滤,可以使用ObjectInputFilter(Java 9+)或第三方库如SerialKiller。 - 依赖组件安全审计:CommonsCollections库本身并不是漏洞,但它提供了危险的“能力”。你的应用间接依赖的组件,都可能成为攻击的跳板。需要定期使用OWASP Dependency-Check、Snyk等工具扫描依赖,及时升级已知存在利用链的组件版本。
- 深度防御:不要依赖单一安全措施。即使修复了Shiro密钥问题,也应在网络层部署WAF,在主机层做好权限最小化,在运行时使用RASP进行行为监控,构建纵深防御体系。
6. 从Shiro漏洞延伸的现代漏洞挖掘思维
分析完一个具体漏洞,我们的思维不能停滞。应该以它为起点,建立起一套主动挖掘和防御类似问题的思维模式。
6.1 如何挖掘同类“默认配置”漏洞
Shiro-550的本质是“不安全的默认配置”。我们可以将这种模式应用到其他框架和组件的审计中:
- 目标筛选:寻找那些提供“开箱即用”体验的框架、中间件、开源系统。重点关注认证、加密、会话管理、管理员功能等模块。
- 文档与代码对照:仔细阅读官方文档中关于安全配置的部分,然后去源码中验证其默认行为。查看构造函数、静态初始化块、默认配置类。
- 搜索关键词:在源码中搜索诸如
DEFAULT_、default、”password”、”admin”、”key”、”secret”、Base64.decode(硬编码值)、new SecretKeySpec(硬编码字节数组)等。 - 测试验证:搭建最小化环境,在不进行任何自定义配置的情况下,测试其安全功能。例如,尝试用默认密码登录管理后台,尝试用空密钥或弱密钥进行通信。
6.2 代码审计中的反序列化“热点”定位
对于Java反序列化漏洞的挖掘,可以遵循以下路径:
入口点寻找:
- 网络入口:搜索
readObject()、readResolve()、ObjectInputStream的调用点。特别关注处理HTTP请求参数、Cookie、Header、RPC协议、消息队列数据的代码。 - 数据流追踪:从这些入口点开始,向后追踪数据的来源。数据是否来自用户可控的输入?是否经过了充分的校验?
- 框架特性:像Shiro的
rememberMe、Fastjson的@type、XStream的fromXML、Jackson的多态反序列化(@JsonTypeInfo)等,都是已知的高危特性。
- 网络入口:搜索
利用链审计:
- 类路径分析:检查项目的依赖中,是否包含了已知的危险库,如老版本的
commons-collections、commons-beanutils、groovy、spring-aop等。可以使用工具如gadget-inspector进行自动化分析。 - 自定义利用链挖掘:这需要更高的技巧。关注那些实现了
Serializable接口,并且在readObject、equals、hashCode、compareTo、toString等方法中,调用了可能改变程序状态或执行代码的方法(如反射调用、类加载、JNDI查询、文件操作等)的类。
- 类路径分析:检查项目的依赖中,是否包含了已知的危险库,如老版本的
自动化辅助:结合静态代码分析工具(SAST)和动态交互式测试(IAST),可以提高审计效率。但工具不能完全替代人工对业务逻辑和架构设计的理解。
回过头看,Shiro-550漏洞的挖掘,其实就是这套思维的成功应用:找到一个重要的安全功能(RememberMe),检查其默认实现(硬编码密钥),分析其数据处理流程(解密后反序列化),最终串联起一个完整的攻击路径。掌握这种从功能到代码,从代码到漏洞的逆向推理能力,才是安全研究员的核心价值所在。每一次对古老漏洞的代码分析,都是一次与过去开发者的对话,也是一次对自身安全认知的加固。