Shiro 反序列化漏洞是 Java 应用侧影响面最广的远程代码执行通道之一:攻击者只需在请求 Cookie 里塞入一段 Base64 字符串,服务端在解析 rememberMe 时会先解密再反序列化,反序列化过程中 gadget 链被触发,命令随即执行。整个过程不需要登录、不需要任何前置凭据,一个 curl 请求即可完成。本文从 Cookie 的加解密链路讲清成因,再给出一套可以直接落地的四层防御配置:升级版本、替换密钥、改名加属性、入口拦截,最后附上默认密钥的批量检测脚本。
适用场景
本方案适用于以下三类环境:一是使用 Apache Shiro 做认证与权限控制、且开启了 rememberMe 功能的 Java Web 项目,典型形态包括 Spring Boot 内嵌 Tomcat 的单体应用与 Shiro 整合 SSM 的传统项目;二是历史遗留系统短期内无法升级到安全版本,只能靠配置层兜底的场景;三是需要对外网暴露登录入口、并且在做常态化漏洞排查的安全运维团队。
如果项目根本没有启用 rememberMe,也就是登录时页面上没有「记住我」勾选项,那么本文的第一优先级动作是直接关闭 RememberMeManager,而不是费力气去换密钥。关闭之后这条攻击链在代码层就不存在了,属于成本最低、效果最彻底的处置方式。
反过来,如果系统已经上线多年、Shiro 版本停留在 1.2.x 甚至更低,那么可以默认认为密钥就是公开的默认值。这种情况下即使暂时无法升级,也必须先把密钥换成随机值并改掉 Cookie 名,否则任何扫描器都能一击命中。
前置条件
- 能确认应用使用的 Shiro 版本,可通过依赖树或运行时类路径查看。
- 具备修改 Java 配置文件、重新打包发布的能力,或具备在网关/WAF 层下发规则的能力。
- 服务器上可执行 curl、unzip、grep 等基础命令,用于本地验证。
- 如果要在 Nginx 层拦截,需要能修改 server 段配置并 reload。
原理说明
Shiro 的 rememberMe 功能为了解决「关闭浏览器后仍保持登录」的需求,会把用户身份序列化成字节数组,加密后 Base64 编码写入 Cookie。请求回来时,服务端读取这个 Cookie,先 Base64 解码,再用同一个密钥做 AES 解密,最后交给 Java 原生反序列化还原对象。
问题出在两个环节。第一,旧版本 Shiro 在代码里硬编码了一个默认的 AES 密钥,所有使用默认配置的项目共用同一把钥匙,攻击者只要拿到这把钥匙就能自己构造密文。第二,Java 原生反序列化本身不具备类型白名单能力,只要类路径上存在可利用的 gadget 链,反序列化就等于执行代码。两者叠加,就形成了「可控密文 + 可控反序列化」的完整攻击面。
这里的关键细节是加密模式。Shiro 的 AesCipherService 使用 AES/CBC/PKCS5Padding,加密时随机生成 16 字节 IV 并把它拼在密文最前面。所以解密时需要先切出前 16 字节作为 IV,剩下的才是真正的密文。理解这一点,后面写检测脚本时才不会把 IV 当成密文的一部分去做校验。
另一个容易被忽略的点是:即使把密钥换掉了,只要服务端仍然接受外部传入的 rememberMe Cookie 并尝试解密,那么在密钥泄露或者 Padding Oracle 存在的条件下,攻击依然可能成立。所以真正稳妥的做法是「换密钥 + 换 Cookie 名 + 入口特征拦截」三件事一起做,形成纵深。
操作步骤
第一步:确认 Shiro 版本与默认密钥风险
先确认项目实际加载的 Shiro 版本。如果是 Maven 构建,可以直接在依赖树里定位;如果是已打包的 fat jar,则从 lib 目录里看文件名。版本号低于 1.2.5 的项目,默认密钥一定可以用。
# Maven 项目查版本
mvn dependency:tree -Dincludes=org.apache.shiro
# 已打包应用中查看
unzip -l app.jar | grep -i shiro-core
# 在解包目录里搜默认密钥常量(旧版本会以 Base64 常量形式存在)
mkdir -p /tmp/shiro_chk && cd /tmp/shiro_chk
unzip -o -q /path/to/app.jar 'BOOT-INF/lib/shiro-core*.jar'
unzip -o -q BOOT-INF/lib/shiro-core*.jar
grep -rl "kPH+bIxk5D2deZiIxcaaaA" . || echo "未发现硬编码默认密钥"
第二步:升级到安全版本
能升级就优先升级。需要区分两个不同的问题:默认密钥问题是 CVE-2016-4437,在 1.2.5 修复;而 rememberMe 的 Padding Oracle 问题(Shiro-721)在 1.4.2 才修复。所以安全基线应当至少定在 1.4.2 以上,实际部署建议直接用 1.13.x 或 Shiro 2.x 的当前稳定版。
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.13.0</version>
</dependency>
升级后必须重新做一轮登录与权限回归测试。Shiro 在 1.4 之后对 RememberMe 的默认行为有调整,部分依赖「自动登录」语义的业务流程可能出现会话失效提前的问题,这类现象属于预期变化,不是升级失败。
第三步:显式配置随机密钥
从 1.2.5 开始,Shiro 不再使用硬编码密钥。如果没有显式调用 setCipherKey,框架会在启动时随机生成一把密钥。这在单机部署下是可以接受的,但在多节点集群里会带来一个隐蔽故障:各节点密钥不同,用户在 A 节点登录生成的 rememberMe Cookie 到 B 节点解密必然失败,表现为「刷新几次就掉登录」。所以集群环境一定要显式配置同一把固定密钥。
# 生成一把 16 字节(AES-128)随机密钥
head -c 16 /dev/urandom | base64
拿到 Base64 结果后写进配置类。同时建议把 Cookie 名从默认的 rememberMe 改成一个业务无关的名字,这样攻击者针对 rememberMe 的现成利用脚本和公开扫描规则会直接失效,属于低成本高收益的一步。
import org.apache.shiro.codec.Base64;
import org.apache.shiro.web.mgt.CookieRememberMeManager;
import org.apache.shiro.web.servlet.SimpleCookie;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ShiroSecureConfig {
@Bean
public CookieRememberMeManager rememberMeManager() {
CookieRememberMeManager mgr = new CookieRememberMeManager();
// 1) 替换默认密钥,密钥从环境变量读取,禁止写死在代码或配置仓库
String key = System.getenv("SHIRO_REMEMBER_KEY");
if (key == null || key.trim().isEmpty()) {
throw new IllegalStateException("SHIRO_REMEMBER_KEY 未设置");
}
mgr.setCipherKey(Base64.decode(key.trim()));
// 2) 重命名 Cookie,切断针对 rememberMe 的通用扫描规则
SimpleCookie cookie = new SimpleCookie("SESS_TOKEN_ID");
cookie.setHttpOnly(true); // 禁止 JS 读取,降低 XSS 窃取风险
cookie.setSecure(true); // 仅 HTTPS 传输
cookie.setMaxAge(60 * 60 * 24 * 7);
cookie.setPath("/");
mgr.setCookie(cookie);
return mgr;
}
}
需要注意密钥长度。AES-128 对应 16 字节、AES-256 对应 32 字节,密钥长度不匹配会直接抛异常导致应用启动失败。如果使用 32 字节密钥并且 JDK 版本较老,还需要确认 JCE 无限制策略已启用,否则同样无法启动。
第四步:不使用就彻底关闭 rememberMe
对于没有「记住我」需求的后台管理系统,最干净的处理是把 RememberMeManager 置空。置空之后 Shiro 不再解析任何 rememberMe 类 Cookie,攻击面从根上消失,也不再需要维护密钥轮换。
@Bean
public org.apache.shiro.mgt.SecurityManager securityManager() {
DefaultWebSecurityManager sm = new DefaultWebSecurityManager();
sm.setRealm(myRealm());
// 关键:关闭 rememberMe,Shiro 不再解析该类 Cookie
sm.setRememberMeManager(null);
return sm;
}
第五步:入口层拦截 Java 序列化特征
配置层加固解决的是已知实例的问题,但一个大的资产池里总会存在漏改的系统。这时候在 Nginx 或 WAF 层做统一拦截,是覆盖面最大的兜底手段。判断依据很稳定:Java 原生序列化流的头四个字节是十六进制 AC ED 00 05,Base64 编码后固定以 rO0AB 开头。攻击者构造的 rememberMe 载荷,解密后的明文必然以这四个字节开始。
# 在 Nginx 的 server 段中加入:拦截 Cookie 中携带 Java 序列化流特征头的请求
map $http_cookie $shiro_bad {
default 0;
"~*rememberMe=[A-Za-z0-9+/=]*rO0AB" 1;
"~*SESS_TOKEN_ID=[A-Za-z0-9+/=]*rO0AB" 1;
}
server {
listen 443 ssl http2;
server_name app.example.com;
if ($shiro_bad) {
return 403;
}
}
这里有一个参数需要说明:正则里的 ~* 表示不区分大小写匹配,因为 Base64 的大小写是有意义的,但攻击者也可能把 Cookie 名做大小写变形来试探规则,所以用不区分大小写更稳。另外不要只用 rememberMe 做匹配键,规则应当同时覆盖你改造后的 Cookie 名,否则改名之后反而漏拦。
如果使用 ModSecurity,可以写成 SecRule 形式,匹配范围和上面等价,差别在于能记录命中日志,便于后续做攻击溯源。
SecRule REQUEST_COOKIES_NAMES "@rx (?i)rememberMe" \
"id:1001,phase:1,deny,status:403,log,msg:'Shiro rememberMe probe blocked'"
SecRule REQUEST_HEADERS:Cookie "@rx rO0AB" \
"id:1002,phase:1,deny,status:403,log,msg:'Java serialized payload in cookie'"
配置验证
验证分三层做:Cookie 属性、误报情况、拦截效果。第一层确认改造生效,发一个登录请求看返回的 Set-Cookie 头里 Cookie 名是否已经变了、HttpOnly 与 Secure 是否都带上。
curl -s -D - -o /dev/null "https://app.example.com/login" \
--data "username=test&password=test&rememberMe=true" \
| grep -i "set-cookie"
第二层验证默认密钥是否还能打通。做法是用默认密钥加密一段无害数据塞进 Cookie,观察服务端是否返回 deleteMe。如果返回 rememberMe=deleteMe,说明服务端解密失败并主动清除了 Cookie,即密钥已经是非默认值,这是期望结果。反过来如果没有 deleteMe 且登录态被接受,说明默认密钥仍然有效,必须立刻处理。
#!/usr/bin/env python3
# 默认密钥批量核验:仅用于自有资产自查
import base64, sys
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
DEFAULT_KEY = base64.b64decode("kPH+bIxk5D2deZiIxcaaaA==")
def make_probe():
# 任意明文即可,目的只是让服务端尝试解密
plain = b"shiro-probe-0123456789abcdef"
iv = b"0123456789abcdef" # 固定 IV 便于复现
ct = AES.new(DEFAULT_KEY, AES.MODE_CBC, iv).encrypt(pad(plain, 16))
# Shiro 的密文格式为 IV + ciphertext
return base64.b64encode(iv + ct).decode()
if __name__ == "__main__":
print(make_probe())
PROBE=$(python3 shiro_probe.py)
curl -s -D - -o /dev/null "https://app.example.com/" \
-H "Cookie: rememberMe=$PROBE" | grep -i "set-cookie"
第三层验证 WAF 规则是否生效且不误伤。直接用 curl 带一个含 rO0AB 的假 Cookie 请求,期望返回 403;再用正常的登录流程走一遍,确认业务不受影响。
# 期望 403
curl -s -o /dev/null -w "%{http_code}\n" "https://app.example.com/" \
-H "Cookie: rememberMe=rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcA=="
# 期望 200,确认未误伤正常业务
curl -s -o /dev/null -w "%{http_code}\n" "https://app.example.com/login"
常见问题
Q1:升级到 1.13 之后,用户频繁掉登录是什么原因?
绝大多数情况是多节点密钥不一致。Shiro 在未显式配置密钥时会在每次进程启动时随机生成,集群里各节点各自随机,rememberMe Cookie 天然无法跨节点解密。解决办法是统一通过环境变量下发同一把 Base64 密钥,并确认所有节点的环境变量完全一致,包括末尾的等号和换行符都不能有差异。另一种可能是 Cookie 的 Secure 属性在 HTTP 环境下导致浏览器拒收,排查时先看浏览器开发者工具的 Cookie 面板里这条记录是否存在。
Q2:改了 Cookie 名之后,老用户的登录态会不会全部失效?
会。Cookie 名变更后,浏览器里留存的旧 rememberMe 不再被服务端识别,等价于所有人需要重新登录一次。如果这个影响不可接受,可以分两步走:第一次发布时保留旧 Cookie 名、只替换密钥并新增拦截规则,等一个 Cookie 生命周期过去之后再改名。分两步的代价是中间这段时间仍有一部分旧载荷能被解析,但密钥已换,实际风险可控。
Q3:只做 WAF 拦截、不改代码可以吗?
不建议作为唯一手段。特征拦截的前提是攻击者使用 Java 原生序列化流,一旦换成其他编码变形或者探测明文特征,规则就可能被绕过。WAF 的正确定位是覆盖面兜底,真正的根治仍然在代码层:关掉不需要的 rememberMe,或者换成非默认密钥。两者叠加才是纵深,单独任何一种都不构成完整方案。
总结
Shiro 反序列化漏洞的本质不是「一个 Cookie 参数没过滤」,而是「可控密文」与「不安全的反序列化原语」两件事同时成立。因此防御也要对准这两个前提:把密钥变成不可预测的、把不需要的解析入口直接移除、把入口层的特征拦截作为兜底。实操顺序建议是先盘点版本、再决定是关闭还是换密钥、最后铺开入口规则,每一步都能独立降低风险,不必等全部做完才见效。