Fix IPv6 extension header walk in handle_ip() - #778
Conversation
The inline extension-header walk in handle_ip() (ip.c) had several
compounding defects: Hdr Ext Len was treated as raw octets instead of
RFC 8200's 8-octet units, there was no bounds checking against the
packet buffer at all, the loop's termination condition could never
actually assign an upper-layer protocol to `protocol` (so the walk
degenerated into a bail-out for essentially any IPv6 packet carrying
an extension header, even a single Hop-by-Hop header before TCP), and
the bail-out path double-added sizeof(ip6_hdr) into the payload offset.
Extract the walk into app/src/main/jni/netguard/ip6_ext.{h,c} (pure C,
no JNI/Android dependency, following the dns_frame.c precedent) so it
is unit-testable on the host, add hard bounds checks and a cap on the
number of headers walked, fix the Hdr Ext Len arithmetic (including
AH's distinct 4-octet-unit encoding), and drop Fragment/ESP/Mobility
from the walkable set (they either cannot be walked at all, or their
length field means something else). handle_ip() now calls into this
module and uses its offset directly, removing the double-add.
Add app/src/test/native/ip6_ext_test.c and a CI step mirroring the
existing dns_frame_test.c host-test precedent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every walkable-chain case in the suite used Hdr Ext Len 0, where RFC 8200's 8 * (len + 1) and the original 8 + len both give 8. Swapping the new arithmetic back for the old one left the whole suite green, so it did not actually cover the defect the fix is mainly about. Add a Hop-by-Hop with Hdr Ext Len 1 (16 bytes, buggy form 9) and a Routing header with Hdr Ext Len 3 (32 bytes, buggy form 11). With only that one line reverted, these two now fail and nothing else does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed Every walkable-chain case used Added a Hop-by-Hop with Also verified independently, on top of what the PR body reports:
|
Fixes #769.
User-visible impact today (before this fix)
The extension-header walk in
handle_ip()has a loop-termination bug: thewhilecondition testsis_lower_layer(ext->ip6e_nxt)(the header after the one currently being examined) but the code assignsprotocol = ext->ip6e_nxt— soprotocolcan only ever be re-assigned another extension-header type, never the real upper-layer protocol found at the end of the chain. Concretely, even the simplest real-world case — a single Hop-by-Hop header immediately followed by TCP — fails: the loop body never executes at all (ext->ip6e_nxtthere already is TCP, which is not "lower layer"), so the walk always falls through to the bail-out branch, which resetsoff = 0andprotocol = ip6hdr->ip6_nxt(the first extension-header type, e.g.0/Hop-by-Hop).Downstream,
protocolno longer matchesIPPROTO_TCP/UDP/ICMP/ICMPV6, so:sport/dportstay 0 and none of the TCP/UDP/ICMP-specific fast paths apply; the packet instead goes through the generic IP-basedis_address_allowed()block decision (so IP/tracker blocking itself still works, just without any port-aware nuance).handle_icmp/handle_udp/handle_tcpfire (none matchprotocol), so the packet is silently dropped — never forwarded, and not logged as blocked either (this is a connectivity bug, not a privacy one).write_wireguard_packet()forwards the full raw packet byte-for-byte regardless ofprotocol/payload, so WireGuard-routed IPv6 traffic with extension headers is unaffected by this bug today.So: real breakage is confined to direct-routed (non-WireGuard) IPv6 flows that use any IPv6 extension header — they get silently blackholed today. This is on top of the separate, more serious memory-safety issue: the walk has no bounds checking at all against the packet buffer, so a header's declared length can walk
ext/payloadpast the end of the buffer.What changed
Extracted the walk into
app/src/main/jni/netguard/ip6_ext.{h,c}— pure C, no JNI/Android headers, following thedns_frame.c/dns_frame.hextraction precedent (PR #773'sHostsBlocklistLogicpattern) — so it can be unit-tested on the host.handle_ip()(ip.c) now callsip6_skip_ext_headers(pkt, length, &protocol, &payload_off)and usespayload_offdirectly (fixing the double-add ofsizeof(ip6_hdr)that existed in the original bail-out arithmetic, latent because the loop never reached a state where it mattered).Fixes applied:
8 * (len + 1), not8 + len.offnever exceedslength.protocol/payload_offonly ever indicate success when an upper-layer protocol was actually found.MAX_IP6_EXT_HEADERS = 8bounds a maliciously (or corruptly) long chain.protocol_outis set to the header/type the walk stopped on (never anIPPROTO_TCP/UDP/ICMP/ICMPV6value), andpayload_off_outis always<= length— so a caller that only special-cases those four values (ashandle_ip()does) treats a stopped walk exactly like today's "unknown protocol" path, never misreading extension-header bytes as a transport header.Header types walked vs. stopped, and why:
(len + 2) * 4): Authentication Header (51) — AH only authenticates, it doesn't encrypt, so a transport-mode AH header still has plaintext headers behind it worth walking into.Hdr Ext Len-shaped byte is actually a reserved field, not a length, so it cannot be walked the same way, and a non-first fragment has no upper-layer header at all. ESP (50) — payload is encrypted, nothing after it is parseable in plaintext. No Next Header (59) — a clean, legitimate end of chain. Mobility (135) was previously listed as walkable; dropped (out of scope/rare, and not safely walkable the same way).is_lower_layer()listed Destination Options (60) twice and never actually needed to (is_upper_layersupersedes it structurally in the new design).No new preferences or user-facing options — this is a pure correctness/safety fix inside the native packet engine, consistent with the project's "simplicity over configurability" bias.
Testing
Added
app/src/test/native/ip6_ext_test.c(mirrors the existingdns_frame_test.chost-test pattern) and a CI step in.github/workflows/test.ymlright after the DNS-framing one. Covers: no extension headers (plain IPv6, the common case); a single Hop-by-Hop header then TCP; a chain of three headers (Hop-by-Hop → Routing → Destination Options → TCP); AH's distinct 4-octet-unit length, followed by UDP; a declared header length that runs past the buffer; a truncated packet (header start present, no room for its own length byte); Fragment; ESP; No Next Header; and a chain long enough to hit the 8-header cap (with a real TCP header planted right where an unbounded walk would wrongly reach it).Built and verified locally:
cc -Wall -Wextra -Werror -Iapp/src/main/jni/netguard -o /tmp/ip6_ext_test app/src/test/native/ip6_ext_test.c app/src/main/jni/netguard/ip6_ext.c && /tmp/ip6_ext_test→ all 10 tests pass../gradlew :app:compileGithubDebugJavaWithJavac→ succeeds../gradlew assembleGithubDebug→ succeeds for all four ABIs (native C + Rust); producedTrackerControl-githubDebug-latest.apk../gradlew :app:testGithubDebugUnitTest→ 279 tests, 0 failures, 0 errors.Negative control: re-implemented the old buggy inline walk (same wrong
8 + lenarithmetic and the same broken loop-termination logic) under the new function signature and linked it against the same, unmodified test file. Result: 10 of the new assertions fail against it —This confirms the tests genuinely exercise the loop-termination bug (defect 4) — even the single-extension-header and three-header-chain cases, which is the dominant real-world failure mode — not just passing vacuously against the new code.
Out of scope
handle_fragment/handle_esp, and building that is unrelated to fixing the walk itself.🤖 Generated with Claude Code