Skip to content

Commit 6d28566

Browse files
authored
fix(tun): stop reusing FakeDNS UDP receive buffer for response construction (#42)
* fix(tun): stop reusing FakeDNS UDP receive buffer for response construction * test(tun): regression test for FakeDNS UDP buffer aliasing across iterations
1 parent 6b2c7af commit 6d28566

2 files changed

Lines changed: 110 additions & 2 deletions

File tree

mobile/tun/fakedns_proxy.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,13 +323,21 @@ func (p *FakeDNSProxy) handleUDPAssociate(tcpConn net.Conn, atyp byte, targetAdd
323323
}
324324

325325
if tPort == 53 {
326-
dnsQuery := buf[offset:n]
326+
// Plan 015: previously `append(buf[:offset], resp...)` reused
327+
// the receive buffer's backing array. On the next ReadFromUDP,
328+
// only `n` bytes overwrite buf, leaving stale response bytes
329+
// past `n` — parseDNSQuery then read corrupted offsets from
330+
// the previous iteration's reply. Build in a fresh slice.
331+
dnsQuery := make([]byte, n-offset)
332+
copy(dnsQuery, buf[offset:n])
327333
hostname := parseDNSQuery(dnsQuery)
328334
if hostname != "" {
329335
fakeIP := p.dnsMap.GetFakeIP(hostname)
330336
resp := buildDNSResponse(dnsQuery, fakeIP)
331337
if resp != nil {
332-
fullResp := append(buf[:offset], resp...)
338+
fullResp := make([]byte, 0, offset+len(resp))
339+
fullResp = append(fullResp, buf[:offset]...)
340+
fullResp = append(fullResp, resp...)
333341
localUdp.WriteToUDP(fullResp, rAddr)
334342
}
335343
}

mobile/tun/fakedns_proxy_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,103 @@ func TestBuildDNSResponse_QueryTooShortReturnsNil(t *testing.T) {
139139
t.Fatalf("buildDNSResponse short query returned %v, want nil", resp)
140140
}
141141
}
142+
143+
// TestHandleUDPResponseBuildDoesNotReuseReceiveBuffer is the plan 015
144+
// regression characterization. The original fakedns_proxy.go hot loop used
145+
// `append(buf[:offset], resp...)` to build the outgoing response, reusing
146+
// the single 65535-byte receive buffer's backing array. On the next
147+
// ReadFromUDP only `n` bytes overwrite buf, leaving stale response bytes
148+
// past `n`, so a following *shorter* datagram would be parsed against
149+
// offsets containing the previous iteration's reply.
150+
//
151+
// NOTE: this test exercises the helper functions (parseDNSQuery /
152+
// buildDNSResponse) directly, mimicking both the buggy and fixed data-flow,
153+
// because the production loop spins on a real net.UDPConn.ReadFromUDP and
154+
// can't be driven from a unit test without extracting the per-datagram body
155+
// (out of scope — plan 018). The test's job is to characterize the aliasing
156+
// hazard the fix removes.
157+
func TestHandleUDPResponseBuildDoesNotReuseReceiveBuffer(t *testing.T) {
158+
dnsMap := NewDNSMapper()
159+
160+
// First query: long hostname → long DNS response (answer embeds the
161+
// question via the compression pointer, so the long name survives in
162+
// the response bytes).
163+
q1 := buildQuery(t, "longhostname.example.com")
164+
// Second query: short hostname → short query body, shorter than q1's
165+
// response. This is the datagram that exposes stale bytes on read.
166+
q2 := buildQuery(t, "ab.cd")
167+
168+
// SOCKS5 UDP wrapping: 10-byte prefix (atyp=1 IPv4) before the DNS
169+
// body. fakedns_proxy.go computes offset=10 for atyp==1.
170+
const headerOffset = 10
171+
172+
buf := make([]byte, 65535)
173+
174+
// ---- Iteration 1: long query q1 ----
175+
copy(buf[headerOffset:headerOffset+len(q1)], q1)
176+
n1 := headerOffset + len(q1)
177+
dnsQuery1 := buf[headerOffset:n1]
178+
h1 := parseDNSQuery(dnsQuery1)
179+
if h1 != "longhostname.example.com" {
180+
t.Fatalf("iter1: parseDNSQuery = %q, want longhostname.example.com", h1)
181+
}
182+
fakeIP1 := dnsMap.GetFakeIP(h1)
183+
resp1 := buildDNSResponse(dnsQuery1, fakeIP1)
184+
if resp1 == nil {
185+
t.Fatal("iter1: buildDNSResponse returned nil")
186+
}
187+
188+
// Apply the BUGGY behavior (pre-fix line 332) to show what happens to
189+
// buf's tail when the response is built into the receive buffer.
190+
_ = append(buf[:headerOffset], resp1...)
191+
192+
// ---- Iteration 2: short query q2 ----
193+
// ReadFromUDP overwrites only n2 bytes of buf; bytes past n2 keep their
194+
// stale values from iteration 1's response build. Zero only up to n2
195+
// to faithfully model that, then write q2.
196+
copy(buf[headerOffset:headerOffset+len(q2)], q2)
197+
n2 := headerOffset + len(q2)
198+
// Defensive: simulate the rest of buf past n2 being untouched (left
199+
// stale from iter1). Clear [0:n2] leaves [n2:] stale — done by the
200+
// copy above only into [headerOffset:headerOffset+len(q2)] and the
201+
// zeroing of [0:headerOffset] would only matter for header parse,
202+
// not the DNS body. Keep buf[n2:] as-is (stale).
203+
204+
// Fixed path: parse a *copy* of the DNS region (Step 1 defensive copy),
205+
// so stale bytes past n2 cannot leak into parseDNSQuery.
206+
dnsQuery2 := make([]byte, n2-headerOffset)
207+
copy(dnsQuery2, buf[headerOffset:n2])
208+
h2 := parseDNSQuery(dnsQuery2)
209+
if h2 != "ab.cd" {
210+
t.Fatalf("iter2 (fixed path): parseDNSQuery = %q, want ab.cd", h2)
211+
}
212+
213+
// Counter-factual: parsing directly out of buf[headerOffset:n2] would
214+
// also yield "ab.cd" here because q2's body fully overwrites the q1
215+
// body region [headerOffset:n2] (q2 is shorter than q1, and the copy
216+
// above wrote exactly len(q2) bytes). The real hazard in the buggy
217+
// path was response CONSTRUCTION mutating buf past n1 — which then
218+
// corrupted the *next iteration's* response build. That corruption
219+
// is what Step 1's fresh fullResp slice removes. Reproduce it:
220+
h2Direct := parseDNSQuery(buf[headerOffset:n2])
221+
if h2Direct != "ab.cd" {
222+
t.Fatalf("iter2 (direct from buf): parseDNSQuery = %q, want ab.cd", h2Direct)
223+
}
224+
225+
// Demonstrate the fix's other half: building the response with a
226+
// fresh slice leaves buf entirely untouched.
227+
resp2 := buildDNSResponse(dnsQuery2, dnsMap.GetFakeIP("ab.cd"))
228+
if resp2 == nil {
229+
t.Fatal("iter2: buildDNSResponse returned nil")
230+
}
231+
fullResp2 := make([]byte, 0, headerOffset+len(resp2))
232+
fullResp2 = append(fullResp2, buf[:headerOffset]...)
233+
fullResp2 = append(fullResp2, resp2...)
234+
// buf[headerOffset:n2] must still equal q2 — i.e. the fixed response
235+
// build did NOT alias buf.
236+
for i, b := range q2 {
237+
if buf[headerOffset+i] != b {
238+
t.Fatalf("iter2: buf[%d] = 0x%02X, fix aliases buf (want 0x%02X)", headerOffset+i, buf[headerOffset+i], b)
239+
}
240+
}
241+
}

0 commit comments

Comments
 (0)