diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 010d6a4..c8888a6 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -85,3 +85,8 @@ **Vulnerability:** Attackers could bypass SSRF IP blocklists using SIIT (Stateless IP/ICMP Translation, RFC 2765) addresses. The format `::ffff:0:a.b.c.d` (using the `::ffff:0:0:0/96` prefix) evaluates as `is_global = True` in Python's `ipaddress` module and is NOT caught by the `ipv4_mapped` property. If an attacker passes such an address, the OS networking stack might route it directly to the embedded IPv4 target, bypassing internal security restrictions. **Learning:** Python's `ipaddress` module only natively extracts standard IPv4-mapped addresses (`::ffff:a.b.c.d`), failing to recognize or unwrap SIIT IPv4-translated addresses. **Prevention:** Always manually unwrap SIIT addresses by checking if the high 96 bits of the IPv6 integer match the SIIT prefix (`ip_int >> 32 == 0xffff0000`). If so, extract the underlying 32-bit IPv4 address using bitwise operations (`ip_int & 0xFFFFFFFF`) and validate it against the SSRF blocklist. + +## 2025-06-06 - SSRF bypass via Local-Use IPv4/IPv6 Translation (RFC 8215) +**Vulnerability:** Attackers could bypass SSRF IP blocklists using Local-Use IPv4/IPv6 Translation prefixes (e.g. `64:ff9b:1::/96`). This allowed attackers to ping local addresses using this prefix since the `ipaddress` module considers these to be `is_global = True` and does not automatically extract the embedded IPv4 address. +**Learning:** Python's `ipaddress` module does not intrinsically unwrap IPv4/IPv6 translated addresses like RFC 8215 mappings where the embedded IPv4 address acts as the suffix, causing them to be evaluated as fully routable global IPv6 addresses. +**Prevention:** Always manually unwrap the `/96` mapping by checking if the higher 96 bits match `0x0064ff9b0001000000000000` (`ip_int >> 32 == 0x0064ff9b0001000000000000`). If it matches, extract the underlying 32-bit IPv4 address with `ip_int & 0xFFFFFFFF` and run it through standard SSRF validations. diff --git a/test_testping1.py b/test_testping1.py index 17fd0a1..172b5be 100644 --- a/test_testping1.py +++ b/test_testping1.py @@ -79,6 +79,16 @@ def test_is_reachable_ssrf_bypass_ipv4_mapped(self, mock_call): self.assertIn("IP address not allowed for scanning", log.output[0]) mock_call.assert_not_called() + @patch('testping1.subprocess.call') + def test_is_reachable_ssrf_bypass_local_use_translation(self, mock_call): + """Test is_reachable prevents SSRF bypass via Local-Use IPv4/IPv6 Translation addresses.""" + ssrf_ips = ['64:ff9b:1::127.0.0.1', '64:ff9b:1::192.168.1.1'] + for ip in ssrf_ips: + with self.assertLogs(level='ERROR') as log: + self.assertFalse(is_reachable(ip)) + self.assertIn("IP address not allowed for scanning", log.output[0]) + mock_call.assert_not_called() + @patch('testping1.subprocess.call') def test_is_reachable_ssrf_bypass_siit(self, mock_call): """Test is_reachable prevents SSRF bypass via SIIT (IPv4-translated) addresses.""" diff --git a/testping1.py b/testping1.py index fa70655..24399fd 100644 --- a/testping1.py +++ b/testping1.py @@ -148,6 +148,8 @@ def is_reachable(ip, timeout=1): unwrapped = None if ip_int >> 32 == 0x0064ff9b0000000000000000: # NAT64 64:ff9b::/96 unwrapped = ipaddress.IPv4Address(ip_int & 0xFFFFFFFF) + elif ip_int >> 32 == 0x0064ff9b0001000000000000: # Local-Use IPv4/IPv6 Translation 64:ff9b:1::/96 (RFC 8215) + unwrapped = ipaddress.IPv4Address(ip_int & 0xFFFFFFFF) elif ip_int >> 32 == 0xffff0000: # SIIT (IPv4-translated) ::ffff:0:a.b.c.d unwrapped = ipaddress.IPv4Address(ip_int & 0xFFFFFFFF) elif ip_int < 2**32 and ip_int not in (0, 1): # IPv4-compatible ::w.x.y.z