CVE-2026-48737
MEDIUMCVE-2026-48737 is a medium-severity (CVSS 4.9) vulnerability in pyload-ng. O3 Security confirms whether CVE-2026-48737 is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
pyLoad: SSRF guard bypass via IPv6 6to4/NAT64 transition wrappers of internal IPs
Real-World Exposure
pyload-ngReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects PyPI packages — download data is not available via public APIs for these ecosystems.
Description
Summary
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 Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐍PyPI | pyload-ng | all versions | No fix |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for pyload-ng. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Remediation status
No patched version of pyload-ng has shipped for CVE-2026-48737 yet. Where your build allows, override or pin the dependency away from the vulnerable range, and apply any maintainer-recommended mitigation.
Mitigate without a patch
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether CVE-2026-48737 is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to CVE-2026-48737. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is CVE-2026-48737 in your dependencies?
O3 detects CVE-2026-48737 across PyPI dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.