☰
Apache Shiro 550反序列化漏洞:原理剖析与加固实战
2026/10/22 20:04:44 网站建设 项目流程

前阵子做内网常规巡检,某后台管理系统又报了 Apache Shiro 550 漏洞(CVE-2016-4437)。这个洞从公开到现在已经很多年了,但每次看资产清单,它依然活跃在一批老旧系统上。很多刚接触安全的朋友看到编号"550"就容易懵,觉得这是什么神秘变种;实际上它就是我们常说的 Shiro 反序列化 RCE,根因一点都不复杂。这篇文章我不打算复述那些到处都能搜到的漏洞公告,而是想从原理剖析讲起,把硬编码密钥、AES 加密、Java 反序列化这条链路彻底拆开,再给出一份在授权测试场景下可以直接落地的实战 SOP,最后把排查经验和加固清单一起整理出来。无论你是开发、运维还是安全工程师,这篇文章应该能帮你少踩几个坑。

1. 漏洞全貌:先搞清楚 Shiro550 是什么

1.1 一句话版本:记住这条触发链路

Apache Shiro 是一个很常见的 Java 安全框架,负责登录认证、权限控制、会话管理。它有一个非常方便的功能叫 RememberMe,就是用户勾选"记住我"之后,服务端会往浏览器写一个 rememberMe cookie,下次访问时自动恢复登录态。

Shiro550 的问题恰恰出在这个 cookie 上。触发链路可以压缩成这么一句话:服务端使用一个写死在代码里的 AES 密钥,对"序列化后的登录对象"进行加密并写入 cookie;攻击者只要能拿到这个密钥,就可以自己伪造一段精心构造的 Java 序列化数据,同样用这个密钥加密成合法 cookie 发送给服务端;服务端解密后直接进行反序列化,攻击者的恶意对象便会在目标机器上被还原,最终导致任意命令执行。

所以这个漏洞的本质是两个问题叠加:硬编码密钥泄露加密凭据,反序列化入口缺少类过滤。单独看其中一个可能还不致命,但组合在一起就成了一个稳定、易用、影响极大的远程代码执行漏洞。

1.2 "550"这个称呼是怎么来的

先说一个容易误解的点:CVE-2016-4437 这个编号本身是官方分配的,而"Shiro550"并不是官方叫法。

业内之所以习惯管它叫 550,是因为早期很多漏洞库在收录这个 Apache Shiro 漏洞时,把它映射到了一个编号为 550 的漏洞条目里,时间久了大家就这么叫开了。你如果去翻 Apache Shiro 自己的 CVE 列表,会发现它后面还有个经常被放在一起比较的 CVE-2019-12422,也就是很多人说的 Shiro721。两者都是反序列化问题,但 Shiro721 的利用条件苛刻得多,需要获得一个合法用户的 rememberMe cookie 才能继续攻击;而 Shiro550 只需要知道默认密钥就能打,门槛完全不在一个量级。

日常沟通中你只需要记住:团队小伙伴说"扫到 Shiro550"时,他指的就是这个 CVE-2016-4437 反序列化漏洞,对应 Shiro 1.x 系列小于 1.4.2 的版本。

1.3 为什么到今天还在被反复利用

按理说一个 2016 年公开的漏洞,到现在应该已经没人用了才对。但我在实际巡检里发现,Shiro550 的检出率一直居高不下,原因不外乎三点。

第一,Shiro 在 Java 服务端框架里太常见了,很多内部管理系统、后台预览系统都在用它做权限控制,属于一个非常容易被攻击者盯上的入口。第二,系统升级难度被低估了。Shiro 是嵌入在业务代码里的组件,升级不是换个包这么简单,涉及 Shiro 配置写法调整、过滤器链顺序验证、session 管理兼容性等一系列问题,很多团队一拖就是好几年。第三,也是最关键的,许多人以为"升级到新版本就安全了",但根本没有处理硬编码密钥的问题。升级只是把默认密钥从新代码里删掉了,如果业务代码里还显式配置着旧密钥,漏洞依然是漏洞。

这也是我写这篇文章的原因:原理不搞清楚,修复就只会照猫画虎。

2. 原理剖析:密钥硬编码与反序列化是怎么串起来的

2.1 硬编码密钥藏在哪个环节

Shiro 记住登录状态的核心类叫 AbstractRememberMeManager,它内部有一个静态常量,用于设置默认的加解密密钥。这个密钥是 Base64 编码后写死在源码里的,解码出来刚好是 16 字节,正好对应 AES-128 的密钥长度。

