前言
前置知识(?)。
RememberMe
先把shiro依赖版本改成1.2.4。
漏洞发生在shiro的rememberMe功能,因为这项功能只能允许访问user权限的路径,所以先改改配置文件中的权限配置:
map.put("/logout", "user");
map.put("/user", "user");
再改改控制器代码,开启这项功能:
UsernamePasswordToken token = new UsernamePasswordToken("Twings", "123456");
token.setRememberMe(true);
subject.login(token);
之后shiro会将身份信息加密,base64编码保存在cookie中,用于下一次身份认证:
RememberMe解密
开始找相关的代码,直接搜索名字相关的类可以找到CookieRememberMeManager类及其父类AbstractRememberMeManager,但是换了几个点,断了几次都没有断下来,后来发现浏览器重启后第一次访问才会触发断点,原因不明,可能跟session有关,后面再来研究。最后的断点打在了getRememberedPrincipals函数,此时的调用栈如下:
getRememberedPrincipals函数如下:
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;
}
这段代码以前似乎还出现过代号为SHIRO-138的问题,这里通过getRememberedSerializedIdentity函数获取了base64解码后的cookie中保存的加密数据:
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);
...
byte[] decoded = Base64.decode(base64);
...
return decoded;
}
...
}
然后调用convertBytesToPrincipals开始解密:
protected PrincipalCollection convertBytesToPrincipals(byte[] bytes, SubjectContext subjectContext) {
if (getCipherService() != null) {
bytes = decrypt(bytes);
}
return deserialize(bytes);
}
这里的处理分为两部分,decrypt函数负责解密:
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;
}
public byte[] getDecryptionCipherKey() {
return decryptionCipherKey;
}
解密的key来自该对象中的decryptionCipherKey属性,通过调试可以发现该属性通过setter赋值,而在不特别设置的情况下,该属性在实例化时赋值:
private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
public AbstractRememberMeManager() {
this.serializer = new DefaultSerializer<PrincipalCollection>();
this.cipherService = new AesCipherService();
setCipherKey(DEFAULT_CIPHER_KEY_BYTES);
}
是一个固定值,而cipherService是一个JcaCipherService对象,其在解密时会从密文中读取iv:
System.arraycopy(ciphertext, 0, iv, 0, ivByteSize);
第二部分的deserialize函数负责反序列化:
public T deserialize(byte[] serialized) throws SerializationException {
...
ByteArrayInputStream bais = new ByteArrayInputStream(serialized);
BufferedInputStream bis = new BufferedInputStream(bais);
try {
ObjectInputStream ois = new ClassResolvingObjectInputStream(bis);
@SuppressWarnings({"unchecked"})
T deserialized = (T) ois.readObject();
ois.close();
return deserialized;
} catch (Exception e) {
String msg = "Unable to deserialze argument byte array.";
throw new SerializationException(msg, e);
}
}
一个没有做安全检验的反序列化点,如果依赖中存在可利用的反序列化链就会导致一个反序列化漏洞。
后续
尝试复现漏洞时发现出现这么一个错误:
org.apache.shiro.io.SerializationException: Unable to deserialze argument byte array.
看起来是不能处理数组类型的对象?不过读读代码就会发现反序列化中只要捕捉到Exception就会抛出这种错误,晚点再看看具体原因。
commons-beanutils
shiro自带的commons-beanutils依赖是1.8.3版本的:
1.9.3版本下打得通的利用链在这个版本生成时会出现一点问题:
问题发生在类的初始化函数中:
public BeanComparator( String property ) {
this( property, ComparableComparator.getInstance() );
}
简单来说就是BeanComparator对象的实例化依赖于commons-collections中的一个类org.apache.commons.collections.comparators.ComparableComparator,而由于依赖中不存在commons-collections就导致了这个问题。按照参考文章的说法就是:
所以如果想在这个版本下实现RCE,就要通过反射的方式实例化BeanComparator类再给它的属性赋值,Comparator找不找倒是无所谓:
BeanComparator beanComparator = (BeanComparator)Utils.createWithoutConstructor("org.apache.commons.beanutils.BeanComparator");
Utils.setField(beanComparator, "property", "outputProperties");
要注意的一点是,最好用byte[]类型来存放Java序列化数据,后面通过base64编码输出,不然转换为String类型时可能会有编码问题,序列化数据头会被弄成两个问号,一开始没注意踩了一会的坑,生成序列化数据后用python写个脚本发送就行了。
commons-collections
CommonsCollections的利用链可以直接打通,看参考文章似乎在tomcat+shiro环境下,会因为数组形式存在加载问题而无法利用。
JRMP
本地Java版本为8u281,这个版本已经有了对JRMP利用的限制,反序列化时会触发安全检查:
2021-10-20 21:49:54.771 INFO 42580 --- [127.0.0.1:1099]] java.io.serialization : ObjectInputFilter REJECTED: class javax.management.BadAttributeValueExpException, array length: -1, nRefs: 2, depth: 1, bytes: 110, ex: n/a
ObjectInputStream类在反序列化JRMPClient传输的数据时,会调用filterCheck函数:
// Call filterCheck on the class before reading anything else
filterCheck(cl, -1);
其检验代码如下:
try {
status = serialFilter.checkInput(new FilterValues(clazz, arrayLength,
totalObjectRefs, depth, bytesRead));
}
serialFilter是一个DGCImpl_Stub对象,其检验代码如下:
if (var1.isPrimitive()) {
return Status.ALLOWED;
} else {
return var1 != UID.class && var1 != VMID.class && var1 != Lease.class && (var1.getPackage() == null || !Throwable.class.isAssignableFrom(var1) || !"java.lang".equals(var1.getPackage().getName()) && !"java.rmi".equals(var1.getPackage().getName())) && var1 != StackTraceElement.class && var1 != ArrayList.class && var1 != Object.class && !var1.getName().equals("java.util.Collections$UnmodifiableList") && !var1.getName().equals("java.util.Collections$UnmodifiableCollection") && !var1.getName().equals("java.util.Collections$UnmodifiableRandomAccessList") && !var1.getName().equals("java.util.Collections$EmptyList") ? Status.REJECTED : Status.ALLOWED;
}
对要反序列化的类做了白名单检测,按照文章所说,应该是8u231版本做的安全检验,然后因为出现了绕过所以8u241做了进一步的修复。
tomcat+shiro
留坑。
参考
https://www.freebuf.com/vuls/178014.html
https://cloud.tencent.com/developer/article/1816604