GraphNode
Back to all posts
AppSec

SSTI: Why Jinja, Twig, and Velocity Still Cause RCEs in 2026

| 11 min read |GraphNode Research

In the summer of 2015, a researcher at PortSwigger named James Kettle was reviewing a Ruby on Rails application that let marketing staff edit the subject lines of transactional email templates. He noticed the application interpolated those subject lines through ERB before sending, which meant the marketing team had a Ruby evaluator behind a friendly textarea. He fed it a payload that resolved a constant lookup chain to Object.const_get, reached the Kernel module, and called system('id'). The id of the Rails service account came back inside a confirmation email. The disclosure that followed coined the phrase Server-Side Template Injection and laid out the polyglot methodology the bug class still uses: probe with arithmetic operators, identify the engine from the response, then climb the language's reflection API to a code execution sink. Seven years later, attackers used essentially the same chain against VMware Workspace ONE Access — CVE-2022-22954, an SSTI in the Identity Manager's id parameter — to land unauthenticated RCE on tens of thousands of internet-facing appliances within days of public disclosure.

SSTI is the bug class where a template engine designed to render untrusted data inside trusted templates is instead handed user input as the template itself. Once the input crosses that line, the attacker has the full expression language of the engine — Turing-complete in most cases, with reflection access to the host language's runtime in many — and from there to RCE is a short walk through whichever object graph the engine exposes. This article walks through how SSTI works, what vulnerable and fixed code looks like in Python and Java, why the classic Jinja2 sandbox-escape chain still works in 2026, and where to put the detection so the next personalization feature does not become the next CVE-2022-22954.

What SSTI Actually Is

Every server-side template engine — Jinja2, Twig, Velocity, FreeMarker, ERB, Pebble, Thymeleaf when misconfigured — has the same shape. The application writes a template mixing static markup with placeholders, and at render time the engine substitutes data into the placeholders. The contract is that templates come from the developer and data comes from the user. SSTI is what happens when that contract inverts. The engine evaluates the user's text as expressions in its own language, which always includes attribute access and method invocation, and almost always includes a path to enumerate the host language's class hierarchy. From there you reach Runtime.getRuntime().exec() in Java or os.popen() in Python; from either you have RCE as the application user. The path from injection to shell is short — usually one or two payload iterations once the engine is fingerprinted.

The Vulnerable Flask View You Have Probably Shipped

Flask's render_template_string is the canonical SSTI primitive in Python. It exists because someone wanted to render a template loaded from a database row instead of a file on disk, and Flask gave them a one-line function to do it. The function accepts a string and treats it as a Jinja2 template — which means anything that flows into that string is, by definition, template source code. The pattern below appears in every greeting, personalization, and email-preview feature ever written by an engineer who knew Flask but had not read about SSTI:

# VULNERABLE
from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route("/hello")
def hello():
    name = request.args.get("name", "guest")
    template = f"<h1>Hello, {name}!</h1>"
    return render_template_string(template)

A request to /hello?name={{7*7}} renders Hello, 49! — the unmistakable fingerprint of a Jinja2 evaluator behind the parameter. From there the attacker pivots to the class-traversal payload below and lands code execution. The same vulnerability shipped in CVE-2019-8341 against the Lektor static site generator, where a render_template_string call in the admin panel exposed SSTI to anyone who could reach the editor preview endpoint. The fix is not to escape the input; it is to never put user input in the template position. Pre-define the template, pass the user input as a named variable, and let Jinja2's autoescape handle the HTML:

# FIXED
from flask import Flask, request, render_template

app = Flask(__name__)

@app.route("/hello")
def hello():
    name = request.args.get("name", "guest")
    return render_template("greeting.html", name=name)

# templates/greeting.html
# <h1>Hello, {{ name }}!</h1>

The shape of the fix is the entire lesson. render_template loads the template from the filesystem under the developer's control, and the user input flows through the name kwarg as data, which Jinja2 HTML-escapes by default. The same correction applies anywhere a templating function accepts a string argument: jinja2.Template(user_input).render(), Environment.from_string(user_input), and the equivalent constructors in every other engine. Treat these patterns as latent RCEs even if no obvious user-controlled path reaches them today, because someone will route user input to them tomorrow.

