从代码审计角度看 shiro 反序列化:CC 链原理与 Gadget 构造
shiro反序列化 - 你好,星
文章从代码审计视角讲解 shiro 反序列化,梳理 Java 集合框架与 Apache Commons Collections 的关系,指出 TransformedMap、LazyMap 在 JDK 原生 Map 上加了自动回调钩子。
今天我们将从代码审计的角度,去进行shiro反序列化的研究。
https://www.cnblogs.com/nihao-xing/p/23363379
这上篇文章我们已经大致的了解了fastjson的反序列化。没看的朋友可以去看一下。
什么是shiro反序列化,它和fastjson反序列化有什么区别,以及如何进行代码审计,这篇文章我都会进行解答。
可能有的师傅就会有问题 ,现在,AI这么发达,本地部署一个agent就可以进行代码审计。
其实这是个很好的问题,我来解答一下,首先呢,
1.有的内部的项目他不让你用ai。
2.之前河北石家庄有个项目是220万行JAVA代码,一周之内审完,你指望agent跑吗,我估计你赚的钱恐怕还没你token烧的钱多。
3.面试他是问你ai怎么挖的,还是听你怎么挖的?
4.如果我让你写一款agent,去写进代码审计,你连原理都不知道,你怎么写。
5.ai容易漏挖,误报(误报可以skill解决),漏挖这个没办法。
我们还是像以前一样,先解释下原理。
从apache的cc链开始转介绍
Apache Commons Collections,Collections是集合的意思,
思考:java里面的集合是什么呢

Java集合框架图讲解
先分清图里的框:虚线 = 接口;实线黑框 = 常用实现类;普通实线框 = 抽象类。
Java集合分两大分支:Collection(单列集合,一个一个存元素)、Map(双列集合,key-value键值对)
一、Collection 分支
Collection 是单列集合根接口,下面两大子接口:List、Set
1. List (有序、可重复、有索引)
ArrayList:数组实现,查询快、增删慢,最常用LinkedList:双向链表实现,增删快、查询慢Vector:古老线程安全数组(Legacy遗留类,性能差,基本淘汰)Stack:继承Vector,栈结构,也属于遗留类
补充:
AbstractList、AbstractSequentialList是抽象父类,帮实现了List大部分通用逻辑。
ListIterator是List专属迭代器,比普通Iterator支持向前遍历。
2. Set(无序、不可重复)
HashSet:底层是HashMap,哈希表,去重,无序LinkedHashSet:继承HashSet,额外维护链表,保留插入顺序TreeSet:底层TreeMap,红黑树,自动排序,实现SortedSet接口
SortedSet:带排序能力的Set子接口;排序依赖Comparable(元素自身比较) /Comparator(外部比较器)
3. Iterator
迭代器接口,所有Collection都可以产出Iterator,用来遍历集合,CC链里很多Transformer就是靠遍历、修改集合触发调用链!
二、Map 分支(键值对 key:value,key不可重复)
Map和Collection是平级,不是继承关系!
HashMap:哈希表,JDK8底层数组+链表+红黑树,最核心!CC、Shiro反序列化里高频出现LinkedHashMap:继承HashMap,维护插入顺序,Lru缓存常用TreeMap:红黑树,有序,实现SortedMapWeakHashMap:弱引用Map,key被GC回收后条目自动消失Hashtable:古老线程安全Map(Legacy,淘汰, ConcurrentHashMap替代)IdentityHashMap:用==而不是equals判断key相等
AbstractMap:Map的抽象父类,实现了Map大部分基础方法。
三、右下角工具类 Utilities(不是集合!)
Collections:集合工具类,提供排序、反转、同步包装(synchronizedList)等静态方法,CC链里很多恶意Transformer就是靠Collections.checkedMap、transformedMap包装出来的!Arrays:数组工具类,数组和集合互相转换
CC / Shiro反序列化重点(非常关键)
CC1、CC2 链核心类:TransformedMap、LazyMap,就来自 Apache Commons Collections,本质就是对Map做包装劫持:
当Map的setValue()、get()被调用时,自动执行绑定的Transformer,最终反射执行命令。
而TransformedMap的父类就是Map体系,和图上HashMap同属Map分支。
补充易混点
- HashSet底层就是new了一个HashMap,元素存到HashMap的key上,value固定占位;
- TreeSet/TreeMap排序:元素实现
Comparable接口,或者构造传入Comparator; - 遗留类 Vector / Hashtable 自带
synchronized,并发性能差,现在开发基本不用,但在老项目审计(Shiro场景)里有可能碰到。
思考:就是在java自带的jdk里面,里面就已经包含了各种各样的集合类型,或者换句通俗易懂的话来讲,都可以叫容器类。比如list可以放字符串,map里面可以放对象,Set里面可以放各种各样的数据。这样的集合类在jd k里面都已经有了,各种各样的实现方式。为什么还要引入Apache Commons Collections(CC)?
Apache Commons Collections(CC)这个第三方包,它做的事情,是在 JDK 原生集合的基础上,包装出了增强版的 Map:TransformedMap、LazyMap
它底层依然是依赖 JDK 的 Map,但是额外加了钩子:
// 伪代码
Map rawMap = new HashMap(); // JDK原生
Map evilMap = TransformedMap.decorate(rawMap, keyTransformer, valueTransformer);
JDK 原生集合 = 基础容器骨架,只负责存数据;
Commons Collections 的 TransformedMap/LazyMap = 装饰过的增强容器,复用原生 Map 的结构,新增了自动回调钩子,反序列化的时候这个钩子就会被触发,一路走到命令执行。
思考
1.哪里出现了可以执行任意代码的问题?
2.反序列化的payload怎么构造?
带着这2个问题看下面的

