Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 10 additions & 0 deletions test_testping1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
2 changes: 2 additions & 0 deletions testping1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading