Skip to content

Remove the DHCP responder that hijacks tethered leases - #964

Merged
kasnder merged 2 commits into
masterfrom
claude/keen-carson-3jc1t4
Sep 19, 2026
Merged

kasnder merged 2 commits into
masterfrom
claude/keen-carson-3jc1t4

Conversation

@kasnder

@kasnder kasnder commented Sep 19, 2026 •

Copy link
Copy Markdown
Member

Found while investigating #962 (Bluetooth tethering). Not a fix for that issue — it removes a hazard in the workaround being recommended there.

The problem

udp.c handed any packet reaching the tun with source port 68 or destination port 67 to check_dhcp(), which answered it with a hard-coded lease instead of forwarding it:

  • address 10.1.10.2, mask 255.255.255.0
  • gateway 10.1.10.1
  • DNS 9.9.9.9

Nothing on the tun is a DHCP client, so that reply is never correct. A tethered peer that receives it is configured onto a subnet with no route off the device. The function carried upstream NetGuard's own // This is untested comment.

The default route set keeps it out of reach. 224.0.0.0/3 covers the 255.255.255.255 broadcast and 192.168.0.0/16 is excluded, so no DHCP packet enters the tun. Tethering compatibility mode arms it: it installs a plain 0.0.0.0/0 default route and, as VpnRoutes.getTetheringRoutes() documents, deliberately puts both the broadcast and the tethering downstream subnets back inside the tunnel. That is exactly the mode users with broken tethering are being told to enable.

Second defect in the same function

inet_pton(AF_INET, "10.1.10.1", (void *) &u->saddr);

u is a const struct udp_session *. This casts the const away to overwrite the live session's stored source address — four bytes into a union that holds 16 for IPv6, and into the tuple the session table matches on.

The change

The responder is dropped rather than gated. Forwarding is the right handling for a DHCP packet that does reach the tun: the broadcast has nowhere to go through a UDP socket and is discarded, leaving the on-link DHCP server — Android's own, for a tethering downstream — to answer.

dhcp_options.c and its test came in with c197cee ("Fix DHCP requests being answered with offers"), which corrected the reply type — DHCPOFFER vs DHCPACK, per option 53 — but left the lease contents, and the responder's premise, untouched. Both go with the responder they serve.

Removed: the udp.c call site, dhcp.c, dhcp_options.{c,h}, the DHCP wire structs and check_dhcp prototype in netguard.h, the two CMakeLists.txt entries, dhcp_options_test.c and the Run DHCP option host tests step in test.yml that compiled it, and the check_dhcp stubs the two UDP tests needed to link.

Testing

  • app/src/test/native/run_defensive_tests.sh — all 8 suites pass (tcp_defensive, udp_defensive, icmp_defensive, ip_header, tcp_window, udp_socket, icmp_socket, ip_flow_policy).
  • The host-test steps adjacent to the removed one (ip6_ext_test, udp_state_test) re-run locally under the workflow's own -Wall -Wextra -Werror invocation: both pass.
  • test.yml parses as YAML after the step removal.
  • Host -fsyntax-only sweep over every remaining jni/netguard/*.c: no new errors. netguard.c and util.c fail on JNI types under the host shim, which is pre-existing and unrelated.
  • Not built against the Android SDK — none is installed in this container, so assembleGithubDebug and the Gradle Java/lint tasks did not run. CI covers that.

The first push missed the test.yml step, which is why the test job went red on ce2cda9; 849f5db fixes it.

Remaining risk

Low. No behaviour changes in the default route set, since the responder was unreachable there. In tethering compatibility mode, DHCP packets are now forwarded (and dropped by the socket layer) rather than answered with a wrong lease.

The checked-in Doxygen output under docs/html/ still documents the removed structs. It is generated output, left to the next regeneration rather than hand-edited here. HOTPATH_BUG_AUDIT.md mentions the DHCP-option suite in its record of a past test run; that is a dated record and is left as written.

https://claude.ai/code/session_01BEU2NQ6M7Zwgwpvn7Vsv5P

check_dhcp() answered any packet reaching the tun with source port 68 or
destination port 67, replying with a hard-coded lease of 10.1.10.2/24,
gateway 10.1.10.1 and DNS 9.9.9.9 instead of forwarding it. Nothing on
the tun is a DHCP client, so that reply is never right; a tethered peer
that receives it is configured onto a subnet with no route off the
device.

The default route set keeps the responder out of reach: 224.0.0.0/3
covers the 255.255.255.255 broadcast and 192.168.0.0/16 is excluded, so
no DHCP packet enters the tun. Tethering compatibility mode installs a
plain 0.0.0.0/0 default route and deliberately puts both back inside the
tunnel, which arms the responder for exactly the users being told to
enable that mode to get tethering working again.

The function also wrote through its own const pointer,

    inet_pton(AF_INET, "10.1.10.1", (void *) &u->saddr);

overwriting the live session's stored source address -- four bytes into
a union that holds 16 for IPv6, and into the tuple the session table
matches on.

Drop the responder rather than gate it. Forwarding is the correct
handling for a DHCP packet that does reach the tun: the broadcast has
nowhere to go through a UDP socket and is discarded, leaving the
on-link DHCP server (Android's own, for a tethering downstream) to
answer. dhcp_options.c existed only to serve it, and its test was never
wired into run_defensive_tests.sh.

The checked-in Doxygen output under docs/html/ still documents the
removed structs; it is generated and left to the next regeneration.

Claude-Session: https://claude.ai/code/session_01BEU2NQ6M7Zwgwpvn7Vsv5P
The removal missed this call site: test.yml compiles dhcp_options_test.c
and dhcp_options.c in a step of its own, separately from
run_defensive_tests.sh, so the `test` job failed on the two source files
no longer being there.

Claude-Session: https://claude.ai/code/session_01BEU2NQ6M7Zwgwpvn7Vsv5P
@kasnder
kasnder marked this pull request as ready for review September 19, 2026 19:52
@kasnder
kasnder merged commit fdb5511 into master Sep 19, 2026
3 checks passed
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.

2 participants