← Blog
October 24, 202611 min readAppSecVulnerability ResearchBug bountyRCESecurity Engineering

{{7*7}} is not RCE: how to actually prove server-side template injection

A template that turns {{7*7}} into 49 is the most over-claimed signal in bug reports: it proves the engine evaluated your input, and almost nothing more. A practitioner's walk through telling detection apart from engine identification apart from a real sandbox escape, confirming server-side template injection without hand-waving, and the hardening that closes it — grounded in two 2026 RCEs in FreeMarker and an AI-agent feature.

By The Xalgorix Team

There is a moment in almost every server-side template injection report where the finder submits {{7*7}}, the page answers 49, and the ticket is filed as “SSTI, RCE confirmed.” That 49 is real and it matters — the server evaluated an expression you supplied instead of echoing it back — but it is the start of the work, not the end of it. The gap between the template engine ran my arithmetic and I executed a command on the host is where most SSTI claims quietly fall apart, and closing that gap, with evidence, is the whole job.

Easy to trigger, easy to over-claim

Server-side template injection happens when user input is concatenated into a template as template code rather than passed into it as data. The engine then treats your input as part of the program it is about to run. In his original research, James Kettle described it as “a frequently critical vulnerability that is extremely easy to mistake for Cross-Site Scripting (XSS), or miss entirely” — and, unlike XSS, one that can “directly attack web servers' internals and often obtain Remote Code Execution (RCE).” That last clause is why the bug is exciting and also why it is so over-reported: the ceiling is RCE, so every evaluated expression gets written up as if it reached the ceiling.

It usually does not. A 49 tells you the engine evaluated your input. It does not tell you which engine, and it does not tell you whether you can get from an evaluated expression to anything dangerous. Those are three separate claims, each with its own burden of proof, and a report that collapses them into one is guessing about the two it did not test.

Input evaluated
{{7*7}} to 49
Engine identified
which syntax
Sandbox escaped
reach real capability
Proven impact
controlled RCE / read
Each arrow is a separate claim with its own evidence; a report that skips one is guessing.

Claim one: is your input evaluated as template code?

Detection starts by telling evaluation apart from reflection. In a plaintext context your input is rendered as page content, so submit a small arithmetic expression in the common template dialects and look for the product. If {{7*7}} or ${7*7} comes back as 49, the server computed it; a reflected literal {{7*7}} is just output, and at most XSS. In a code context your input already sits inside a template statement — think a name that is later used as greeting = data.name — so you have to break out of the expression first. Submit a bare HTML tag to rule out plain XSS, then try to close the surrounding expression and append your own.

Plaintext context (your input is rendered as content):
  ${7*7}        FreeMarker / Velocity style    -> look for 49
  {{7*7}}        Jinja2 / Twig style            -> look for 49
  <%= 7*7 %>     ERB / Ruby style               -> look for 49

A 49 means the engine evaluated your expression.
It does NOT mean you have code execution yet.

