【Java安全】Java 反序列化断点调试与 Shiro 调用链剖析
JAVA反序列化断点调试
1. 这次我要回答的问题
之前我能复述“HashMap + Commons Collections + TemplatesImpl”,但自己下断点时还是会乱:对象什么时候变成字节?为什么构造期和反序列化期都会进 hashCode()?两次明明是同一个方法,为什么结果不同?
这次我没有先背流程图,而是按 shiro.java 的执行顺序实际走了一遍,重点记录下面四件事:
- rememberMe 在对象、字节、Base64 文本之间怎样变化;
- 构造期第一次
hashCode()调到的是哪个方法; LazyMap的缓存与iMethodName分别在什么时候改变;readObject()后,调用来源怎样从HashMap.put变成反序列化恢复过程。
2. 代码和对象关系
| 代码位置 | 这次负责什么 | 我实际观察的重点 |
|---|---|---|
main | 组装、序列化、加密、解密、反序列化 | 每个数据边界前后的类型与值 |
makePayload | 组装 HashMap、LazyMap 和 TiedMapEntry | 第一次安全调用、清缓存、换方法名 |
makeTemplatesImpl | 准备 TemplatesImpl 的字段 | _bytecodes 与相关字段是否已写入 |
serialize / encryptRememberMe | 生成 rememberMe | AC ED 00 05 和 Base64 文本 |
decryptRememberMe / readObject | 模拟服务端恢复对象 | 解密字节一致性与第二次调用来源 |
flowchart TD
A["TemplatesImpl<br/>保存本地测试类字节"] --> B["InvokerTransformer<br/>按方法名字段做反射调用"]
B --> C["LazyMap<br/>缓存未命中时调用 transform"]
C --> D["TiedMapEntry<br/>getValue 会触发 LazyMap.get"]
D --> E["外层 HashMap<br/>恢复 key 时会计算 hashCode"]
E -- "readObject()" --> D
D -- "hashCode() -> getValue()" --> C
C -- "缓存未命中" --> B
B -- "newTransformer()" --> A
我现在把它理解成三件事:HashMap 提供入口,TiedMapEntry 和 LazyMap 负责转发,iMethodName 决定反射调用的最终方法。
3. 开始前的环境和调试器设置
pom.xml 中的源码级别是 Java 8,依赖是 Shiro 1.2.4、Commons Collections 3.2.1 和 Javassist 3.29.2-GA。我的运行配置、Project SDK 和 Maven Runner 都使用 JDK 8;否则内部 Xalan 类或反射访问可能先被模块系统拦住。
另外,TiedMapEntry.toString() 会继续调用 getValue(),而后者会调用 LazyMap.get()。为了不让 Variables 窗口替我走链,我在 IDEA 的 Settings -> Debugger -> Data Views -> Java 中关闭了:
Enable 'toString()' object viewEnable alternate view for Collections classes
后面的截图里我只看当前栈帧、局部变量和安全的字节数组;不展开 tiedMapEntry、expMap 或 payload 的完整字符串表示。
4. 实际断点
下表不是老师给的示意图,而是这次运行中我实际保存的节点。截图文件名里的“前/后”表示蓝色高亮语句执行前或执行后最接近的稳定状态。
| 断点 | 观察点 | 执行前 | 执行后 / 下一稳定状态 |
|---|---|---|---|
| A | makePayload() 调用 | A 前 | 进入 F |
| F | 创建并配置 TemplatesImpl | 创建前 | 对象已创建、字段完成 |
| G | 创建初始转换器 | G 前 | G 后 |
| H | 创建 TiedMapEntry | 前一状态 | H 后 |
| I | expMap.put(...) | I 前 | 第一次 transform 返回 Class |
| J | lazyMap.clear() | 第一次缓存写入前 | 走到 K 前 |
| K | 修改 iMethodName | K 前 | 回到 main,payload 已完成 |
| B | 序列化 | B 前 | B 后 |
| C | rememberMe 加密 | 序列化完成 | C 后 |
| D | rememberMe 解密 | 密文已生成 | D 后 |
| E | ois.readObject() | E 前 | 第二次 L |
| L / M / N | 反序列化的第二轮调用 | L2、M2 | N2 |
| O | TemplatesImpl.newTransformer() | N2 的 method.invoke 前 | 本机实际异常堆栈 |
5. 阶段一:先确认数据边界
A 到 B:对象变成序列化字节
在 A 处,payload 还没有赋值;进入 makePayload() 后,最终回到 B 前时,内联变量已经显示 payload 是外层 HashMap。


执行 serialize(payload) 后,serialized 是 byte[]。我的截图中前四个十进制值是 -84, -19, 0, 5,换成十六进制就是:
AC ED 00 05

