Vulnerability Analysis

CVE-2026-40478: Thymeleaf SSTI Sandbox Bypass — What It Is & How to Fix It

Executive Summary

CVE-2026-40478 is a critical Server-Side Template Injection (SSTI) vulnerability in Thymeleaf — the most widely-used Java template engine in the Spring Boot ecosystem — affecting all versions through 3.1.3.RELEASE. A whitespace-parsing gap combined with an incomplete expression blocklist allows a remote, unauthenticated attacker to bypass Thymeleaf's built-in sandboxing and execute arbitrary Spring Expression Language (SpEL) code on the server, ultimately achieving full Remote Code Execution (RCE). Applications that render user-controlled data through Thymeleaf templates are at immediate risk, and upgrading to 3.1.4.RELEASE is the only complete fix.


1. What Is This Vulnerability?

Thymeleaf is a Java-based server-side template engine used in millions of Spring Boot applications to render dynamic HTML, email, and text output. It ships with a built-in sandbox designed to prevent untrusted input from being evaluated as executable expressions. CVE-2026-40478 is a bypass of that sandbox.

The Root Cause: Two Blind Spots in One

Security researchers at Endor Labs found that Thymeleaf's sanitization layer had two independent weaknesses that, when combined, fully defeated its protections:

1. Whitespace-parsing gap — The sanitizer normalized new ClassName() expressions but not new\tClassName() (with a tab character instead of a space). The real SpEL parser, however, treats tab characters as equivalent to spaces. The sanitizer said "safe"; SpEL said "execute."

2. Incomplete package blocklist — Thymeleaf's blocklist only covered java.* packages. Spring's own classes — including those that can spawn child processes — were left unrestricted. An attacker using Spring's ProcessBuilder wrapper or java.lang.Runtime through a T() operator on an aliased class could sidestep the list entirely.

Together, these allowed injection through the $${...} and nested #{...} pre-processing step, which is evaluated before the main template renderer applies security rules.

Technical Breakdown

When Thymeleaf processes a template string such as:

<!-- Vulnerable controller passes raw user input into model -->
<p th:text="${userInput}">Placeholder</p>

...and a Spring controller places unvalidated request data into userInput, an attacker can supply a payload like:

${T(java.lang.Runtime).getRuntime().exec('id')}

Or, to bypass the java.* blocklist using the tab-character trick:

${new	java.lang.ProcessBuilder(new String[]{'id'}).start()}

The preprocessing step evaluates the SpEL expression before rendering, executing the system command on the server and returning the output to the attacker.

Attack Vector

The attack requires that:

  1. A Spring controller passes user-controlled data (request parameters, headers, path variables, form fields) directly into a Thymeleaf model attribute or template string without sanitization.
  2. The Alpha HTTP port (or application HTTP port) is reachable by the attacker — which in any Internet-facing application it is.

No authentication is required. No special privileges are needed. The attacker only needs to be able to send an HTTP request.

Real-World Impact

While no public breaches have been attributed to CVE-2026-40478 as of publication, SSTI vulnerabilities in Thymeleaf have historically been among the highest-exploited Java web flaws. The 2022–2024 Thymeleaf SSTI wave (CVE-2023-38286 and derivatives) was actively weaponized by multiple APT groups targeting Spring Boot microservices in financial and healthcare sectors. Given that CVE-2026-40478 is a bypass of the very defenses added after those incidents, exploitation is expected to follow quickly.


2. Who Is Affected?

Component Affected Versions
thymeleaf (core) ≤ 3.1.3.RELEASE
thymeleaf-spring5 ≤ 3.1.3.RELEASE
thymeleaf-spring6 ≤ 3.1.3.RELEASE
thymeleaf-extras-* All versions paired with core ≤ 3.1.3

You are affected if all of the following are true:

  • Your application uses Thymeleaf ≤ 3.1.3.RELEASE
  • A controller method places user-controlled data (request parameters, path variables, form fields, headers, cookie values) into the Spring Model or ModelAndView
  • That data is rendered via th:text, th:utext, th:value, th:href, or similar Thymeleaf attribute expressions in a template

You are likely not affected if:

  • All user data is HTML-escaped before it reaches the model (though defense in depth still recommends upgrading)
  • You use a different template engine (Freemarker, Mustache, Pebble)
  • You are on Thymeleaf 3.1.4.RELEASE or later

3. How to Detect It (Testing)

Manual Testing Steps

Step 1 — Identify injection points. Map all request parameters, path variables, form fields, and headers that are reflected in the application's HTML output. These are the candidate injection points.

Step 2 — Send a benign probe. Supply a simple SpEL probe that returns a predictable value with no side effects:

name=__$%7BT%28java.lang.String%29.valueOf%287*7%29%7D__

URL-decoded: name=__${T(java.lang.String).valueOf(7*7)}__

