diff --git a/.jules/sentinel.md b/.jules/sentinel.md index d3e2b0a..90ea24d 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -75,3 +75,8 @@ **Vulnerability:** Python's `ipaddress.ip_address()` function accepts both `str` and `bytes`. When passing an extremely large bytes object (e.g., `b"A" * 10**8`), the module can take several seconds to raise a `ValueError` due to inefficient internal parsing logic, leading to a CPU exhaustion Denial of Service (DoS). **Learning:** Checking the length of `str` inputs before passing them to `ipaddress.ip_address()` is not sufficient to prevent DoS, as an attacker could pass a massive `bytes` object if the function accepts polymorphic types. **Prevention:** Always enforce strict length limits (e.g., <= 100 characters/bytes) on *both* `str` and `bytes` inputs before attempting to parse them using the `ipaddress` module. Use `isinstance(ip, (str, bytes))` and check `len()`. + +## 2024-05-30 - SSRF Bypass via ISATAP (RFC 5214) Tunneling Addresses +**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. diff --git a/test_testping1.py b/test_testping1.py index 9f39c61..a3fcd04 100644 --- a/test_testping1.py +++ b/test_testping1.py @@ -79,6 +79,18 @@ 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_isatap(self, mock_call): + """Test is_reachable prevents SSRF bypass via ISATAP tunneling addresses.""" + # 2001:db8::5efe:127.0.0.1 encapsulates 127.0.0.1 + # 2001:db8::200:5efe:192.168.1.1 encapsulates 192.168.1.1 + ssrf_ips = ['2001:db8::5efe:127.0.0.1', '2001:db8::200:5efe: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_cgnat_prevention(self, mock_call): """Test is_reachable prevents SSRF by rejecting Carrier-Grade NAT (CGNAT) IPs.""" diff --git a/testping1.py b/testping1.py index aa0ff6e..f0d5c7b 100644 --- a/testping1.py +++ b/testping1.py @@ -128,7 +128,7 @@ def is_reachable(ip, timeout=1): not t_cli.is_global or t_cli.is_multicast ) else: - # 🛡️ Sentinel: Unpack NAT64 (RFC 6052) and IPv4-compatible (RFC 4291) addresses manually + # 🛡️ Sentinel: Unpack NAT64 (RFC 6052), IPv4-compatible (RFC 4291), and ISATAP (RFC 5214) addresses manually # as Python's ipaddress module does not natively unwrap them for SSRF checks. ip_int = int(ip_obj) unwrapped = None @@ -136,6 +136,10 @@ def is_reachable(ip, timeout=1): 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: + isatap_id = (ip_int >> 32) & 0xFFFFFFFF + if isatap_id in (0x00005efe, 0x02005efe): # ISATAP tunnel + unwrapped = ipaddress.IPv4Address(ip_int & 0xFFFFFFFF) if unwrapped is not None: is_blocked = not unwrapped.is_global or unwrapped.is_multicast