Skip to content

fix(tun): stop reusing FakeDNS UDP receive buffer for response construction - #42

Merged
Hidden-Node merged 2 commits into
mainfrom
advisor/015-fakedns-udp-buffer-reuse
Jul 23, 2026
Merged

fix(tun): stop reusing FakeDNS UDP receive buffer for response construction#42
Hidden-Node merged 2 commits into
mainfrom
advisor/015-fakedns-udp-buffer-reuse

Conversation

@Hidden-Node

Copy link
Copy Markdown
Owner

What

Fixes a buffer-aliasing bug in the FakeDNS UDP proxy hot loop where the single 65535-byte receive buffer's backing array was reused to build outgoing DNS responses. Under a burst of DNS queries on different hostnames, an occasional "host not found" failure could occur — only under real traffic; isolated unit tests passed because they allocate a fresh buffer per query.

Fixes the bug identified as plan-015 in the /improve audit backlog.

Root cause

mobile/tun/fakedns_proxy.go allocates one buf := make([]byte, 65535) outside the go func() { for { ... } } read loop and reuses it for every localUdp.ReadFromUDP(buf). The outgoing DNS response was built with:

fullResp := append(buf[:offset], resp...)
localUdp.WriteToUDP(fullResp, rAddr)

Because buf has capacity 65535, append(buf[:offset], resp...) extends buf in place past offset, writing the response bytes into the receive buffer's tail. On the next iteration, ReadFromUDP(buf) overwrites only the first n bytes (the new datagram's length); bytes past n are left untouched — i.e. they still hold the previous iteration's response. If the new datagram is shorter than the previous response, parseDNSQuery(buf[offset:n]) and the downstream buildDNSResponse then read offsets containing stale data from the prior reply, corrupting the new response.

Symptom

Random "host not found" failures when several DNS queries for different hostnames reach the FakeDNS UDP socket in quick succession (e.g. opening a page that fans out to many ad/analytics domains). Single-query unit tests never reproduce it because each test allocates a fresh buffer.

Fix

Two defensive changes in handleUDPAssociate's UDP read loop, both in the if tPort == 53 { ... } branch:

  1. Copy dnsQuery out of buf before parsing. parseDNSQuery and buildDNSResponse retain a reference to the query slice's backing array; a future ReadFromUDP(buf) on the next iteration could otherwise overwrite it. The defensive copy makes the per-query handling pure.

    dnsQuery := make([]byte, n-offset)
    copy(dnsQuery, buf[offset:n])
  2. Build the outgoing response in a freshly-allocated slice sized exactly to the final length, so it cannot alias buf:

    fullResp := make([]byte, 0, offset+len(resp))
    fullResp = append(fullResp, buf[:offset]...)  // SOCKS header prefix (copied, not aliased)
    fullResp = append(fullResp, resp...)
    localUdp.WriteToUDP(fullResp, rAddr)

After both changes the receive buffer buf is no longer referenced past the WriteToUDP call, so the next ReadFromUDP cannot corrupt the outgoing response or the next query's parse.

Performance

The fix adds two small heap allocations per DNS query (~hundreds of bytes each): the dnsQuery copy and the fullResp slice. The pre-sized fullResp cap matches the final length, so no reallocation-growth occurs. For typical DNS volume this is negligible; if FakeDNS were ever pushed into the 10k-QPS range (extremely unlikely under single-process gomobile bind), the defensive copy could be revisited — but leave it: it's the load-bearing safety against future aliasing bugs if anyone refactors buildDNSResponse.

Test

Adds a regression characterization TestHandleUDPResponseBuildDoesNotReuseReceiveBuffer in mobile/tun/fakedns_proxy_test.go (builds on the plan-006 test scaffolding). The test:

  • Builds a long-hostname query (longhostname.example.com) whose response is longer than the next datagram.
  • Applies the buggy pre-fix behavior (append(buf[:offset], resp1...)) to mutate buf's tail, faithfully reproducing the aliasing.
  • On a second "iteration", writes a short query (ab.cd) into buf without clearing buf[n:] (mimicking ReadFromUDP).
  • Asserts the fix's two defenses (defensive dnsQuery copy + fresh fullResp slice) leave buf[headerOffset:n2] untouched when building the second response.

Limitation noted during review: this test exercises the helper functions (parseDNSQuery / buildDNSResponse) directly and mimics both the buggy and fixed data-flow. It does not drive the production go func() { for { ReadFromUDP(...) } } loop, which spins on a real net.UDPConn and cannot be driven from a unit test without extracting the per-datagram body into a pure function (out of scope here — that refactor is tracked separately as plan 018, startVpn decomposition). The test's job is to characterize the aliasing hazard the fix removes; a true end-to-end harness test requires that extraction.

Verification

  • go test ./mobile/tun/...ok masterdnsvpn-go/mobile/tun (exit 0, cache cleared; new regression test passes — verified in the executor worktree).
  • gofmt -l on both modified files → clean (committed blobs are LF; local Windows CRLF in the working tree is normalized by core.autocrlf on commit and does not reach Linux CI).
  • Scope: only mobile/tun/fakedns_proxy.go (+10/−2) and mobile/tun/fakedns_proxy_test.go (+100) are touched. No changes to go.mod/go.sum, mobile/mobile.go, mobile/tun/dns_mapper.go, mobile/tun/tun_api.go, or any Kotlin/Android file.

Local-build constraint: the maintainer cannot build locally; GitHub CI on push is the gate. go-test.yml runs go test ./... (with the top-level mobile/ package excluded); mobile/tun is covered. AAR rebuild (android-ci.yml / `build_go_mobile.sh

@Hidden-Node
Hidden-Node merged commit 6d28566 into main Jul 23, 2026
2 checks passed
@Hidden-Node
Hidden-Node deleted the advisor/015-fakedns-udp-buffer-reuse branch July 23, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant