HexBrain

curl · Report #4099618

CVE-2026-107929: local interface connection reuse mixup

Validstatus
lowseverity
5upvotes
14 Oct 2026disclosed

How it was found

Summary:

When CURLOPT_INTERFACE is set with one of the documented prefixes (if!, host!, ifhost!), that binding is not part of the connection reuse key, so a later transfer asking for a different source can silently reuse an existing connection and go out from the earlier transfer's source address. Curl_parse_interface() parses the option into three separate values and setstropt_interface() stores them as STRING_DEVICE, STRING_INTERFACE and STRING_BINDHOST (lib/cf-socket.c:588, lib/setopt.c:155-183), and bindlocal() uses all three (lib/cf-socket.c:661-663), but allocate_conn() copies only STRING_DEVICE into conn->localdev (lib/url.c:1208-1209) and the local-binding test in url_match_connect_config() compares only that field and the local ports (lib/url.c:625-628). The unprefixed form is unaffected because it lands in STRING_DEVICE and is already in the key, which is why the control below behaves correctly. Reuse never rebinds a socket, so the second transfer keeps the first connection's source address, and for the same reason a non-matching if! skips the CURLE_INTERFACE_FAILED that CURLOPT_INTERFACE(3) documents. I know the comment at lib/url.c:619-624 calls this matching deliberately rough, but it assumes reuse will "reuse the exact same binding parameters", and here they are explicitly different and the miss goes in the unsafe direction.

Affected version

8.9.0 through 8.22.0, and current master. I reproduced it on released 8.22.0 and on master efd42dfbbf656870f8885815d07b0d16a5771f69. On master 424aa994ac223c0f90839aadee0fb59595344fce I only read the source: allocate_conn() has moved to url.c:1147 but still copies STRING_DEVICE alone, and STRING_INTERFACE and STRING_BINDHOST are still read nowhere but the bind path. The 8.9.0 lower bound comes from reading commit 3060557af702dd59155ea703a0790455c2f49a59, which split the option into three strings without adding the two new ones to the reuse key; I did not run 8.9.0.

curl 8.22.0 (x86_64-pc-linux-gnu) libcurl/8.22.0 OpenSSL/3.0.13 zlib/1.3 zstd/1.5.5
Release-Date: 2026-09-02

Linux 6.8.0 x86_64, clang 18.1.3, built from the signed 8.22.0 release tarball. That build was configured with --enable-debug; the code above is not debug-conditional.

Steps To Reproduce:

The server has to speak HTTP/1.1 keep-alive, otherwise nothing is reusable and the bug stays hidden. Minimal one:

import http.server, socketserver
class H(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    def do_GET(self):
        b = ("peer=%s\n" % self.client_address[0]).encode()
        self.send_response(200)
        self.send_header("Content-Length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)
socketserver.ThreadingTCPServer(("127.0.0.1", 18232), H).serve_forever()
  1. The bug. The second transfer asks for 127.0.0.3 and goes out from 127.0.0.2:
curl -s -o /dev/null -w "local=%{local_ip} connects=%{num_connects}\n" --interface "host!127.0.0.2" http://127.0.0.1:18232/ --next -o /dev/null -w "local=%{local_ip} connects=%{num_connects}\n" --interface "host!127.0.0.3" http://127.0.0.1:18232/

local=127.0.0.2 connects=1
local=127.0.0.2 connects=0     <- this one asked for 127.0.0.3
  1. Control. The same pair unprefixed is correct:
--interface "127.0.0.2"   then   --interface "127.0.0.3"

local=127.0.0.2 connects=1
local=127.0.0.3 connects=1
  1. The same prefixed value twice still reuses, so putting it in the key costs no legitimate reuse:
--interface "host!127.0.0.2"   then   --interface "host!127.0.0.2"

local=127.0.0.2 connects=1
local=127.0.0.2 connects=0
  1. The documented error is skipped. Make the second transfer --interface "if!no-such-if-here": it reuses the warm connection and exits 0. The same option on its own, with no warm connection, correctly exits 45.

I have a minimal patch against efd42dfb that rebuilds the exact successful spelling, prefix included, into the existing conn->localdev, so the comparison and cleanup paths are untouched and no new per-connection pointers are added. It comes with a test asserting connects=1/connects=1/connects=0/, which fails on unpatched 8.22.0 at 1/0/0. Say the word and I will attach it or open an ordinary PR with no security framing.

Impact

an application that gives different principals different source bindings while sharing a libcurl connection cache, for example per-tenant egress addresses or a source-IP ACL at the far end, a request configured for identity B goes out over identity A's already-bound socket and is treated as A. I tested that with a loopback server that accepts the protected request only from 127.0.0.2 and returns 403 otherwise: the transfer configured for host!127.0.0.3 reused the .2 socket and got the success response, 3 runs out of 3, and with the patch it opened .3 and got 403, 3 runs out of 3. Where no such boundary exists the effect is narrower, the option is quietly ignored on reuse and the documented if! error never fires.

By curl's own wording I would put this at Medium, "a problem that usually needs something else to also happen to become serious", but Low is a fair call given how rarely the prefixed forms get used, and I will not argue it.

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