The Java FreeMarker Pattern That Reads Your Filesystem

Java has a richer collection of template engines than any other language and a correspondingly richer SSTI surface. Apache Velocity, FreeMarker, Pebble, and Thymeleaf in TEMPLATE mode all accept user input as template source through one method or another. The FreeMarker pattern below recurs most often in enterprise codebases — a CMS or marketing-automation feature where end users edit "email body templates" through a textarea:

// VULNERABLE
import freemarker.template.Configuration;
import freemarker.template.Template;
import java.io.StringReader;
import java.io.StringWriter;

public String renderEmail(String userTemplate, Map<String, Object> data) throws Exception {
    Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
    Template tpl = new Template("email", new StringReader(userTemplate), cfg);
    StringWriter out = new StringWriter();
    tpl.process(data, out);
    return out.toString();
}

FreeMarker's expression language includes the ?new built-in, which instantiates a class by name, and the freemarker.template.utility.Execute class, which runs an OS command. A user-supplied template containing <#assign ex="freemarker.template.utility.Execute"?new()>{{ex("id")}} returns the output of id embedded in the rendered email. Velocity has the analogous $ex.getClass().forName("java.lang.Runtime") chain through reflection. The fix mirrors the Python case: stop accepting templates from users, and if the application genuinely requires user-edited template content, run it through the engine's restricted resolver:

// FIXED
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateClassResolver;

public String renderEmail(String templateName, Map<String, Object> data) throws Exception {
    Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
    cfg.setClassForTemplateLoading(getClass(), "/templates");
    cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER);
    cfg.setAPIBuiltinEnabled(false);
    Template tpl = cfg.getTemplate(templateName);
    StringWriter out = new StringWriter();
    tpl.process(data, out);
    return out.toString();
}

setClassForTemplateLoading binds the engine to a developer-controlled directory of .ftl files; the templateName parameter selects among them but cannot author one. ALLOWS_NOTHING_RESOLVER closes the ?new path that climbs to Execute. setAPIBuiltinEnabled(false) disables the ?api built-in that reaches the underlying Java object's full method surface. Confluence CVE-2022-26134 is a close cousin — not FreeMarker but Atlassian's OGNL expression language, evaluated against attacker-controlled input in the URL, with the same class-loading reflection path to Runtime.exec() and the same mass-exploitation outcome once a working chain leaked.

The Jinja2 Sandbox Escape That Refuses to Die

The mechanic is easier to reason about with the actual payload in front of you. The classic Jinja2 RCE chain looks like this:

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

The expression starts from a string literal — any value reachable in the template context works. __class__ returns the class object, __mro__ returns the method resolution order tuple, and __mro__[1] is object, the root of every Python class hierarchy. __subclasses__() on object returns every class loaded in the interpreter — several hundred on a typical Flask process, including subprocess.Popen. The attacker scans the output for the index of subprocess.Popen, then chains {{ ''.__class__.__mro__[1].__subclasses__()[INDEX]('id', shell=True, stdout=-1).communicate() }} to invoke it. Newer Jinja2 versions removed some intermediate access patterns under the SandboxedEnvironment, but the primitive — reflection from a literal value to the global class registry — survives in every CPython interpreter exposing __class__ and __subclasses__. The defense is not to sandbox Jinja2; it is to keep user input out of the template position.

Why SSTI Persists in 2026

Three structural reasons keep SSTI alive a decade after Kettle named the class. First, templating engines feel safe to developers in a way that string concatenation does not. The presence of autoescape and a documented template-versus-data separation conditions the engineer to assume that whatever flows through the engine is shielded. The actual security boundary — between developer-authored template source and user-supplied data — is invisible in code; both are strings, both flow through the same render call, and the difference depends on which argument position the developer chose. Second, user-controlled template content slips in through "personalization" features nobody flags as risky during design: email subject lines, marketing copy, dashboard widgets, custom report titles, low-code formula fields — template strings authored by privileged users today and exploited by less-privileged users tomorrow.

