Web Security

What Is a Template Engine? Server-Side Templating Explained

A foundational guide to server-side template engines: how they separate presentation from logic, and why attacker-controlled templates can lead to remote code execution.

Beginner6 min·Nyxeara Security Research·2026-09-16·CWE-VERIFY
templatesweb-fundamentalssstijinja2
Prerequisites
what-is-http

Short answer

A template engine is software that merges a template (text with placeholders) with data to produce a rendered output. When user input ends up inside the template itself — rather than in the data passed to it — the attacker can inject template syntax that the engine executes as code.

The idea in one minute

Imagine a form letter with blank spaces: "Dear , your order # has shipped." You fill in the blanks with a name and an order number. The reader sees only the completed letter, not the blanks or the instructions for filling them.

A template engine does this programmatically. It takes a template string like "Dear {{ name }}, your order #{{ order_id }} has shipped." and a data object like {name: "Alice", order_id: 4471}, and replaces each {{ placeholder }} with the corresponding value. The result: "Dear Alice, your order #4471 has shipped."

Now imagine the attacker provides the template itself — not just the data for the blanks. They write: "Dear {{ name }}, your order #{{ order_id }} has shipped. {{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen('cat /etc/passwd').read() }}". If the application renders this attacker-supplied template, the engine executes the injected expression, and the attacker gets arbitrary code execution on the server.

That is Server-Side Template Injection (SSTI): the difference between filling in blanks and writing the blanks themselves.

How template engines actually work

Template engines exist in nearly every programming language. Some of the most common:

