Back to Blog

【Java安全】Java 反序列化断点调试与 Shiro 调用链剖析

JAVA反序列化断点调试

1. 这次我要回答的问题

之前我能复述“HashMap + Commons Collections + TemplatesImpl”,但自己下断点时还是会乱:对象什么时候变成字节?为什么构造期和反序列化期都会进 hashCode()?两次明明是同一个方法,为什么结果不同?

这次我没有先背流程图,而是按 shiro.java 的执行顺序实际走了一遍,重点记录下面四件事:

  • rememberMe 在对象、字节、Base64 文本之间怎样变化;
  • 构造期第一次 hashCode() 调到的是哪个方法;
  • LazyMap 的缓存与 iMethodName 分别在什么时候改变;
  • readObject() 后,调用来源怎样从 HashMap.put 变成反序列化恢复过程。

2. 代码和对象关系

代码位置这次负责什么我实际观察的重点
main组装、序列化、加密、解密、反序列化每个数据边界前后的类型与值
makePayload组装 HashMapLazyMapTiedMapEntry第一次安全调用、清缓存、换方法名
makeTemplatesImpl准备 TemplatesImpl 的字段_bytecodes 与相关字段是否已写入
serialize / encryptRememberMe生成 rememberMeAC 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 提供入口,TiedMapEntryLazyMap 负责转发,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 中关闭了:

  1. Enable 'toString()' object view
  2. Enable alternate view for Collections classes

后面的截图里我只看当前栈帧、局部变量和安全的字节数组;不展开 tiedMapEntryexpMappayload 的完整字符串表示。

4. 实际断点

下表不是老师给的示意图,而是这次运行中我实际保存的节点。截图文件名里的“前/后”表示蓝色高亮语句执行前或执行后最接近的稳定状态。

断点观察点执行前执行后 / 下一稳定状态
AmakePayload() 调用A 前进入 F
F创建并配置 TemplatesImpl创建前对象已创建字段完成
G创建初始转换器G 前G 后
H创建 TiedMapEntry前一状态H 后
IexpMap.put(...)I 前第一次 transform 返回 Class
JlazyMap.clear()第一次缓存写入前走到 K 前
K修改 iMethodNameK 前回到 main,payload 已完成
B序列化B 前B 后
CrememberMe 加密序列化完成C 后
DrememberMe 解密密文已生成D 后
Eois.readObject()E 前第二次 L
L / M / N反序列化的第二轮调用L2M2N2
OTemplatesImpl.newTransformer()N2 的 method.invoke本机实际异常堆栈

5. 阶段一:先确认数据边界

A 到 B:对象变成序列化字节

在 A 处,payload 还没有赋值;进入 makePayload() 后,最终回到 B 前时,内联变量已经显示 payload 是外层 HashMap

image-20260811174714972

image-20260811174744340

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

AC ED 00 05

image-20260811174905447

C 到 D:字节变成文本,再恢复为字节

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

image-20260811174944392

到断点 D 后:

  1. 在 Variables 中展开 serializeddecrypted
  2. 在 Debug Console 的 Evaluate Expression 中执行:
java.util.Arrays.equals(serialized, decrypted)

image-20260811175251706

结果是

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 对象的字段

  1. _bytecodes(恶意字节码):存放编译好的 .class 字节数组,被加载实例化时自动触发命令执行。
  2. _name(模板名):必须非空(如 "Pwn"),否则校验失败抛异常中断。
  3. _tfactory(工厂实例):设为 new TransformerFactoryImpl(),防止内部加载字节码时报空指针。
  4. _class(类缓存):故意设为 null,强制触发 TemplatesImpl_bytecodes 中读取加载新类。

G、H、I:第一次 hash 为什么是安全的

G 处创建的 InvokerTransformer 的方法名是 getClass,不是 newTransformer。H 处则把 templateslazyMap 组合成 TiedMapEntry

image-20260811181152793

现在transformer的方法名还是getClass,作用是获取 Class 对象,非常安全。

在 I 前,外层 expMap 已创建但尚未放入 key,这里就是为什么要把 InvokerTransformer 的方法名设置为安全的 getClass

  1. expMap 为了计算 key 在桶里的位置,会自动调用 tiedMapEntry.hashCode()

  2. TiedMapEntry.hashCode() 内部调用 getValue(),进而触发 lazyMap.get(templates)

  3. LazyMap 检查发现缓存中没有 templates 这个 key,于是把 templates 传给 InvokerTransformer.transform(templates)

  4. 由于此时 iMethodName 是安全的 "getClass",所以实际只执行了 templates.getClass(),安全返回了 TemplatesImpl.class,在本地不会执行恶意代码

  5. expMap.put()

    执行完成后:

    • lazyMap 内部留下一条缓存(缓存大小 innerMap.size01)。
    • 外层 expMap 成功装入 tiedMapEntry(大小 expMap.size01)。

image-20260811183108425

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。

image-20260811191727427

7. 阶段三:反序列化的第二次调用

E 前,ObjectInputStream 已经创建,但 readObject() 还没有执行:

image-20260811192538155

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

image-20260811192556483

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

image-20260811192620627

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

image-20260811192741571

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

8. O 点与本机实际结果

image-20260811192826926

9. 这次真正记住的点

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