Skip to main content
All articles

Server-side template injection: why {{7*7}} is a bad test, and how CVE-2023-22527 got to CVSS 10.0

Most SSTI write-ups teach one Jinja2 payload and stop. Here is the actual bug class, the per-engine probe table, a lab you can build in ten minutes, and the code-review rule that removes it entirely.

9 min read2 views

If you have read about server-side template injection before, you have probably read the same article: type {{7*7}}, see 49, congratulations, it is Jinja2, here is a long chain of __class__.__mro__ lookups that ends in a shell.

That article leaves you with a party trick and no model of the bug. It fails you in two specific ways. First, the moment you meet an application built on Mako, Tornado, Freemarker, Velocity, Thymeleaf or OGNL, {{7*7}} renders as the literal string {{7*7}} and you write the target off as safe. Second, it teaches exploitation before detection, so you never learn to spot the vulnerability in source code, which is where it is cheapest to kill.

This post fixes both. By the end you will be able to identify the vulnerable pattern in any templating stack, probe an unknown engine methodically instead of guessing, and explain to a developer exactly which line to change.

The bug, in one distinction

Template engines exist to mix data into a document. The vulnerability appears when user input is concatenated into the template source rather than passed as a variable to a template.

That is the whole thing. Everything else is syntax.

# Vulnerable: the name becomes part of the program
render_template_string("Hello " + name)

# Safe: the name is data the program receives
render_template("greeting.html", name=name)

In the first form the user is no longer supplying text. They are supplying source code to an interpreter you have handed them, and that interpreter usually runs with the full privileges of the application.

It looks obvious written like that. It never looks like that in the wild. It looks like an email templating feature where marketing can write Hi {{first_name}}, a report generator, a "custom label" field, a webhook body builder, an error-page customiser, or a wiki macro. In every one of those, letting the user write template syntax is the feature. The vulnerability is that the sandbox around that feature does not exist, or does not hold.

Why the payload differs everywhere

Each engine picked different delimiters and exposes a different amount of the host language. That produces a spectrum of severity worth knowing before you report anything:

EngineDelimitersWhat you get
Jinja2 (Python){{ }}, {% %}Sandboxed. Exploitation means walking the object graph to reach something that executes.
Mako (Python)${ }, <% %><% %> takes raw Python. There is no sandbox. Injection is execution.
Tornado (Python){{ }}, {% %}{% import %} reaches Python directly.
Cheetah3 (Python)$var, #importImport anything.
Chameleon (Python)${ }${python: ...} is exactly what it looks like.
Freemarker (Java)${ }, <#...>Execute and ObjectConstructor built-ins unless explicitly unsafe-listed.
Velocity (Java)$var, #setReflection via $class.inspect(...).
OGNL (Java)${ }, %{ }A full expression language with method invocation. This is the Struts / Confluence family.
Twig (PHP){{ }}, {% %}Sandboxed by default, but _self, filters and extensions have repeatedly given ways out.

Two practical consequences.

For a tester: {{7*7}} returning 49 narrows it to one family. Returning the literal string proves nothing at all. Probe every family before concluding anything:

{{7*7}}      ${7*7}      #{7*7}      <%= 7*7 %>
{7*7}        %{7*7}      *{7*7}      @(7*7)
${{7*7}}     {{7*'7'}}   ${7*'7'}

The 7*'7' variants are the useful discriminator: Python returns 7777777 (string repetition), Java and most others error or return 49. That single character tells you which language you are standing in.

