GHSA-M5X5-28JR-GPJJ
Vulnerability from github – Published: 2026-07-09 13:35 – Updated: 2026-07-09 13:35Summary
is_global_address in src/pyload/core/utils/web/check.py is the central guard against SSRF-style outbound connections in pyload-ng. It tests whether a given IP is "globally routable" via Python's ipaddress.ip_address(value).is_global, and callers treat not is_global as "deny":
def is_global_address(value):
try:
return ipaddress.ip_address(value).is_global
except ValueError:
return False
def is_global_host(value):
ips = host_to_ip(value)
return ips and all((is_global_address(ip) for ip in ips))
Python's ipaddress.IPv6Address.is_global classifies the NAT64 well-known prefix as globally routable on every supported Python version (3.9 through 3.14 confirmed), and on older Pythons (3.9-3.11) the 6to4 prefix as well:
| address | is_global on Py 3.9-3.11 |
is_global on Py 3.12+ |
wrapped IPv4 |
|---|---|---|---|
2002:7f00:0001:: (6to4) |
True | False | 127.0.0.1 |
2002:0a00:0001:: (6to4) |
True | False | 10.0.0.1 |
2002:a9fe:a9fe:: (6to4) |
True | False | 169.254.169.254 (IMDS) |
64:ff9b::a9fe:a9fe (NAT64) |
True | True | 169.254.169.254 |
64:ff9b::7f00:1 (NAT64) |
True | True | 127.0.0.1 |
pyload-ng declares python_requires = >=3.9 (setup.cfg), so deployments on Python 3.9-3.11 see the 6to4 path too. The NAT64 path is universal. is_global returns True for these wrappers, so is_global_address returns True and the deny check passes. The pycurl PREREQFUNC at src/pyload/core/network/http/http_request.py:680 consults the same helper just before TCP-connect:
if not self.allow_private_ip:
is_proxy_ip = self.http_proxy_host and self.http_proxy_host == (conn_primary_ip, conn_primary_port)
if not is_global_address(conn_primary_ip) and not is_proxy_ip:
return pycurl.PREREQFUNC_ABORT
return pycurl.PREREQFUNC_OK
On a host with 6to4 routing (legacy operator tunnels; 2002::/16 still configurable) or NAT64 (cloud IPv6-only subnets with NAT64 gateway), the encoded form routes to the embedded IPv4 and the curl connection terminates at the internal endpoint, defeating the deny.
is_global_host (the helper that callers like parse_urls use against a URL hostname) feeds through host_to_ip which pins family=AF_INET, so hostname-based reach to these forms relies on the attacker supplying an IPv6 literal in the URL — but the curl PREREQFUNC sees the actual resolved IP (the AAAA returned for the hostname), so a hostname with an AAAA record set to one of the bypass forms reaches the same gap.
Cross-reference: this is the same incomplete-coverage class as pydantic-ai's GHSA-cqp8-fcvh-x7r3 / CVE-2026-46678. pyload-ng's prior SSRF advisories GHSA-7gvf-3w72-p2pg and GHSA-8rp3-xc6w-5qp5 both went through is_global_host / is_global_address; the IPv6 transition gap is orthogonal to those redirect-bypass classes.
Severity
MEDIUM — CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:L/I:N/A:L = 4.7
AC:H— exploitation requires the host network to route 6to4 (2002::/16traffic), have a NAT64 gateway, or otherwise resolve the IPv6 transition form to an internal IPv4 endpoint at the TCP layer.PR:L—parse_urls(src/pyload/core/api/__init__.py:582) requiresPerms.ADD, which any account capable of adding links holds. The curl PREREQFUNC athttp_request.py:680is reached by every downloader plugin that runs afteris_global_hostpassed.C:L/A:L— internal-network recon and timing-based confirmation; cloud-metadata exfiltration on networks where the transition form actually routes.
CWE-918: Server-Side Request Forgery (SSRF).
Affected versions
pyload-ng from the introduction of is_global_address / is_global_host in src/pyload/core/utils/web/check.py up to and including the current main HEAD as of filing.
Vulnerable code
src/pyload/core/utils/web/check.py:
def is_global_address(value):
try:
return ipaddress.ip_address(value).is_global
except ValueError:
return False
Python ipaddress.IPv6Address.is_global returns True for every address in 2002::/16 (6to4) and 64:ff9b::/96 (NAT64) regardless of the IPv4 they wrap, so this guard is a one-line bypass for the prefix the attacker chooses.
Reproduction
research_wave5/poc/pyload_ipv6_ssrf/poc.py drives both is_global_address and is_global_host against IPv6 transition forms whose embedded IPv4 points at loopback, RFC 1918, and AWS IMDS. The helper returns "globally routable" for every form. A second pass replays the same forms through the PREREQFUNC logic in http_request.py:680 and shows the connection would be ALLOWED in each case.
Suggested fix
Treat IPv6 transition-encoding forms by unwrapping the embedded IPv4 and re-running the global check, plus an explicit blocklist of well-known embedding prefixes for defence in depth:
import ipaddress
_NAT64_WELL_KNOWN = ipaddress.IPv6Network("64:ff9b::/96")
_NAT64_DISCOVERY = ipaddress.IPv6Network("64:ff9b:1::/48")
def _embedded_ipv4(addr):
if isinstance(addr, ipaddress.IPv6Address):
if addr.ipv4_mapped is not None:
return addr.ipv4_mapped
if addr.sixtofour is not None: # 2002::/16 6to4
return addr.sixtofour
if addr in _NAT64_WELL_KNOWN or addr in _NAT64_DISCOVERY:
return ipaddress.IPv4Address(addr.packed[-4:])
return None
def is_global_address(value):
try:
addr = ipaddress.ip_address(value)
except ValueError:
return False
embedded = _embedded_ipv4(addr)
if embedded is not None:
addr = embedded
return addr.is_global
A patch implementing this approach (plus tests covering 6to4 and NAT64 wraps for 127.0.0.1, 10.0.0.1, 172.16.0.1, 192.168.1.1, 100.64.0.0/10, and 169.254.169.254) accompanies the fix PR.
Credits
Reported by tonghuaroot.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "pyload-ng"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "0.5.0b3.dev100"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-48737"
],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-09T13:35:22Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\n`is_global_address` in [`src/pyload/core/utils/web/check.py`](https://github.com/pyload/pyload/blob/1b12dc7f348db8c144e0f39215680415e90ca4d2/src/pyload/core/utils/web/check.py) is the central guard against SSRF-style outbound connections in pyload-ng. It tests whether a given IP is \"globally routable\" via Python\u0027s `ipaddress.ip_address(value).is_global`, and callers treat `not is_global` as \"deny\":\n\n```python\ndef is_global_address(value):\n try:\n return ipaddress.ip_address(value).is_global\n except ValueError:\n return False\n\ndef is_global_host(value):\n ips = host_to_ip(value)\n return ips and all((is_global_address(ip) for ip in ips))\n```\n\nPython\u0027s `ipaddress.IPv6Address.is_global` classifies the NAT64 well-known prefix as **globally routable** on every supported Python version (3.9 through 3.14 confirmed), and on older Pythons (3.9-3.11) the 6to4 prefix as well:\n\n| address | `is_global` on Py 3.9-3.11 | `is_global` on Py 3.12+ | wrapped IPv4 |\n|----------------------------------|---------------------------|--------------------------|----------------------|\n| `2002:7f00:0001::` (6to4) | True | False | 127.0.0.1 |\n| `2002:0a00:0001::` (6to4) | True | False | 10.0.0.1 |\n| `2002:a9fe:a9fe::` (6to4) | True | False | 169.254.169.254 (IMDS)|\n| `64:ff9b::a9fe:a9fe` (NAT64) | True | True | 169.254.169.254 |\n| `64:ff9b::7f00:1` (NAT64) | True | True | 127.0.0.1 |\n\npyload-ng declares `python_requires = \u003e=3.9` (`setup.cfg`), so deployments on Python 3.9-3.11 see the 6to4 path too. The NAT64 path is universal. `is_global` returns True for these wrappers, so `is_global_address` returns True and the deny check passes. The pycurl `PREREQFUNC` at [`src/pyload/core/network/http/http_request.py:680`](https://github.com/pyload/pyload/blob/1b12dc7f348db8c144e0f39215680415e90ca4d2/src/pyload/core/network/http/http_request.py#L680) consults the same helper just before TCP-connect:\n\n```python\nif not self.allow_private_ip:\n is_proxy_ip = self.http_proxy_host and self.http_proxy_host == (conn_primary_ip, conn_primary_port)\n if not is_global_address(conn_primary_ip) and not is_proxy_ip:\n return pycurl.PREREQFUNC_ABORT\nreturn pycurl.PREREQFUNC_OK\n```\n\nOn a host with 6to4 routing (legacy operator tunnels; `2002::/16` still configurable) or NAT64 (cloud IPv6-only subnets with NAT64 gateway), the encoded form routes to the embedded IPv4 and the curl connection terminates at the internal endpoint, defeating the deny.\n\n`is_global_host` (the helper that callers like `parse_urls` use against a URL hostname) feeds through `host_to_ip` which pins `family=AF_INET`, so hostname-based reach to these forms relies on the attacker supplying an IPv6 literal in the URL \u2014 but the curl PREREQFUNC sees the actual resolved IP (the AAAA returned for the hostname), so a hostname with an AAAA record set to one of the bypass forms reaches the same gap.\n\nCross-reference: this is the same incomplete-coverage class as pydantic-ai\u0027s [GHSA-cqp8-fcvh-x7r3](https://github.com/pydantic/pydantic-ai/security/advisories/GHSA-cqp8-fcvh-x7r3) / CVE-2026-46678. pyload-ng\u0027s prior SSRF advisories [GHSA-7gvf-3w72-p2pg](https://github.com/pyload/pyload/security/advisories/GHSA-7gvf-3w72-p2pg) and [GHSA-8rp3-xc6w-5qp5](https://github.com/pyload/pyload/security/advisories/GHSA-8rp3-xc6w-5qp5) both went through `is_global_host` / `is_global_address`; the IPv6 transition gap is orthogonal to those redirect-bypass classes.\n\n## Severity\n\n**MEDIUM** \u2014 `CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:L/I:N/A:L` = **4.7**\n\n- `AC:H` \u2014 exploitation requires the host network to route 6to4 (`2002::/16` traffic), have a NAT64 gateway, or otherwise resolve the IPv6 transition form to an internal IPv4 endpoint at the TCP layer.\n- `PR:L` \u2014 `parse_urls` ([`src/pyload/core/api/__init__.py:582`](https://github.com/pyload/pyload/blob/1b12dc7f348db8c144e0f39215680415e90ca4d2/src/pyload/core/api/__init__.py#L582)) requires `Perms.ADD`, which any account capable of adding links holds. The curl PREREQFUNC at [`http_request.py:680`](https://github.com/pyload/pyload/blob/1b12dc7f348db8c144e0f39215680415e90ca4d2/http_request.py#L680) is reached by every downloader plugin that runs after `is_global_host` passed.\n- `C:L/A:L` \u2014 internal-network recon and timing-based confirmation; cloud-metadata exfiltration on networks where the transition form actually routes.\n\n**CWE-918**: Server-Side Request Forgery (SSRF).\n\n## Affected versions\n\n`pyload-ng` from the introduction of `is_global_address` / `is_global_host` in [`src/pyload/core/utils/web/check.py`](https://github.com/pyload/pyload/blob/1b12dc7f348db8c144e0f39215680415e90ca4d2/src/pyload/core/utils/web/check.py) up to and including the current main HEAD as of filing.\n\n## Vulnerable code\n\n[`src/pyload/core/utils/web/check.py`](https://github.com/pyload/pyload/blob/1b12dc7f348db8c144e0f39215680415e90ca4d2/src/pyload/core/utils/web/check.py):\n\n```python\ndef is_global_address(value):\n try:\n return ipaddress.ip_address(value).is_global\n except ValueError:\n return False\n```\n\n`Python ipaddress.IPv6Address.is_global` returns True for every address in `2002::/16` (6to4) and `64:ff9b::/96` (NAT64) regardless of the IPv4 they wrap, so this guard is a one-line bypass for the prefix the attacker chooses.\n\n## Reproduction\n\n[`research_wave5/poc/pyload_ipv6_ssrf/poc.py`](https://github.com/pyload/pyload/blob/1b12dc7f348db8c144e0f39215680415e90ca4d2/research_wave5/poc/pyload_ipv6_ssrf/poc.py) drives both `is_global_address` and `is_global_host` against IPv6 transition forms whose embedded IPv4 points at loopback, RFC 1918, and AWS IMDS. The helper returns \"globally routable\" for every form. A second pass replays the same forms through the `PREREQFUNC` logic in [`http_request.py:680`](https://github.com/pyload/pyload/blob/1b12dc7f348db8c144e0f39215680415e90ca4d2/http_request.py#L680) and shows the connection would be ALLOWED in each case.\n\n## Suggested fix\n\nTreat IPv6 transition-encoding forms by unwrapping the embedded IPv4 and re-running the global check, plus an explicit blocklist of well-known embedding prefixes for defence in depth:\n\n```python\nimport ipaddress\n\n_NAT64_WELL_KNOWN = ipaddress.IPv6Network(\"64:ff9b::/96\")\n_NAT64_DISCOVERY = ipaddress.IPv6Network(\"64:ff9b:1::/48\")\n\n\ndef _embedded_ipv4(addr):\n if isinstance(addr, ipaddress.IPv6Address):\n if addr.ipv4_mapped is not None:\n return addr.ipv4_mapped\n if addr.sixtofour is not None: # 2002::/16 6to4\n return addr.sixtofour\n if addr in _NAT64_WELL_KNOWN or addr in _NAT64_DISCOVERY:\n return ipaddress.IPv4Address(addr.packed[-4:])\n return None\n\n\ndef is_global_address(value):\n try:\n addr = ipaddress.ip_address(value)\n except ValueError:\n return False\n embedded = _embedded_ipv4(addr)\n if embedded is not None:\n addr = embedded\n return addr.is_global\n```\n\nA patch implementing this approach (plus tests covering 6to4 and NAT64 wraps for 127.0.0.1, 10.0.0.1, 172.16.0.1, 192.168.1.1, 100.64.0.0/10, and 169.254.169.254) accompanies the fix PR.\n\n## Credits\n\nReported by tonghuaroot.",
"id": "GHSA-m5x5-28jr-gpjj",
"modified": "2026-07-09T13:35:22Z",
"published": "2026-07-09T13:35:22Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/pyload/pyload/security/advisories/GHSA-m5x5-28jr-gpjj"
},
{
"type": "PACKAGE",
"url": "https://github.com/pyload/pyload"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:L/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "pyLoad: SSRF guard bypass via IPv6 6to4/NAT64 transition wrappers of internal IPs"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.