CVE-2026-73530: reaching loopback through the IPv6 unspecified address
An SSRF guard bypass I reported in flyto-core: the denylist blocks 0.0.0.0 twice over and its IPv6 twin :: not at all — and the stack routes :: straight to the local host.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every request in it went to a marker service on my own loopback interface. The issue was reported privately through the project’s own security advisory process, and is written up here only after the advisory and the fixed release were public.
The flyto-core project published GHSA-gc4h-hj7x-gp5p — a Server-Side Request Forgery (CWE-918) guard bypass, rated High at CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N, 7.7. It is indexed as CVE-2026-73530. I reported it on 9 August; the advisory and the fix in 2.28.0 went out on the 13th.
The two records live in different places and neither links to the other, so both are worth citing: the GitHub advisory for the technical detail and the affected ranges, and the CVE record for the identifier your scanner or ticket is keyed on.
Upgrade to 2.28.0. Everything up to and including 2.27.0 is affected.
The bug is one address missing from a denylist. What makes it worth a write-up is where that denylist sits, and why three separate layers of a careful guard each had a good reason to let this particular address through.
Where the guard sits
Flyto2 Core is the open-source execution engine behind Flyto2 — a runtime for AI-agent and automation workflows, exposing several hundred modules that agents call as tools over MCP, or that run from YAML recipes. Three of those modules are http.get, http.request and http.batch: a caller hands over a URL, the engine fetches it and returns the response.
That is a Server-Side Request Forgery sink by construction, and the project treats it as one. Every one of those modules routes its URL through validate_url_ssrf in src/core/utils.py before a socket is opened, and the guard is the thing standing between a caller-supplied URL and whatever the host running the engine can reach on its own interfaces.
It matters who the caller is. In this deployment shape the URL frequently is not written by the operator: it arrives in a recipe, or it is chosen by a model calling the module as a tool, possibly influenced by a page that model just scraped. The guard exists precisely because that input is not trusted. So a bypass of it is not a step towards the impact — the bypass is the impact.
The two lists
The predicate at the centre of the guard is is_private_ip, and it works off a hand-maintained list of ranges:
PRIVATE_IP_RANGES = [
ipaddress.ip_network('10.0.0.0/8'), # RFC 1918 Class A
...
ipaddress.ip_network('127.0.0.0/8'), # Loopback
ipaddress.ip_network('169.254.0.0/16'), # Link-local
ipaddress.ip_network('0.0.0.0/8'), # Current network <-- IPv4 covered
...
# IPv6
ipaddress.ip_network('::1/128'), # Loopback
ipaddress.ip_network('fc00::/7'), # Unique local
ipaddress.ip_network('fe80::/10'), # Link-local
ipaddress.ip_network('ff00::/8'), # Multicast
] # <-- no ::/128
A second layer denies hostnames by string:
BLOCKED_HOSTNAMES = {
'localhost',
'localhost.localdomain',
'127.0.0.1',
'::1',
'0.0.0.0', # <-- no '::'
'metadata.google.internal',
'169.254.169.254',
'metadata.internal',
}
Each list is reasonable read on its own. Put the IPv4 entries next to the IPv6 entries and the asymmetry is the whole finding: the IPv4 unspecified address, 0.0.0.0, is blocked twice over — by name in the hostname set, and by range via 0.0.0.0/8. Its IPv6 twin, ::, is on neither list.
That is the only reading trick involved. Not “does this list look complete”, which is unanswerable, but “what is on one side of this list and not the other”, which you can answer line by line.
Why :: reaches the local host
0.0.0.0 and :: are unspecified addresses. As a bind target they mean “every interface”. As a connect target they are not routable at all, so the stack substitutes loopback and the connection lands on the local host. That is what makes http://0.0.0.0:8080/ a well-known SSRF payload, and why 0.0.0.0 is on every denylist worth the name. :: does exactly the same thing on IPv6. It is simply less famous.
The entire finding rests on that premise, so I verified it rather than deriving it. With a marker service bound to ::, raw connects to each candidate:
::1 -> connected, resp=b'HTTP/1.0 200 OK\r\nSer'
:: -> connected, resp=b'HTTP/1.0 200 OK\r\nSer'
::ffff:0:7f00:1 -> TimeoutError: timed out
Three good reasons to miss one address
There is a third layer, and it is the reason this guard is better than most. _extract_embedded_ipv4 unwraps IPv6 transition forms — IPv4-mapped, IPv4-compatible, 6to4, NAT64 — and range-checks whatever IPv4 address falls out. That is what makes ::ffff:127.0.0.1, 2002:7f00:1:: and 64:ff9b::a9fe:a9fe (NAT64 for 169.254.169.254) all resolve to something already on the denylist. Somebody sat down and thought about IPv6 encodings properly.
And that layer skips :: on purpose:
# IPv4-compatible ::a.b.c.d (deprecated), excluding :: and ::1
if raw[:12] == bytes(12) and raw[12:] not in (bytes(4), b'\x00\x00\x00\x01'):
return ipaddress.IPv4Address(raw[-4:])
return None
That exclusion is correct in its own terms. :: carries no meaningful embedded IPv4, and unwrapping it to 0.0.0.0 would be a category error. The function’s job is translating transition forms, and :: is not one.
So the address falls through all three layers for three individually defensible reasons: not in the range list, not in the hostname set, not a transition form. is_private_ip('::') returns False, and the final gate hands the URL back as valid:
for ip in resolved_ips:
if is_private_ip(ip):
raise SSRFError(f"URL resolves to private IP: {hostname} -> {ip}. ...")
return url
This is the interesting shape of the bug, and the reason it is not a story about someone forgetting IPv6 — they plainly did not. It is a value handled by no layer because every layer has a good local reason to treat it as somebody else’s problem. Maintaining a denylist per address family invites exactly that, and the drift stays invisible while each list is read on its own terms.
What it reaches
Concretely: a workflow step, or an agent tool call, that supplies http://[::]:8080/ gets back the body of whatever is listening on port 8080 of the machine running the engine — a service that may be bound to loopback specifically so that nothing outside the host can talk to it. Internal dashboards, dev servers, queue consoles and unauthenticated admin endpoints all live at exactly that address.
The honest bounds, which went into the report above the severity discussion rather than waiting for a triager to find them:
- Not a credential-theft path.
::reaches the local host, not arbitrary internal hosts. Cloud metadata at169.254.169.254is IPv4-only and stays out of reach. - Not arbitrary ports. The port allowlist still applies — 80, 443, 8080 and 8443. That covers a lot of internal admin and dev surface, but it is not arbitrary.
- Not IPv4 loopback. A service bound only to
127.0.0.1is untouched; the target has to be listening on::1or::.
Two variants also passed validation and are not counted as vulnerabilities, because they do not route: 0177.0.0.1 resolves to 177.0.0.1, which is a correct classification, and ::ffff:0:127.0.0.1 times out. Writing those down costs a paragraph and saves a triager from rediscovering them.
Bounding the reach costs nothing, and the claim that survives is still worth 7.7: a complete bypass of the loopback half of a guard whose stated purpose is blocking loopback, with the response body returned to the caller.
Proving it as a differential
A claim that one URL works is weak on its own. A claim that one URL works while the two the guard was built for do not, in the same run is a result.
The proof-of-concept binds a marker service to [::1]:8080, reachable over no public route, and drives the library’s real http.get module — not a stub, and not a reimplementation of the predicate:
[+] internal service on [::1]:8080 (IPv6 loopback only)
http://[::1]:8080/ -> blocked: [NETWORK_ERROR] Hostname blocked: ::1
http://127.0.0.1:8080/ -> blocked: [NETWORK_ERROR] Hostname blocked: 127.0.0.1
http://[::]:8080/ -> BYPASS: {'status': 200, 'body':
'INTERNAL-ONLY-SERVICE: flag{loopback_reached_via_ipv6_unspecified}',
'headers': {'Server': 'BaseHTTP/0.6 Python/3.10.4', ...}}
The response body comes back to the caller, so this is a read SSRF rather than a blind one.
At the guard level, every neighbouring encoding is rejected and only the unspecified form passes:
http://[::]:8080/ PASS
http://[::]/ PASS
http://[0:0:0:0:0:0:0:0]:8080/ PASS
http://[::ffff:127.0.0.1]/ blocked
http://0.0.0.0:8080/ blocked
http://[64:ff9b::a9fe:a9fe]/ blocked
http://169.254.169.254/ blocked
Those blocked rows are doing real work in the report. They say: this guard is good, this is not a review of it, here is the single input it does not classify.
The report
The whole thing went in through the project’s private vulnerability reporting, in the shape the project’s own published advisories use, with the source references as # path:line comments inside the code fences so anything I asserted could be checked against the tree rather than taken on trust.
Three choices in it are worth repeating anywhere else:
One CVSS vector, defended metric by metric. Not two, not a range. Offering the maintainer a choice of scores reads as uncertainty about your own finding rather than as transparency. I scored it AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N — 7.7 — and justified each metric against what the proof-of-concept actually did, so the reasoning could be argued with piece by piece. It was accepted as filed.
Say which claims are demonstrated and which are read. One consequence went in as a code path rather than a demonstration, and was labelled that way: the per-hop redirect revalidation calls the same predicate, so a 302 Location: http://[::]:8080/ is accepted at the hop check exactly as at the initial check. I tested the initial-URL path. Marking that boundary is what makes the rest of a report trustworthy.
Propose the fix as finished code, not as a diff. The reader is deciding whether the shape is right, not applying a patch. I proposed the general form rather than adding ::/128 to the range list, because one predicate covers both address families and cannot drift the way two parallel lists drift:
# 0.0.0.0 and :: are both routed to loopback by the stack.
if ip.is_unspecified:
return True
The fix that shipped
That is what went into 2.28.0, with a comment naming the reason better than mine did:
# Both IPv4 and IPv6 unspecified addresses can be routed to the local host.
# Check the address property so every textual representation is covered.
if ip.is_unspecified:
Every textual representation is the point. ::, [0:0:0:0:0:0:0:0], 0.0.0.0, 0000::0 — a property on the parsed address covers the whole set for free, where a denylist only ever covers the spellings someone thought to type.
Getting the CVE ID
One practical note, because it is the step that turns an advisory into a citable identifier and I had not seen it written down.
A GitHub Security Advisory is not a CVE. GitHub is a CNA, but assignment through it is opt-in — the maintainer requests the ID, and is under no obligation to. This advisory published without one.
That is recoverable after the fact, and it took a single email. Third-party CNAs — VulnCheck, Snyk, and MITRE as the fallback — scope themselves to vulnerabilities “not in another CNA’s scope”, and GitHub’s own scope is written as “CVEs requested by code owners using the GitHub Security Advisories feature”, plus projects assessed by GitHub and Microsoft researchers. No request from the code owner, and I am not a GitHub or Microsoft researcher, so it was never in that scope to begin with — a scope statement rather than an escalation, and nothing asked of maintainers who were already finished.
What made it fast was sending the finished record instead of a request for someone else to write one: affected versions and the fixed release, CWE, the CVSS vector from the published advisory, root cause with file and line, the reachability path, the proof-of-concept with its controls, the bounds section, the shipped fix, and the credit line. CVE-2026-73530 came back published the same day, against a quoted window of two to three business days, crediting euriconicacio as finder, with the title, CWE, scoring and affected-module list carried across nearly verbatim.
One consequence of taking that route: the ID lives on the CVE record, not on the GitHub advisory. A repository advisory only carries a cve_id when the ID came through GitHub as CNA, and nothing backfills a third-party assignment into it afterwards. Both records describe the same vulnerability, so link both rather than assuming a reader who has one can find the other.
Timeline
| 9 Aug 2026 | Reported through the project’s private vulnerability reporting |
| 13 Aug 2026 | GHSA-gc4h-hj7x-gp5p published; 2.28.0 released with the fix |
| 13 Aug 2026 | CVE-2026-73530 assigned and published by VulnCheck as a third-party CNA |
Four days from report to published fix, with the maintainers taking the stronger of the fix shapes offered and improving the comment on it. That is a good disclosure experience by any standard.
If you maintain a guard like this one, the cheap check is not “do we handle IPv6” — the answer is usually yes, and it was yes here. It is: put your IPv4 denylist and your IPv6 denylist side by side and ask what is on one and not the other. 0.0.0.0 was on both. :: was on neither.
Eurico Nicacio — @h3llh0und