Shiro rememberMe Deserialization Risk: Root Cause, Detection and Hardening

免责声明:本文为个人技术学习与工程实践笔记,所涉操作仅应在获得授权的环境中进行。因不当使用造成的后果由使用者自行承担。

写在前面

先说一段与技术无关的经历。我在 2024 至 2025 年之间投递过几次实习与校招简历,投出去大多没有回音,当时颇为不解:自己参加过若干攻防演练和 CTF 竞赛,也做过一些安全相关的小项目,为何连初筛都过不去。事后复盘才发现问题出在时间线上——春招通常集中在 2 月至 4 月,秋招集中在 9 月至 10 月,而我的投递时间分别是 2024 年 5 月下旬、2024 年 11 月下旬和 2025 年 6 月下旬,都落在窗口关闭之后。另一处疏漏是简历的在线版本长期没有同步维护,页面上的展示内容与正式简历不一致,这同样直接影响了筛选结果。

1
2
3
4
5
6
2024 年 2 月 ~ 4 月春招
笔者 2024 年 5 月下旬投递简历
2024 年 9 月 ~ 10 月秋招
笔者 2024 年 11 月下旬投递简历
2025 年 2 月 ~ 4 月春招开始
笔者 2025 年 6 月下旬投递简历

下面两份截图是当时在线简历的展示状态,留作提醒:与技术能力无关的细节,同样会决定结果。

image-20260302223800472

image-20260302224011557

言归正传。本文讨论 Apache Shiro 的 rememberMe 机制在 Cookie 场景下的反序列化风险:它是如何形成的、在流量与日志中如何识别、以及为什么「对 Cookie 长度设阈值」这类做法并不足以构成防线。

风险成因:一条被默认配置放大的信任链

Shiro 的 rememberMe 设计初衷是让用户在会话失效后仍能被识别。为实现这一点,客户端会持有一个 rememberMe Cookie,服务端读取该 Cookie 后依次执行三步操作:Base64 解码、AES 解密、反序列化。前两步用于保证数据的机密性与完整性,第三步则把 Cookie 内容还原成 Java 对象——风险正是在这一步引入的:Cookie 的内容最终会进入 Java 原生反序列化流程,只要外部能够构造出合法的密文,服务端就会尝试还原并处理其中的对象图。

在早期版本中,这条链路的门槛被进一步降低。org.apache.shiro.mgt.AbstractRememberMeManager 中提供了硬编码的默认加密密钥常量 DEFAULT_CIPHER_KEY_BYTES,如果部署时没有显式替换,所有使用该版本的实例会共用同一个密钥,而该常量在公开代码与文档中均可查阅。

image-20260302224603194

在这种配置下,外部只需要按相同的密钥与算法生成密文,即可让服务端反序列化任意对象。需要强调的是,能把这一步转化为命令执行,还依赖另外两个条件:一是应用类路径中存在可被复用的链式结构,Shiro 自身依赖了 commons-beanutils,在缺少完整 commons-collections 的情况下部分历史链依然成立;二是反序列化过程中能够触达具备类加载或反射调用能力的组件,从而把对象图还原过程转换为代码执行。在本地搭建的验证环境中,上述条件同时满足时整条链路即可完成。

image-20260302225826923

对运维与安全工作而言,这里更值得记录的是三个条件之间的依赖关系:默认密钥、可用的组件链、rememberMe 处于开启状态,三者缺一,风险就不成立。这决定了后续的排查顺序与加固优先级。

依赖条件与版本差异

链式结构的成立高度依赖组件版本。Shiro 自带的 commons-beanutils 并不包含完整的 commons-collections,因此不同版本组合下可复用的结构并不相同。本地验证环境使用 commons-beanutils 1.8.3 与 commons-collections 3.2.1,这也是历史链能够成立的前提。

版本不一致时的典型表现是 serialVersionUID 校验失败,服务端会直接拒绝反序列化并在日志中留下类定义不兼容的异常。

image-20260302224850120

