h3llh0und

CVE-2026-115700: a redirect from a trusted host could still send hackney to an internal address

hackney refuses a URL whose host is an IP address in disguise, so that an application checking hostnames is not tricked into calling its own internal services. I found two disguises that still got through, a bracketed IPv6 form and a trailing dot, and that a redirect from a host the application already trusted could deliver either one. The disguise the earlier fix was written for stayed refused. CVSS 6.9 Medium (v4.0), fixed in 4.9.0.

Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Everything below ran on my own machine: hackney built from git in September, and the 4.8.7 and 4.9.1 packages from Hex in October, inside disposable Erlang containers where every listener was bound to a loopback address. No HTTP request left the container. The issue was reported privately through the project’s security advisory process. It is written up here after the fixed release and the CVE record were public; the advisory itself was closed rather than published.

hackney is the HTTP client underneath a large part of the Erlang and Elixir ecosystem. Applications that fetch URLs other people give them (webhooks, link previews, feed readers, import-from-URL buttons) usually put a check in front of it, and the most common check asks one question about the host. If the host is an IP address, is it a private one? If it is a name, it goes through.

In May, hackney fixed a way around that check (CVE-2026-47076). A host written as %31%32%37%2E%30%2E%30%2E%31 is not an IP address to the caller, who sees the percent signs. hackney decoded it to 127.0.0.1 and connected there. Since 4.0.1, hackney_url:normalize/2 refuses the request when the decoded host is an IP address. In August a second commit extended the same check to the Unicode full stops (。, ., 。) that fold into ASCII dots. Both commits say what the check is for: so that a caller’s allowlist cannot be fooled by a host that turns into an address later.

A redirect from a host the app trusts

The check matters most when the caller’s own code cannot run. If an application allows https://partner.example and hackney is told to follow redirects, the caller checks the first URL, and every later hop is handled inside hackney. On a redirect, hackney’s check is the only one there is.

This is the run that made the report. An origin on 127.0.0.1:18090 stands in for the trusted host, and the internal service is a listener bound only to 127.0.0.1:18091. The client asks for /start on the origin and follows redirects:

--- HOP 1 (origin 127.0.0.1:18090) got ---
GET /start HTTP/1.1
--- HOP 1 replies ---
HTTP/1.1 302 Found
Location: http://%5B%3A%3Affff%3A127%2E0%2E0%2E1%5D:18091/internal

*** HOP 2 REACHED: internal listener 127.0.0.1:18091 from {{127,0,0,1},48026} ***
GET /internal HTTP/1.1
Host: [::ffff:127.0.0.1]:18091

result -> {ok,200,[{<<"Content-Length">>,<<"6">>}],<<"SECRET">>}

The internal service’s body came back to the caller as the answer to a request for the partner’s page. Whoever runs the trusted host, or can make it redirect, chooses the internal address.

One address, two spellings

The Location host decodes to [::ffff:127.0.0.1]: 127.0.0.1 written as an IPv4-mapped IPv6 address, inside square brackets. The check and the code that opens the socket each read that string, and they read it differently.

The check passed it to inet_parse:address/1, which parses addresses, not URL hosts. To that function a bracket is not part of an address, so "[::ffff:127.0.0.1]" is not one, and the request was allowed. The connect path, hackney_happy:parse_address/1, removes one pair of brackets first, because that is how IPv6 literals are written in URLs, and then parses ::ffff:127.0.0.1. The check said this was a name, and the socket connected to 127.0.0.1.

Without the percent-encoding, the brackets would never have reached the check. In http://[::1]/, hackney’s URL parser removes the brackets while it splits the URL apart, so the caller and the check both see ::1 and both treat it as an address. Only the encoded form arrives at the check with its brackets still on, because the parser does not decode the host and the check decodes it without removing them.

The second spelling was the May payload with one character added: %31%32%37%2E%30%2E%30%2E%31%2E, which decodes to 127.0.0.1.. To inet_parse:address/1 the trailing dot makes it a name. In a DNS name, a trailing dot marks the name as complete, so example.com. and example.com are the same host.

Every bracketed form I tried behaved the same way: %5B%3A%3A1%5D reached a listener bound only to ::1, and the hex form ::ffff:7f00:1 and a mixed-case spelling (%5b%3A%3AFFFF%3A127.0.0.1%5d) reached 127.0.0.1. The May payload, sent in the same run without the extra character, was refused with invalid_url_host, as it should be.

Where a trailing dot becomes an address

In the report I wrote that the resolver reads the trailing dot as the root label and returns 127.0.0.1. When I retested this month, I looked at what actually returns the address, and that sentence put it in the wrong place.

When a host does not parse as an address, hackney looks it up with inet_res, Erlang’s own DNS client, and connects to whatever comes back. For 127.0.0.1., inet_res sends a DNS query for the name 127.0.0.1, and the answer comes from the DNS server. Inside Docker Desktop that server answers a query for a dotted-quad name with the address itself, and so did my local network’s resolver. With the container’s network switched off, even 127.0.0.1 with no dot fails to resolve through inet_res. I asked three public resolvers the same question, and 1.1.1.1, 8.8.8.8 and 9.9.9.9 all answered NXDOMAIN.