If the response contains __49__ instead of the literal string, the expression was evaluated — the application is vulnerable.

Step 3 — Test the tab-character bypass. If step 2 is blocked, try the whitespace bypass:

name=${new%09java.lang.ProcessBuilder(new+String[]{'id'}).start()}

%09 is a URL-encoded tab. A delayed response or error from the application indicates successful evaluation.

Step 4 — Check server side effects. For a safe, read-only confirmation, attempt:

${T(java.lang.System).getProperty('os.name')}

If the OS name appears in the response, RCE is confirmed.

Automated Scanning

Tool: OWASP ZAP with SSTI add-on

# Install SSTI scanner via ZAP marketplace, then run:
zap-cli quick-scan \
  --spider \
  --ajax-spider \
  --scan \
  --scanners ssti \
  http://your-target-app.example.com

Expected output: ZAP flags any parameter where SSTI probes (${7*7}, #{7*7}, *{7*7}) return evaluated results.

Tool: tplmap (open source SSTI scanner)

python3 tplmap.py \
  -u "http://your-app.example.com/greet?name=INJECT" \
  --engine Thymeleaf \
  --level 5

Expected output: [+] Thymeleaf plugin is pointing to a code injection

Tool: Snyk / Dependabot (dependency scanning)

# Maven
mvn snyk:test

# Gradle
./gradlew snykTest

Expected output: ✗ Critical severity vulnerability found in org.thymeleaf:thymeleaf@3.1.3.RELEASE

Code Review Checklist

  • Search for model.addAttribute( calls that include request.getParameter(, @RequestParam, @PathVariable, or header values
  • Confirm no raw user input flows into TemplateEngine.process() as the template string itself
  • Verify th:utext (unescaped text) is not used with user-controlled data anywhere in templates
  • Check that thymeleaf.version in pom.xml or build.gradle is 3.1.4.RELEASE or higher
  • Confirm Spring Boot's managed version of Thymeleaf is not being overridden by an older pinned version

4. How to Fix It (Mitigation)

Step-by-Step Remediation

1. Upgrade Thymeleaf to 3.1.4.RELEASE (the only complete fix).

For Maven (pom.xml):

<!-- If using Spring Boot's dependency management, override the managed version: -->
<properties>
    <thymeleaf.version>3.1.4.RELEASE</thymeleaf.version>
</properties>

<!-- Or pin the dependency directly: -->
<dependency>
    <groupId>org.thymeleaf</groupId>
    <artifactId>thymeleaf-spring6</artifactId>
    <version>3.1.4.RELEASE</version>
</dependency>

For Gradle (build.gradle):

ext['thymeleaf.version'] = '3.1.4.RELEASE'

// Or with version catalog (libs.versions.toml):
// thymeleaf = "3.1.4.RELEASE"

2. Verify the dependency resolved correctly.

# Maven
mvn dependency:tree | grep thymeleaf

# Gradle
./gradlew dependencies --configuration runtimeClasspath | grep thymeleaf

Expected: All Thymeleaf artifacts show 3.1.4.RELEASE.

3. Never pass raw user input as a template string. If your code calls templateEngine.process(userInput, context) where userInput comes from a request, rewrite it to pass only a safe template name/path, not the template content itself.

4. Deploy the updated artifact through your normal CI/CD pipeline and verify in staging before production rollout.

Code Fix Example

BEFORE (Vulnerable pattern):

@GetMapping("/greet")
public String greet(@RequestParam String name, Model model) {
    // Directly placing user input into the model — dangerous if template renders it unsanitized
    model.addAttribute("username", name);
    return "greet"; // renders greet.html
}
<!-- greet.html — VULNERABLE: th:utext renders without escaping -->
<p th:utext="${username}">Guest</p>

AFTER (Safe pattern):

@GetMapping("/greet")
public String greet(@RequestParam String name, Model model) {
    // Input validation: strip anything that isn't alphanumeric + spaces
    String safeName = name.replaceAll("[^a-zA-Z0-9 ]", "");
    model.addAttribute("username", safeName);
    return "greet";
}
<!-- greet.html — SAFE: th:text HTML-escapes output by default -->
<p th:text="${username}">Guest</p>

The critical changes are: (1) upgrade Thymeleaf to 3.1.4.RELEASE to patch the sandbox bypass, (2) switch from th:utext to th:text so output is always HTML-escaped, and (3) validate and sanitize input before it enters the model.

Configuration Hardening

Even after upgrading, apply these additional controls:

  • Disable the Thymeleaf cache in development only. In production, spring.thymeleaf.cache=true (the default) prevents template re-compilation that could be abused.
  • Use a Content Security Policy (CSP) header to limit what injected scripts can do even if SSTI is somehow achieved:
    Content-Security-Policy: default-src 'self'; script-src 'self'
    
  • Run the application with least privilege. The OS user running the JVM should not have write access outside its working directory, limiting what an RCE payload can do.

5. How to Test the Fix (Validation)

Regression Test Scenarios

  • Scenario A: Submit the original probe ${7*7} as a name parameter. The rendered page must show the literal string ${7*7} — not 49.
  • Scenario B: Submit the tab-character bypass ${new\tjava.lang.ProcessBuilder(...).start()}. The application must reject or escape it without executing any command.
  • Scenario C: Submit a normal name like Alice. The page must render Alice correctly — confirming no functionality was broken by the fix.

Security Test Cases

Test Case 1: Verify the SSTI probe is no longer evaluated

  • Precondition: Deploy application with Thymeleaf 3.1.4.RELEASE
  • Steps: GET /greet?name=%24%7B7*7%7D (URL-encoded ${7*7})
  • Expected Result: Response body contains the literal text ${7*7} or an escaped equivalent — not 49

Test Case 2: Verify tab-character bypass is blocked

  • Precondition: Same as above
  • Steps: GET /greet?name=%24%7Bnew%09java.lang.Runtime%7D
  • Expected Result: Literal string rendered or HTTP 400 — no command execution

Test Case 3: Verify normal rendering still works

  • Precondition: Same as above
  • Steps: GET /greet?name=Alice
  • Expected Result: Response contains Alice — application functions normally

Automated Tests

Add this JUnit/MockMvc test to your Spring Boot test suite:

@SpringBootTest
@AutoConfigureMockMvc
class SstiSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void ssti_probe_is_not_evaluated() throws Exception {
        mockMvc.perform(get("/greet").param("name", "${7*7}"))
            .andExpect(status().isOk())
            .andExpect(content().string(not(containsString("49"))))
            .andExpect(content().string(containsString("${7*7}")
                .or(containsString("&#36;{7*7}"))));
    }

    @Test
    void tab_character_bypass_is_blocked() throws Exception {
        mockMvc.perform(get("/greet").param("name", "${new\tjava.lang.Runtime}"))
            .andExpect(status().isOk())
            .andExpect(content().string(not(containsString("java.lang.Runtime@"))));
    }

    @Test
    void normal_name_renders_correctly() throws Exception {
        mockMvc.perform(get("/greet").param("name", "Alice"))
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("Alice")));
    }
}

