From 52ce2e6e8537ea89321b2f0056b78bd544999890 Mon Sep 17 00:00:00 2001 From: yhirose Date: Mon, 27 Apr 2026 22:25:30 +0900 Subject: [PATCH] Add reproducer for #2431 (getaddrinfo_a use-after-free) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux/glibc, getaddrinfo_with_timeout() runs DNS asynchronously via getaddrinfo_a(GAI_NOWAIT) using a stack-local gaicb. When gai_suspend() hits the connection timeout, gai_cancel() is called and the function returns immediately — but gai_cancel() is non-blocking and can return EAI_NOTCANCELED, leaving the resolver worker thread alive and still referencing the destroyed stack frame. Adds three opt-in gtest cases (GetAddrInfoAsyncCancelTest.*) that exercise the cancel path repeatedly. They are gated on Linux/glibc + CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO at compile time, and on the CPPHTTPLIB_TEST_ISSUE_2431=1 env var at runtime, so a normal `make test` run is unaffected. Also adds a dedicated CI job (issue-2431-repro) and a Docker-based local runner (test/run_issue_2431_repro.sh) that sinkhole UDP/53 so the timeout branch is taken, and run the test under ASAN/LSAN. With the bug present these runs are expected to fail; with a fix applied they should pass. Refs: https://github.com/yhirose/cpp-httplib/issues/2431 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/test.yaml | 50 ++++++++++++++ test/run_issue_2431_repro.sh | 75 +++++++++++++++++++++ test/test.cc | 122 +++++++++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100755 test/run_issue_2431_repro.sh diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 69cc8d7..9e57c07 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -114,6 +114,56 @@ jobs: - name: build and run ThreadPool test run: cd test && make test_thread_pool && ./test_thread_pool + # Reproducer for https://github.com/yhirose/cpp-httplib/issues/2431. + # On Linux/glibc, getaddrinfo_with_timeout() schedules an asynchronous + # DNS lookup with getaddrinfo_a(GAI_NOWAIT) using a stack-local gaicb. + # When gai_suspend() hits the connection timeout, gai_cancel() is called + # but does not block; the resolver worker can keep writing into the + # destroyed stack frame. To exercise that path the runner blocks UDP/53 + # so DNS hangs and the timeout branch is taken on every call. + issue-2431-repro: + runs-on: ubuntu-latest + if: > + (github.event_name == 'push') || + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name) || + (github.event_name == 'workflow_dispatch' && github.event.inputs.test_linux == 'true') + name: issue-2431 repro (Linux + ASAN) + steps: + - name: checkout + uses: actions/checkout@v4 + - name: install libraries + run: | + sudo apt-get update + sudo apt-get install -y libssl-dev zlib1g-dev libbrotli-dev \ + libzstd-dev libcurl4-openssl-dev iptables util-linux + - name: sinkhole DNS + run: | + # Force the resolver path: Ubuntu's default nsswitch short-circuits + # to NOTFOUND through mdns4_minimal before glibc DNS code runs, so + # the gai_cancel() branch would never get exercised. + sudo sed -i 's/^hosts:.*/hosts: dns/' /etc/nsswitch.conf + # Drop UDP/53 in both directions so DNS queries hang silently + # rather than failing fast with ICMP unreachable. + sudo iptables -I OUTPUT -p udp --dport 53 -j DROP + sudo iptables -I INPUT -p udp --sport 53 -j DROP + # Sanity check: a real DNS lookup must hang now. + if timeout 2 getent hosts example.com >/dev/null 2>&1; then + echo "ERROR: DNS unexpectedly resolved — sinkhole is not in effect" >&2 + exit 1 + fi + echo "[ok] DNS UDP/53 is being dropped" + - name: build test binary + run: cd test && make test + - name: run GetAddrInfoAsyncCancelTest + run: | + cd test + ARCH=$(uname -m) + CPPHTTPLIB_TEST_ISSUE_2431=1 \ + LSAN_OPTIONS=suppressions=lsan_suppressions.txt \ + setarch "$ARCH" -R \ + ./test --gtest_filter='GetAddrInfoAsyncCancelTest.*' + macos: runs-on: macos-latest if: > diff --git a/test/run_issue_2431_repro.sh b/test/run_issue_2431_repro.sh new file mode 100755 index 0000000..8dc0ef4 --- /dev/null +++ b/test/run_issue_2431_repro.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Reproducer runner for Issue #2431 +# (https://github.com/yhirose/cpp-httplib/issues/2431). +# +# Spins up an Ubuntu container, points the resolver at a fake nameserver +# that never replies (so getaddrinfo_a actually hits its timeout), builds +# the test suite with g++ + ASAN, and runs the GetAddrInfoAsyncCancelTest +# cases. +# +# Expected outcomes: +# - HEAD prior to the fix: ASAN reports a use-after-free / heap-buffer +# overflow during one of the GetAddrInfoAsyncCancelTest cases. +# - HEAD with the fix applied: all three cases PASS. +# +# Usage: +# bash test/run_issue_2431_repro.sh +# +# Requirements: Docker (Linux container support). The container needs +# --privileged because the test binary uses `setarch -R` to disable ASLR +# for ASAN compatibility, and because the script binds UDP/53 inside the +# container. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +docker run --rm --privileged \ + -v "$REPO_ROOT:/work" \ + -w /work/test \ + ubuntu:24.04 bash -c ' +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive + +apt-get update -qq +apt-get install -y -qq --no-install-recommends \ + ca-certificates g++ make pkg-config iptables util-linux coreutils file \ + libssl-dev zlib1g-dev libbrotli-dev libzstd-dev libcurl4-openssl-dev \ + >/dev/null + +# Force DNS-only resolution: Ubuntu defaults nsswitch.conf to +# "hosts: files mdns4_minimal [NOTFOUND=return] dns ...", which +# short-circuits to NOTFOUND before reaching glibc DNS code, so the +# gai_cancel() branch never gets exercised. +sed -i "s/^hosts:.*/hosts: dns/" /etc/nsswitch.conf + +# Drop all outbound UDP/53 traffic so DNS queries hang silently — this +# matches the iptables-based setup in the original reproducer for +# Issue #2431. Drop incoming responses too, in case anything sneaks +# through (defense in depth). +iptables -I OUTPUT -p udp --dport 53 -j DROP +iptables -I INPUT -p udp --sport 53 -j DROP +trap "iptables -D OUTPUT -p udp --dport 53 -j DROP 2>/dev/null; iptables -D INPUT -p udp --sport 53 -j DROP 2>/dev/null" EXIT + +# Sanity check: a real DNS lookup must hang (and time out) now. +if timeout 2 getent hosts example.com >/dev/null 2>&1; then + echo "ERROR: DNS unexpectedly resolved — DROP / nsswitch is not in effect" >&2 + exit 1 +fi +echo "[ok] DNS UDP/53 is being dropped (expected for the repro)" + +cd /work/test +echo "=== building test binary (g++ + ASAN) ===" +make CXX=g++ test 2>&1 | tail -5 + +ARCH=$(uname -m) +echo "=== running GetAddrInfoAsyncCancelTest with CPPHTTPLIB_TEST_ISSUE_2431=1 ===" +set +e +CPPHTTPLIB_TEST_ISSUE_2431=1 setarch "$ARCH" -R \ + ./test --gtest_filter="GetAddrInfoAsyncCancelTest.*" 2>&1 +rc=$? +set -e +echo "=== test exit: $rc ===" +exit $rc +' diff --git a/test/test.cc b/test/test.cc index 30daf37..93a6e4b 100644 --- a/test/test.cc +++ b/test/test.cc @@ -1549,6 +1549,128 @@ TEST(GetAddrInfoDanglingRefTest, LongTimeout) { std::this_thread::sleep_for(std::chrono::seconds(8)); } +#if defined(__linux__) && defined(__GLIBC__) && \ + defined(CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO) + +// Reproducer for https://github.com/yhirose/cpp-httplib/issues/2431. +// +// On Linux/glibc, getaddrinfo_with_timeout() runs the lookup via +// getaddrinfo_a(GAI_NOWAIT) using a stack-local `struct gaicb`. When the +// gai_suspend() call hits the connection timeout the function calls +// gai_cancel() and returns immediately. gai_cancel() is non-blocking and +// can return EAI_NOTCANCELED, in which case the resolver worker thread is +// still alive and still references the now-destroyed stack frame. +// +// Triggering the bug requires DNS to actually hang (UDP/53 dropped, etc.), +// so these tests are gated on CPPHTTPLIB_TEST_ISSUE_2431=1 and are skipped +// during normal runs. test/run_issue_2431_repro.sh sets up the environment +// and runs them in a container. +namespace { +bool should_run_issue_2431_tests() { + const char *v = getenv("CPPHTTPLIB_TEST_ISSUE_2431"); + return v && *v && std::string(v) != "0"; +} + +std::string unique_unresolvable_host(int n) { + // .invalid is reserved (RFC 6761) and is never served by real DNS, but + // glibc still asks the configured nameserver — which is exactly the path + // we want to exercise. A unique label per call avoids the resolver cache. + auto t = std::chrono::steady_clock::now().time_since_epoch().count(); + return "h-" + std::to_string(::getpid()) + "-" + std::to_string(t) + "-" + + std::to_string(n) + ".invalid"; +} +} // namespace + +TEST(GetAddrInfoAsyncCancelTest, DirectCallSingleThread) { + if (!should_run_issue_2431_tests()) { + GTEST_SKIP() + << "Set CPPHTTPLIB_TEST_ISSUE_2431=1 (and sinkhole DNS) to run"; + } + + for (int i = 0; i < 8; ++i) { + struct addrinfo hints; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + + auto host = unique_unresolvable_host(i); + struct addrinfo *result = nullptr; + int rc = detail::getaddrinfo_with_timeout(host.c_str(), "80", &hints, + &result, /*timeout_sec=*/1); + if (rc == 0 && result) { freeaddrinfo(result); } + } + + // Give orphaned getaddrinfo_a worker threads a chance to write into the + // stack region they still believe holds their gaicb. + std::this_thread::sleep_for(std::chrono::seconds(3)); +} + +TEST(GetAddrInfoAsyncCancelTest, DirectCallMultiThread) { + if (!should_run_issue_2431_tests()) { + GTEST_SKIP() + << "Set CPPHTTPLIB_TEST_ISSUE_2431=1 (and sinkhole DNS) to run"; + } + + std::atomic stop{false}; + std::vector threads; + for (int t = 0; t < 8; ++t) { + threads.emplace_back([t, &stop] { + int i = 0; + while (!stop.load(std::memory_order_relaxed)) { + struct addrinfo hints; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + + auto host = unique_unresolvable_host(t * 100000 + i++); + struct addrinfo *result = nullptr; + int rc = detail::getaddrinfo_with_timeout(host.c_str(), "80", &hints, + &result, /*timeout_sec=*/1); + if (rc == 0 && result) { freeaddrinfo(result); } + } + }); + } + + std::this_thread::sleep_for(std::chrono::seconds(8)); + stop.store(true, std::memory_order_relaxed); + for (auto &th : threads) { + th.join(); + } + std::this_thread::sleep_for(std::chrono::seconds(3)); +} + +TEST(GetAddrInfoAsyncCancelTest, ClientGetMultiThread) { + if (!should_run_issue_2431_tests()) { + GTEST_SKIP() + << "Set CPPHTTPLIB_TEST_ISSUE_2431=1 (and sinkhole DNS) to run"; + } + + std::atomic stop{false}; + std::vector threads; + for (int t = 0; t < 8; ++t) { + threads.emplace_back([t, &stop] { + int i = 0; + while (!stop.load(std::memory_order_relaxed)) { + auto host = unique_unresolvable_host(t * 100000 + i++); + Client cli(host, 80); + cli.set_connection_timeout(1, 0); + cli.set_read_timeout(1, 0); + cli.set_write_timeout(1, 0); + (void)cli.Get("/"); + } + }); + } + + std::this_thread::sleep_for(std::chrono::seconds(8)); + stop.store(true, std::memory_order_relaxed); + for (auto &th : threads) { + th.join(); + } + std::this_thread::sleep_for(std::chrono::seconds(3)); +} + +#endif // __linux__ && __GLIBC__ && CPPHTTPLIB_USE_NON_BLOCKING_GETADDRINFO + TEST(ConnectionErrorTest, InvalidHost) { auto host = "-abcde.com";