What it is
SSTI is when user input reaches the template rather than the data a template renders. A template engine is designed to run code — loops, conditionals, property access — so a user string that becomes template code is code execution, usually all the way to RCE.
Why you should care
The distinction to grasp immediately: SSTI differs from XSS in that the code runs on the SERVER. XSS runs in the victim's browser; SSTI runs in your process, with your process's privileges. So the outcome is not session theft but reading files, environment variables (which hold secrets), and RCE.
Three things that make it more common than it looks:
- It always comes from concatenating into the template.
render("Hello " + name)instead ofrender("Hello {{name}}", {name}). A one-line difference, and it is the whole vulnerability. - It lives where the template is data. Marketing email a user composes with
{{firstName}}, a reporting system with custom templates, a customisable error page — anywhere the template is user-supplied. - Impact depends on the engine, and developers rarely know how powerful theirs is. Jinja2, Freemarker, Velocity and Twig expose almost the entire runtime. Razor compiles C# itself — a user-supplied Razor template is compiling their code.
And one fact about .NET: Razor is not a string-based template engine. You do not "render a string" in Razor the way you do in Jinja2 — but RazorLight and runtime Razor-rendering libraries do, and they turn SSTI into direct C# compilation. If you see a library rendering templates at runtime, that is where to look.
How the attack works
The mechanism is a boundary confusion like SQL injection and command injection: user input lands on the CODE side of the template rather than the data side.
flowchart TD I["name = {{7*7}}"] --> C{Where does the input go?} C -->|"Concatenated into the template SOURCE<br/>render(#quot;Hi #quot; + name)"| T1["The engine EVALUATES {{7*7}}"] T1 --> E["Returns #quot;Hi 49#quot; → SSTI confirmed"] E --> R["{{config.__class__.__init__...}}<br/>→ read files, env vars, RCE"] C -->|"Passed as DATA<br/>render(#quot;Hi {{name}}#quot;, name=name)"| T2["The engine renders name<br/>as an ordinary string"] T2 --> G["Returns #quot;Hi {{7*7}}#quot; verbatim"]The key point: {{7*7}} returns 49 only when the input is in the template source. If it is passed-in data, the engine prints it verbatim. This is also how SSTI is detected — send {{7*7}} and see whether the response contains 49 (each engine has its own syntax: ${7*7}, #{7*7}, <%= 7*7 %>).
From "can evaluate an expression" to RCE is a chain of access through already-present objects. In Jinja2, the classic chain climbs from any object up to subprocess:
{{ ''.__class__.__mro__[1].__subclasses__() }} → enumerate every class{{ ...Popen('id', shell=True, stdout=-1).communicate() }} → RCEA detection table by engine — the same payload, different syntax:
| Engine | Probe payload | Result if SSTI |
|---|---|---|
| Jinja2, Django, Twig | {{7*7}} | 49 |
| Freemarker, Velocity | ${7*7} or #{7*7} | 49 |
| Razor (RazorLight) | @(7*7) | 49 |
| ERB, EJS | <%= 7*7 %> | 49 |
| Handlebars, Mustache | {{7*7}} | {{7*7}} (logic-less — safer) |
The last row matters: logic-less template engines (Mustache, and Handlebars in its default mode) do not evaluate expressions, so they have no RCE-style SSTI. Choosing such an engine when the template is data is one of the defences in block 6.
Diagram description: A branching diagram for the input name equal to {{7*7}}. The wrong branch concatenates the input into the template source, so the engine evaluates {{7*7}} and returns "Hi 49" — the signature confirming SSTI, from which a longer payload reads files, environment variables or runs commands. The right branch passes name as data into a template with a {{name}} placeholder, so the engine renders name as an ordinary string and returns "Hi {{7*7}}" verbatim.
Concrete example
A marketing email feature: a user composes a template with {{firstName}}, the system renders it per recipient. The template is data — that is why the feature exists, and why it is dangerous.
# ① Probe. Send {{7*7}} and inspect the response.POST /api/campaigns/preview HTTP/1.1{"template":"Hello {{7*7}}"} HTTP/1.1 200 OK{"preview":"Hello 49"} ← 49, not {{7*7}} → SSTI confirmed# ② Read secrets from environment variables (Jinja2).{"template":"{{ cycler.__init__.__globals__.os.environ }}"} HTTP/1.1 200 OK{"preview":"{'DATABASE_URL': 'postgres://user:S3cr...', 'JWT_SECRET': '...'}"}# ③ RCE (Jinja2). The chain climbs from any object up to subprocess.{"template":"{{ ''.__class__.__mro__[1].__subclasses__()[...]('id',shell=True,stdout=-1).communicate() }}"} {"preview":"(b'uid=1000(app) gid=1000(app)\n', None)"}# After the fix: the template is FIXED, the input is only data.POST /api/campaigns/preview HTTP/1.1{"template":"Hello {{7*7}}","data":{"firstName":"Alice"}} HTTP/1.1 200 OK{"preview":"Hello {{7*7}}"} ← {{7*7}} printed verbatim; only {{firstName}} is substitutedusing RazorLight; public sealed class ReportRenderer{ private readonly IRazorLightEngine _engine = new RazorLightEngineBuilder() .UseMemoryCachingProvider().Build(); public async Task<string> RenderAsync(string userTemplate, ReportData data) { // ❌ RazorLight biên dịch template thành C# rồi CHẠY nó. Một userTemplate là // @{ System.Diagnostics.Process.Start("cmd","/c ..."); } là RCE TRỰC TIẾP — // không cần chuỗi gadget như Jinja2, vì Razor vốn là C#. // // "Chúng ta dùng .NET nên an toàn với SSTI" đúng cho Razor tĩnh (.cshtml // trong repo), và SAI cho mọi API render chuỗi runtime. return await _engine.CompileRenderStringAsync("report", userTemplate, data); }}from flask import requestfrom jinja2 import Template @app.post("/api/campaigns/preview")def preview(): template_text = request.json["template"] # do người dùng soạn # ❌ Input NGƯỜI DÙNG trở thành NGUỒN template. Jinja2 được thiết kế để chạy # code, nên {{7*7}} thành 49, và {{ ''.__class__.__mro__[1].__subclasses__() # ...Popen('id',shell=True)... }} là RCE trên SERVER — không phải XSS ở # trình duyệt. # # Khác biệt với bản vá đúng là chỗ NÀO chuỗi người dùng nằm: nguồn hay dữ liệu. result = Template(template_text).render(firstName="Alice") return {"preview": result}What happened in the wild
Uber, 2016 (Orange Tsai) — SSTI in Jinja2. A feature let input reach a Flask template, and the {{ ... __subclasses__ ... }} chain reached RCE. It is one of the reports that made SSTI well known, and it shows the chain from "can evaluate 7*7" to RCE is shorter than people expect.
James Kettle's research (PortSwigger, 2015) — "Server-Side Template Injection". The paper that defined the class and showed it appears in Freemarker, Velocity, Smarty, Jinja2 and Twig — each engine its own syntax, one shared cause: user input entering the template source. It is the source of the detection table in block 3.
And a recurring shape in .NET: RazorLight and runtime Razor-rendering libraries. Many reports describe a "custom email template" or "customisable report" feature using RazorLight.CompileRenderStringAsync on a user-supplied template — and because Razor compiles C#, that is direct RCE, with none of Jinja2's gadget chain needed.
How to defend
The template is a constant in code; user input is only DATA
mandatoryThis is the fix for 95% of cases, and it is one line — the same shape as the SQL injection and command injection fixes: keep the input on the data side of the boundary.
# WRONG — input concatenated into the template sourceTemplate("Hello " + name).render()# RIGHT — a fixed template, the input a passed-in variableTemplate("Hello {{ name }}").render(name=name)The principle: the template source must be a string constant in your code, or a file in your repo — never a value from a request, from user-written DB rows, or a parameter. If the template is a constant there is no path for input to become template code.
And autoescaping must be ON (the default in Jinja2 for .html, and in Django): it closes XSS at the output, a different hole from SSTI but the same rendering feature.
// ── Đường 1 · template TĨNH trong repo, dữ liệu qua model ─────────────────────// Razor tĩnh an toàn: template là một file .cshtml trong repo của bạn, không đến// từ request. Người dùng chỉ cung cấp DỮ LIỆU, và nó vào model.public sealed class ReportRenderer(IRazorLightEngine engine){ public Task<string> RenderAsync(ReportData data) => // "report" là KEY của một template compile sẵn từ file trong repo, KHÔNG // phải một chuỗi do người dùng cung cấp. {{...}} trong data render như dữ liệu. engine.CompileRenderAsync("Templates.Report", data);} // ── Đường 2 · template do NGƯỜI DÙNG cung cấp → engine logic-less ─────────────using HandlebarsDotNet; public sealed class UserTemplateRenderer{ // Handlebars là logic-less: nó thay placeholder và chạy các helper ĐÃ ĐĂNG KÝ, // không đánh giá biểu thức C# tuỳ ý. {{7*7}} → "{{7*7}}", không có Process.Start. private readonly IHandlebars _hb; public UserTemplateRenderer() { _hb = Handlebars.Create(new HandlebarsConfiguration { // Không đăng ký helper nào cho phép truy cập kiểu/phương thức .NET. // Danh sách helper là ALLOWLIST những gì template người dùng làm được. NoEscape = false, // autoescape BẬT — đóng XSS ở đầu ra }); } public string Render(string userTemplate, IReadOnlyDictionary<string, string> allowedVars) { var template = _hb.Compile(userTemplate); // allowedVars đã được caller lọc xuống đúng các trường được phép, nên một // {{internalScore}} không có gì để in. return template(allowedVars); }}from jinja2 import Environment, select_autoescapeimport chevron # Mustache — logic-less # ── Đường 1 · template CỐ ĐỊNH, input chỉ là dữ liệu (95% trường hợp) ──────────# Cùng hình dạng bản vá với SQL injection và command injection: giữ input ở phía# DỮ LIỆU của ranh giới. Template là hằng trong code, không đến từ request._env = Environment(autoescape=select_autoescape(["html", "xml"]))_WELCOME = _env.from_string("Xin chào {{ first_name }}, đơn {{ order_id }} đã xác nhận.") def render_welcome(first_name: str, order_id: str) -> str: # {{7*7}} trong first_name in ra NGUYÊN VĂN, vì nó là dữ liệu truyền vào một # template cố định — engine không đánh giá nó. return _WELCOME.render(first_name=first_name, order_id=order_id) # ── Đường 2 · template BẮT BUỘC là dữ liệu (email marketing) ──────────────────# Ở đây lớp 1 không áp dụng được — tính năng tồn tại CHÍNH VÌ template do người# dùng soạn. Câu trả lời là ĐỔI ENGINE, không phải sanitize một engine mạnh.## Mustache (chevron) là logic-less: nó chỉ thay placeholder và lặp trên dữ liệu.# Không có đường nào để {{7*7}} thành 49, nên không có SSTI dạng RCE.## Đánh đổi phải nói rõ: người dùng mất khả năng viết logic. Với email marketing# đó thường là điều ta MUỐN — {{firstName}} và vài vòng lặp là đủ, và ta không# muốn người dùng chạy code trên server của mình.ALLOWED_VARS = {"firstName", "lastName", "orderId", "unsubscribeUrl"} def render_user_template(template_text: str, recipient: dict) -> str: # Chỉ truyền đúng các biến được phép — recipient có thể chứa cả trường nội bộ, # và một template logic-less vẫn in ra được mọi biến ta đưa vào context. data = {k: recipient.get(k, "") for k in ALLOWED_VARS} # chevron không đánh giá biểu thức. {{7*7}} → "{{7*7}}", {{os.environ}} → # rỗng (không có biến tên đó trong data). Không có __class__, không có RCE. return chevron.render(template_text, data)When the template genuinely must be data: use a logic-less engine
mandatorySome features genuinely need a user-supplied template — marketing email, custom reports. There layer 1 does not apply, and the answer is to change the engine, not to sanitise.
A logic-less engine (Mustache, Handlebars in its default mode) does not evaluate expressions — it only substitutes placeholders and loops over the data you supply. There is no path for {{7*7}} to become 49, so there is no RCE-style SSTI. See the last row of the block 3 table.
State the trade-off: users lose the ability to write logic in the template. For marketing email that is usually what you WANT — {{firstName}} and a couple of loops is enough, and you do not want users running code. If they genuinely need logic, expose an allowlisted set of functions you define, not the whole language.
Do not write your own template engine, and do not write a sanitiser for a powerful one: a blocklist of __class__, __globals__, {{, }} is a race you lose — the same reasoning as blocklists in SQL injection and command injection.
Sandbox the engine if you must allow logic
When neither layer 1 nor 1b is enough (users genuinely need rich template logic), run the engine in a sandboxed mode — but understand this is the weakest layer, and template sandbox bypasses are their own family of CVEs.
- Jinja2:
SandboxedEnvironment. It blocks access to attributes beginning with_, so the__class__.__mro__chain is blocked. But bypasses through other paths have existed, so the version must be current. - Freemarker:
TemplateClassResolver.SAFER_RESOLVERto block?newand class access.
A sandbox is layer 1c rather than layer 1 because it assumes the correct list of what to block — and each new bypass is a place that list was short. It buys time and reduces the surface, not a closed fix. If you choose it, track the engine's CVEs like a real dependency (see the dependencies-sbom topic).
Bound the damage when SSTI becomes RCE
This layer catches what layer 1 will miss: a bypassed sandbox, or a powerful engine used where nobody thought a template was involved. The same controls as command-injection and deserialization layer 2, because the outcome is the same — RCE:
- A non-root user,
readOnlyRootFilesystem: true, dropped capabilities, seccomp. - Closed egress — SSTI is commonly used to read the metadata service and exfiltrate; see ssrf layer 2.
- Secrets not in the render process's environment variables. The
{{ os.environ }}payload in block 4 leaks secrets because the secrets are right there. If the template renders in a separate worker holding no credentials,os.environreturns nothing.
And one control specific to this topic: render templates in an isolated process or worker. Marketing email rendered in a background job with no DB connection, no secrets, no egress — means even RCE runs in an empty sandbox.
Detection: template syntax in input is a low-noise signal
In most applications users do not type {{, ${, #{, <%= into an ordinary field. So their appearance in an input that is NOT a template field is a probing signal.
- Log when an input (other than the template-composing field itself) contains
{{,}},${,#{,<%, or the strings__class__,__globals__,__subclasses__,.constructor. That last one appears almost exclusively in an SSTI or prototype-pollution payload. - Pay particular attention to
{{7*7}},{{7*'7'}},${7*7}— these are the classic probe payloads, and a request containing them is an attempt, not a misconfigured client.
Layer 3 because it blocks nothing, but for SSTI it is especially useful: the probe payload ({{7*7}}) always comes BEFORE the RCE payload, so detecting the probe is detection before the damage.
Verifying the fix
1. A test taking the DETECTION TABLE from block 3 as input data. Send each engine's probe payload and assert the response contains the payload verbatim, not its evaluated result (49). See the python / test tab.
2. A merge-blocking grep for concatenation into the template source — the highest-yield check:
# Python — Template/render with an f-string or concatenationgrep -rnE '(Template|from_string|render_template_string)\s*\(\s*(f["\x27]|[^)]*\+)' \ --include='*.py' src/ && { echo "template built from a dynamic string"; exit 1; } # .NET — RazorLight and runtime render APIs with a stringgrep -rnE 'CompileRenderStringAsync|RazorLight|Handlebars\.Compile\(' \ --include='*.cs' src/ | grep -iE 'request|input|user|body' \ && { echo "runtime template render with user input"; exit 1; }exit 03. Probe end-to-end against staging — every engine's probe payload, since you may not know which engine runs where:
B=https://staging.example.comfor p in '{{7*7}}' '${7*7}' '#{7*7}' '<%= 7*7 %>' '@(7*7)'; do r=$(curl -s -X POST "$B/api/campaigns/preview" \ -H 'Content-Type: application/json' \ -d "{\"template\":\"probe $p end\"}") echo "$r" | grep -q 'probe 49 end' && { echo "SSTI with payload: $p"; exit 1; }doneexit 04. Check secrets are not readable from the render process (layer 2) — the {{ os.environ }} payload in block 4 only leaks secrets when they live in that process:
# In the template render worker, os.environ must NOT contain production secrets.docker exec render-worker printenv | grep -iE 'SECRET|PASSWORD|KEY|TOKEN|DATABASE_URL' \ && { echo "the render worker has secrets in its environment"; exit 1; }exit 05. If using a logic-less engine: assert it does NOT evaluate expressions. This check proves the layer-1b engine choice is right — a unit test sending {{7*7}} into Mustache and asserting the output is {{7*7}}.
import pytest class TestNoSsti: """Mỗi payload là một hàng của bảng phát hiện ở khối 3. Khẳng định quan trọng: kết quả chứa payload NGUYÊN VĂN, KHÔNG chứa 49. Một test kiểm "không throw" sẽ xanh trên cả code lỗi — code lỗi render {{7*7}} thành 49 một cách vui vẻ, không có exception nào. """ @pytest.mark.parametrize("payload", [ "{{7*7}}", # Jinja2, Django, Twig "{{7*'7'}}", # biến thể — Jinja2 ra '7777777', Twig ra 49 "49", # Freemarker, Velocity "#{7*7}", # Velocity, một số engine JVM "<%= 7*7 %>", # ERB, EJS "{{ ''.__class__ }}", # bước đầu của chuỗi RCE Jinja2 "{{ config }}", # Flask — rò toàn bộ config nếu SSTI "{{ cycler.__init__.__globals__ }}", # đường tới os trong Jinja2 ]) def test_fixed_template_treats_payload_as_data(self, payload): # Đường 1: payload đi vào first_name, một biến DỮ LIỆU của một template cố định. result = render_welcome(first_name=payload, order_id="1042") # Payload xuất hiện NGUYÊN VĂN... assert payload in result # ...và KHÔNG bị đánh giá. assert "49" not in result assert "7777777" not in result @pytest.mark.parametrize("payload", ["{{7*7}}", "49", "{{ config }}"]) def test_logic_less_engine_does_not_evaluate(self, payload): # Đường 2: template do người dùng cung cấp, render bằng Mustache. result = render_user_template( template_text=f"Xin chào {{{{firstName}}}}, thử {payload} xong", recipient={"firstName": "Alice", "internalScore": 999}, ) assert "Xin chào Alice" in result # placeholder hợp lệ vẫn hoạt động assert payload in result # payload in nguyên văn assert "49" not in result # Và biến nội bộ KHÔNG rò ra dù người dùng thử {{internalScore}}: assert "999" not in render_user_template("{{internalScore}}", {"firstName": "A", "internalScore": 999}) def test_environ_is_not_reachable(self): """Payload {{os.environ}} ở khối 4 chỉ rò secret khi engine đánh giá được biểu thức VÀ secret nằm trong process. Cả hai đều không đúng ở đây.""" result = render_user_template("{{os.environ}}", {"firstName": "A"}) assert "os.environ" not in result or "{{os.environ}}" in result assert "SECRET" not in result assert "postgres://" not in resultCommon mistakes
| The "fix" | Why it is wrong |
|---|---|
Blocklist {{, }}, __class__ | Other encodings, other engine syntaxes (${}, #{}), and new gadget chains. Same reason blocklists lose in SQL injection |
| Escape the HTML output | Closes XSS, NOT SSTI: the code already ran on the server BEFORE there was output to escape |
| Sandbox the engine and call it done | Sandbox bypasses are their own family of CVEs. It is layer 1c, not layer 1 — track the version |
| Write your own "simple" template engine | An engine useful enough to have logic is powerful enough to have SSTI |
render_template_string(f"...{user}...") | The f-string concatenates input into the template SOURCE. That is the vulnerability, in newer syntax |
| Believing "we use Razor so we are safe" | Static Razor is safe, but RazorLight rendering runtime strings compiles C# — direct RCE |
| Treating SSTI as a kind of XSS | XSS runs in the browser, SSTI runs on the SERVER. The outcome is RCE and secret disclosure, not session theft |
The classification mistake, and it is the central one: labelling SSTI "medium" like an XSS. It runs on the server, so the {{ os.environ }} payload reads every secret and the __subclasses__ chain is RCE. It belongs in the same tier as command injection and deserialization, not with XSS.
The scoping mistake: hunting on "the template rendering page". The real bug lives in marketing email, custom reports, a display name reaching a notification template, or a library building PDFs from an HTML template. The way to find it is to grep the template render APIs, not the word "template".
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…