--- name: hunt-ssrf description: Hunting skill for ssrf vulnerabilities. Built from 15 public bug bounty reports including AWS metadata SSRF (HackerOne $25k Analytics PDF, Shopify Exchange $25k, Capital One 106M-record breach, Dropbox/HelloSign $4,913), GCP metadata SSRF (Snapchat $4k), Azure IMDS SSRF (Azure DevOps $15k chain, ChatGPT Custom Actions MSRC), DNS rebinding SSRF (Concrete CMS, GitLab UrlBlocker), gopher-protocol-to-Redis-RCE (Yahoo Mail $15k), link-preview SSRF (Reddit Matrix $6k), and headless-browser PDF-generator SSRF chains. Use when hunting SSRF on any target — OOB Collaborator confirmation mandatory for blind cases. sources: github, hackerone_public, portswigger_research, binarysecurity_research report_count: 34 --- ## Crown Jewel Targets SSRF is highest-value when the target runs on cloud infrastructure (AWS, GCP, Azure) where metadata services expose credentials, or when the server sits inside a complex internal network (Kubernetes clusters, microservice meshes, internal APIs). Priority targets: - **Cloud-hosted SaaS products** (GCP metadata at `169.254.169.254` or `metadata.google.internal`, AWS IMDSv1) - **Kubernetes/orchestration platforms** — aggregated API servers, metrics-server, kubelet endpoints expose privileged cluster operations - **Internal developer tooling** — CI/CD, workflow orchestration (Flyte, Argo), admin panels not exposed externally - **Link preview / URL fetching features** — Reddit-style preview APIs, Slack-style unfurling, media processors - **Dataset/file import pipelines** — anything that fetches remote URLs on behalf of a user - **Enterprise self-hosted software** (GitHub Enterprise, GitLab) — SSRF frequently chains to RCE via internal services Payouts are highest when SSRF reaches: cloud credentials → account takeover, internal admin APIs → data exfil, or chains to RCE. --- ## OOB-Or-It-Didn't-Happen Gate (Read First) **Claims of blind SSRF require an out-of-band (OOB) confirmation. Always. No exceptions.** OOB means: a Burp Collaborator domain, an `interactsh-client` listener, a canarytoken, or any DNS+HTTP receiver you control that confirms the server actually made an outbound network connection on your behalf. ### What is NOT confirmation of SSRF - The server **echoing your URL back in an error message**. Example: `"The Web application at http://evil.example.com/x could not be found"` — this is the server formatting your input into an error string, NOT making an outbound HTTP request. The error came from string formatting, not from network failure. - The server returning a different status code for an external URL vs `localhost`. Different error responses can come from URL-scheme validators, not from actual fetching. - A delayed response when the URL is sent. Delay can come from DNS resolution attempts within the parser, not from completed HTTP fetches. ### What IS confirmation of SSRF - A DNS lookup for your unique Collaborator subdomain appears in the OOB listener. - An HTTP request to your Collaborator HTTP endpoint with the server's source IP and User-Agent. - For SSRF in JavaScript-execution contexts (PDF renderers, headless browsers), a fetch from the server to your callback URL. ### Default workflow 1. **Plant the Collaborator payload first.** Sub-tagging (`dlsrcurl.`, `import.`) only works if your listener actually reports the queried subdomain back to you — **verify that before relying on it.** Burp's `get_collaborator_interactions` keys results by **payload ID, not by subdomain**, so several sub-tags generated from one payload are indistinguishable in the output. When that is the case, **generate a fresh payload per candidate parameter** and send exactly one request per payload. 2. **Send the request** to the target endpoint. 3. **Wait 30–120 seconds**, then poll the OOB listener. 4. **Only after a confirmed callback** do you claim SSRF. 5. If zero callbacks across all sub-tagged sinks: SSRF claims must be retracted, even if error messages echo URLs. **Lesson from a authorized engagement:** SharePoint's `/_layouts/15/download.aspx?SourceUrl=` returned 500 with the title `"The Web application at could not be found"`. Initial scan flagged this as SSRF (server clearly processed the URL). 38 Collaborator-tagged payloads across 12+ URL-accepting parameters yielded **zero DNS or HTTP interactions**. The "echo" was client-side error-string formatting; the server never made an outbound HTTP request. The path is actually an SP-internal `SPFile`/`SPWebApplication` resolver, not a generic URL fetcher. Reporting this as SSRF would have been N/A'd at triage. ### Attribute the callback to ONE parameter before reporting A callback proves the server made a request. It does **not** tell you which parameter caused it, and the fix depends entirely on that. ``` BAD — four candidate fields, one payload, fired in one batch -> callbacks arrive, attribution impossible, retest required GOOD — fresh payload per field, one request each, poll between url -> callbacks <- this is the sink apiUrl -> none endpoint -> none target -> none ``` **Run the negative control.** A parameter that produces *no* callback is evidence, and it belongs in the report — it is what lets the client fix the right field instead of allowlisting the wrong one. **Lesson from an authorized engagement.** A server-side request-forwarding endpoint accepted both `url` and `apiUrl`. The application's own stored config used `apiUrl`, so that was the obvious suspect — but `apiUrl` was inert and **`url` was the live sink**. Batch-firing both had produced callbacks with no attribution; only per-payload isolation identified the real parameter. A report naming `apiUrl` would have sent the client to patch a field that does nothing. ### Blind vs full-read — establish which before scoring After a callback confirms the request leaves the server, **check whether the upstream response body is returned to you.** These are different findings: - **Blind** (callback only, no body): on the never-submit list standalone. Needs an internal service reached, or data returned, to be reportable. - **Full-read** (upstream body in the response): substantially higher severity — read arbitrary internal endpoints directly. ```bash # one request settles it: fetch something with a known, recognisable body -d '{"url":"https://example.com/"}' # {"statusCode":200,"data":"...Example Domain..."} # ^ body returned = full-read, not blind ``` Also body-diff a known-internal target against a known-external one. A **distinct status** on a link-local address (e.g. `401` from `169.254.169.254` where every other target returns `200`) is the metadata service answering — that proves reach to a non-internet-routable address, which a status code alone otherwise cannot. --- ## Attack Surface Signals ### URL Patterns to Hunt ``` /api/*/preview /api/*/fetch /api/*/import /api/*/webhook /api/*/proxy /api/*/render /api/*/link /api/*/screenshot /api/*/export /api/*/validate ?url= ?uri= ?endpoint= ?redirect= ?src= ?source= ?feed= ?host= ?target= ?dest= ?file= ?path= ?callback= ?image= ?load= ?fetch= ``` ### JS Patterns (in client-side code) ```javascript // Look for these in JS bundles fetch(userInput) axios.get(params.url) XMLHttpRequest + variable URL url: req.body.url src: params.source href: query.endpoint ``` ### Response Header Signals ``` X-Forwarded-For headers echoed back Server: internal-service Via: 1.1 internal-proxy X-Cache headers revealing internal hostnames ``` ### Tech Stack Signals - **Kubernetes** — any public-facing aggregated API, metrics endpoints - **GCP** — any service fetching URLs that runs on Compute Engine/GKE - **Node.js/Python** with URL-fetching libraries (`requests`, `node-fetch`, `axios`) - **Headless browsers** (Puppeteer, PhantomJS) used for screenshots/PDF — extremely high value - **XML/DSPL/CSV import features** — XXE-style SSRF vector - **OAuth/webhook registration** endpoints --- ## Step-by-Step Hunting Methodology 1. **Map all URL-input parameters** across the target: spider JS files for fetch calls, check all API docs, look for file-import, link-preview, webhook, image-proxy, and redirect features. 2. **Set up an out-of-band detection server** using Burp Collaborator, interactsh, or `https://canarytokens.org` — you need a unique per-test DNS/HTTP callback domain. 3. **Send your callback URL as the parameter value first** (blind SSRF check before anything else): ``` url=https://YOUR.interactsh.com/test ``` Confirm the server makes an outbound connection. This proves execution before attempting internal targets. 4. **Test internal cloud metadata endpoints**: - GCP: `http://metadata.google.internal/computeMetadata/v1/` - AWS: `http://169.254.169.254/latest/meta-data/` - Azure: `http://169.254.169.254/metadata/instance` 5. **Test localhost and common internal ports**: ``` http://localhost/ http://127.0.0.1:8080/ http://127.0.0.1:6443/ (Kubernetes API) http://127.0.0.1:2379/ (etcd) http://127.0.0.1:9090/ (Prometheus) http://127.0.0.1:9200/ (Elasticsearch) ``` 6. **Check for redirect-based SSRF** — if the endpoint validates the initial URL but follows 30x redirects, host a redirect server pointing to internal addresses. Kubernetes report (Report 3) was specifically triggered by hijacked API servers returning 30x responses. 7. **Test JavaScript-execution contexts** (headless browsers, PDF renderers): - Inject `