Java代码运行原理讲解
原图的流程:
- 源码(
.java) javac编译器 → 编译生成字节码(.class)- 不同平台的JVM,把字节码翻译成当前操作系统的机器指令执行
核心口号:一次编译,到处运行。
.class字节码不是直接给CPU跑的机器码,是给JVM看懂的中间指令。Windows、Linux、Mac各自装对应版本JVM,同一份class文件都可以执行。
- 源码 .java
我们手写的Java代码,就是这个源码文件。
public class Test{
public static void main(String[] args){
System.out.println("hello");
}
}
- javac 编译阶段
执行命令:javac Test.java
编译器做语法检查、语义分析,输出 Test.class 字节码文件。
⚠️编译阶段不运行代码,只翻译。
反序列化漏洞和编译无关,漏洞是程序运行阶段(JVM加载class之后)才触发。
- JVM(Java虚拟机)加载 + 解释/编译执行字节码
JVM里面有类加载器先把.class加载进内存,之后有两种执行方式:
- 解释器:逐行把字节码翻译成操作系统本地指令(原图写的就是这个)
- JIT即时编译器(热点代码):频繁执行的代码直接编译成本地机器码,加速运行
的反序列化、CC链关联
这个运行流程,刚好能解释Gadget为什么能跑:
- CC链里的
TransformedMap、InvokerTransformer这些类,本身也是一个个.class; - 项目启动的时候,class文件被JVM类加载器加载,类信息、方法、
transform()、readObject()都加载到JVM内存; - 反序列化的时候:
ObjectInputStream.readObject()不是编译新代码,是在JVM已经加载好的类里,按照序列化二进制流的描述,在内存new出对象、并且自动调用readObject()方法; - 顺着Gadget链路,调用
InvokerTransformer.transform(),最终反射调用Runtime.exec(),交给JVM去执行操作系统命令。
序列化/反序列化,是JVM运行期、内存对象的操作,不经过javac重新编译。Payload本质只是构造对象的二进制描述,利用已经加载在JVM里的恶意Gadget类的逻辑。