关键点在于,Shiro 的老版本代码里,这个默认值会在创建 RememberMeManager 时被当成默认配置直接使用。如果开发者没有显式调用 setCipherKey 换成自己的密钥,那么所有使用该版本 Shiro 的应用,它们的 rememberMe cookie 加密密钥就是同一个。这就相当于全世界的门都用了同一把钥匙,而且这把钥匙的图纸就贴在门框上。

很多人问:AES 不是够安全吗?密钥不泄露的话,攻击者根本解不开 cookie。但这里的问题就是,密钥不是"不泄露",而是"全局默认公开"。攻击者不需要去破解密码学算法,只需要把网上公开的默认密钥抄下来,就能合法地加密自己的恶意数据。安全边界直接退化成了"秘密是否公开",而不是"算法是否可破"。

2.2 反序列化:从 cookie 到对象的过程

再来看反序列化这一环。Shiro 在校验 rememberMe cookie 时,会把 cookie 里的内容取出来,先进行 Base64 解码,再用 AES 密钥解密,得到一段字节流。接着,这段字节流会被交给 Java 原生的对象输入流去还原成对象,大致逻辑是拿到字节流后用 readObject 直接读取。

问题就出在这个"直接读取"上。Java 原生的反序列化机制在设计时就没有做类白名单校验,只要目标 ClassLoader 能找到对应的类,它就会按字节流里的描述把对象创建出来。所谓 gadget 链,就是利用 Java 框架里一些本身就存在的危险类,这些类在反序列化过程中会触发额外的方法调用,经过一系列跳板,最终能够执行任意代码。

Shiro 这里没有额外的安全防护,没有类似 ObjectInputFilter 的机制来限制可以反序列化的类,于是攻击者只要能把精心构造的字节流喂给这个入口,就能获得一个代码执行的机会。更有意思的是,由于 rememberMe cookie 是攻击者自己构造并加密的,整个输入内容完全可控,这让漏洞的利用稳定性非常高。

2.3 把链路串起来的完整请求示例

为了让你直观感受整条链路,我手绘一个正常请求和攻击请求的对比流程。

正常用户登录成功后,Shiro 会做四件事:把登录主体对象(PrincipalCollection)序列化成字节流,用默认密钥做 AES 加密,再做 Base64 编码,拼接到 rememberMe cookie 里返回浏览器。

下次请求时,服务端按相反顺序处理:读取 cookie,Base64 解码,AES 解密,得到原始序列化字节流,再调用 readObject 还原对象,恢复登录态。

攻击请求则把第二步的"登录主体对象"换成了恶意 gadget 链的序列化数据,其余处理流程完全一致。因为密钥是公开的,攻击者可以离线完成整个加密和编码过程,最终只需要发送一个构造好的 Cookie 头,例如:

Cookie: rememberMe=<Base64编码后的加密payload>

服务端只要存在漏洞且能访问到 gadget 链依赖的类,就会在反序列化时触发恶意逻辑,完成命令执行或外带数据。

这个链路里有一个很反直觉的地方:攻击者甚至不需要知道目标系统里有什么类,只需要在 payload 里引用目标环境中大概率存在的公共库即可,因此漏洞的通用性极强。

3. 实战 SOP:授权场景下的检测与验证流程

先说明一个前提:下面的流程只适用于你拥有明确授权的测试目标,比如自己的测试环境、公司分配的渗透测试任务、或者应急演练靶标。对没有授权的目标做任何验证都是违规行为,这一点没有商量余地。

3.1 第一步:资产指纹识别

Shiro 的指纹特征非常明显,识别起来几乎不会费劲。

最简单的方法是给目标发一个假的 rememberMe cookie,内容随便填,比如rememberMe=1。如果目标使用了 Shiro 的 RememberMe 功能,服务端在解析这个非法 cookie 失败后,会在响应的 Set-Cookie 头里返回一个特征标记:rememberMe=deleteMe。

实际测试时可以用 curl 快速验证:

curl -i -k -s -c "rememberMe=1" https://目标地址/ | grep -i rememberMe

常见的响应特征有这么几类:

响应特征含义可信度
Set-Cookie: rememberMe=deleteMe基本可以确认是 Shiro高
无 Set-Cookie 但有 rememberMe 字样可能是其他框架低
登录页同时具备rememberMe参数辅助确认中

需要注意,如果目标部署了 WAF 或前置网关,可能会把响应头里的 deleteMe 去掉,但流量日志里往往仍有记录。单靠一个特征可能误判,建议结合登录接口路径、Cookie 名称、返回页面特征一起判断。

3.2 第二步:密钥弱点的无害化验证

指纹确认之后,下一步是验证目标是否还在使用默认硬编码密钥。这一步的关键原则是:优先做无害化验证,不要一上来就执行系统命令。

