shiro反序列化

今天我们将从代码审计的角度,去进行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:红黑树,有序,实现SortedMap
  • WeakHashMap:弱引用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分支。

补充易混点

  1. HashSet底层就是new了一个HashMap,元素存到HashMap的key上,value固定占位;
  2. TreeSet/TreeMap排序:元素实现Comparable接口,或者构造传入Comparator;
  3. 遗留类 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代码运行原理讲解

原图的流程:

  1. 源码(.java)
  2. javac编译器 → 编译生成字节码(.class)
  3. 不同平台的JVM,把字节码翻译成当前操作系统的机器指令执行

核心口号:一次编译,到处运行 。

.class字节码不是直接给CPU跑的机器码,是给JVM看懂的中间指令。Windows、Linux、Mac各自装对应版本JVM,同一份class文件都可以执行。

  1. 源码 .java
    我们手写的Java代码,就是这个源码文件。
java 复制代码
public class Test{
    public static void main(String[] args){
        System.out.println("hello");
    }
}
  1. javac 编译阶段
    执行命令:javac Test.java
    编译器做语法检查、语义分析,输出 Test.class 字节码文件。

⚠️编译阶段不运行代码 ,只翻译。

反序列化漏洞和编译无关,漏洞是程序运行阶段(JVM加载class之后)才触发。

  1. 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()方法,接收一个对象,通过反射调用这个对象的指定方法 ,可以传入方法名、参数类型、参数值。

这是整条链命令执行的核心 。

示例:

java 复制代码
// 拿到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里用法:

java 复制代码
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代码的时候:编译期你就知道要调用哪个类、哪个方法,直接写死调用:

java 复制代码
Runtime.getRuntime().exec("calc.exe");

编译器在javac阶段就清楚Runtime是什么类、exec是什么方法。

而反射 :在代码运行的时候(JVM运行期) ,动态去获取一个类的信息、实例化对象、调用方法、读写属性。

类名、方法名可以是字符串,编译阶段代码根本不知道要操作哪个类。

CC链里的InvokerTransformer,底层就是反射!

它接收字符串 "exec",运行时再去找到这个方法并且执行。

java 复制代码
// 反射伪代码,就是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本质就是反射带来的能力被恶意利用:

  1. 如果Java没有反射,InvokerTransformer根本不可能做到:传入方法名字符串,运行时去执行任意方法;
  2. 反序列化漏洞 = 可控输入 + 危险Gadget,而很多Gadget的最后一步RCE,都依赖反射;
  3. 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 中,密钥是这样设置的:

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:

yaml 复制代码
# Shiro
shiro:
  cookie:
    # 设置密钥,务必保持唯一性...
    cipherKey: zSyK5Kp6PZAAjlT+eeNMlg==   # ← 找到了!

完整的审计路径

复制代码
ShiroConfig.java                          application.yml
─────────────────                         ─────────────────
@Value("${shiro.cookie.cipherKey}")  ──→  shiro:
private String cipherKey;                    cookie:
                                             cipherKey: zSyK5Kp6PZAAjlT+eeNMlg==
        │                                              │
        │         通过 @Value 注解关联                    │
        └──────────────────────────────────────────────┘

总结:

  1. 先看 ShiroConfig.java 中 CookieRememberMeManager 的密钥设置
  2. 发现用了 @Value("${shiro.cookie.cipherKey}") 注解
  3. 知道这是 Spring Boot 项目 → 去 resources 目录找配置文件
  4. 在 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 版本引入的注解,用于属性注入:

java 复制代码
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. 从配置文件读取值

java 复制代码
@Value("${shiro.cookie.cipherKey}")
private String cipherKey;

2. 从环境变量读取值

java 复制代码
@Value("${ENV_VAR_NAME}")
private String envValue;

3. 直接赋值

java 复制代码
@Value("default value")
private String defaultValue;

4. SpEL 表达式

java 复制代码
@Value("#{systemProperties['user.home']}")
private String userHome;

在 ShiroConfig.java 中的使用

java 复制代码
@Value("${shiro.cookie.cipherKey}")   // ← Spring 的 @Value 注解
private String cipherKey;

