1. 项目概述:从“黑盒”到“白盒”的漏洞认知之旅
在安全研究领域,Shiro反序列化漏洞是一个绕不开的经典课题。它不仅仅是一个CVE编号,更是一个理解Java安全、框架设计缺陷和攻击者思维的绝佳样本。很多初学者拿到一个漏洞编号,比如CVE-2016-4437,第一反应往往是去网上找一份现成的利用脚本(POC),运行一下,看到回显的“whoami”就宣告复现成功。但这真的够了吗?在我看来,这仅仅是“黑盒”测试的起点。真正的价值在于“白盒”分析——深入代码层面,弄清楚漏洞为何会产生、攻击链是如何构造的、框架的防御机制又为何失效。这个过程,才是从“脚本小子”迈向“安全研究员”的关键一步。
“Shiro反序列化漏洞代码分析”这个项目,正是要带你走完这段旅程。它面向的是已经对Web安全基础(如HTTP、Cookie、Session)和Java Web开发有初步了解,不满足于单纯运行POC,渴望理解底层原理的开发者或安全爱好者。通过这个项目,你将不再被动地接受“这里有个漏洞”的结论,而是能够主动地、像侦探一样,顺着代码的逻辑线索,亲手揭开漏洞的神秘面纱。你会发现,那些看似复杂的攻击载荷(Payload),其设计思路在源码面前变得异常清晰;你也会明白,为什么某些版本的Shiro可以被打,而另一些则免疫,这背后的判断依据都写在代码里。
2. 漏洞背景与核心原理深度拆解
2.1 Shiro框架的“记忆”机制:RememberMe与序列化
要理解这个漏洞,必须先理解Shiro框架中的一个便利功能:RememberMe(记住我)。这是一个提升用户体验的常见设计,用户登录一次后,关闭浏览器再打开,无需重新输入密码即可保持登录状态。Shiro是如何实现这个“记忆”功能的呢?答案就是序列化(Serialization)。
序列化,简单来说,就是把一个内存中的Java对象,转换成一串可以存储或传输的字节流的过程。反序列化则是其逆过程,将这串字节流还原成内存中的对象。当用户勾选“记住我”并成功登录后,Shiro会做以下几件事:
-
将用户的身份信息(如用户名)、登录时间等,封装成一个
PrincipalCollection对象。 - 使用一个 固定的、硬编码在源码中的密钥(AES Key) ,对这个对象进行AES加密。
- 将加密后的字节流进行Base64编码。
-
将编码后的字符串,设置为名为
rememberMe的Cookie,发送给用户的浏览器。
当用户再次访问网站时,浏览器会自动带上这个Cookie。Shiro的过滤器会拦截请求,发现
rememberMe
Cookie后,便执行反向操作:Base64解码 -> AES解密 -> 反序列化,还原出
PrincipalCollection
对象,从而自动完成登录认证。
注意 :这里埋下了第一个祸根—— 硬编码的默认密钥 。如果开发者在部署应用时没有修改这个密钥,那么所有使用该版本Shiro的应用,其加密密钥都是公开的。攻击者可以轻易地伪造或解密Cookie。
2.2 漏洞的“命门”:Java反序列化攻击链
问题的核心在于第三步:反序列化。Java的反序列化机制有一个特性:在将字节流还原为对象时,会自动调用该对象的
readObject()
方法。如果被反序列化的类重写了
readObject()
方法,那么该方法中的代码就会被执行。
攻击者的思路由此产生:我能否构造一个特殊的字节流,当Shiro试图反序列化它时,最终触发一段执行任意命令的代码?答案是肯定的,这依赖于“反序列化利用链”(Gadget Chain)。这条链由一系列存在于项目依赖库(如commons-collections, commons-beanutils)中的类组成,它们像多米诺骨牌一样,通过巧妙的属性设置和方法调用,最终可以触发命令执行(如
Runtime.getRuntime().exec(“calc”)
)。
Apache Commons Collections 3.2.1版本中著名的
TransformedMap
、
InvokerTransformer
等类,就构成了这样一条经典的攻击链(通常称为CC链)。Shiro在反序列化时,并没有严格检查即将被反序列化的对象类型,它只是忠实地执行了
ObjectInputStream.readObject()
。如果攻击者能够提供一条精心构造的、包含恶意代码的CC链字节流,并且用正确的密钥(默认或泄露的)加密,那么当Shiro解密并反序列化这个Cookie时,恶意代码就会在服务器端执行。
2.3 漏洞利用的关键条件与演变
这个漏洞的利用需要同时满足几个条件:
- Shiro版本 :通常指<=1.2.4的版本,因其使用了默认的硬编码密钥。但后续版本若密钥泄露,同样可被利用。
- 存在可利用的依赖 :目标应用的ClassPath中必须包含存在反序列化利用链的库,如commons-collections <= 3.2.1。
- 密钥已知或可爆破 :攻击者需要知道AES加密的密钥,才能构造出能被正确解密的恶意Cookie。
随着漏洞的公开和修复,攻防也在升级:
- 密钥爆破 :由于密钥长度固定(128位),一些工具开始尝试使用常见的密钥字典进行爆破,尝试解密Cookie来判断密钥是否正确。
- 利用链的扩展 :除了CC链,安全研究人员陆续发现了多条新的利用链,如CB链(CommonsBeanutils)、无依赖的JRMP链等,扩大了攻击面。
- Padding Oracle攻击(CVE-2020-1957) :这是一个与加解密模式相关的旁路攻击,允许攻击者在不知道密钥的情况下,通过服务端的错误响应差异,逐步解密或加密Cookie,危害极大。
3. 核心代码层析:从Cookie到命令执行
3.1 入口点:
AbstractRememberMeManager#getRememberedPrincipals
让我们把视线聚焦到Shiro 1.2.4版本的源码。一切始于
org.apache.shiro.mgt.AbstractRememberMeManager
类。当请求到来时,
RememberMeAuthenticationFilter
会调用其
getRememberedPrincipals
方法。
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) {
PrincipalCollection principals = null;
try {
byte[] bytes = getRememberedSerializedIdentity(subjectContext); // 1. 获取Cookie字节数组
if (bytes != null && bytes.length > 0) {
principals = convertBytesToPrincipals(bytes, subjectContext); // 2. 关键转换
}
} catch (Exception e) {
// 日志记录...
}
return principals;
}
3.2 解密过程:
DefaultRememberMeManager#decrypt
convertBytesToPrincipals
方法会调用
decrypt
来解密字节数组。在
DefaultRememberMeManager
中,我们可以看到AES解密的过程。
protected byte[] decrypt(byte[] encrypted) {
byte[] serialized = encrypted;
CipherService cipherService = getCipherService();
if (cipherService != null) {
ByteSource byteSource = cipherService.decrypt(encrypted, getDecryptionCipherKey());
serialized = byteSource.getBytes();
}
return serialized;
}
这里的
getDecryptionCipherKey()
返回的就是那个致命的密钥。在1.2.4版本中,它被硬编码为
kPH+bIxk5D2deZiIxcaaaA==
(Base64编码后的AES-128密钥)。
3.3 漏洞触发点:
AbstractRememberMeManager#deserialize
解密后得到的是序列化后的字节流,接下来就是最关键的 反序列化 操作。
protected PrincipalCollection deserialize(byte[] serializedIdentity) {
try {
ByteArrayInputStream bais = new ByteArrayInputStream(serializedIdentity);
ObjectInputStream ois = new ClassResolvingObjectInputStream(bais); // 使用自定义的OIS
PrincipalCollection principals = (PrincipalCollection) ois.readObject(); // 漏洞触发点
ois.close();
return principals;
} catch (Exception e) {
// 异常被捕获,但反序列化过程已执行!
}
}
ois.readObject()
是Java原生反序列化的入口。
ClassResolvingObjectInputStream
是Shiro自定义的流,主要为了解析类名,但它并没有重写
resolveClass
或
resolveProxyClass
方法来严格限制可反序列化的类(在后续修复版本中,这里得到了加强)。因此,当攻击者传入一个精心构造的、包含CC链的序列化对象字节流时,
readObject()
会忠实地按照其内容还原对象,并执行链中
InvokerTransformer
等类的危险方法,最终达到命令执行的目的。
3.4 攻击载荷的构造逻辑
攻击者侧的POC构造,核心是以下几步:
-
组装利用链
:使用ysoserial等工具,指定利用链(如
CommonsCollections5)和要执行的命令(touch /tmp/success),生成一个恶意的序列化对象字节数组。 - 加密 :使用Shiro的默认密钥(或已知/爆破出的密钥),采用AES-CBC模式,对恶意字节数组进行加密。
-
编码与封装
:将加密后的字节数组进行Base64编码,然后设置为
rememberMeCookie的值。
当这个Cookie被发送到存在漏洞的Shiro服务时,就会沿着上述代码路径,触发漏洞。
4. 漏洞修复方案与代码层面的防御演进
4.1 官方修复之路
Apache Shiro团队在漏洞曝光后,采取了多层次的修复策略,这些修复点都体现在后续版本的源码中:
-
强制修改默认密钥(1.2.5+) :最直接的修复。新版本在启动时,如果检测到使用的是默认密钥,会直接抛出异常,强制开发者必须在配置中显式地设置一个唯一的、高强度的密钥。
// 在初始化CipherKey时进行检查 if (DEFAULT_CIPHER_KEY_BYTES.equals(cipherKey)) { String msg = "The default cipher key is being used for remember me encryption. " + "This is a security risk. Please set a unique key."; throw new IllegalStateException(msg); } -
引入反序列化类白名单(1.2.6+) :这是更根本的修复。Shiro创建了
DefaultSerializer类,并在其中整合了SerializationFilter。或者通过重写ClassResolvingObjectInputStream.resolveClass方法,只允许反序列化少数安全的、预期的类(如SimplePrincipalCollection),而拒绝所有其他类。// 近似逻辑的示例 protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String className = desc.getName(); if (!className.startsWith(“org.apache.shiro.”) && !className.startsWith(“java.lang.”)) { throw new InvalidClassException(“Unauthorized deserialization attempt”, className); } return super.resolveClass(desc); } -
修复Padding Oracle漏洞(1.5.0+) :针对CVE-2020-1957,将加密模式从CBC改为GCM等认证加密模式,确保密文被篡改后解密会直接失败,不再泄露信息。
4.2 开发者的安全实践
从代码分析中,我们可以总结出对开发者至关重要的安全启示:
-
永远不要使用默认密钥
:这是红线。必须在
shiro.ini或Spring配置中,使用随机生成的强密钥。# shiro.ini 示例 securityManager.rememberMeManager.cipherKey = your_strong_base64_encoded_key_here - 及时升级依赖 :将Shiro升级到最新稳定版,确保包含所有安全修复。
-
控制依赖库
:即使Shiro本身修复了,如果项目中引入了存在危险链的旧版commons-collections等库,仍然可能通过其他反序列化入口点被攻击。应使用Maven的
dependency:tree命令检查并排除或升级危险依赖。 -
最小化反序列化入口
:在业务代码中,避免直接使用
ObjectInputStream处理来自外部的数据。如果必须使用,应严格实施白名单过滤。
5. 动手调试与分析:搭建动态分析环境
读代码不如调试代码。要真正吃透漏洞,建议你搭建一个动态分析环境。
5.1 环境准备
- 漏洞环境 :使用Vulhub或vulfocus快速搭建一个Shiro 1.2.4的靶场。这比你自己编译一个老版本Web应用要方便得多。
-
IDE
:推荐使用IntelliJ IDEA。将其远程调试功能配置到靶场运行的Tomcat上。具体步骤是:在Tomcat的启动脚本(
catalina.sh)中加入调试参数-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005,然后在IDEA中创建“Remote JVM Debug”配置,连接至localhost:5005。 - 源码关联 :在IDEA中导入对应版本的Shiro源码(可以从Apache官网下载源码包)。确保IDEA中的源码版本与靶场中使用的Jar包版本一致。
5.2 关键断点设置
在源码中打下以下几个关键断点,然后从浏览器发送一个带有恶意Cookie的请求:
-
AbstractRememberMeManager.getRememberedPrincipals():入口。 -
DefaultRememberMeManager.decrypt():观察解密前后字节数组的变化。 -
AbstractRememberMeManager.deserialize(): 重中之重 。在这里步进(Step Into)ois.readObject()。 -
在
commons-collections库的TransformedMap.checkSetValue()或InvokerTransformer.transform()处打上断点。当攻击链被触发时,程序会跳转到这些地方。
5.3 调试心法与观察要点
-
观察调用栈(Call Stack)
:当程序在
InvokerTransformer.transform()处中断时,仔细观察调用栈。你会清晰地看到从readObject()开始,到AnnotationInvocationHandler.invoke(),再到TransformedMap.setValue(),最终到InvokerTransformer.transform()的完整链条。这是理解“链”式调用最直观的方式。 -
查看变量值
:在
InvokerTransformer中,查看iMethodName、iParamTypes、iArgs这几个成员变量的值。你会看到它们被设置为”getRuntime”、null、null,然后在后续调用中被组合成Runtime.getRuntime(),最终iMethodName变成”exec”,iArgs变成命令字符串。这个过程完美展示了攻击链是如何通过反射动态调用危险方法的。 -
尝试修改
:在调试过程中,你可以尝试修改内存中的变量,比如将命令从
”calc”改成”whoami”,观察执行结果的变化,加深理解。
6. 从分析到挖掘:漏洞研究思维的建立
完成一次完整的代码分析后,你的目标不应止步于此。这套方法论可以迁移到几乎任何漏洞的研究中。
-
定位入口点
:对于任何漏洞,首先在源码中全局搜索敏感函数(如
readObject、exec、eval、反序列化关键词等),找到数据从外部输入到危险函数被调用的完整路径。 - 理解数据流 :跟踪用户可控的数据(如HTTP参数、Cookie、Headers、文件内容)是如何在代码中流动、被处理、最终到达漏洞触发点的。这个过程可能需要你熟悉框架的过滤器、拦截器、控制器等机制。
- 构造利用条件 :分析为了达到漏洞触发点,需要满足哪些前置条件(如特定的参数值、特定的类路径、特定的配置状态)。这对应着漏洞的“利用限制”。
-
探索绕过方法
:针对修复方案(如黑名单、WAF规则),思考是否存在逻辑缺陷或过滤不严的情况,从而构造出绕过的Payload。例如,Shiro早期白名单修复后,就出现过因使用
Class.forName加载类而导致的绕过。
通过对Shiro反序列化漏洞的深度代码分析,你收获的不仅仅是对一个特定漏洞的理解,更是一套适用于二进制或Web漏洞的静态/动态分析组合拳。下次当你听到“Fastjson反序列化”、“Log4j2 JNDI注入”时,你不会再感到陌生和畏惧,因为你知道,拿起代码,设置断点,沿着数据流追踪下去,一切谜底都将在你眼前展开。这才是安全研究最本质、也最迷人的乐趣所在。
2486

被折叠的 条评论
为什么被折叠?



