diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 90ea24d..010d6a4 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -80,3 +80,8 @@ **Vulnerability:** Python's `ipaddress` module evaluates ISATAP addresses (e.g., `2001:db8::5efe:127.0.0.1`) as `is_global = True` and lacks a native `is_isatap` property. This allows attackers to bypass SSRF validations by embedding private or reserved IPv4 addresses inside ISATAP IPv6 structures. **Learning:** `ipaddress` module does not intrinsically unwrap and validate all forms of IPv6 encapsulation. **Prevention:** To prevent SSRF bypasses via ISATAP tunneling addresses, manually identify and unwrap the embedded IPv4 address. Extract the 32-bit ISATAP identifier using `(ip_int >> 32) & 0xFFFFFFFF` and check for `0x00005efe` or `0x02005efe`, then validate the underlying IPv4 address against SSRF rules. + +## 2025-05-18 - SSRF Bypass via SIIT (IPv4-translated) Addresses +**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. diff --git a/test_testping1.py b/test_testping1.py index 622fbbd..17fd0a1 100644 --- a/test_testping1.py +++ b/test_testping1.py @@ -79,6 +79,17 @@ 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_siit(self, mock_call): + """Test is_reachable prevents SSRF bypass via SIIT (IPv4-translated) addresses.""" + # ::ffff:0:a.b.c.d encapsulates an IPv4 address + ssrf_ips = ['::ffff:0:127.0.0.1', '::ffff:0: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_isatap(self, mock_call): """Test is_reachable prevents SSRF bypass via ISATAP tunneling addresses.""" diff --git a/testping1.py b/testping1.py index 9393a6b..4566891 100644 --- a/testping1.py +++ b/testping1.py @@ -143,6 +143,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 == 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 unwrapped = ipaddress.IPv4Address(ip_int) else: