Là gì
SSTI là khi input người dùng đi vào template thay vì vào dữ liệu mà template render. Template engine được thiết kế để chạy code — vòng lặp, điều kiện, gọi thuộc tính — nên một chuỗi người dùng trở thành mã template là quyền chạy code, thường tới tận RCE.
Vì sao bạn quan tâm
Điểm phân biệt phải nắm ngay: SSTI khác XSS ở chỗ code chạy trên SERVER. XSS chạy trong trình duyệt nạn nhân; SSTI chạy trong process của bạn, với quyền của process đó. Nên hậu quả không phải đánh cắp phiên mà là đọc file, biến môi trường (chứa secret), và RCE.
Ba điều làm nó phổ biến hơn vẻ ngoài:
- Nó luôn đến từ việc nối chuỗi vào template.
render("Xin chào " + name)thay vìrender("Xin chào {{name}}", {name}). Khác biệt một dòng, và nó là toàn bộ lỗ hổng. - Nó nằm ở chỗ template là dữ liệu. Email marketing cho người dùng tự soạn với
{{firstName}}, một hệ thống báo cáo có template tuỳ biến, một trang lỗi tuỳ chỉnh — bất cứ đâu template do người dùng cung cấp. - Mức độ phụ thuộc engine, và developer thường không biết engine của mình mạnh thế nào. Jinja2, Freemarker, Velocity, Twig cho truy cập gần như toàn bộ runtime. Razor thì biên dịch cả C# — một template Razor do người dùng cung cấp là biên dịch code của họ.
Và một sự thật về .NET: Razor không phải template engine dạng chuỗi. Bạn không "render một chuỗi" trong Razor như trong Jinja2 — nhưng RazorLight và các thư viện render Razor động thì có, và chúng biến SSTI thành biên dịch C# trực tiếp. Nếu bạn thấy một thư viện render template runtime, đó là chỗ cần nhìn.
Cơ chế hoạt động
Cơ chế là một sự nhầm lẫn về ranh giới giống SQL injection và command injection: input người dùng nằm ở phía CODE của template thay vì phía dữ liệu.
flowchart TD I["name = {{7*7}}"] --> C{Input đi vào đâu?} C -->|"Nối vào NGUỒN template<br/>render(#quot;Hi #quot; + name)"| T1["Template engine ĐÁNH GIÁ {{7*7}}"] T1 --> E["Trả về #quot;Hi 49#quot; → xác nhận SSTI"] E --> R["{{config.__class__.__init__...}}<br/>→ đọc file, biến môi trường, RCE"] C -->|"Truyền như DỮ LIỆU<br/>render(#quot;Hi {{name}}#quot;, name=name)"| T2["Engine render name<br/>như một chuỗi thường"] T2 --> G["Trả về #quot;Hi {{7*7}}#quot; nguyên văn"]Điểm cốt lõi: {{7*7}} trả về 49 chỉ khi input nằm ở nguồn template. Nếu nó là dữ liệu truyền vào, engine in nó ra nguyên văn. Đây cũng là cách phát hiện SSTI — gửi {{7*7}} và xem response có 49 không (mỗi engine có cú pháp riêng: ${7*7}, #{7*7}, <%= 7*7 %>).
Từ "đánh giá được biểu thức" tới RCE là một chuỗi truy cập object có sẵn. Trong Jinja2, chuỗi kinh điển đi từ một object bất kỳ lên tới subprocess:
{{ ''.__class__.__mro__[1].__subclasses__() }} → liệt kê mọi class{{ ...Popen('id', shell=True, stdout=-1).communicate() }} → RCEBảng phát hiện theo engine — cùng một payload, cú pháp khác:
| Engine | Payload dò | Kết quả nếu SSTI |
|---|---|---|
| Jinja2, Django, Twig | {{7*7}} | 49 |
| Freemarker, Velocity | ${7*7} hoặc #{7*7} | 49 |
| Razor (RazorLight) | @(7*7) | 49 |
| ERB, EJS | <%= 7*7 %> | 49 |
| Handlebars, Mustache | {{7*7}} | {{7*7}} (logic-less — an toàn hơn) |
Hàng cuối đáng biết: template engine logic-less (Mustache, và Handlebars ở chế độ mặc định) không đánh giá biểu thức, nên chúng không có SSTI dạng RCE. Chọn một engine như vậy khi template là dữ liệu là một biện pháp phòng chống ở khối 6.
Mô tả sơ đồ: Sơ đồ nhánh cho input name bằng {{7*7}}. Nhánh sai nối input vào nguồn của template, nên template engine đánh giá {{7*7}} và trả về "Hi 49" — dấu hiệu xác nhận SSTI, và từ đó một payload dài hơn đọc file, biến môi trường hoặc chạy lệnh. Nhánh đúng truyền name như dữ liệu vào template có sẵn placeholder {{name}}, nên engine render name như một chuỗi thường và trả về "Hi {{7*7}}" nguyên văn.
Ví dụ cụ thể
Một tính năng email marketing: người dùng soạn template có {{firstName}}, hệ thống render cho mỗi người nhận. Template là dữ liệu — đó là lý do tính năng tồn tại, và cũng là lý do nó nguy hiểm.
# ① Dò. Gửi {{7*7}} và xem response.POST /api/campaigns/preview HTTP/1.1{"template":"Xin chào {{7*7}}"} HTTP/1.1 200 OK{"preview":"Xin chào 49"} ← 49, không phải {{7*7}} → xác nhận SSTI# ② Đọc secret từ biến môi trường (Jinja2).{"template":"{{ cycler.__init__.__globals__.os.environ }}"} HTTP/1.1 200 OK{"preview":"{'DATABASE_URL': 'postgres://user:S3cr...', 'JWT_SECRET': '...'}"}# ③ RCE (Jinja2). Chuỗi leo từ một object bất kỳ lên subprocess.{"template":"{{ ''.__class__.__mro__[1].__subclasses__()[...]('id',shell=True,stdout=-1).communicate() }}"} {"preview":"(b'uid=1000(app) gid=1000(app)\n', None)"}# Sau khi vá: template là template CỐ ĐỊNH, input chỉ là dữ liệu.POST /api/campaigns/preview HTTP/1.1{"template":"Xin chào {{7*7}}","data":{"firstName":"Alice"}} HTTP/1.1 200 OK{"preview":"Xin chào {{7*7}}"} ← {{7*7}} in ra nguyên văn; chỉ {{firstName}} được thayusing 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}Chuyện đã xảy ra
Uber, 2016 (Orange Tsai) — SSTI trong Jinja2. Một tính năng cho phép input đi vào template Flask, và chuỗi {{ ... __subclasses__ ... }} dẫn tới RCE. Đây là một trong những báo cáo làm SSTI trở nên nổi tiếng, và nó cho thấy chuỗi từ "đánh giá được 7*7" tới RCE ngắn hơn người ta tưởng.
Nghiên cứu của James Kettle (PortSwigger, 2015) — "Server-Side Template Injection". Bài báo định nghĩa lớp lỗ hổng này và cho thấy nó xuất hiện trong Freemarker, Velocity, Smarty, Jinja2, Twig — mỗi engine một cú pháp, cùng một nguyên nhân: input người dùng đi vào nguồn template. Đây là nguồn của bảng phát hiện ở khối 3.
Và một dạng lặp lại trong .NET: RazorLight và các thư viện render Razor runtime. Nhiều báo cáo mô tả một tính năng "template email tuỳ chỉnh" hoặc "báo cáo tuỳ biến" dùng RazorLight.CompileRenderStringAsync với template do người dùng cung cấp — và vì Razor biên dịch C#, đó là RCE trực tiếp, không cần chuỗi gadget nào như Jinja2.
Cách phòng chống
Template là hằng số trong code; input người dùng chỉ là DỮ LIỆU
bắt buộcĐây là bản vá cho 95% trường hợp, và nó là một dòng — cùng hình dạng với bản vá SQL injection và command injection: giữ input ở phía dữ liệu của ranh giới.
# SAI — input nối vào nguồn templateTemplate("Xin chào " + name).render()# ĐÚNG — template cố định, input là biến truyền vàoTemplate("Xin chào {{ name }}").render(name=name)Nguyên tắc: nguồn template phải là một hằng chuỗi trong code của bạn, hoặc một file trong repo của bạn — không bao giờ là một giá trị đến từ request, DB do người dùng ghi, hay một tham số. Nếu template là một hằng thì không có đường nào để input trở thành mã template.
Và autoescape phải BẬT (mặc định trong Jinja2 với .html, và trong Django): nó đóng XSS ở đầu ra, một lỗ khác với SSTI nhưng cùng một tính năng render.
// ── Đườ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)Khi template BẮT BUỘC là dữ liệu: dùng engine logic-less
bắt buộcMột số tính năng thật sự cần template do người dùng cung cấp — email marketing, báo cáo tuỳ biến. Ở đó lớp 1 không áp dụng được, và câu trả lời là đổi engine, không phải sanitize.
Engine logic-less (Mustache, Handlebars ở chế độ mặc định) không đánh giá biểu thức — chúng chỉ thay placeholder và chạy vòng lặp trên dữ liệu bạn cung cấp. Không có đường nào để {{7*7}} thành 49, nên không có SSTI dạng RCE. Xem hàng cuối bảng ở khối 3.
Đánh đổi phải nói rõ: người dùng mất khả năng viết logic trong template. Với email marketing thì đó thường là điều bạn MUỐN — {{firstName}} và một vài vòng lặp là đủ, và bạn không muốn người dùng chạy code. Nếu họ thật sự cần logic, cung cấp một tập hàm allowlist do bạn định nghĩa, không phải toàn bộ ngôn ngữ.
Không tự viết engine template và không tự viết sanitizer cho một engine mạnh: blocklist __class__, __globals__, {{, }} là một cuộc đua bạn thua — cùng lý do như blocklist ở SQL injection và command injection.
Sandbox engine nếu buộc phải cho phép logic
Khi cả lớp 1 lẫn 1b đều không đủ (người dùng thật sự cần logic template phong phú), chạy engine trong chế độ sandbox — nhưng hiểu rằng đây là lớp yếu nhất, và các bypass của sandbox template là một họ CVE riêng.
- Jinja2:
SandboxedEnvironment. Nó chặn truy cập thuộc tính bắt đầu bằng_, nên chuỗi__class__.__mro__bị chặn. Nhưng đã có bypass qua các đường khác, nên phiên bản phải mới. - Freemarker:
TemplateClassResolver.SAFER_RESOLVERđể chặn?newvà truy cập class.
Sandbox là lớp 1c chứ không phải lớp 1 vì nó giả định đúng danh sách những gì cần chặn — và mỗi bypass mới là một chỗ danh sách đó thiếu. Nó mua thêm thời gian và giảm bề mặt, không phải một bản vá kín. Nếu chọn nó, phải theo dõi CVE của engine như một dependency thật (xem topic dependencies-sbom).
Giới hạn thiệt hại khi SSTI thành RCE
Lớp này bắt đúng cái lớp 1 sẽ bỏ sót: một sandbox bị bypass, hay một engine mạnh dùng ở chỗ không ai coi là template. Cùng bộ biện pháp như command-injection và deserialization lớp 2, vì hậu quả giống nhau — RCE:
- User không phải root,
readOnlyRootFilesystem: true, drop capability, seccomp. - Egress đóng — SSTI thường được dùng để đọc metadata service và đẩy dữ liệu ra ngoài; xem topic ssrf lớp 2.
- Secret không nằm trong biến môi trường của process render. Payload
{{ os.environ }}ở khối 4 rò secret vì secret nằm ngay đó. Nếu template được render trong một worker riêng không có credential nào thìos.environtrả về rỗng.
Và một biện pháp riêng của topic này: render template trong một process/worker cách ly. Email marketing render trong một job nền không có DB connection, không có secret, không có egress — thì kể cả RCE cũng chạy trong một sandbox rỗng.
Phát hiện: cú pháp template trong input là tín hiệu độ nhiễu thấp
Ở phần lớn ứng dụng, người dùng không gõ {{, ${, #{, <%= vào một trường bình thường. Nên sự xuất hiện của chúng ở một input KHÔNG phải trường template là một tín hiệu dò.
- Log khi một input (ngoài chính trường soạn template) chứa
{{,}},${,#{,<%, hoặc chuỗi__class__,__globals__,__subclasses__,.constructor. Chuỗi cuối gần như chỉ xuất hiện trong một payload SSTI/prototype-pollution. - Đặc biệt chú ý
{{7*7}},{{7*'7'}},${7*7}— đây là payload dò kinh điển, và một request chứa chúng là một lần thử, không phải một client cấu hình sai.
Đây là lớp 3 vì nó không chặn gì, nhưng với SSTI nó đặc biệt hữu ích: payload dò ({{7*7}}) luôn đến TRƯỚC payload RCE, nên phát hiện lần dò là phát hiện trước khi có thiệt hại.
Kiểm chứng đã vá
1. Test lấy BẢNG PHÁT HIỆN ở khối 3 làm dữ liệu vào. Gửi payload dò của mỗi engine và khẳng định response chứa payload nguyên văn, không phải kết quả đánh giá (49). Xem tab python / test.
2. Grep chặn merge cho việc nối chuỗi vào nguồn template — phép kiểm có tỉ lệ cao nhất:
# Python — Template/render với chuỗi f hoặc phép nốigrep -rnE '(Template|from_string|render_template_string)\s*\(\s*(f["\x27]|[^)]*\+)' \ --include='*.py' src/ && { echo "template dựng từ chuỗi động"; exit 1; } # .NET — RazorLight và các API render runtime với chuỗigrep -rnE 'CompileRenderStringAsync|RazorLight|Handlebars\.Compile\(' \ --include='*.cs' src/ | grep -iE 'request|input|user|body' \ && { echo "render template runtime với input người dùng"; exit 1; }exit 03. Thử dò end-to-end trên staging — payload dò của mọi engine, vì bạn có thể không biết engine nào đang chạy ở đâu:
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 với payload: $p"; exit 1; }doneexit 04. Kiểm secret không đọc được từ process render (lớp 2) — payload {{ os.environ }} ở khối 4 chỉ rò secret khi secret nằm trong process đó:
# Trong worker render template, os.environ KHÔNG được chứa secret sản phẩm.docker exec render-worker printenv | grep -iE 'SECRET|PASSWORD|KEY|TOKEN|DATABASE_URL' \ && { echo "worker render có secret trong biến môi trường"; exit 1; }exit 05. Nếu dùng logic-less engine: khẳng định nó KHÔNG đánh giá biểu thức. Đây là phép kiểm chứng minh lựa chọn engine ở lớp 1b đúng — một unit test gửi {{7*7}} vào Mustache và khẳng định đầu ra là {{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 resultSai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
Blocklist {{, }}, __class__ | Encoding khác, cú pháp engine khác (${}, #{}), và chuỗi gadget mới. Cùng lý do blocklist thua ở SQL injection |
| Escape HTML đầu ra | Đóng XSS, KHÔNG đóng SSTI: code đã chạy trên server TRƯỚC khi có đầu ra để escape |
| Sandbox engine rồi coi là xong | Sandbox bypass là một họ CVE riêng. Nó là lớp 1c, không phải lớp 1 — cần theo dõi phiên bản |
| Tự viết engine template "đơn giản" | Một engine đủ hữu dụng để có logic là một engine đủ mạnh để có SSTI |
render_template_string(f"...{user}...") | f-string nối input vào NGUỒN template. Đây chính là lỗ hổng, viết bằng cú pháp mới |
| Tin rằng "chúng ta dùng Razor nên an toàn" | Razor tĩnh thì an toàn, nhưng RazorLight render chuỗi runtime biên dịch C# — RCE trực tiếp |
| Coi SSTI là một dạng XSS | XSS chạy ở trình duyệt, SSTI chạy ở SERVER. Hậu quả là RCE, đọc secret, không phải đánh cắp phiên |
Sai lầm về phân loại, và nó là sai lầm chính: gắn nhãn SSTI là "medium" như một XSS. Nó chạy trên server, nên payload {{ os.environ }} đọc mọi secret và chuỗi __subclasses__ là RCE. Nó thuộc cùng hạng với command injection và deserialization, không cùng hạng với XSS.
Sai lầm về phạm vi: đi tìm ở "trang render template". Lỗi thật nằm ở email marketing, báo cáo tuỳ biến, tên hiển thị đi vào một template thông báo, hay một thư viện tạo PDF từ HTML template. Cách tìm là grep API render template, không grep chữ "template".
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…