这一点容易造成误判:环境不匹配导致的失败只是版本差异的体现,并不代表风险不存在,因为参与序列化的类与生成数据的工具都在外部一侧,版本差异可以在本地对齐。因此判断系统是否受影响,依据应当是密钥来源与依赖清单,而不是”能不能打通”。

载荷体积压缩:为什么长度阈值不是防线

在讨论检测之前,需要先说明一个常见的做法为何不可靠。曾有工具专门用于压缩反序列化载荷的体积,其思路是缩减对象图与字节码中的冗余信息,以降低载荷经 Base64 编码后的长度。原仓库已被删除,目前可查到的存档为 https://github.com/freeFV/ShortPayload。下表是该工具给出的压缩效果对照,注意表中所指长度为载荷经 Base64 编码后的长度。

反序列化链 YSOSERIAL长度 缩小后长度 缩小率
CommonsBeanutils1 3692 1296 64.8%
CommonsCollections1 1868 1748 6.4%
CommonsCollections2 4176 1708 41.4%
CommonsCollections3 4784 2444 48.9%
CommonsCollections4 4720 2256 52.2%
CommonsCollections5 2772 3044 -8.9%
CommonsCollections6 1708 1560 8.6%
CommonsCollections7 1700 1636 3.7%
CommonsCollectionsK1 2464 1708 30.6%
CommonsCollectionsK2 2472 1716 30.5%
CommonsCollectionsK3 1644 1604 2.4%
CommonsCollectionsK4 1652 1612 2.4%

表中最值得关注的一点是:不同结构的压缩空间差异极大,部分结构可以缩减三到六成,部分结构反而略有增加。压缩的通用手段包括动态生成字节码并去除 LineNumberTable 等调试信息,以及对常量池与字段顺序做等价改写。

一条未经体积优化的 Cookie 长度通常在两千字符以上,下图即为本地验证环境中的一次记录。

image-20260302225738232

优化之后,长度会明显下降。

image-20260302230716907

image-20260302230801862

这组数据的防御含义很直接:载荷体积不是一个稳定特征。同一风险的载荷长度可以在数百到数千字符之间浮动,取决于选用哪条链、是否做过体积优化、编码方式如何,因此以「Cookie 长度超过某个阈值」作为判定条件,既会产生大量误报,也可以被低成本规避。检测应当建立在结构性特征与完整性校验之上,而不是体积。

分块投递:让单请求检测失效的做法

当载荷被压缩到极限之后,另一条思路是不再追求单请求体积,而是把载荷拆成多段分散投递。其原理是利用应用侧的文件写入能力:多段请求以追加方式分别写入服务器可写目录中的同一文件,最后再触发一次读取与加载。这种方式规避的是两类限制——单个 HTTP 头部的长度上限,以及针对超长载荷做正则匹配的网关规则。

这一思路与早年间以合法业务写入能力拖取数据库的做法在逻辑上是一致的:把一次性的大动作拆成多次看似正常的小动作。

在本地验证环境中,可以观察到临时目录被写入内容,随后由一次请求完成读取与类加载。

image-20260302232029052

image-20260302232130010

image-20260302232804648

这一手法对检测提出的要求是明确的:任何只看单条请求的规则都会在这里失效。识别它需要把同一会话、同一来源在短时间内的请求聚合起来判断,并与其后的文件写入、动态类加载行为做关联。

检测方法

结合上述成因,可以从以下几个层面建立检测:

  • 流量层结构性特征。检查 rememberMe Cookie 是否符合其应有的形态:长度分布是否明显偏离业务基线、字符集是否为合法 Base64、同一客户端是否在短时间内反复提交该 Cookie。对于掌握自身密钥的一方,可以尝试解密并校验内容:解密失败、或解密后出现 Java 序列化流魔数(0xACED0005)而非预期的业务结构时,属于强异常特征——因为该 Cookie 正常只应承载标识信息,不应承载序列化对象。
  • 日志层特征。Cookie 解密失败、反序列化异常、类定义不兼容(serialVersionUID 不一致)、类加载失败以及字节码处理相关异常,都是值得单独告警的信号。这些异常在生产环境中通常并非由正常业务触发。
  • 主机层特征。重点关注应用账户对临时目录、上传目录、模板缓存目录的写入,以及运行时动态生成的类文件或 JAR;对 Web 目录与可写目录做文件完整性监控。
  • 出网与 DNS。反序列化探测与回连通常伴随异常的外部请求或 DNS 查询;时间延迟型的探测则会表现为同一接口的响应耗时出现规律性异常。DNS 日志与出网日志是发现这类行为的重要位置。
  • 会话级聚合。分块投递手法要求同一次攻击跨越多个请求,因此按会话统计「携带 rememberMe 的请求数量、请求间隔、目标路径分布」可以暴露出流水线式的投递行为。