Third, the dangerous methods are easy to grep for but rarely audited. render_template_string, Environment.from_string, new Template(reader), Velocity.evaluate, Twig_Loader_String — each is an API call that says "this argument is template source code" in a contract the developer probably did not read. CVE-2022-22954 shipped because exactly this pattern appeared in an authentication endpoint, against a parameter the surrounding framework treated as user input.

Detection: Where Each Layer Earns Its Keep

SSTI has the cleanest static-analysis fingerprint of any injection class because the dangerous sinks are a small enumerable list of API calls per engine. SAST traces user input from request parameters into the specific template-source positions: render_template_string, jinja2.Template(...), FreeMarker's new Template(name, Reader), Velocity.evaluate, Twig_Environment::createTemplate. When taint reaches one of those sinks without a sanitizer, the finding is the same: user input authored a template. Inter-procedural data flow catches the cases where the unsafe call is wrapped in a helper one or two layers up — see why data flow analysis matters for the cross-method tainting mechanics.

DAST attacks the running application by submitting Kettle's polyglot probes — arithmetic operators across multiple template syntaxes, then engine-specific reflection chains once a fingerprint comes back. The false-positive rate is low because the response either contains 49 or it does not. Where DAST loses is in personalization features locked behind authentication and setup workflows that a crawler will never reach without a hand-built scenario. SCA flags vulnerable template-engine versions in the dependency tree, catching the cases where the application code is fine but the engine ships a known sandbox-escape regression — Jinja2 has had several such CVEs against its SandboxedEnvironment.

Prevention Checklist

Six rules close the overwhelming majority of real-world SSTI. Apply them in order; each later rule assumes the earlier ones are in place.

  • Never pass user input into a template-source position. User input is data, not template code. Pre-define every template the application renders, and pass user values through named variables to the renderer. This single rule closes most of the bug class.
  • Audit every template-source API call in the codebase. Grep for render_template_string, Template(, from_string, Velocity.evaluate, createTemplate, and the equivalents in your engine of choice. Treat every match as a latent RCE until a reviewer confirms the input is not user-controlled.
  • Use sandboxed template environments where the engine supports them. Jinja2's SandboxedEnvironment, FreeMarker's ALLOWS_NOTHING_RESOLVER, and Velocity's SecureUberspector reduce the blast radius even when user input does reach the template position. They are defense in depth, not a substitute for the first rule.
  • Reject low-code "formula" features that evaluate user expressions on the server. If the product manager asks for a feature where users author their own template logic that the server evaluates, the answer is a sandboxed JavaScript interpreter in a separate process, not the production application's template engine.
  • Keep template engine dependencies on the latest patched version. The sandbox escapes that periodically appear against Jinja2's SandboxedEnvironment, Velocity's secure uberspector, and FreeMarker's class resolver are real. SCA in CI catches the regressions.
  • Gate template-source sinks in CI with SAST. A pull request that introduces render_template_string with a user-controlled argument should fail the build, not generate a Jira ticket six months later.

Where GraphNode SAST Fits

GraphNode SAST traces user input into the template-source sinks of Jinja2, Twig, Velocity, FreeMarker, ERB, and the other major engines across 13+ languages, flagging the call sites where the developer-versus-user contract inverts. Findings surface on the diff that introduced the unsafe render call. SSTI sits under A03 Injection because the mechanic is the same as SQL injection — a parser given user input as code — and SAST catches it while the fix is still one line.

Closing

SSTI has a documented fix that fits in a one-line code change: pass user input as a kwarg, not as the template body. Despite that, it shipped in Lektor's admin panel in 2019, in VMware Workspace ONE Access in 2022, in Atlassian Confluence's adjacent OGNL injection the same year, and in the next personalization feature near you. The bug class persists because template engines feel safer than they are, because user-controlled template content slips in through features nobody flags as risky, and because the seam where input authors a template is a single API argument no reviewer is paid to scrutinize. The teams that stop shipping SSTI are the ones that move detection upstream into the diff and refuse low-code formula features that turn the production application into a remote evaluator. Eleven years after Kettle named the class, that is still where the economics shift in the defender's favor.

GraphNode SAST flags template-source sinks across 13+ languages — request a demo.

Request Demo