6. Prevention & Hardening

Best Practices

  • Never use user input as template content. Template names/paths must come from a controlled whitelist — never from request parameters. If you must render dynamic content, use th:text (not th:utext) and validate inputs strictly.
  • Pin and audit dependency versions. Use a Software Bill of Materials (SBOM) tool such as cyclonedx-maven-plugin to maintain an inventory of all transitive dependencies. Subscribe to GitHub Dependabot alerts and the Thymeleaf security mailing list.
  • Adopt a WAF rule for SSTI payloads. Deploy a Web Application Firewall with rules that block common SSTI syntax: ${}, #{}, *{}, [[...]] in user-controlled query parameters and form fields. This is not a substitute for patching, but buys time and detects active exploitation attempts.
  • Enforce input validation at the controller layer. Use Bean Validation (@Pattern, @Size) and a global @ControllerAdvice to reject malformed input before it touches the template layer.
  • Conduct regular DAST scanning with tools like OWASP ZAP, Burp Suite Pro, or Invicti as part of your CI/CD pipeline to catch SSTI and other injection-class vulnerabilities before they reach production.

Monitoring & Detection

Add the following to your application and infrastructure monitoring:

Log anomaly detection: Alert on any request parameter containing the character sequences ${, #{, *{, [[, or ]] appearing in access logs — these are Thymeleaf expression delimiters and have no legitimate place in user input.

Example logback pattern to flag suspicious requests:

<filter class="ch.qos.logback.core.filter.EvaluatorFilter">
    <evaluator>
        <expression>
            message.contains("${") || message.contains("#{") || message.contains("[[")
        </expression>
    </evaluator>
    <onMatch>ACCEPT</onMatch>
    <onMismatch>DENY</onMismatch>
</filter>

Process-level monitoring: Alert on any unexpected child process spawned by your JVM process (e.g., sh, bash, cmd.exe, powershell). Tools like Falco (Linux) or Sysmon (Windows) can detect this behavior:

# Falco rule: detect shell spawned by Java process
- rule: Java Spawns Shell
  desc: A Java process spawned an interactive shell — possible RCE exploitation
  condition: >
    spawned_process and
    proc.pname in (java, java11, java17, java21) and
    proc.name in (bash, sh, zsh, dash, cmd, powershell)
  output: "Java spawned shell (parent=%proc.pname pid=%proc.ppid child=%proc.name)"
  priority: CRITICAL

Runtime Application Self-Protection (RASP): Consider deploying a Java RASP agent (e.g., Contrast Security, Sqreen) that can block SpEL evaluation of dangerous patterns at runtime, regardless of library version.


References

Latest from the blog

See all →