Your SSRF Filter Probably Doesn't Handle Unicode IPs
You know that regex you wrote to validate IP addresses? The one that blocks requests to internal networks?
It probably doesn't handle this:
⑥⑨.㉚.⑳⑥.⑬⓪ That resolves to 69.20.206.130. Or this:
①②⑦.⓪.⓪.① That's 127.0.0.1.
What's happening
glibc's getaddrinfo() — the function almost every Linux program uses to resolve hostnames — accepts IPv4 addresses written with circled digits, superscripts, subscripts, and math-alphabet characters. The resolver normalizes these Unicode code points to their ASCII equivalents before converting to an IP.
So when your application does:
import socket socket.getaddrinfo('①②⑦.⓪.⓪.①', 80) It returns the same result as 127.0.0.1.
Why this matters
Most SSRF protections work like this:
- Parse the URL
- Extract the host
- Check if the host matches a blocklist (e.g.,
127.0.0.0/8,10.0.0.0/8,169.254.0.0/16) - If it passes, make the request
The problem: step 3 uses a regex or IP parser that only understands ASCII digits. Step 2 never converts the Unicode digits to ASCII. So ①②⑦.⓪.⓪.① passes your blocklist check, then getaddrinfo() resolves it to 127.0.0.1, and your request goes to localhost.
A real example
Say you have a webhook handler that fetches user-provided URLs:
import requests import re def is_safe_ip(host): # Block private ranges if re.match(r'^(127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)', host): return False return True @app.route('/webhook', methods=['POST']) def webhook(): url = request.json['url'] host = urlparse(url).hostname if not is_safe_ip(host): return "Blocked", 403 response = requests.get(url) return response.text The regex checks for 127. at the start. But ①②⑦.⓪.⓪.① doesn't match. So the request proceeds, and requests.get() (which uses getaddrinfo() under the hood) fetches from 127.0.0.1.
The fix
Resolve the hostname to an IP before checking the blocklist:
import socket from ipaddress import ip_address, ip_network def is_safe_ip(host): try: # Resolve to IP first addr_info = socket.getaddrinfo(host, None) ip_str = addr_info[0][4][0] ip = ip_address(ip_str) # Check private ranges private_ranges = [ ip_network('127.0.0.0/8'), ip_network('10.0.0.0/8'), ip_network('172.16.0.0/12'), ip_network('192.168.0.0/16'), ip_network('169.254.0.0/16'), ip_network('::1/128'), ip_network('fc00::/7'), ] for network in private_ranges: if ip in network: return False return True except (socket.gaierror, ValueError): return False Now the Unicode digits get normalized by getaddrinfo(), you extract the actual IP, and your blocklist check works correctly.
A few notes
- This isn't a glibc bug — it's documented behavior. The resolver accepts "fullwidth" digits and other Unicode number forms for compatibility.
- The issue affects any language that uses the system resolver (Python, Ruby, Node.js on Linux, Go with CGO enabled, etc.).
- If you're writing a validator in JavaScript (which doesn't use glibc), you might not be vulnerable. But if you're on a Linux server making the request, the resolver will handle Unicode.
The takeaway
When validating IPs for security purposes, always resolve the hostname first. Don't trust the raw input. Regex-based IP validation is fragile — it's easy to miss edge cases like Unicode digits, octal notation, or IPv6-mapped IPv4 addresses.
The ipaddress module (or your language's equivalent) handles the parsing correctly. Use it.
This one caught me off guard. I'd written the regex-based validator years ago and never questioned it. Took a security audit to realize it was bypassable. The fix is straightforward, but you have to know to look for it.
原文:https://www.a8q9.cn/en/article/your-ssrf-filter-probably-doesn-t-handle-unicode-ips-1b517e.html