CC关键类逐句讲解
这四个就是CC1链最核心的组件,全部实现了
Transformer接口,是整条Gadget的骨架
1. InvokerTransformer
幻灯片原文:利用Java反射机制来创建类实例
更准确完整解释:
它的transform()方法,接收一个对象,通过反射调用这个对象的指定方法,可以传入方法名、参数类型、参数值。
这是整条链命令执行的核心。
示例:
// 拿到Runtime对象之后,调用exec方法执行calc.exe
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc.exe"});
注意:它不只是创建实例,核心能力是反射调用任意对象的任意方法。
2. ChainedTransformer
幻灯片原文:实现了Transformer链式调用,我们只需要传入一个Transformer数组,ChainedTransformer就可以实现依次的去调用每一个Transformer的transform()方法
通俗解释:
串联器,把一堆Transformer串成流水线。
上一个transform的返回结果,作为下一个transform的入参,顺序执行。
CC1的链路:
ConstantTransformer(拿到Runtime.class)→InvokerTransformer.getMethod("getRuntime")→InvokerTransformer.invoke拿到Runtime实例→InvokerTransformer.exec执行命令
全部打包放进ChainedTransformer数组,一键顺序跑通。
3. ConstantTransformer
幻灯片原文:transform()返回构造函数的对象
修正+通俗解释:
固定返回你传入的那个对象,不管传入什么输入,transform永远返回预先指定的常量对象。
CC里用法:
new ConstantTransformer(Runtime.class);
无论传什么进来,都返回Runtime.class,作为整条链的起点输入。
4. TransformedMap
幻灯片没有写备注,这里补上重点:
它是包装增强Map(装饰器模式),底层包装一个原生HashMap。
当它的setValue()方法被触发时,会自动调用我们提前绑定好的Transformer的transform()。
这就是触发入口:反序列化过程中,readObject流程里会触发Map的setValue,进而调用ChainedTransformer,启动整条命令执行链。
串起来完整CC1链路
反序列化 readObject()
↓
AnnotationInvocationHandler.readObject() 遍历 memberValues
↓
Map.Entry.setValue() ← TransformedMap 的钩子
↓
ChainedTransformer.transform()
CC1 依赖 JDK 8u71 之前的 AnnotationInvocationHandler,这个入口在 JDK 8u71 之后就被修了(AnnotationInvocationHandler 不再直接调 setValue)。
审计视角总结
InvokerTransformer:执行器(危险能力)ChainedTransformer:流水线控制器ConstantTransformer:起点常量供给TransformedMap:触发开关(反序列化回调入口)
这四类组合在一起,才构成完整CC Gadget,单独一个类无法完成RCE。
思考:什么是反射,编程语言是否都有反射这个机制
一、什么是反射
正常正向写Java代码的时候:编译期你就知道要调用哪个类、哪个方法,直接写死调用:
Runtime.getRuntime().exec("calc.exe");
编译器在javac阶段就清楚Runtime是什么类、exec是什么方法。
而反射:在代码运行的时候(JVM运行期),动态去获取一个类的信息、实例化对象、调用方法、读写属性。
类名、方法名可以是字符串,编译阶段代码根本不知道要操作哪个类。
CC链里的
InvokerTransformer,底层就是反射!
它接收字符串"exec",运行时再去找到这个方法并且执行。
// 反射伪代码,就是InvokerTransformer干的事情
Class<?> clazz = Runtime.class;
Method method = clazz.getMethod("exec", String.class);
method.invoke(runtimeObj, "calc.exe");
正向调用:写代码的时候就确定调用逻辑;反射:运行时动态发现、调用类和方法。
二、是不是所有编程语言都有反射?
不是所有语言都支持反射,反射是一套运行时自省(introspection)+动态调用的能力,不同语言支持程度差别很大:
支持反射的语言
- Java:强反射,功能完整(CC链、很多漏洞的根基)
- Python:天生强反射(
getattr、setattr、__class__) - C#:有反射
- JavaScript:自带反射(可以动态拿对象属性、构造函数)
- PHP:有反射API
ReflectionClass
原生没有反射 / 反射极弱的语言
- C、C++:标准C/C++没有原生反射。编译之后类名、方法名符号大部分会被strip掉,运行期没有类信息;只能靠自己手写元数据模拟。
- Go:反射包
reflect是运行期反射,但能力有限; - Rust:反射非常受限,不支持像Java那样随便动态调用任意方法。
- 汇编:完全没有,直接操作内存和寄存器。
一个关键区分:自省 ≠ 完整反射
有些语言只能拿到对象的类型信息(自省),但是不能动态调用方法、构造对象,这不叫完整反射。
三、结合反序列化
CC链的RCE本质就是反射带来的能力被恶意利用:
- 如果Java没有反射,
InvokerTransformer根本不可能做到:传入方法名字符串,运行时去执行任意方法; - 反序列化漏洞 = 可控输入 + 危险Gadget,而很多Gadget的最后一步RCE,都依赖反射;
- Fastjson反序列化RCE,很多利用链同样也是靠反射实例化类、调用方法。
反射本身不是漏洞,它是Java的正常特性;漏洞是不可信的输入可以控制反射调用的目标。
底层补的差不多了,开始审计。
本期的项目文件:RuoYi-4.6.0
┌─────────────────────────────────────────────────────────┐
│ 手动代码审计流程 │
├─────────────────────────────────────────────────────────┤
│ Step 1: 确定 Shiro 版本(pom.xml) │
│ ↓ │
│ Step 2: 查找 rememberMe 密钥(application.yml) │
│ ↓ │
│ Step 3: 分析 Shiro 配置类(ShiroConfig.java) │
│ ↓ │
│ Step 4: 确认加密算法(反编译 AesCipherService) │
│ ↓ │
│ Step 5: 追踪反序列化调用链(source → sink) │
│ ↓ │
│ Step 6: 检查 classpath gadget 依赖 │
│ ↓ │
│ Step 7: 综合判断漏洞是否存在并给出结论 │
└─────────────────────────────────────────────────────────┘
Step 1:确定 Shiro 版本 —— pom.xml
审计目标:确认 Shiro 版本是否小于 1.8.0(Shiro-550 影响版本)。
实际文件:F:\daimashenji\RuoYi-4.6.0\pom.xml

将来如果嫌组件难翻的话,直接把pom.xml丢给ai,看有哪些危险组件
然后呢,扯句题外话,我希望大家每天能早起15分钟。原因很简单,就是你比别人早起15分钟,你就比别人早上多了15分钟的准备时间。就拿我早上上水课举例子,那节课的教室里就只有一个充电的桩位,我每次周一都会比同班的人早起15分钟去抢那个东西。他们大约是7点半起床,我是7点15起床,我每次都能吃完早饭并且还能抢到。因为早起的人不多,早起带来的收益确是滚雪球一样的,起的早,早饭排队的人就少,能比别人更早吃上早饭,还能抢到充电的桩位,不然,你们看到这篇文章还得等到今天晚上。
Step 2:查找 rememberMe 密钥
思考:我要怎么找到密钥
定位密钥的过程:从代码追溯到配置
第一步:看 ShiroConfig.java 中的密钥引用
在 ShiroConfig.java 中,密钥是这样设置的:
/**
* 设置cipherKey密钥
*/
@Value("${shiro.cookie.cipherKey}") // ← 关键线索:Spring 的 @Value 注解
private String cipherKey;

