pyLoad: SSRF guard bypass via IPv6 6to4/NAT64 transition wrappers of internal IPs
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:L/I:N/A:L상세 설명
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":
1def is_global_address(value): 2 try: 3 return ipaddress.ip_address(value).is_global 4 except ValueError: 5 return False 6 7def is_global_host(value): 8 ips = host_to_ip(value) 9 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:
1if not self.allow_private_ip: 2 is_proxy_ip = self.http_proxy_host and self.http_proxy_host == (conn_primary_ip, conn_primary_port) 3 if not is_global_address(conn_primary_ip) and not is_proxy_ip: 4 return pycurl.PREREQFUNC_ABORT 5return pycurl.PREREQFUNC_OKOn 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:
1def is_global_address(value): 2 try: 3 return ipaddress.ip_address(value).is_global 4 except ValueError: 5 return FalsePython 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:
1import ipaddress 2 3_NAT64_WELL_KNOWN = ipaddress.IPv6Network("64:ff9b::/96") 4_NAT64_DISCOVERY = ipaddress.IPv6Network("64:ff9b:1::/48") 5 6 7def _embedded_ipv4(addr): 8 if isinstance(addr, ipaddress.IPv6Address): 9 if addr.ipv4_mapped is not None:10 return addr.ipv4_mapped11 if addr.sixtofour is not None: # 2002::/16 6to412 return addr.sixtofour13 if addr in _NAT64_WELL_KNOWN or addr in _NAT64_DISCOVERY:14 return ipaddress.IPv4Address(addr.packed[-4:])15 return None16 17 18def is_global_address(value):19 try:20 addr = ipaddress.ip_address(value)21 except ValueError:22 return False23 embedded = _embedded_ipv4(addr)24 if embedded is not None:25 addr = embedded26 return addr.is_globalA 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.
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.