Polyglot to smoke out an interpreter (an error is a lead, not proof):
  ${{<%[%'"}}%\

PortSwigger's Web Security Academy recommends fuzzing with the polyglot ${{<%[%'"}}%\: if the application throws an exception, something is trying to parse your template syntax, which is worth chasing. But an error is a lead, not a finding. The only detection result you can stand behind at this stage is “input in this parameter is evaluated as template code,” and the evidence for it is a computed value you can reproduce, not a stack trace.

Claim two: which engine (and don't trust one probe)

The exploit path is engine-specific, so misidentifying the engine wastes the next hour on payloads that cannot work. Two techniques narrow it down. The first is error messages: feeding deliberately invalid syntax such as <%=foobar%> can make a Ruby ERB backend return a NameError that names the engine, and sometimes its version. The second is divergent behaviour on the same probe. The Academy's canonical example is {{7*'7'}}: it returns 49 in Twig, which coerces the string and multiplies, but 7777777 in Jinja2, which repeats the string seven times. One probe, two engines, two different truths.

Same probe, different engines, different answers:

  {{7*'7'}}     ->  49         (Twig: coerces, then multiplies)
                ->  7777777    (Jinja2: repeats the string seven times)

  <%=foobar%>   ->  NameError  (Ruby ERB leaks the engine in its error)

One probe is a hypothesis. Confirm the engine before you
reach for an engine-specific exploit.
Output contains 49
Not just XSS
the server ran template logic; it did not echo your string back
Engine unknown
you still cannot tell Jinja2 from Twig from FreeMarker
RCE unproven
escaping the template sandbox may or may not be possible
Evaluation rules out plain XSS and proves template execution — but not which engine, and not its reach.

Claim three: the sandbox, and whether you can leave it

This is the step most reports skip, and the one that actually separates detection from proof. Kettle's framing is precise: template injection is “essentially server-side code execution inside a sandbox,” and escaping that sandbox “may be possible depending on the template engine.” The engine evaluates your expression, but in an environment that is supposed to expose only a limited set of objects and methods. Getting to RCE or arbitrary file read means finding a reachable object that bridges out of that environment to the host — and whether such a bridge exists, and whether it is reachable from your injection point, is an empirical question, not an assumption.

Two disclosures from 2026 show what a confirmed escape looks like, and what it costs the attacker. In CVE-2026-22244, published January 7, 2026 and rated CVSS 9.1, OpenMetadata loaded email-template content from its database and passed it straight to FreeMarker with no safer class resolver and the ?api built-in left enabled. An administrator could edit a template through the document-store endpoint and inject a payload using FreeMarker's Execute utility class; the code ran the next time the server rendered an email — a password change, an invitation, a test send. The proof-of-concept ran whoami and pwd and got the server's user and working directory back. It was fixed in version 1.11.4, and no in-the-wild exploitation was reported.

The second case moves the sink somewhere newer. CVE-2026-34724, published April 8, 2026 and rated CVSS 8.7, was an SSTI-to-RCE in Zammad 7.0.0 reached through its AI Agent feature: text that influenced type_enrichment_data flowed into a template and was evaluated on the server. It is classified under CWE-1336 (improper neutralization of special elements used in a template engine) and CWE-94 (code injection), and it was fixed in 7.0.1. The AI angle is worth sitting with: any feature that lets model output or enrichment data reach a template is a template-injection sink, and the model is not a trust boundary.

Both bugs required high privilege — an admin role, or control of an administrative configuration field. That does not make them non-issues; it makes the privilege boundary part of the finding. “You need admin” is a real mitigating factor to state honestly, not a reason to downgrade a verified RCE to a note. Report the capability and the conditions it needs together.

Deliberately, this article does not hand you a weaponised payload for each engine; those are documented, and the point here is method, not a copy-paste exploit. On an authorised engagement the escape is confirmed with the smallest reversible demonstration that settles the question — a single benign command such as id or whoami, or a read of a known-harmless file — and nothing destructive. Running arbitrary commands on a system you do not own, or beyond your scope, is where verification stops and harm begins.

What actually proves it, and what to write down

A finding is only as strong as the claim it can defend. For SSTI that means three things on the page, in order: the parameter and context where input is evaluated, with a reproducible computed value; the identified engine, with the evidence that identified it; and, where you reached it on an authorised target, a controlled demonstration of the capability, with the privilege it required. A screenshot of {{7*7}} returning 49 is a lead that belongs at the top of that chain — not a substitute for the rest of it.

The failure mode is the inverted report: “SSTI leading to RCE” as the headline, with a 49 as the only artefact. A triager who tries it, sees the sandbox hold, and finds no escape has every reason to distrust the next report from the same source. Claiming only what you proved is not timidity; it is what makes the proven parts believable.

The controls that hold

SSTI is a design problem before it is a payload problem, and the controls that survive a single mistake are the ones worth building.

  • Never mix user input into template code. Pass user data as values into a template that is fixed at deploy time. The moment a user can supply any part of the template source — not the data it renders — you are one oversight away from this bug.
  • Treat the sandbox as defence in depth, not the control. Kettle's warning stands: template-engine sandboxes are error-prone and “should not be relied on except as a defence in depth measure.” Harden the engine anyway — for FreeMarker that means a restrictive TemplateClassResolver and disabling dangerous built-ins such as ?api and ?new, exactly the gap the OpenMetadata fix closed.
  • Prefer logic-less templates where the use-case allows it. An engine that cannot call methods or resolve classes has a far smaller escape surface than one built to run arbitrary expressions.
  • Guard the new sinks. Any feature where model output, enrichment data, or other semi-trusted text can reach a template — AI agents included — is a template-injection sink. Scope and validate that input the same way you would a raw HTTP parameter.

Proof, not a 49

A queue of “possible SSTI” tickets backed only by evaluated arithmetic is the same unactionable pile that “possible SSRF” and “a 200, so IDOR” tend to become: triaged to the bottom, with the occasional real one rotting among the guesses. A finding that carries its evidence — input evaluated here, engine identified by this, and on an authorised target this controlled command came back — is one an engineer fixes this sprint. That is the premise of exploit-verified testing, and the same discipline behind confirming SSRF rather than flagging it. Xalgorix works its 22-phase methodology, attempts the escape, and reports SSTI as RCE only when it can demonstrate it.

It is worth being honest about the limits of automating this. The detection step — “this input is evaluated” — is easy to automate and easy to over-trust; the escape step is engine-specific, context-dependent, and exactly the part that must stop short of harm on systems you do not own. A tool worth trusting proves the escape with a controlled, reversible demonstration and says so plainly — and treats a lone {{7*7}} echo as a lead to chase, never as an RCE to announce.

References

Ready to see it prove a bug?

Start scanning on Cloud →