So the trailing-dot spelling reaches an internal address only where the DNS server in the path answers queries like that. Container platforms and small local resolvers often do, and the big public resolvers do not. The bracketed spellings involve no DNS at all: the connect path parses them into an address before any lookup.

What I did not reach

Every connection in this work went to a listener inside the container, bound to 127.0.0.1 or ::1. For the cloud metadata address and the private ranges I only looked at what hackney makes of the string: %5B%3A%3Affff%3A169%2E254%2E169%2E254%5D passes the check and turns into the address tuple for ::ffff:169.254.169.254, and ::ffff:10.0.0.1 does the same. Whether a host can actually reach those addresses depends on its network, and I did not test it.

Two other groups stayed out of the report. Decimal, octal, hex and short IPv4 forms (2130706433, 0177.0.0.1, 0x7f.0.0.1, 127.1) also reach 127.0.0.1, but those hosts are never percent-decoded, so the check was never written for them. The gap there lies between the caller’s parser and the operating system’s, and every HTTP client has it. I also drove only HTTP/1.1. The check sits in normalize/2, before hackney chooses a protocol, so HTTP/2 and HTTP/3 should get the same result, but I did not measure them.

4.8.7 against 4.9.1

My September runs used a build from git at 7aae193, and the report said so: I had not run a released package. This month I installed 4.8.7, the last release before the fix, and 4.9.1 from Hex, and ran the same cells against both, with the same loopback-only listeners:

                                              4.8.7         4.9.1
127.0.0.1                    (control)        200           200
[::1]                        (control)        200           200
%31%32%37%2E%30%2E%30%2E%31  (May payload)    refused       refused

%5B%3A%3A1%5D                                 200 via ::1   refused
%5B%3A%3Affff%3A127%2E0%2E0%2E1%5D            200           refused
%5B%3A%3Affff%3A7f00%3A1%5D                   200           refused
%5b%3A%3AFFFF%3A127.0.0.1%5d                  200           refused
%31%32%37%2E%30%2E%30%2E%31%2E                200           refused
127.0.0.1.                                    200           refused
%5B%5B%3A%3A1%5D%5D                           200 via ::1   refused
%5B127%2E0%2E0%2E1%5D                         200           refused

302 to %5B%3A%3Affff%3A127%2E0%2E0%2E1%5D     200 SECRET    refused

The two controls show the listeners could be reached on both versions, so a refusal on 4.9.1 means hackney stopped, not that the target was unreachable. The two rows below the trailing dot were new in this run: two pairs of brackets, and a plain IPv4 address in brackets. Both reached the listener on 4.8.7. The trailing-dot rows were measured inside Docker Desktop, whose DNS server answers those queries.

The fix is f19561a (#987). Before the check, it removes what the connect path ignores:

ip_candidate("[" ++ Rest) ->
  case lists:reverse(Rest) of
    "]" ++ Inner -> ip_candidate(lists:reverse(Inner));
    _ -> "[" ++ Rest
  end;
ip_candidate(Host) ->
  case lists:reverse(Host) of
    "." ++ Inner when Inner =/= [] -> lists:reverse(Inner);
    _ -> Host
  end.

Because the function calls itself after removing a pair, nested brackets are handled too, which is why the double-bracket row is refused. The tests that came with it also check that the fix did not break ordinary hosts: example.com. is still a name, and http://[::1]:8080/ still gives ::1.

If you call hackney with URLs you did not write

Upgrade to 4.9.0 or later; 4.9.1 is what Hex resolves today. 4.9.0 was released on GitHub on 25 October but only reached Hex early the next day, because its documentation build failed.

If you have a check of your own in front of hackney, the lesson of this fix and the one in May is that a check on the URL string is a check on one way of writing the address. A check holds up when it runs on the address hackney is about to connect to. Resolve the host yourself, check every address you get back, and connect to the address you checked. Or turn off follow_redirect and send each Location back through your check before following it. A network rule that stops the process reaching internal ranges at all protects you whichever way the address was spelled.


Advisory GHSA-5643-rgxx-pfw7 (closed by the maintainer, never published) · CVE CVE-2026-115700 · CVSS 6.9 Medium (v4.0), AV:L/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N, on the record · CWE CWE-436, CWE-918 · Fix f19561a (#987) · Affected >= 4.0.1, < 4.9.0 · Fixed 4.9.0

Reported 8 September, with reporter credit accepted the same day · closed by Benoit Chesneau on 25 October as a direct fix in the next release, “without a separate CVE”, with #987 merged half an hour later · 4.9.0 released on GitHub that evening and on Hex early on 26 October, with 4.9.1 twenty minutes after it · the Erlang Ecosystem Foundation’s CNA opened a case the next morning and published CVE-2026-115700 at 13:49 UTC, crediting me as finder and reporter. Because the advisory was closed and never published, the CVE record is its only public record.

Eurico Nicacio — @h3llh0und

← all posts Eurico Nicacio · h3llh0und