这个注解的作用是:

  1. 从配置文件(application.yml)读取 shiro.cookie.cipherKey
  2. 注入到 cipherKey 字段中
  3. 然后在 rememberMeManager() 方法中使用

为什么在 Spring Boot 项目中常见?

因为 Spring Boot:

  1. 默认支持配置文件(application.yml, application.properties)
  2. 自动加载配置(无需手动配置 PropertySources)
  3. 环境变量优先级(可以覆盖配置文件)

对比:其他框架的类似功能

框架 类似功能 注解/方式
Spring 属性注入 @Value
Spring Boot 配置注入 @Value + 自动配置
Guice 注入 @Inject
CDI (Java EE) 注入 @Inject
JEE 资源注入 @Resource

总结

@Value 不是 Spring Boot 独有的,而是 Spring Framework 的核心注解。但在 Spring Boot 项目中,它被广泛使用,因为:

  1. Spring Boot 默认支持配置文件
  2. 配置管理更简单
  3. 环境变量支持更好

所以,在审计 ShiroConfig.java 时,看到 @Value 注解,可以确定:

  1. 这是一个 Spring/Spring Boot 项目
  2. 密钥配置在配置文件中
  3. 需要查看 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

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 版本

java 复制代码
<shiro.version>1.7.0</shiro.version>

Shiro 1.7.0 使用 AES/GCM 算法,而不是老版本的 AES/CBC。


为什么需要反编译 Shiro jar 包?

虽然手工审计时我们看不到 Shiro 源码,但为了确认加密算法,我们需要:

  1. 反编译 shiro-crypto-cipher-1.7.0.jar
  2. 查看 AesCipherService 的构造函数
  3. 确认使用的是 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,没有加密方法

java 复制代码
cookieRememberMeManager.setCipherKey(Base64.decode(cipherKey));  // 只设置密钥

加密逻辑在 Shiro 框架里。要定位它,就得顺着 CookieRememberMeManager 的继承链往上找。


第 1 步:确认继承链

CookieRememberMeManager(shiro-web-1.7.0.jar)继承 AbstractRememberMeManager(shiro-core-1.7.0.jar)。

加密方法就藏在 AbstractRememberMeManager 里。反编译字节码,找到两个方法:

java 复制代码
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[]) 字节码,真实逻辑是:

java 复制代码
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 的地方:

java 复制代码
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 已经看到:

java 复制代码
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

反编译 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(),还原逻辑:

java 复制代码
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
    if (getCipherService() != null) {
        bytes = decrypt(bytes);      // ② 先解密(AES-GCM)
    }
    return deserialize(bytes);       // ③ 再反序列化
}

deserialize() 再委托给序列化器:

java 复制代码
protected PrincipalCollection deserialize(byte[] serializedIdentity) {
    return getSerializer().deserialize(serializedIdentity);  // DefaultSerializer
}

5.4 ④ 真正的 sink:readObject()

反汇编 DefaultSerializer.deserialize(),还原成 Java:

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;

两个审计要点:

  1. 用的是 Shiro 自己写的 ClassResolvingObjectInputStream(继承 ObjectInputStream),但本质就是 readObject(),没有任何白名单/黑名单过滤;

  2. 这里抛异常时,日志就是那行著名的:

    复制代码
    Unable to deserialize argument byte array.

    字节码常量池里有这句明文(后面 360 RASP 拦截日志里的 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 行:

java 复制代码
public CookieRememberMeManager rememberMeManager()
{
    CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
    cookieRememberMeManager.setCipherKey(Base64.decode(cipherKey));
    ...
}

看到 new CookieRememberMeManager() 和 setCipherKey() → 类名 CookieRememberMeManager 就从这里来。

第 2 步:import 语句给出全限定名

第 17 行:

java 复制代码
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 里确认

bash 复制代码
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,就暴力搜

bash 复制代码
# 把所有 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 确认。

这就是代码审计里"顺藤摸瓜"的起点:永远从你自己项目里看得见的代码出发,一步步追到框架内部的类,而不是凭空知道要去反编译谁。

好了,今天就先到这里,后面一章去复现。