凌晨接到告警电话,说某老系统的登录接口出现了大量异常请求。我第一反应是去看响应头,果然看到set-Cookie: rememberMe=deleteMe,这个特征太熟悉了,Apache Shiro,而且是受 RememberMe 反序列化漏洞影响的版本。后来确认,正是 Shiro 1.2.4 跑在 JDK 8 上,用的是默认密钥。
这篇文章把 Shiro RememberMe 1.2.4 反序列化漏洞从头到尾拆一遍:漏洞是怎么从一段"记住我"的 Cookie 变成远程代码执行的,为什么 1.2.4 这个版本成了经典案例,以及我在多个老系统上做排查和加固时踩过的坑。如果你正在维护一个用了 Shiro 的老项目,或者只是想搞清楚这类 Java 安全漏洞的通用套路,这篇文章应该对你有用。
1. RememberMe 这条 Cookie 背后到底藏了什么
很多开发者把 Shiro 的 RememberMe 当成一个普通的"记住登录状态"开关,觉得无非就是往浏览器写个长字符串。实际上这条 Cookie 承载的数据链路远比想象中复杂,而 1.2.4 版本的漏洞恰恰埋在这条链路的最后一步。
1.1 从登录成功到 Cookie 落地,中间经历了什么
Shiro 的 RememberMe 机制设计是这样的:用户登录成功后,框架从当前 Subject 里取出身份信息,打包成PrincipalCollection对象,然后做三层处理:
- 序列化:用 Java 自带的
ObjectOutputStream把对象转成二进制流。 - 加密:用 AES 算法对序列化字节流加密,得到密文。
- 编码:对密文做 Base64 编码,写进 Cookie。
对应到代码里就是AbstractRememberMeManager的rememberSerializedIdentity方法,整个流程可以简化成下面这条链路:
登录成功 → 获取PrincipalCollection → Java序列化 → AES加密 → Base64编码 → 写入Cookie反过来,每次请求进入时,Shiro 读取 Cookie,做逆操作:
读取Cookie → Base64解码 → AES解密 → Java反序列化 → 还原PrincipalCollection → 判断登录身份关键就在最后一步:ObjectInputStream.readObject()直接把解密后的字节流反序列化成对象。如果攻击者能控制这段字节流的内容,而依赖库里又有可用的" gadget chain",那么反序列化过程就会执行攻击者构造的恶意代码。
1.2 1.2.4 版本的致命伤:一把公开的万能钥匙
这里要说到 1.2.4 版本最著名的问题——硬编码的默认 AES 密钥。在AbstractRememberMeManager的源码里,默认密钥是直接写死的:
public abstract class AbstractRememberMeManager implements RememberMeManager { private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA=="); ... }kPH+bIxk5D2deZiIxcaaaA==这把密钥从非常早的版本就存在,且是公开的。任何一个熟悉 Shiro 的人都知道这把钥匙。如果项目没有显式调用setCipherKey覆盖默认值,那么所有使用该版本的项目都共用同一把加密密钥。
这意味着什么?攻击者只要把恶意数据用同样的密钥做 AES 加密,再 Base64 编码成 Cookie 发给目标服务器,服务器就会按正常流程解密、反序列化、触发恶意逻辑。等于所有用了默认密钥的站点,门锁的钥匙都在攻击者手里。
实际排查中我发现,相当多的老项目根本没改过密钥,有些甚至不知道有setCipherKey这个配置存在。
1.3 一个值得记住的细节:默认密钥不等于所有脆弱版本都共用同一个值
需要澄清一个容易混淆的点:我见过不少人把"默认密钥"和"某个版本的密钥"画等号。实际上,Shiro 后来在 1.4.2 版本调整过加密模式和默认密钥的生成策略。但对于 1.2.4 这类早期版本来说,密钥就是那把公开的kPH+bIxk5D2deZiIxcaaaA==,这一点已经够致命了。
另外,早期版本使用 AES-CBC 模式,密文没有完整性校验,这又引入了 Padding Oracle 类的攻击面。也就是说即便你换了密钥,只要框架版本停留在 1.4.2 之前,依然存在密文篡改与字节翻转的风险空间。这部分后面修复方案里会再展开。
2. 反序列化触发链与版本边界:别把锅全甩给序列化本身
很多人一听到"反序列化漏洞"就默认是 Java 序列化机制的锅,其实序列化本身是个中性机制,真正的风险在于:反序列化时对象的readObject或readResolve方法会执行类内部逻辑,攻击者只要精心挑选依赖库中已有的"危险类"拼成一条调用链,就能在反序列化过程中执行任意代码。
2.1 解密之后的那一瞬间发生了什么
以 1.2.4 为例,服务器收到伪造的 RememberMe Cookie 后,会执行getRememberedSerializedIdentity方法,流程如下:
Cookie读取 → Base64解码 → setCipherService.decrypt()解密 → ObjectInputStream.readObject() → 返回PrincipalCollection利用了反序列化,最终目标从"还原登录身份"变成了"触发危险类方法"。
这里有个细节值得注意:反序列化漏洞利用的成功与否,不完全取决于 Shiro 本身,而是取决于classpath 里有没有可用的 gadget 链。如果目标系统里只有 Shiro 和少量基础库,没有可被利用的类,反序列化攻击也会落空。这也是为什么同样版本的 Shiro,有的系统被打穿,有的系统却只是报几个异常日志。
2.2 依赖治理:一条链可能藏在你想不到的地方
经典的 Shiro 漏洞利用链大多基于commons-collections、commons-beanutils这类库。很多项目里这些库不是显式声明的,而是作为第三方框架的传递依赖进来的。比如某系统用 Shiro 做权限框架,同时接了另一个内部组件,那个组件拉了一个旧版commons-beanutils,于是整条链就齐了。
我在排查某个老项目时遇到过这种情况:项目自己没直接依赖任何高危库,但mvn dependency:tree拉出来一看,commons-beanutils:1.8.3躲在某个内部 SDK 的传递依赖里,版本老到可以拼出完整利用链。所以排查此类漏洞时,光看 Shiro 自身依赖远远不够,必须全量梳理。
下面两个清单是当时整理的关键排查项:
| 检查项 | 关注点 |
|---|---|
| Shiro 版本 | 是否在 1.4.2 之前,是否用了默认密钥 |
| 高危依赖 | commons-collections、commons-beanutils 等版本是否过旧 |
| 传递依赖 | 是否有内部组件间接引入高危库 |
| 反序列化入口 | 除 RememberMe 外,Session 序列化等其他入口是否也存在 |
2.3 为什么 1.2.4 成了最出名的版本,但 1.4.2 之前都不算安全
1.2.4 之所以成为经典案例,很大程度上是因为它在当时被大量使用,且默认密钥公开到了人尽皆知的程度。但从漏洞本质看,1.4.2 之前的版本在反序列化风险等级上并没有根本差异,区别主要在于:
- 1.2.4 及同批早期版本:默认密钥固定且公开,配合 AES-CBC 模式,利用条件极其简单,几乎等于白送。
- 1.4.x 早期:默认密钥有调整,但如果没有显式配置,依然存在弱默认值风险。
- 1.4.2 起:引入 AES-GCM 模式,默认密钥逻辑也有变化,同时官方在文档中把"必须显式配置密钥"提到了更重要的位置。
所以我在写加固建议时,从来不会只说"升到某个版本就安全了",版本只是一个维度,密钥管理和依赖治理才是长期防线。
3. 不碰恶意载荷的排查流程:如何在系统不出事的前提下确认风险
业务系统一旦上了生产,没人愿意为了验证安全风险故意打一条会执行命令的恶意载荷。下面这套排查流程是我在实际项目里总结出来的,全程不做任何破坏性操作,只通过静态检查和被动观察完成风险判断。
3.1 先从依赖层面锁定 Shiro 版本
最快的办法是看构建文件。Maven 项目直接查pom.xml:
<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-core</artifactId> <version>1.2.4</version> </dependency>如果项目用的是 Gradle,就看build.gradle里的implementation 'org.apache.shiro:shiro-core:1.2.4'这类声明。但这里要注意:依赖版本不一定等于实际运行版本,有可能某个内部组件重新定义或覆盖了版本。所以我通常还会用mvn dependency:tree做一次全量核对,把 Shiro 相关的所有组件都拉出来看一遍。
3.2 通过未授权请求观察框架特征
Shiro 在响应头里有个明显特征:当 RememberMe Cookie 缺失或无效时,会返回set-Cookie: rememberMe=deleteMe。这一点完全可以通过普通浏览器开发者工具观察到,不涉及任何攻击行为。
具体操作是:打开一个需要登录的页面,清空浏览器 Cookie,刷新页面,在响应头里找Set-Cookie字段。如果看到rememberMe=deleteMe,说明这个系统用了 Shiro 的 RememberMe 机制,接下来再进入版本确认环节。
另外一个被动观察点是登录成功后返回的 Cookie 长度。Shiro RememberMe Cookie 经过序列化、加密、Base64 三层处理后,长度通常在几百字节甚至更长。如果某个系统返回的 Cookie 特别长,且 Base64 解码后前两个字节的魔数是0xAC 0xED,那基本可以确定存在 Java 序列化数据。这里我用一个临时脚本做过验证:
import base64 cookie = "你的rememberMeCookie值" try: raw = base64.b64decode(cookie) except Exception: raw = b"" if raw[:2] == b"\xac\xed": print("命中Java序列化数据特征,需进一步确认版本与密钥配置") elif raw[:2] == b"\x1f\x8b": print("命中GZIP压缩特征,可能是新版Shiro的压缩逻辑") else: print("未识别到典型特征")这个脚本只做特征识别,不会向服务器发送任何伪造数据,安全合规。
3.3 一次真实排查实例:三个系统三种结论
我曾接到一个任务,说某业务域有多个系统共存,怀疑存在 Shiro 反序列化风险。我按上面的方法逐一排查,结论完全不同:
第一个系统:pom.xml里明确写着 1.2.4,且配置类里没有任何setCipherKey调用,响应头也返回rememberMe=deleteMe。这个可以直接判定为高风险,需要立刻进入修复流程。
第二个系统:Shiro 用的是 1.7.0,但配置类里居然把密钥显式设置成了那把公开的kPH+bIxk5D2deZiIxcaaaA==。这个属于"版本新但配置旧",看起来不是漏洞版本,实际风险一点都不低。
第三个系统:Shiro 版本同样较新,密钥用了环境变量注入,且依赖树里没有旧版 commons-collections,响应头也没有deleteMe特征。这个基本判定为低风险。
同一个任务里,三个系统问题的成因完全不同。所以排查时要同时关注"框架版本、密钥配置、依赖清单、响应特征"四个维度,而不是只看某一个因素。
4. 修复方案:换掉版本只是开始,真正的难点在后面
把 Shiro 升个版本、改一下密钥,看起来十分钟能搞定。但在生产系统里真正落地时,难度远超想象。下面按实施顺序聊聊每一步该怎么做,以及哪些地方容易翻车。
4.1 升级与密钥轮换的正确顺序
先升级框架版本,再轮换密钥。顺序不能反,原因在于:
- 先处理加密方式变更:升级到 1.4.2+ 后,RememberMe 的加密从 AES-CBC 变成 AES-GCM,这本身就是一个大变化。如果先改密钥再升级,线上会出现新旧加密方式混用,排查问题时会多一个干扰变量。
- 再替换默认密钥:确认升级完成后,显式设置一个足够复杂的新密钥,避免依赖任何默认值。
密钥设置参考如下,生产环境建议把密钥放到环境变量或配置中心,不要写死在代码里:
@Bean public RememberMeManager rememberMeManager() { SimpleCookie rememberMeCookie = new SimpleCookie("rememberMe"); rememberMeCookie.setHttpOnly(true); CookieRememberMeManager manager = new CookieRememberMeManager(); manager.setCookie(rememberMeCookie); // 建议通过配置中心注入,避免硬编码 String cipherKey = System.getenv("SHIRO_REMEMBER_ME_KEY"); manager.setCipherKey(Base64.decode(cipherKey)); return manager; }注意cipherKey的长度要满足 AES 密钥要求。如果是 AES-128,Base64 解码后应该是 16 字节。用 32 字节的随机密钥更稳妥,对应 AES-256。
我这里提供一个密钥生成思路,直接在 Java 环境里用 SecureRandom 生成随机字节,再 Base64 编码:
# 或者用openssl一行生成随机密钥 openssl rand -base64 324.2 升级后所有用户掉线:Cookie 兼容性是最容易忽略的坑
这是我在升级过程中踩过最大的一次坑。Shiro 从 AES-CBC 切到 AES-GCM 后,加密方式完全不兼容,之前签发的所有 RememberMe Cookie 全部失效。也就是说,升级发布的那一瞬间,所有勾了"记住我"的用户都会被强制下线,需要重新登录。
对于个人用户来说,重新登录没什么大不了。但对面向大量 C 端用户的系统来说,这是一次"无预警全量下线",直接导致投诉激增。
后来我总结出的稳妥做法是:
- 提前在公告或版本更新说明里告知用户,选择低活跃时段发布。
- 发布后监控登录失败率和
rememberMe=deleteMe响应头比例。 - 如果短期内无法接受全量下线,可以考虑前端先清除 RememberMe Cookie,引导用户走一次正常登录流程,再逐步切流到新版本。
4.3 老系统实在无法升级时的临时防御方案
有些上了岁数的系统,被一堆内部组件绑定,Shiro 版本动不了。这种情况不能硬升,原地做加固。临时防御思路按优先级排列:
| 措施 | 说明 | 成本 |
|---|---|---|
| 修改密钥 | 至少换掉公开默认密钥,阻断无脑利用 | 低 |
| 关闭 RememberMe 功能 | 直接停用 Cookie 记住登录,牺牲一部分体验 | 低 |
| WAF 规则拦截异常 Cookie | 拦截超大 Cookie、非 Base64 特征 Cookie | 中 |
| 出网限制 | 限制应用服务器主动访问外网,阻断回连通道 | 中 |
这里特别提醒:只改密钥不堵回连通道,仍然存在被绕过利用的风险。因为攻击者如果拿到了密钥(比如通过其他漏洞读到了配置),依然能构造出合法密文。所以老系统的加固要做组合拳,不要指望某一项措施一劳永逸。
5. 加固之后仍然踩到过的几个坑
这一节写的是我在多次 Shiro 漏洞处置中遇到的、常规文章里不太会写但非常真实的坑。每一个都花了不小代价才反应过来。
5.1 密钥写进 Git 仓库,等于没换
有次排查一个团队提交的加固方案,看到他们在application.yml里写了一个很长的自定义密钥,表面看的确覆盖了默认值。但这份配置随着代码一起提交到了仓库,而仓库对整个公司开发人员可读。
等于什么?密钥已经泄露了。攻击者如果拿到代码仓权限,或者代码仓本身被拖库,这个密钥跟默认密钥没有任何区别。
正确的做法是:密钥只在部署环境里存在,通过环境变量、KMS、配置中心等方式注入,代码仓库里只保留占位符。这一条看起来是常识,但执行层面真的很容易走样。
5.2 版本升级后业务 Session 序列化出问题
Shiro 升级后,除了 RememberMe Cookie 不兼容,还有另一个隐蔽问题:如果 Session 也用了序列化存储,升级后 ObjectInputStream 的类解析行为可能变化,导致部分 Session 读取失败。
那次事故的表现很典型:应用能正常启动,登录也能过,但某些用户一访问就报ClassNotFoundException或SerializationException。排查到最后发现,是旧版本在序列化 Session 时写入的类描述与新版反序列化时的类加载逻辑不匹配,导致历史 Session 数据直接作废。
这个问题的解决办法分两层:
- 发布前主动检查一下有没有自定义的 Session 存储和序列化逻辑。
- 发布后允许 Session 数据自然过期,不要尝试跨版本恢复历史 Session。
当时我们为了"尽量少打扰用户",尝试了各种兼容方案,最后发现最省事、最可靠的做法就是让 Session 自然失效,用户重新登录一次。
5.3 WAF 规则误报与漏报并存
给系统上了 WAF 拦截规则后,新的问题出现了:规则太宽松会被绕过,规则太严格会误杀正常用户。
举个例子,早期拦截规则是"RememberMe Cookie 长度超过一定阈值就拦截"。结果有段时间不少正常用户的 Cookie 因为序列化内容较长,也被拦了,用户反馈"登录就报错"。反过来,某些精心构造的恶意 Cookie 长度其实并不夸张,长度阈值根本拦不住。
后来我调整了规则逻辑,不再看长度,而是同时检查几个更稳定的特征:
- Cookie 是否包含正确的 Base64 字符集
- 解码后是否有 Java 序列化魔数(如
AC ED) - 请求路径是否为登录或鉴权相关接口
联动 URL 特征后,误报率明显下降,针对无效密文探测的行为也能被覆盖到。
写在最后
Shiro RememberMe 反序列化这个漏洞,从 2016 年被公开到现在,依然是不少老系统排查清单上的常客。原因很简单:框架升级牵动面大,密钥管理容易被忽略,依赖治理又是一个长期活儿。
我在多次处理这类问题后最深的体会是:不要急于给自己的系统定性,拿到问题先分清楚到底是版本问题、配置问题还是依赖问题,再决定动作顺序。排查时按"版本、密钥、依赖、响应特征"四个维度逐个确认,修复时优先处理加密模式升级和密钥轮换,再考虑 WAF 和出网限制这类外围防御。
如果你手头也有一个跑着 Shiro 的系统,建议现在就去问三个问题:Shiro 什么版本?密钥是默认值还是自己生成的?依赖树里有没有旧版高危库?三个问题都答不上来的话,今天就是个合适的排查时机。