C 到 D:字节变成文本,再恢复为字节
加密后 rememberMe 是 Base64 文本;完整密文每次运行都可能变化,所以我只用“非空、可见为文本”判断,不把整段密文当作固定答案。

到断点 D 后:
- 在 Variables 中展开
serialized和decrypted。 - 在 Debug Console 的 Evaluate Expression 中执行:
java.util.Arrays.equals(serialized, decrypted)

结果是
true
结论:
对象图 --序列化--> byte[]
byte[] --AES 加密并 Base64--> rememberMe 字符串
rememberMe --解密--> 原来的 byte[]
byte[] --反序列化--> 对象图
Shiro 的问题不在于 “Base64 有危险”。危险点是:如果密钥可被猜到或已知,攻击者可以构造一段对象字节,服务端解密后又把它交给 readObject()。
6. 阶段二:构造期的第一次调用
F:TemplatesImpl 对象的创建
断点 F (makeTemplatesImpl) 正是整个漏洞利用链的最终终点:
-
它是恶意字节码的载体:
_bytecodes字段里塞进了包含calc.exe的.class字节码。 -
它是最终被反射调用的目标对象:无论是构造期的
templates.getClass(),还是反序列化阶段最终被反射调用的templates.newTransformer(),调用的目标对象都是在这个断点处生成的TemplatesImpl实例。
TemplatesImpl 对象的字段
_bytecodes(恶意字节码):存放编译好的.class字节数组,被加载实例化时自动触发命令执行。_name(模板名):必须非空(如"Pwn"),否则校验失败抛异常中断。_tfactory(工厂实例):设为new TransformerFactoryImpl(),防止内部加载字节码时报空指针。_class(类缓存):故意设为null,强制触发TemplatesImpl从_bytecodes中读取加载新类。
G、H、I:第一次 hash 为什么是安全的
G 处创建的 InvokerTransformer 的方法名是 getClass,不是 newTransformer。H 处则把 templates 和 lazyMap 组合成 TiedMapEntry。

现在transformer的方法名还是getClass,作用是获取 Class 对象,非常安全。
在 I 前,外层 expMap 已创建但尚未放入 key,这里就是为什么要把 InvokerTransformer 的方法名设置为安全的 getClass。
-
expMap为了计算 key 在桶里的位置,会自动调用tiedMapEntry.hashCode()。 -
TiedMapEntry.hashCode()内部调用getValue(),进而触发lazyMap.get(templates)。 -
LazyMap检查发现缓存中没有templates这个 key,于是把templates传给InvokerTransformer.transform(templates)。 -
由于此时
iMethodName是安全的"getClass",所以实际只执行了templates.getClass(),安全返回了TemplatesImpl.class,在本地不会执行恶意代码 -
expMap.put()执行完成后:
lazyMap内部留下一条缓存(缓存大小innerMap.size从0变1)。- 外层
expMap成功装入tiedMapEntry(大小expMap.size从0变1)。

expMap.put()实际单步后的调用是:
HashMap.put
-> TiedMapEntry.hashCode()
-> TiedMapEntry.getValue()
-> LazyMap.get(templates)
-> InvokerTransformer.transform(templates)
-> templates.getClass()
J、K:清缓存与方法名切换
I 之后代码依次执行 lazyMap.clear() 和把setFieldValue(transformer, "iMethodName", "newTransformer");iMethodName 改为 newTransformer 。在 K 前停住时仍能看到初始阶段的 getClass 状态以及lazyMap的size变为0,之后程序已经返回 main 并进入 B。

7. 阶段三:反序列化的第二次调用
E 前,ObjectInputStream 已经创建,但 readObject() 还没有执行:

执行后第二次停在 L。此时 Frames 的来源不再是构造期的 HashMap.put,而是 main:51 的 readObject();这就是两轮最关键的区别。

key 是恢复出的 TemplatesImpl,并且代码再次停在 map.containsKey(key) == false:

最后停在 method.invoke(input, iArgs) 前;反射目标已经不是第一次的 getClass,而是 newTransformer。

因此,两次调用的区别不是 hashCode() 的实现变了,而是序列化前保存的对象状态变了:第一次方法名是 getClass,第二次方法名是 newTransformer,且缓存已被清理。
8. O 点与本机实际结果

9. 这次真正记住的点
- 相同的
TiedMapEntry.hashCode()可以来自完全不同的调用来源:第一次是HashMap.put,第二次是readObject()。 - 第一次调用并不是什么都没发生;它实际走到了
InvokerTransformer,只是方法名是getClass。 - 调用链能否继续,不只看对象关系,也看
LazyMap的缓存状态和iMethodName字段。 - 调试截图应记录“执行前后可见的状态”,而不是把预期结果当作已经观察到的事实。