常见的无害化验证思路是使用 DNS 回连。用一个只触发 DNS 解析请求、不执行系统命令的反序列化验证链,比如常见的 URLDNS 链,把它作为 payload 序列化后,再用候选密钥加密成 rememberMe cookie 发给目标。如果目标存在漏洞并且可以出网,你会在自己可控的 DNS 服务上看到目标 IP 的回连记录。

我可以给一个用于生成 cookie 的 Python 脚本框架,它只负责 AES 加密和 Base64 编码,payload 部分请使用你在授权测试中自行生成的验证数据:

import base64 from Crypto.Cipher import AES from Crypto.Util.Padding import pad # 这里填入你准备测试的 Shiro 密钥,注意是 Base64 字符串 KEY_B64 = "此处填写待测试的候选密钥" key = base64.b64decode(KEY_B64) assert len(key) == 16, "密钥长度必须是16字节" # payload 为一个反序列化工具的产物,例如无害的 DNS 验证链 payload = b"<your_serialized_java_object>" cipher = AES.new(key, AES.MODE_CBC, AES.MODE_ECB) # 实际模式以目标为准 encrypted = cipher.encrypt(pad(payload, AES.block_size)) cookie = base64.b64encode(encrypted).decode() print(f"rememberMe={cookie}")

这里我要特别说明,Shiro 不同小版本的 IV 取法存在差异,而且网上流传的密钥字典有一大串,手动一个个试的效率很低。实际工作中建议直接使用社区成熟的 Shiro 检测工具,它们内部已经处理了版本差异和密钥字典。自己写脚本的价值在于理解原理,真到实战还是用成熟工具更靠谱。

一旦确认目标能解析用默认密钥加密的恶意对象,基本上就可以判定漏洞存在。接下来要做的是记录证据,而不是继续扩大战果。

3.3 第三步:确认后的影响判定与记录

漏洞确认后,很多人容易犯一个错误:立刻在当前环境弹一个计算器或者直接执行命令去"证明"漏洞,结果要么污染了业务数据,要么留下了大量攻击痕迹,甚至把生产环境搞崩。

正确的做法是先判定影响面,再做最小化确认。你需要记录三件事:

  • 漏洞 URL 和服务器 IP,精确到端口和路径;
  • 使用的密钥特征和验证链类型,证明可解密可反序列化;
  • 当前的响应时间、响应头变化等关键证据截图。

如果一定要做代码执行层面的确认,建议选择无害命令,比如执行id或hostname,而且要放在授权的测试环境,提前确认这台机器上没有敏感业务。任何写操作、反弹 shell 操作都需要额外的书面授权。不要觉得"能执行命令"和"真的执行了命令"是同一件事,从安全测试合规的角度看,两者差别很大。

3.4 流量侧与日志侧的检测特征

在应急响应和日常监控里,我们不能只依赖主动扫描,还要在流量侧建立被动检测能力。

从流量角度,Shiro550 的攻击请求有一个很明显的特征:Cookie 里的 rememberMe 字段长度非常大。正常用户登录后生成的 rememberMe cookie 通常只有几百字节,而反序列化攻击 payload 动辄几千字节甚至几万字节。你可以在 WAF 或网络层加一条规则:单个 Cookie 中 rememberMe 的值超过 2048 字节就要告警。

从日志角度,反序列化失败时,服务端会抛出类似com.esotericsoftware.kryo.KryoException、java.io.InvalidClassException、ClassNotFoundException这样的异常。建议对应用日志做关键字采集,重点关注rememberMe、deserialize、ObjectInputStream相关的报错。很多攻击在真正成功之前会先有大量的探测尝试,这些异常日志就是最早的告警信号。

4. 常见问题排查与应急处理实录

4.1 误报来源一:框架默认页与自定义页的干扰

我在巡检中遇到的误报,相当一部分来自框架默认首页或静态资源页面。比如访问一个不存在路径时,后端返回的 404 页面里恰好包含rememberMe字符,或者某些前端框架自己定义了同名 cookie,都可能让扫描器误判。

排查时不要看到 deleteMe 就下结论,至少要做两步验证:先确认目标确实是一个 Java 应用,再看 rememberMe cookie 是否由后端 Set-Cookie 主动写入,而不是前端脚本写入的。必要时直接查看返回头里的完整 cookie 属性,Shiro 写出的 cookie 通常带有 Path 和 Max-Age 参数。

4.2 误报来源二:密钥列表不全导致的漏报