Also probe in error messages. A malformed expression such as ${ or {{ alone often produces a stack trace naming the engine and version, which is faster and quieter than a working payload.

For a defender: the ranking matters when you triage. A Jinja2 injection is serious and exploitable with effort. A Mako, Chameleon or OGNL injection is immediately fatal, because those engines were designed to evaluate code and are behaving exactly as documented.

The real one: CVE-2023-22527

Atlassian Confluence Data Center and Server, disclosed January 2024, CVSS 10.0, unauthenticated remote code execution, added to CISA's Known Exploited Vulnerabilities catalog and mass-exploited within days of the proof of concept appearing.

Confluence uses OGNL to render dynamic content in templates. The vulnerability was a template injection reachable from an unauthenticated endpoint: a crafted POST body reached a Velocity template where user input was evaluated as an OGNL expression rather than treated as data. Because OGNL is an expression language with full method invocation, evaluating attacker-controlled OGNL means invoking arbitrary Java methods, which means ProcessBuilder, which means a shell.

Three things about it are worth internalising:

  1. It was a re-run. Confluence and Struts have had a long series of OGNL injection issues (CVE-2021-26084, CVE-2022-26134, and the Struts lineage before them). Each fix blocked a specific path to the evaluator rather than removing the evaluator from the request path. A blocklist in front of a Turing-complete expression language is a shape that keeps producing CVEs.
  2. The affected versions were 8.0.x through 8.5.3, current, supported, patched-that-year software. This is not a legacy-system problem.
  3. The exploit was one HTTP request. No authentication, no chaining, no user interaction. That combination is why the ransomware crews moved on it inside a week.

If you run Confluence, Struts, or anything else built on OGNL, the version number is the entire control. There is no configuration that makes an OGNL injection survivable.

Build a lab in ten minutes

Reading about it is not the same as watching the object graph unfold. Two containers, two engines, so you can feel the difference between "sandboxed" and "not".

# app.py -- deliberately vulnerable. Localhost only. Never expose this.
from flask import Flask, request, render_template_string
from mako.template import Template as MakoTemplate

app = Flask(__name__)

@app.get("/jinja")
def jinja():
    # The bug: user input concatenated into template SOURCE
    return render_template_string("<h1>Hello " + request.args.get("name", "") + "</h1>")

@app.get("/mako")
def mako():
    return MakoTemplate("<h1>Hello " + request.args.get("name", "") + "</h1>").render()

app.run(host="127.0.0.1", port=5000)
pip install flask mako
python app.py

Now compare the two. Against /jinja, ?name={{7*7}} gives you 49 and ?name={{7*'7'}} gives 7777777. You have confirmed Python and confirmed Jinja2, and from there exploitation is a search problem: you are looking for an object reachable from your current scope whose module namespace contains something useful. Start by looking at what the sandbox actually exposes:

{{ config }}
{{ self.__init__.__globals__ }}
{{ ''.__class__.__mro__ }}

Against /mako, there is no search problem. Mako's <% %> block is raw Python by design. You will reach execution on the first attempt. That difference, minutes of object-graph walking versus zero, is the thing to take away, and it is invisible if you only ever read Jinja2 write-ups.

Once you have both working, add the fix and watch the payloads become inert:

return render_template_string("<h1>Hello {{ name }}</h1>", name=request.args.get("name", ""))

The payload is now printed on the page as text. It was never about escaping the input; it was about which side of the compile boundary the input landed on.

Finding it in source code

Grep is genuinely effective here, because the vulnerable shape is narrow and syntactic. Unlike XSS or SQL injection, there is no "it depends on the sink". The sink is a template compiler and there are only a handful of ways to call it.

# Python
grep -rn "render_template_string\|Template(" --include="*.py" .
grep -rn "from_string\|env.from_string" --include="*.py" .

# Java
grep -rn "OgnlUtil\|Ognl.getValue\|new StringTemplateLoader\|processTemplateIntoString" --include="*.java" .

# PHP / Node
grep -rn "createTemplate\|compile(\|new Twig_Environment" --include="*.php" --include="*.js" .

Then look for three things at each hit:

  • Is the template built from a string variable rather than loaded from a file on disk?
  • Is there string concatenation, +, %, .format() or an f-string inside the render call?
  • Is the template name user-controlled? That is a different bug, path traversal into arbitrary template files, with the same ending.

The rule that removes the class entirely, and the sentence to put in your code-review checklist:

Template sources are static files written by developers. User input arrives only as named variables.

If you never build a template from a string, you cannot have this vulnerability, in any engine, ever. That is a much stronger guarantee than any escaping strategy, and it is testable in CI with the greps above.

What if the feature genuinely requires user templates?

Sometimes it does. A marketing tool where users write their own email templates is a legitimate product. In that case:

  • Use a logic-less template language: Mustache, or Handlebars without helpers. There is no expression evaluator to escape from because there are no expressions.
  • If you must use a sandboxed engine, render in a separate process with a hard timeout, a read-only filesystem, no network egress, and a non-root user. Treat a sandbox escape as when-not-if and make the blast radius the process, not the host.
  • Do not maintain a blocklist of dangerous attribute names. The Jinja2 sandbox escape history is a long record of that approach losing.

Detection

If you cannot patch immediately, you can at least see it. Template injection has a recognisable request signature because the payloads must carry delimiters that ordinary user input does not:

  • Requests where a parameter value contains {{, ${, <%, %{ or #{, high volume of false positives in isolation, near-zero when combined with the next two.
  • Requests where a parameter contains __class__, __mro__, __subclasses__, __globals__, getRuntime, ProcessBuilder, Runtime.exec, or freemarker.template.utility.Execute.
  • Application errors mentioning your template engine's package name (jinja2.exceptions, mako.exceptions, ognl.OgnlException, freemarker.core) with a user-supplied string in the message. This one is gold: it is what a failed probe looks like, and probes come before working exploits.

That last signal is the most valuable thing in this section. Attackers get the syntax wrong several times before they get it right, and each of those failures generates a stack trace. If your error monitoring drops template-engine exceptions as noise, you are discarding your earliest warning.

Take this away

The question in a code review is never "which template engine is this?" It is "is any template here built from a string?"

Everything else, which delimiters, which sandbox, which object-graph chain, follows from the answer.


Further reading

Was this useful?

Share

Tags

  • web security
  • python
  • cve analysis
  • secure code review
  • penetration testing

Comments

Loading comments…

Leave a comment

Comments are read and approved by hand before they appear, so yours will not show up straight away. Your email address is optional, is never published, and is only used if we need to reply to you directly.

0/5000

Related service

Web Application Testing

Manual, business-logic-aware testing of the applications your customers touch.

If you want to know whether what you have just read applies to your own systems, that is the engagement that answers it.