1. 项目概述一次对经典安全漏洞的深度“考古”最近在整理内部安全审计的案例库翻到了一个老项目里关于Apache Shiro的漏洞利用记录。虽然Shiro-550、Shiro-721这些编号现在听起来像是“上古”漏洞很多新入行的兄弟可能都没听说过但直到今天我依然能在一些企业的老旧系统里找到它们的影子。安全领域有个特点漏洞本身会过时但漏洞背后的设计缺陷和编码逻辑却像一面镜子能持续映照出我们在开发中容易犯的共性错误。这次我们不搞简单的漏洞复现那太“脚本小子”了。我想带大家做一次彻底的“代码考古”亲手把Shiro那个著名的反序列化漏洞CVE-2016-4437从源码层面扒开看看它究竟是怎么“炼成”的。这不仅仅是满足好奇心更重要的是通过理解一个经典漏洞的完整诞生过程我们能建立起一套分析同类问题的思维框架——下次再遇到其他框架的“rememberMe”功能或者其他加密参数你就能条件反射般地想到“这里会不会有类似的坑”这个分析过程本质上是一次针对特定漏洞的“根本原因分析”。它要求我们跳出单纯使用工具进行黑盒测试的舒适区深入到Java代码、加密解密、序列化协议乃至框架设计哲学的层面。对于开发者而言这能帮你写出更健壮的代码避免踩进同样的陷阱对于安全研究员这能极大提升你的漏洞挖掘深度和武器化能力不再停留在“知其然”的层面。整个分析之旅我们会从Shiro的默认配置出发追踪一个加密Cookie的生成、传递到解析的全链路最终定位到那一行决定性的、使用了硬编码密钥的代码。相信我当你亲眼在源码里找到那个Base64.decode(“kPHbIxk5D2deZiIxcaaaA”)时那种“原来如此”的顿悟感是任何漏洞扫描报告都无法给予的。2. 漏洞原理与架构缺陷深度剖析2.1 Shiro身份认证的核心流程与“记住我”机制要打蛇得先知道七寸在哪。Shiro作为一个强大的安全框架其核心职责之一是管理用户的会话和认证状态。在Web应用中为了提升用户体验“记住我”RememberMe是一个常见功能。Shiro的实现方式是当用户勾选“记住我”登录成功后服务器端会生成一个包含用户身份信息的令牌加密后发送给浏览器保存在名为rememberMe的Cookie中。下次用户访问时即使会话过期浏览器也会自动带上这个CookieShiro会尝试解密并反序列化其中的内容自动重建登录状态。这个流程听起来很合理但魔鬼藏在细节里。整个安全链条的强度取决于最薄弱的那一环。在这里链条包括序列化算法、加密算法、加密密钥和反序列化逻辑。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(kPHbIxk5D2deZiIxcaaaA); private SerializerPrincipalCollection serializer new DefaultSerializerPrincipalCollection(); 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和硬编码的密钥kPHbIxk5D2deZiIxcaaaA进行加密。加密时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 groupIdorg.apache.shiro/groupId artifactIdshiro-spring/artifactId version1.2.4/version !-- 漏洞版本 -- /dependency dependency groupIdorg.apache.shiro/groupId artifactIdshiro-web/artifactId version1.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变量它的值正是我们“熟悉”的kPHbIxk5D2deZiIxcaaaA解码后的字节数组。这就是铁证跟踪反序列化过程解密成功后代码会返回字节数组接着调用deserialize方法。跟进deserialize你会进入DefaultSerializer的deserialize方法。这里创建了一个ByteArrayInputStream和ObjectInputStream然后直接调用了ois.readObject()。就在这一刻恶意payload被反序列化。如果你的payload是弹计算器Windows或者创建文件Linux此时命令就会被执行。你可以在调试器的变量窗口看到ois.readObject()返回的对象是一个复杂的、包含Transformer链的LazyMap或TiedMapEntry等对象。实操心得在调试反序列化触发命令执行时建议payload先使用无害命令如echo test /tmp/shiro_test或calcWindows 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需正确配置等。如果必须使用要严格进行白名单过滤可以使用ObjectInputFilterJava 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检查其默认实现硬编码密钥分析其数据处理流程解密后反序列化最终串联起一个完整的攻击路径。掌握这种从功能到代码从代码到漏洞的逆向推理能力才是安全研究员的核心价值所在。每一次对古老漏洞的代码分析都是一次与过去开发者的对话也是一次对自身安全认知的加固。