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:

  1. Parse the URL
  2. Extract the host
  3. Check if the host matches a blocklist (e.g., 127.0.0.0/8, 10.0.0.0/8, 169.254.0.0/16)
  4. 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.