另一类常见问题是漏报。企业的二次开发框架往往会在父类里统一配置一个自定义密钥,这个密钥可能也是硬编码的,但不在网上公开的默认密钥字典里。扫描器如果只测默认密钥列表,就会漏掉这一类目标。

所以我的检测策略是分两步走:第一步,用公开字典做快速验证,确认是否为常见默认密钥;第二步,如果第一步没有任何命中,但指纹特征明显指向 Shiro,就需要提取目标 jar 包或配置文件,查一下代码里是否有setCipherKey的调用。对授权测试来说,拿到源码后直接定位密钥是最可靠的方式。

4.3 应急响应的处理顺序

如果确认生产环境存在 Shiro550,而且没有条件立即停机,应急处理顺序建议如下:

  • 优先在网络层封禁来源 IP,尤其是扫描器和攻击源的 IP 段,先把利用入口压住;
  • 在 WAF 上临时拦截异常的 rememberMe cookie,规则可以先粗暴一点,比如 cookie 包含 base64 常见填充特征且长度可疑就拦截;
  • 关闭服务器的出网访问,阻断反序列化链的二次外带通道,这一步对很多依赖 HTTP/DNS 回连的利用方式非常有效;
  • 联系业务方评估是否能直接升级 Shiro 版本,制定变更窗口。

这里最怕的是"只封 IP 不修代码"。攻击者换个代理就回来了,防护效果只是暂时性的。真正的闭环一定是修复代码或升级组件,临时措施只是给你争取时间。

5. 防御加固清单:从应急到长效机制

5.1 版本升级与密钥轮换

修复 Shiro550 最核心的动作有两个:升级版本和更换密钥,两者缺一不可。

版本方面,需要升级到 1.4.2 或更高的稳定版本。新版本在默认配置里不再内置硬编码密钥,同时增加了反序列化安全校验逻辑。但就像前面反复强调的,只升级不换密钥等于白修,旧的硬编码密钥如果继续显式配置在代码里,攻击路径依然畅通。

更换密钥的做法并不复杂。用任意安全随机数生成器产出一个 16 字节的随机值,做 Base64 编码,然后通过setCipherKey注入。需要注意,密钥一旦更换,所有已登录用户的 rememberMe cookie 都会失效,用户需要重新登录,这是业务上可以接受的代价。变更前最好和产品团队确认一下影响的用户范围,选在低峰期操作。

5.2 代码与配置层的改造建议

如果你不是只负责一个系统,而是维护一批 Java 应用,建议把这些改造纳入统一的开发规范:

  • 禁止在代码仓库里出现 Shiro 相关的硬编码密钥,密钥统一放配置中心或环境变量,并在密钥管理系统里做定期轮换;
  • 如果业务根本不需要"记住我"功能,直接在配置里禁用 RememberMe 管理器,这是最简单粗暴的收敛方式;
  • 对需要保留 RememberMe 的登录接口,在过滤器链里单独控制,确保只有登录接口能写入 rememberMe cookie;
  • 定期扫描依赖库版本,把 Shiro 纳入企业软件资产清单,避免依赖传递引用了老版本而不自知。

很多开发团队会习惯性地复制粘贴老代码配置,把旧密钥也一起带过来。建议在 CI 阶段加一个密钥检测规则,扫描代码中是否出现了已知的弱密钥或硬编码密钥特征值,一旦命中直接让构建失败。

5.3 监控与纵深防御

最后说一下纵深防御。漏洞不会被彻底消灭,我们能做的是在漏洞存在的窗口期尽量增加攻击成本。

日志监控方面,在日志平台配置针对反序列化异常的告警,上面提到的ClassNotFoundException、InvalidClassException都是重点。流量监测方面,重点关注 rememberMe cookie 的超常大小和短时间内的多次尝试。主机侧可以部署 RASP 产品,在反序列化入口处做行为检测,这是目前效果最直接的运行时防护手段。

网络层面,尽量收敛 Java 应用服务器的出网权限。很多反序列化利用链都需要目标服务器主动发起外连才能完成数据外带,限制出网等于直接切断了命令执行后的通信通道。这个措施说起来简单,但很多企业因为业务原因迟迟没做,我建议把它当成一项长期安全基线来推进。

对我来说,Shiro550 最大的教育意义不是教我们怎么抓反序列化漏洞,而是提醒我们:安全框架的默认配置不等于安全配置。老版本留下了一把全世界通用的钥匙,而新版本只是不再发这把钥匙,过去发出的钥匙并没有自动作废。每次做修复复盘时,我都会多问一句:密钥换了吗?配置中心里那个值是不是还是老代码里的 Base64 串?如果你负责的系统还在用 Shiro,现在就可以去查一下,说不定会有意外发现。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询