@Value("${shiro.cookie.cipherKey}") 是 Spring 框架的注解,意思是:从配置文件中读取 shiro.cookie.cipherKey 这个属性的值。
这就是线索——密钥不在 Java 代码里硬编码,而是来自外部配置文件。
第二步:追踪 @Value 注解的来源
Spring Boot 项目的配置文件通常有以下几个位置(按优先级):
src/main/resources/
├── application.yml ← 主配置文件(最常见)
├── application.properties ← 属性格式
├── application-dev.yml ← 开发环境
└── application-prod.yml ← 生产环境
所以,看到 @Value("${shiro.cookie.cipherKey}") 后,审计者自然会去 application.yml 里找这个属性。
第三步:在 application.yml 中找到密钥
打开 application.yml,搜索 cipherKey:
# Shiro
shiro:
cookie:
# 设置密钥,务必保持唯一性...
cipherKey: zSyK5Kp6PZAAjlT+eeNMlg== # ← 找到了!

完整的审计路径
ShiroConfig.java application.yml
───────────────── ─────────────────
@Value("${shiro.cookie.cipherKey}") ──→ shiro:
private String cipherKey; cookie:
cipherKey: zSyK5Kp6PZAAjlT+eeNMlg==
│ │
│ 通过 @Value 注解关联 │
└──────────────────────────────────────────────┘
总结:
- 先看
ShiroConfig.java中CookieRememberMeManager的密钥设置 - 发现用了
@Value("${shiro.cookie.cipherKey}")注解 - 知道这是 Spring Boot 项目 → 去
resources目录找配置文件 - 在
application.yml中找到shiro.cookie.cipherKey的值
思考:为什么我要先看ShiroConfig.java
因为所有与 rememberMe 相关的关键配置都在这里。
(1)如果先看 application.yml:
知道密钥,但不知道是否启用 rememberMe
不知道 cookie 配置
不知道过滤器链设置
(2)先看 ShiroConfig.java 的优势:
一次性获取多个关键信息:rememberMe 启用状态、密钥、cookie 配置、过滤器链
直接关联:从代码中看到密钥的来源(@Value 注解)
调用链起点:从这里开始追踪整个反序列化流程
思考:@Value一定是spring boot框架嘛
@Value 注解不一定是 Spring Boot 框架的,但它是 Spring 框架的核心注解。
@Value 注解不一定是 Spring Boot 框架的,但它是 Spring 框架的核心注解。让我详细解释:
@Value 注解的来源
1. Spring 框架(非 Spring Boot)
@Value 是 Spring Framework 2.5 版本引入的注解,用于属性注入:
import org.springframework.beans.factory.annotation.Value;
public class MyService {
@Value("${some.property}")
private String someProperty;
// ...
}
2. Spring Boot
Spring Boot 继承了 Spring Framework 的所有注解,包括 @Value,并做了增强:
- 自动配置(Auto-configuration)
- 配置文件支持(application.yml, application.properties)
- 环境变量支持
3. 使用场景
@Value 可以在以下框架中使用:
Spring Framework (纯 Spring)
Spring Boot
Spring Cloud
Spring MVC
@Value 的作用
1. 从配置文件读取值
@Value("${shiro.cookie.cipherKey}")
private String cipherKey;
2. 从环境变量读取值
@Value("${ENV_VAR_NAME}")
private String envValue;
3. 直接赋值
@Value("default value")
private String defaultValue;
4. SpEL 表达式
@Value("#{systemProperties['user.home']}")
private String userHome;
在 ShiroConfig.java 中的使用
@Value("${shiro.cookie.cipherKey}") // ← Spring 的 @Value 注解
private String cipherKey;
这个注解的作用是:
- 从配置文件(application.yml)读取
shiro.cookie.cipherKey - 注入到
cipherKey字段中 - 然后在
rememberMeManager()方法中使用
为什么在 Spring Boot 项目中常见?
因为 Spring Boot:
- 默认支持配置文件(application.yml, application.properties)
- 自动加载配置(无需手动配置 PropertySources)
- 环境变量优先级(可以覆盖配置文件)
对比:其他框架的类似功能
| 框架 | 类似功能 | 注解/方式 |
|---|---|---|
| Spring | 属性注入 | @Value |
| Spring Boot | 配置注入 | @Value + 自动配置 |
| Guice | 注入 | @Inject |
| CDI (Java EE) | 注入 | @Inject |
| JEE | 资源注入 | @Resource |
总结
@Value 不是 Spring Boot 独有的,而是 Spring Framework 的核心注解。但在 Spring Boot 项目中,它被广泛使用,因为:
- Spring Boot 默认支持配置文件
- 配置管理更简单
- 环境变量支持更好
所以,在审计 ShiroConfig.java 时,看到 @Value 注解,可以确定:
- 这是一个 Spring/Spring Boot 项目
- 密钥配置在配置文件中
- 需要查看
application.yml或application.properties
其实代码审计是个集大成的活,需要很强的经验
Step 3:分析 Shiro 配置类 —— ShiroConfig.java
审计目标:确认 rememberMe 功能是否启用、Cookie 名、密钥如何设置。
3.1 SecurityManager 中启用 rememberMe
@Bean
public SecurityManager securityManager(UserRealm userRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(userRealm);
// 记住我
securityManager.setRememberMeManager(rememberMeManager()); // ← 审计点:无条件启用
securityManager.setCacheManager(getEhCacheManager());
securityManager.setSessionManager(sessionManager());
return securityManager;
}
判断:setRememberMeManager() 被调用 → rememberMe 启用。
3.2 Cookie 名
public SimpleCookie rememberMeCookie() {
SimpleCookie cookie = new SimpleCookie("rememberMe"); // ← 审计点:Cookie 名
cookie.setDomain(domain);
cookie.setPath(path);
cookie.setHttpOnly(httpOnly);
cookie.setMaxAge(maxAge * 24 * 60 * 60);
return cookie;
}
判断:cipherKey 来自 @Value("${shiro.cookie.cipherKey}"),即 application.yml 中的固定值
3.4 过滤链(确认 rememberMe 不需要认证)
// 所有请求需要认证
filterChainDefinitionMap.put("/**", "user,kickout,onlineSession,syncOnlineSession");
判断:过滤链使用 user 过滤器(不是 anon),user 过滤器会处理 rememberMe cookie → 反序列化在访问任何页面时都可能触发。
现实里就把这ShiroConfig.java文件先丢给ai,然后自己在手工验证。
Step 4:确认加密算法 —— 反编译 AesCipherService
思考:我怎么确认加密算法的位置。
审计目标:确定 rememberMe cookie 使用的加密方式(CBC 还是 GCM),这决定了 payload 加密方式。
先说答案:AesCipherService
思考:我没查官方的配置文档,我不知道啊该怎么办
在审计 Shiro-550 漏洞时,定位 org.apache.shiro.crypto.AesCipherService 的过程是通过代码调用链追踪的。让我详细解释这个定位过程:
第一步:查看 ShiroConfig.java
public CookieRememberMeManager rememberMeManager() {
CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
cookieRememberMeManager.setCipherKey(Base64.decode(cipherKey));
return cookieRememberMeManager;
}
第二步:理解 CookieRememberMeManager 的作用
CookieRememberMeManager 构造函数只设置了 cookie,没有设置任何 CipherService;
setCipherKey() / setCipherService() 都定义在父类 AbstractRememberMeManager 里;
父类构造函数里是直接 putfield 给 cipherService 字段赋值(new AesCipherService()),不是调用 setCipherService() 方法
第三步:推断加密算法
从 ShiroConfig.java 中我们知道:
- 使用了
Base64.decode(cipherKey)→ 说明密钥是 Base64 编码的 CookieRememberMeManager默认使用AesCipherService进行加密
第四步:确认 Shiro 版本
<shiro.version>1.7.0</shiro.version>
Shiro 1.7.0 使用 AES/GCM 算法,而不是老版本的 AES/CBC。
为什么需要反编译 Shiro jar 包?
虽然手工审计时我们看不到 Shiro 源码,但为了确认加密算法,我们需要:
- 反编译
shiro-crypto-cipher-1.7.0.jar - 查看
AesCipherService的构造函数 - 确认使用的是 AES/GCM 而不是 AES/CBC
实际手工审计步骤
1. 查看 ShiroConfig.java
- 找到
rememberMeManager()方法 - 确认
CookieRememberMeManager的创建
2. 查看 application.yml
- 找到
cipherKey配置 - 确认密钥是否固定
3. 查看 pom.xml
- 确认 Shiro 版本
- 确认是否小于 1.8.0
4. 检查依赖
- 查看是否有 commons-beanutils、commons-collections 等 gadget 依赖
5. 确认加密算法
- 通过 Shiro 版本推断(1.7.0 → AES/GCM)
- 反编译 Shiro jar 包确认
思考:反编译后怎么确认加密算法
基于刚才 javap 反编译 ruoyi-admin.jar 里的真实字节码,加密方法的定位路径如下。
定位加密方法的完整链路(全部有字节码证据)
起点:ShiroConfig.java 里只有 setCipherKey,没有加密方法
cookieRememberMeManager.setCipherKey(Base64.decode(cipherKey)); // 只设置密钥
加密逻辑在 Shiro 框架里。要定位它,就得顺着 CookieRememberMeManager 的继承链往上找。
第 1 步:确认继承链
CookieRememberMeManager(shiro-web-1.7.0.jar)继承 AbstractRememberMeManager(shiro-core-1.7.0.jar)。
加密方法就藏在 AbstractRememberMeManager 里。反编译字节码,找到两个方法:
protected byte[] encrypt(byte[] serialized) {
byte[] value = serialized;
CipherService cipherService = getCipherService(); // ← 拿 CipherService
if (cipherService != null) {
ByteSource encrypted = cipherService.encrypt(serialized, getEncryptionCipherKey());
value = encrypted.getBytes();
}
return value;
}
protected byte[] decrypt(byte[] encrypted) {
byte[] value = encrypted;
CipherService cipherService = getCipherService();
if (cipherService != null) {
ByteSource decrypted = cipherService.decrypt(encrypted, getDecryptionCipherKey());
value = decrypted.getBytes();
}
return value;
}
关键点:真正的加密不是在这里做的,而是委托给 CipherService 接口。
第 2 步:找 CipherService 接口的真实实现
getCipherService() 返回的对象,是构造函数里 new AesCipherService() 创建的那个(前面反编译 AbstractRememberMeManager 构造函数已经确认了)。
AesCipherService 的继承链:
AesCipherService
└─ DefaultBlockCipherService
└─ AbstractSymmetricCipherService
└─ JcaCipherService ← 真正的 encrypt/decrypt 实现在这里
反编译 JcaCipherService.encrypt(byte[], byte[]) 字节码,真实逻辑是:
public ByteSource encrypt(byte[] plaintext, byte[] key) {
byte[] iv = null;
boolean generateIV = isGenerateInitializationVectors(false);
if (generateIV) {
iv = generateInitializationVector(false); // 生成随机 IV
}
return encrypt(plaintext, key, iv, generateIV);
}
private ByteSource encrypt(byte[] plaintext, byte[] key, byte[] iv, boolean prependIV) {
byte[] output;
if (prependIV && iv != null && iv.length > 0) {
byte[] encrypted = crypt(plaintext, key, iv, ENCRYPT_MODE); // 加密
output = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, output, 0, iv.length); // IV 拼前面
System.arraycopy(encrypted, 0, output, iv.length, encrypted.length);
} else {
output = crypt(plaintext, key, iv, ENCRYPT_MODE);
}
return ByteSource.Util.bytes(output);
}
关键字节码证据:encrypt 把 iv 和 encrypted 用 System.arraycopy 拼接,返回 IV + 密文。
第 3 步:定位真正的 JCE 加密调用
JcaCipherService 里还有个私有方法 crypt,这才是最终调用 JDK 加密 API 的地方:
private byte[] crypt(byte[] plaintext, byte[] key, byte[] iv, int mode) {
Cipher cipher = initNewCipher(mode, key, iv, false); // 初始化 Cipher
return crypt(cipher, plaintext);
}
private byte[] crypt(Cipher cipher, byte[] plaintext) {
return cipher.doFinal(plaintext); // ← 真正的加密 sink
}
字节码证据:
Method javax/crypto/Cipher.doFinal:([B)[B
第 4 步:确认 GCM 模式(关键!)
initNewCipher 初始化 Cipher 时,会调用 AesCipherService.createParameterSpec。之前反编译 AesCipherService 已经看到:
public AesCipherService() {
super("AES");
setMode(OperationMode.GCM); // ← GCM 模式
setStreamingMode(OperationMode.GCM);
setPaddingScheme(PaddingScheme.NONE); // ← NoPadding
}
protected AlgorithmParameterSpec createParameterSpec(byte[] iv, boolean streaming) {
...
return new GCMParameterSpec(getKeySize(), iv); // 128-bit tag
}
结论:GCM 模式下 cipher.doFinal() 返回的是 密文 + tag,再加上前面拼的 IV,最终 rememberMe 的结构就是:
base64( IV[16] + 密文 + tag[16] )
手工审计定位加密方法的 4 种实际操作
| 方法 | 操作 | 适用场景 |
|---|---|---|
| ① 顺藤摸瓜 | 从 ShiroConfig 的 setCipherKey → 找到 CookieRememberMeManager → 继承链找到 AbstractRememberMeManager.encrypt/decrypt |
有源码 jar 时最直观 |
| ② 搜方法名 | 在 jar 里搜 encrypt(、decrypt(、CipherService、doFinal |
快速定位 |
| ③ 反编译字节码 | jar tf 找类 → javap -p -c 看字节码 |
没有源码时(本次就是这么做的) |
| ④ 看接口实现 | CipherService 接口 → JcaCipherService 实现 |
理解委托关系 |
完整的定位路径图(真实字节码确认)
ShiroConfig.java:359
setCipherKey(Base64.decode(cipherKey)) ← 固定密钥
│
CookieRememberMeManager (shiro-web-1.7.0.jar)
├─ 构造函数:只设置 cookie(rememberMe, httpOnly, maxAge)
└─ super() → AbstractRememberMeManager (shiro-core-1.7.0.jar)
├─ 构造函数:cipherService = new AesCipherService()
│ setCipherKey(随机key)
├─ encrypt(byte[]) → getCipherService().encrypt(data, key)
└─ decrypt(byte[]) → getCipherService().decrypt(data, key)
│
AesCipherService (继承链)
└─ JcaCipherService.encrypt(plaintext, key)
├─ generateInitializationVector() → 生成 IV
├─ crypt() → initNewCipher(mode, key, iv)
│ └─ GCMParameterSpec(128, iv) ← GCM
└─ cipher.doFinal(plaintext) ← 真正加密
│
返回 IV + 密文 + tag
定位加密方法 = 从 setCipherKey 出发,沿继承链找到 AbstractRememberMeManager.encrypt/decrypt,再顺藤摸瓜到 JcaCipherService.crypt() 里的 javax.crypto.Cipher.doFinal()。
所以说这个东西原理真是太复杂了
这个东西真的很难讲清楚。
我实在是想不明白面试啥都要问反序列化这个问题,还有那个内存马,这个东西是几句话就能讲清楚的吗。
Step 5:追踪反序列化调用链(source → sink)
思考:前面已经确认了密钥、加密算法,那漏洞的"危险动作"到底发生在哪一行代码?
答案是 ObjectInputStream.readObject()。这一步要做的,就是从"读 cookie"一路追到"readObject",把整条数据流串起来,确认中间没有任何过滤。
声明:Shiro 框架内部逻辑(jar 里的
.class)没有源码,以下均通过javap -p -c反汇编字节码还原。方法名、字符串常量、readObject调用是字节码实证;变量名和代码排版是还原时的命名。
5.1 调用链全貌
AbstractShiroFilter.doFilterInternal() ← source:每个请求都进这里
→ createSubject()
→ DefaultSecurityManager.resolvePrincipals()
→ getRememberedIdentity()
→ AbstractRememberMeManager.getRememberedPrincipals()
→ getRememberedSerializedIdentity() ← ① 读 Cookie: rememberMe
→ convertBytesToPrincipals()
→ decrypt() ← ② AES-GCM 解密
→ deserialize() ← ③ 反序列化
→ DefaultSerializer.deserialize()
→ ObjectInputStream.readObject() ← ④ sink → RCE
5.2 ① 读 Cookie 的地方(source)
反编译 CookieRememberMeManager.getRememberedSerializedIdentity(),关键字节码:
getCookie().readValue(request, response) // 从 HTTP 请求读 rememberMe 的值
ensurePadding(value) // 补 base64 的 =
Base64.decode(value) // 还原成字节数组
字节码实证:Method Base64.decode:(Ljava/lang/String;)[B
这就是 source:Cookie: rememberMe 的值,攻击者完全可控。
5.3 ② 解密 + ③ 反序列化的中转站
反汇编 AbstractRememberMeManager.convertBytesToPrincipals(),还原逻辑:
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (getCipherService() != null) {
bytes = decrypt(bytes); // ② 先解密(AES-GCM)
}
return deserialize(bytes); // ③ 再反序列化
}
deserialize() 再委托给序列化器:
protected PrincipalCollection deserialize(byte[] serializedIdentity) {
return getSerializer().deserialize(serializedIdentity); // DefaultSerializer
}
5.4 ④ 真正的 sink:readObject()
反汇编 DefaultSerializer.deserialize(),还原成 Java:
public T deserialize(byte[] serialized) {
ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
BufferedInputStream bis = new BufferedInputStream(bais);
ClassResolvingObjectInputStream ois = new ClassResolvingObjectInputStream(bis);
return (T) ois.readObject(); // ← ★ sink
}
字节码实证:
new ClassResolvingObjectInputStream
invokevirtual ObjectInputStream.readObject:()Ljava/lang/Object;
两个审计要点:
- 用的是 Shiro 自己写的
ClassResolvingObjectInputStream(继承ObjectInputStream),但本质就是readObject(),没有任何白名单/黑名单过滤; - 这里抛异常时,日志就是那行著名的:
字节码常量池里有这句明文(后面 360 RASP 拦截日志里的Unable to deserialize argument byte array.SerializationException正来自这里)。
5.5 手工审计"怎么一步步找到 readObject"的定位方法
| 步骤 | 从哪看到 | 跳到哪 | 依据 |
|---|---|---|---|
| 1 | ShiroConfig.java 的 rememberMeManager() |
CookieRememberMeManager |
new CookieRememberMeManager() |
| 2 | CookieRememberMeManager 的 extends |
AbstractRememberMeManager |
继承关系 |
| 3 | AbstractRememberMeManager 里搜 deserialize |
DefaultSerializer |
getSerializer().deserialize() |
| 4 | DefaultSerializer.deserialize 里搜 readObject |
sink | invokevirtual readObject |
顺着"谁读了 cookie → 谁解密 → 谁反序列化"这条链,用 jar tf 找类、javap -c 看字节码,一环扣一环往下跳。
5.6 判断
- source(cookie)→ sink(readObject)全链路无过滤;
- 中间 decrypt 用的密钥和攻击者一致(都是固定 key);
- 结论:反序列化漏洞坐实,sink 为
ObjectInputStream.readObject()。
思考:反汇编和反编译的区别?
反编译(decompile):把 .class 还原成 .java 源码,工具是 JD-GUI、CFR、Fernflower 这类。
反汇编(disassemble):javap -c 输出的是 JVM 字节码指令,长这样:
0: aload_0
1: ldc #2 // String AES
3: invokespecial #3 // Method DefaultBlockCipherService."<init>":(Ljava/lang/String;)V
6: getstatic #4 // Field OperationMode.GCM
不行,先缓缓,这个东西确实有点扎实,后面中午睡完午觉在搞。
思考:在jar包怎么找反编译的类CookieRememberMeManager
类名就是从 ShiroConfig.java 里直接看到的,不是猜的。完整定位链路如下:
定位 CookieRememberMeManager 的完整链路
第 1 步:在 ShiroConfig.java 里看到这个类
第 355~357 行:
public CookieRememberMeManager rememberMeManager()
{
CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
cookieRememberMeManager.setCipherKey(Base64.decode(cipherKey));
...
}
看到 new CookieRememberMeManager() 和 setCipherKey() → 类名 CookieRememberMeManager 就从这里来。
第 2 步:import 语句给出全限定名
第 17 行:
import org.apache.shiro.web.mgt.CookieRememberMeManager;
全限定名到手:org.apache.shiro.web.mgt.CookieRememberMeManager

第 3 步:根据包名推断它在哪个 jar
Shiro 的 jar 命名和包名有严格对应关系:
| 包名前缀 | 对应的 jar |
|---|---|
org.apache.shiro.web.* |
shiro-web-x.x.x.jar |
org.apache.shiro.mgt.* |
shiro-core-x.x.x.jar |
org.apache.shiro.crypto.* |
shiro-crypto-*x.x.x.jar |
org.apache.shiro.web.mgt → 前缀是 web → 在 shiro-web-1.7.0.jar 里。
第 4 步:在 jar 里确认
unzip -l shiro-web-1.7.0.jar | grep CookieRememberMeManager
输出:
org/apache/shiro/web/mgt/CookieRememberMeManager.class
找到了,接下来才是 javap 反汇编。
一图总结
ShiroConfig.java
│
├─ import org.apache.shiro.web.mgt.CookieRememberMeManager; ← ① 全限定名
├─ new CookieRememberMeManager() ← ② 类名来源
└─ setCipherKey(...) ← ③ 告诉我"这里和加密有关"
│
│ 包名前缀 org.apache.shiro.web → shiro-web jar
▼
shiro-web-1.7.0.jar → org/apache/shiro/web/mgt/CookieRememberMeManager.class
│
▼
javap -p -c -classpath shiro-web-1.7.0.jar org.apache.shiro.web.mgt.CookieRememberMeManager
三个实用技巧(万一 import 没给全限定名)
技巧 1:IDE 里 Ctrl+点击类名直接跳转
在 IDEA 里点 CookieRememberMeManager,直接跳到反编译的 class 源码,最省事。
思考:反编译的 class 源码里有jar包名字嘛
结论:反编译的 class 里没有 jar 名
javap 输出头部只有这些
Compiled from "CookieRememberMeManager.java" ← 只说源文件名
public class org.apache.shiro.web.mgt.CookieRememberMeManager
extends org.apache.shiro.mgt.AbstractRememberMeManager { ← 只有全限定类名
有:类全限定名 org.apache.shiro.web.mgt.CookieRememberMeManager
没有:shiro-web、1.7.0、任何 jar 名
原因:class 文件本身就不记录"自己在哪个 jar 里"。jar 只是 class 的压缩容器,class 编译时根本不知道自己将来会被打进哪个 jar。
IDEA 为什么能跳转?靠的是"外部索引",不是 class 里的 jar 名
IDEA 跳转的真实过程
Ctrl+点击 CookieRememberMeManager
│
▼
① 拿类全限定名:org.apache.shiro.web.mgt.CookieRememberMeManager
│
▼
② 查 IDEA 自己建的索引(index)
┌─────────────────────────────────────────┐
│ 类全限定名 → 所在 jar │
│ org.apache.shiro.web.mgt.CookieRememberMeManager │
│ → BOOT-INF/lib/shiro-web-1.7.0.jar │
│ org.apache.shiro.mgt.AbstractRememberMeManager │
│ → BOOT-INF/lib/shiro-core-1.7.0.jar │
└─────────────────────────────────────────┘
│
▼
③ 找到 jar,用内置反编译器(Fernflower)解出 class,展示"源码"

规律:Shiro 的 jar 是按模块划分的,包名前两段决定 jar:
org.apache.shiro.web.* → shiro-web (web 层)
org.apache.shiro.mgt.* → shiro-core (核心管理)
org.apache.shiro.codec.* → shiro-core (编解码)
org.apache.shiro.subject.* → shiro-core (主体)
org.apache.shiro.crypto.* → shiro-crypto (加密)
关键:这个"类 → jar"的映射是 IDEA 在加载项目时扫描所有 jar 建出来的,存在 IDEA 的索引文件里(.idea / caches 目录),不是 class 文件自己记录的。
IDEA 的索引,本质就是"自动化的 unzip -l 所有 jar"。你手工做的是:包名推断 + unzip -l 确认;IDEA 做的是:启动时把所有 jar 扫一遍建表,点一下直接查表。原理一模一样,只是它快、你慢。
IDEA 跳转 ≠ class 里存了 jar 名。跳转靠的是 IDEA 启动时扫描 jar 建的"类→jar 索引表",和手工 unzip -l 是同一件事的自动化版本。这也再次说明:定位 jar 永远靠外部映射,不靠 class 内部信息。
技巧 2:如果只有 jar、没有源码,按包名规律猜
Shiro / Spring / Apache 的包名和 artifactId 高度一致,看到 org.apache.shiro.web.mgt 就知道是 shiro-web 这个 jar。
技巧 3:实在不知道在哪个 jar,就暴力搜
# 把所有 jar 抽出来,挨个搜哪个含这个类
for j in $(ls *.jar); do
if unzip -l "$j" 2>/dev/null | grep -q "CookieRememberMeManager"; then
echo "找到了:$j"
fi
done
类名 = ShiroConfig.java 里的 new CookieRememberMeManager();全限定名 = 它上面的 import 语句;jar 位置 = 包名前缀(org.apache.shiro.web → shiro-web jar)+ unzip -l 确认。
这就是代码审计里"顺藤摸瓜"的起点:永远从你自己项目里看得见的代码出发,一步步追到框架内部的类,而不是凭空知道要去反编译谁。
好了,今天就先到这里,后面一章去复现。
来源:博客园 · cnblogs.com