上一周周末举办的“强网杯”大赛中,有一题“彩蛋”特别有意思。题面如下:
(不要问我为什么系统字体这么奇怪,我也不知道……)
这题我最后使用非预期解 PostgreSQL 的 UDF 来解决,但正解迟迟未找到。正好,orange大神的解析文章让我找到了正解:http://blog.orange.tw/2018/03/pwn-ctf-platform-with-java-jrmp-gadget.html 。因此,写下这篇文章。本文不谈正解,只是我的个人研究笔记。
现在原作者已经修复了该漏洞,因此,我们从他修复漏洞前的分支开始:https://github.com/zjlywjh001/PhrackCTF-Platform-Team/tree/13a35fad2cdb7394ccb70739812a5ee60f27af33 。考虑到环境的搭建过于复杂,可以使用此 Docker 仓库搭建本地环境:https://github.com/zjlywjh001/phrackCTF-Team-Docker 。需要注意的是,这个Docker镜像里面有些URL可能已经失效,需要自己修改一下。我修改过的 Dockerfile 见https://gist.github.com/zsxsoft/b3ad673fa4d8560747bbf497221681d7 。
这篇文章的开头先给出几篇有价值的参考文章,并且会多次提到它们。
- Orange: Pwn a CTF Platform with Java JRMP Gadget
- Go low - Exploiting JVM deserialization vulns despite a broken class loader
- 【漏洞分析】Shiro RememberMe 1.2.4 反序列化导致的命令执行漏洞
言归正传,让我们开始吧。
当时拿到这个题目,我直接定位到了 pom.xml 里面的shiro 1.2.4。由开头提到的文章3,我们知道了,漏洞产生于传递给网站的rememberMe
这个Cookie,通过构造恶意 Cookie,可以让 Shiro 反序列化出恶意代码,从而实现RCE。而这个Cookie的格式,是「AES(serializedData)」。
但我们如果使用这篇文章里面的 payload 是处理不了的。原因有二。
原因一,是加密密钥的问题。原文注意到了此行代码,认为加密密钥是硬编码的。
private static final byte[] DEFAULT_CIPHER_KEY_BYTES = Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");
此密钥在org.apache.shiro.mgt.AbstractRememberMeManager
类中被定义,而我们要用的org.apache.shiro.web.mgt.CookieRememberMeManager
继承了此类。如变量名所述,这是“DEFAULT_KEY”,他实际使用的Key是以下两个变量,而这个硬编码密钥是在 Class Constructor 里面通过setCipherKey
初始化的。
private byte[] encryptionCipherKey;
private byte[] decryptionCipherKey;
public void setCipherKey(byte[] cipherKey) {
//Since this method should only be used in symmetric ciphers
//(where the enc and dec keys are the same), set it on both:
setEncryptionCipherKey(cipherKey);
setDecryptionCipherKey(cipherKey);
}
我们可能会发现,没有任何一个函数调用了setCipherKey
函数。但不要忘记 Java World 是一个充满配置文件的世界。我们回头看看那个项目的配置文件:
$ cat src/main/resources/spring-shiro.xml | grep CookieRememberMe -A5 -n
37: <bean id="rememberMeManager" class="org.apache.shiro.web.mgt.CookieRememberMeManager">
38- <!-- rememberMe cookie加密的密钥 建议每个项目都不一样 默认AES算法 密钥长度(128 256 512 位)-->
39- <property name="cipherKey"
40- value="#{T(org.apache.shiro.codec.Base64).decode('cGhyYWNrY3RmREUhfiMkZA==')}"/> <!-- Cookie加密密钥:phrackctfDE!~#$d -->
41- <property name="cookie" ref="rememberMeCookie"/>
42- </bean>
Java的反射总是充满了神奇,不是么 :)
原因二,则是依赖的问题。
这篇文章添加了依赖commons-collections4
,但我们本次的环境内是没有的。我们的环境只有commons-collections-3.2.1
。
原作者对这个依赖的作用并没有作出详细的说明。搜索其他的文章,我发现,中文互联网内对此漏洞的分析,只存在基于那篇文章、亦步亦趋的复现文章,根本没有任何的价值。这首先启示了我,珍爱生命,少看二手文章(((
使用这个依赖,是为了使用 ysoserial 的CommonsCollections2
payload。观察此项目的readme,似乎有commons-collections:3.1
的 payload,说不定能用呢,试试看吧。
——当然不行。查看catalina.out
,发现以下错误提示:
[02:26:34:462] [WARN] - org.apache.shiro.mgt.DefaultSecurityManager.getRememberedIdentity(DefaultSecurityManager.java:609) - Delegate RememberMeManager instance of type [org.apache.shiro.web.mgt.CookieRememberMeManager] threw an exception during getRememberedPrincipals().
org.apache.shiro.io.SerializationException: Unable to deserialze argument byte array.
此错误提示在 readObject
,也就是 unserialize 的作用点处被抛出。
那就下个断点看吧。通过/opt/tomcat/bin/catalina.sh stop
关闭服务后,JPDA_ADDRESS=0.0.0.0:8000 /opt/tomcat/bin/catalina.sh jpda start
重启服务即可让调试器attach。
跟踪栈,我们会发现,在此时它抛出了错误:
// resolveClass: 53, ClassResolvingObjectInputStream (org.apache.shiro.io)
return ClassUtils.forName(osc.getName());
// forName: 127, ClassUtils (org.apache.shiro.util)
// fqcn = [Lorg.apache.commons.collections.Transformer;
Class clazz = THREAD_CL_ACCESSOR.loadClass(fqcn);
// loadClass: 228, ClassUtils$ExceptionIgnoringAccessor (org.apache.shiro.util)
clazz = cl.loadClass(fqcn);
最后 JRE 里找不到此类。
注意到这个fqcn的名字是[Lorg.apache.commons.collections.Transformer;
。 [L
是一个JVM的标记,说明实际上这是一个数组,即Transformer[]
。文章2里说,
ChainedTransformer - the chain of transformers inside this object is an array, thus we cannot use ChainedTransformer at all
我们还是自己分析一下,看看 serialize 出的东西吧。
00000190 6c 65 63 74 69 6f 6e 73 2e 66 75 6e 63 74 6f 72 |lections.functor|
000001a0 73 2e 43 68 61 69 6e 65 64 54 72 61 6e 73 66 6f |s.ChainedTransfo|
000001b0 72 6d 65 72 30 c7 97 ec 28 7a 97 04 02 00 01 5b |rmer0…(z…..[|
000001c0 00 0d 69 54 72 61 6e 73 66 6f 72 6d 65 72 73 74 |..iTransformerst|
000001d0 00 2d 5b 4c 6f 72 67 2f 61 70 61 63 68 65 2f 63 |.-[Lorg/apache/c|
000001e0 6f 6d 6d 6f 6e 73 2f 63 6f 6c 6c 65 63 74 69 6f |ommons/collectio|
000001f0 6e 73 2f 54 72 61 6e 73 66 6f 72 6d 65 72 3b 78 |ns/Transformer;x|
对比此类:
此处,000001c0
处,按照 Java Serializion 规范,[00 0d]
作为 field name 的长度,[0x74]: TC_STRING
对应一个新的字符串。因为此类型是一个 Object,所以类型需要 JVM signature,也就是 “[L” 标记的由来。
那究竟为什么 JVM 找不到这个类呢?看看以下代码
package org.apache.shiro.io;
public class ClassResolvingObjectInputStream extends ObjectInputStream {
@Override
protected Class<?> resolveClass(ObjectStreamClass osc) throws IOException, ClassNotFoundException {
try {
return ClassUtils.forName(osc.getName());
} catch (UnknownClassException e) {
throw new ClassNotFoundException("Unable to load ObjectStreamClass [" + osc + "]: ", e);
}
}
}
和原版ObjectInputStream
对比:
protected Class<?> resolveClass(ObjectStreamClass var1) throws IOException, ClassNotFoundException {
String var2 = var1.getName();
try {
return Class.forName(var2, false, latestUserDefinedLoader());
} catch (ClassNotFoundException var5) {
Class var4 = (Class)primClasses.get(var2);
if (var4 != null) {
return var4;
} else {
throw var5;
}
}
}
我们能注意到,Shiro重写了 resolveClass 的实现,更换了查找方式。正如在 Orange 的那篇文章的评论中,一个匿名的哥们指出的原因:
Shiro resovleClass使用的是ClassLoader.loadClass()而非Class.forName(),而ClassLoader.loadClass不支持装载数组类型的class。
嗯?这是什么梗?查查看吧。ClassLoader.loadClass
往后会以如下参数调用Class.forName
。再往下追,就是各路Java面试官最喜欢考的所谓双亲委派之类的实现啦。
我注意到,若我在不同的过程中,执行Class.forName
,结果是不一样的。为排除数组的影响,我只留下了类名。如图。
为什么呢?注意这里的调用栈,发给 forName 的 classLoader 不一样。当我传递给 forName 的是 Tomcat 的 ParallelWebappClassLoader 时,一切正常。
但事实上,真正调用到此处时,使用的是URLClassLoader……
debug进去看看到底怎么回事。
根据流程,这个 ParallelWebappClassLoader 会先寻找内部缓存,如果找不到的话再交给URLClassLoader。我们看这个图里的path……这个,能找得到,反而才奇怪。因此,这就是数组类型 Shiro 找不到的真正原因。经过测试,把数组去掉,果然正常初始化。
我不知道各位读者看到这里,此时是什么心情。Shiro 由于 Tomcat 的一个 buggy 实现,歪打正着,解决了和commons-collections:3.1
搭配使用时造成的 RCE。
那么最后,我们怎么保证 readObject 的安全性呢?参阅《Effictive Java(第二版)》一书的《第76条:保护性地编写readObject方法》吧。
最后,可惜比赛GG了~