CVE-2026-48737
Published:July 09, 2026
Updated:August 02, 2026
Summary "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'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"" (https://github.com/pyload/pyload/blob/1b12dc7f348db8c144e0f39215680415e90ca4d2/src/pyload/core/network/http/http_request.py#L680) 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" (https://github.com/pydantic/pydantic-ai/security/advisories/GHSA-cqp8-fcvh-x7r3) / CVE-2026-46678. pyload-ng's 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. 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::/16" traffic), 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"" (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. - "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"" (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. Vulnerable code ""src/pyload/core/utils/web/check.py"" (https://github.com/pyload/pyload/blob/1b12dc7f348db8c144e0f39215680415e90ca4d2/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"" (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. 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.
Related Resources (5)
Do you need more information?
Contact UsCVSS v4
Base Score:
2.3
Attack Vector
NETWORK
Attack Complexity
HIGH
Attack Requirements
NONE
Privileges Required
LOW
User Interaction
NONE
Vulnerable System Confidentiality
LOW
Vulnerable System Integrity
NONE
Vulnerable System Availability
LOW
Subsequent System Confidentiality
LOW
Subsequent System Integrity
NONE
Subsequent System Availability
LOW
CVSS v3
Base Score:
4.9
Attack Vector
NETWORK
Attack Complexity
HIGH
Privileges Required
LOW
User Interaction
NONE
Scope
CHANGED
Confidentiality
LOW
Integrity
NONE
Availability
LOW
Weakness Type (CWE)
Server-Side Request Forgery (SSRF)