| Engine | Language | Syntax | |---|---|---| | Jinja2 / Django Templates | Python | {{ expr }}, {% tag %}, {# comment #} | | FreeMarker | Java | ${expr}, <#tag> | | Velocity | Java | $expr, #foreach | | Pug / EJS | JavaScript (Node.js) | #{expr}, <%= expr %> | | ERB | Ruby | <%= expr %>, <% code %> | | Twig | PHP | {{ expr }}, {% tag %} |

Every engine follows the same architecture:

The rendering pipeline

Template text + data context
    │
    ▼
Parser: tokenize template into literal text and tags ({{ }}, {% %}, etc.)
    │
    ▼
AST Builder: construct an abstract syntax tree of nodes
    │
    ▼
Compiler: translate AST into executable code (e.g., Python bytecode, JS source)
    │
    ▼
Executor: run the compiled code with the data context → produce output string
    │
    ▼
Rendered output

The critical step is the compiler. Template engines are not simple find-and-replace tools. They compile templates into executable code. {{ user.name }} becomes something like output += context['user']['name'] in the target language. {% for item in items %} becomes an actual loop. This is what gives template engines their power — and what makes them dangerous when untrusted input enters the template string.

A minimal example

A Flask application that renders a user's profile with a customizable greeting:

from flask import request, render_template_string

@app.route("/greeting")
def greeting():
    username = request.args.get("username", "Guest")
    template = f"<h1>Welcome, {username}!</h1>"
    return render_template_string(template)

Intended usage — the user submits ?username=Alice:

Input: Alice
Template rendered: "<h1>Welcome, Alice!</h1>"
Output: Welcome, Alice!

Attacker usage — the user submits ?username={{7*7}}:

Input: {{7*7}}
Template rendered: "<h1>Welcome, {{7*7}}!</h1>"
Output: Welcome, 49!

The expression {{7*7}} was evaluated by the template engine, not treated as text. 7*7 is a Jinja2 expression that multiplies two integers. The engine compiled it, executed it, and inserted the result. The attacker just proved they can inject template syntax.

From here, escalation depends on the engine. In Jinja2, the attacker can access Python built-ins through the template's internal object graph:

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

This accesses str.__class__.__mro__ (the method resolution order, arriving at object), then object.__subclasses__() — a list of every class loaded in the Python runtime. Somewhere in that list is a class like subprocess.Popen or os._wrap_close that provides access to operating system commands. From a {{7*7}} math test to arbitrary code execution is a matter of traversing the object graph.

Why template injection is dangerous

Template engines are designed to execute code — that's their job. They compile templates into functions, evaluate expressions, call methods, and perform iteration. The entire architecture is built around the assumption that the template author is trusted.

When an attacker supplies the template text, they bypass every security boundary the engine provides. The template's sandboxing features (if any) are designed to limit what a trusted template author can do, not to withstand a determined attacker who controls the template itself. Most sandboxes have been escaped.

Three patterns create this risk:

  • User input concatenated into the template string: render_template_string("Hello " + user_input) — the input becomes part of the template grammar
  • User-controlled template names or paths: if an attacker can specify which template file to load, they can point to an uploaded file containing malicious template code
  • Template upload features: allowing users to upload or write template files, even with sandboxing, is extremely risky

What attackers look for

  • Any application feature that returns user-supplied text with template syntax still intact (e.g., {{7*7}} rendered as 49)
  • Error messages that reveal the template engine in use ("Jinja2", "Twig", "FreeMarker", "template parse error")
  • Custom 404 pages, email templates, profile bio rendering, or any feature where user text is rendered back with formatting
  • Form fields that accept markup or formatting syntax — some template engines are used as "safe" rich-text renderers
  • The characters {{, {%, ${{, ${, <%= anywhere in user input that is reflected in the response

Detection

  • Code review: search for render_template_string, template.render(), or equivalent functions that accept a template string built from user-controlled values. Check whether the template is passed as a string (dangerous) or loaded from a file (safer — but verify the file path is also not user-controlled).
  • Dynamic testing: submit {{7*7}}, ${7*7}, {{7*'7'}}, and other math-expression probes into every text-input parameter. If the response contains 49, 7777777, or similar computed values, template injection is confirmed.
  • Error-based: submit malformed template syntax like {{ or {% and observe error messages that reveal the engine name, file paths, or line numbers.
  • Blind testing: if output is not reflected, use template syntax that triggers a time delay ({% for i in range(10000000) %}{% endfor %} in Jinja2) or an out-of-band request.

Verification: real vulnerability or false positive?

A reflected {{7*7}} appearing as text — not evaluated — is not a vulnerability. The application may escape template syntax before rendering. Confirm by showing one of:

  • A math expression that evaluates: {{7*7}} → 49 (evaluated by the engine) rather than {{7*7}} (escaped as literal text)
  • A string operation that confirms engine evaluation: {{ "test"|upper }} → TEST in Jinja2
  • An object-access probe that returns the runtime type or class information: {{ "".__class__ }} returns <class 'str'>
  • A time-based delay from a loop construct unique to the engine

The test must prove the engine evaluated injected syntax as code, not treated it as literal output.

Real-world impact

  • Remote code execution: full control of the server's operating system — reading secrets, modifying source code, installing backdoors
  • Data exfiltration: reading environment variables, configuration files, database credentials, and source code through template expressions
  • Internal network access: using the compromised server to reach internal services, similar to SSRF
  • Privilege escalation: if the application runs with elevated permissions, the attacker inherits those permissions

Prevention

  • Never concatenate user input into template strings. User input belongs in the data context, not the template text. Pass it as a variable to a static template file: render_template("greeting.html", username=user_input).
  • Use static template files loaded from a trusted directory, not dynamic template strings. If the template file path itself could be user-controlled, validate it against an allowlist.
  • If dynamic templates are required (e.g., a feature that lets users customize email templates), consider using a non-template rendering approach: simple string replacement with a limited character set, or a dedicated rich-text format that doesn't compile to code.
  • Run the template engine with minimal scope: disable access to dangerous built-ins, limit available filters/tags, and avoid exposing the full data context to user-controlled templates.
  • Apply sandboxing carefully and test it. Jinja2's SandboxedEnvironment is stronger than the default but has been escaped before. Sandboxing is defense in depth, not a guarantee.

Related vulnerabilities

  • SSTI (Server-Side Template Injection) — the direct vulnerability class
  • Command injection — SSTI often escalates to command execution
  • XSS (Cross-Site Scripting) — template injection in the browser (client-side templating) can also produce XSS, though the impact is different
  • Local File Inclusion — some template engines have include or import directives that can read arbitrary files if the path is user-controlled

Testing methodology (do this safely)

  • Test against your own applications or authorized targets only. SSTI can lead to remote code execution — unauthorized testing is illegal in most jurisdictions.
  • Start with benign math expressions ({{7*7}}) to confirm engine evaluation before attempting object-graph traversal or code execution.
  • If math evaluation is confirmed, escalate to read-only information-gathering probes (reading environment variables, checking the runtime version) before attempting command execution.
  • Use a dedicated OAST domain for out-of-band confirmation if the response is not reflected.
  • Document the exact input parameter, the template syntax used, the engine identified, and the evidence of evaluation. A confirmed math expression is the minimum bar for a reportable finding; code execution evidence is stronger.

Further reading

Nyxeara perspective

Nyxeara's scan engine treats every endpoint that renders user-supplied text as a candidate for SSTI. The engine probes with engine-agnostic math expressions ({{7*7}}, ${7*7}, #{7*7}) and compares the rendered output against the expected evaluation results to identify which engine — if any — is active. Once the engine is identified, the scanner probes the object graph for known sandbox-escape and code-execution chains specific to that engine. Findings are classified by depth: template evaluation confirmed (expression-level), object access confirmed (information disclosure), and code execution confirmed (remote compromise). Each level requires corresponding evidence before elevation.

Published 2026-09-16 · Updated 2026-09-16