需要承认的是,上述检测都存在被规避的可能:加密使得内容不可直接判读,体积压缩使得长度阈值失效,分块投递使得单请求规则失效。因此检测的价值在于提高攻击成本与缩短发现时间,而不能替代修复。

修复方案

  1. 密钥治理。确认当前部署是否仍在使用框架内置的默认密钥常量。只要沿用公开示例中的默认值,任何部署该版本的系统都处于同一风险之下;应当由部署方生成独立密钥并纳入配置管理,同时评估对历史密钥做轮换。
  2. 关闭不必要的功能。业务上不需要 rememberMe 的场景,应直接关闭该功能或移除对应的 RememberMeManager 配置。这是成本最低、效果最确定的措施。
  3. 替换序列化实现。Shiro 允许通过自定义 Serializer 实现替换默认的 Java 原生序列化,把 Cookie 内容改为 JSON 一类的数据格式,可以从根本上移除反序列化面。若必须保留 Java 原生序列化,应启用 JDK 内置的序列化过滤器(JEP 290,可通过 jdk.serialFilter 配置)并设置类白名单。
  4. 依赖治理。核对 commons-beanutils、commons-collections 等组件的版本与用途,能移除的移除,无法移除的纳入 SCA 跟踪;同时保持框架本身处于官方当前维护的版本。
  5. 应用与容器加固。应用以最小权限账户运行,根文件系统只读,限制应用账户对临时目录与 Web 目录的写入能力,避免出现「可写目录 + 可加载类」的组合。

分层防护

单一控制点在本类风险面前都不充分,需要按层布置:

  • 网络与网关:对 rememberMe 相关 Cookie 设置合理上限与频次限制,识别异常长、非 Base64、高频变化的取值。这层的作用是提高成本与产生线索,而非阻断——上文的压缩与分块手法已经说明,仅靠长度与正则的规则可以被绕过。
  • 应用层:密钥独立化、关闭无用功能、替换序列化实现、白名单化,这些才是边界所在。
  • 运行时:在 JVM 内对反序列化调用链、动态类定义与反射调用做监控(RASP 一类方案),可在载荷内容不可判读的情况下,从行为侧发现异常。
  • 主机与网络:只读文件系统、临时目录与可写目录的写入监控、出网白名单与 DNS 日志集中采集。
  • 配置与变更管理:密钥、Cookie 名称、是否启用 rememberMe 等配置项应纳入变更审计,避免不同环境之间配置漂移导致风险重新引入。

排查清单

  1. 确认框架版本,并核对代码与配置中密钥的来源,判断是否仍为框架内置的默认值。
  2. 确认业务是否真的需要 rememberMe;不需要则关闭并验证关闭生效。
  3. 定位 Cookie 名称与 RememberMeManager 配置,确认是否存在自定义 Serializer。
  4. 导出依赖清单,检查 commons-beanutils、commons-collections 等组件是否存在及实际用途。
  5. 在历史日志中检索解密失败、反序列化异常、类定义不兼容等关键字,确认是否已有此类尝试。
  6. 检查应用账户对临时目录、上传目录、Web 目录的写权限,并确认文件完整性监控已覆盖这些位置。
  7. 确认 DNS 与出网日志已集中采集,并能按会话与来源做聚合分析。

Support via Solana

Solana

Solana

Solana Pay

Solana Pay

WeChat

WeChat