From 8aa3e7d9622d22cefc01fa8022bf9f2cd79f670a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 13 Jul 2026 10:59:07 +0200 Subject: [PATCH] Add change explanation: tether-hotspot-block Interactive /explain-diff walkthrough of the phone_focus_mode hotspot/tethering enforcer (commit range HEAD~2..HEAD): background on netfilter OUTPUT vs FORWARD, the second-phone bypass and fix, a steppable FORWARD-chain simulator, and a debiased 5-question quiz. Refresh the explanations gallery index. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Q6i3cdhKBoUrKxy4rmx6hH --- ...7-13-explanation-tether-hotspot-block.html | 453 ++++++++++++++++++ docs/explanations/index.html | 6 +- 2 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 docs/explanations/2026-07-13-explanation-tether-hotspot-block.html diff --git a/docs/explanations/2026-07-13-explanation-tether-hotspot-block.html b/docs/explanations/2026-07-13-explanation-tether-hotspot-block.html new file mode 100644 index 00000000..446205c2 --- /dev/null +++ b/docs/explanations/2026-07-13-explanation-tether-hotspot-block.html @@ -0,0 +1,453 @@ +Explaining: the hotspot / tethering block + + +
+ + phone_focus_mode · shell / netfilter +

Closing the hotspot bypass

+

How a new tether_enforcer.sh stops a second phone on the hotspot from walking straight around focus mode — and why it lives in the FORWARD chain, not OUTPUT.

+ + + +

1 · Background

+

phone_focus_mode is a location-gated app blocker for a rooted Android phone (Magisk). When the phone is within ~150 m of home it enters focus mode: a daemon pm disable-users every app that isn't whitelisted, and several always-on "enforcer" daemons harden the phone further. The change lives entirely in this subsystem.

+ +

Three of those enforcers are about the network, and all three share a hidden assumption. To see it you need one idea from the Linux firewall (netfilter): every IP packet the kernel handles passes through one of a few chains, depending on where it's going.

+ +
+
OUTPUT
Packets this phone originates — its own apps talking to the internet.
+
INPUT
Packets addressed to this phone.
+
FORWARD
Packets merely routed through this phone — the tethering / NAT path.
+
+ +

Here is what each existing layer actually inspects, straight from the scripts:

+
    +
  • hosts_enforcer.sh locks /system/etc/hosts — but that file is only consulted by this phone's system resolver.
  • +
  • dns_enforcer.sh blocks DoH/DoT by adding rules to the OUTPUT chain.
  • +
  • the curfew net layer in curfew_enforcer.sh is a per-UID allow-list, also on OUTPUT.
  • +
+

Every one of them filters traffic the phone sends on its own behalf. None of them touches FORWARD. That is the seam the bug slips through.

+ +

2 · Intuition

+

Turn the phone into a Wi-Fi hotspot and connect a second phone to it. Now the second phone's packets don't originate on your phone — they arrive over Wi-Fi, get NAT'd, and leave over mobile data. They are forwarded, so they travel the FORWARD chain, which nobody was guarding. The second phone browses YouTube freely on your data while your own phone sits there dutifully blocked.

+ +
+ before +
+
📱 2nd phone
wants youtube.com
+
→
+
🔁 FORWARD
no rules here — packet sails through
+
→
+
🌐 internet
reached. bypass complete.
+
+
+ +

The fix is a fourth enforcer, tether_enforcer.sh. While focus mode is on (i.e. you're home), it does three things every few seconds, and it's careful to be reversible the moment you leave:

+ +
+
1 · offload off
Disable Android's tether hardware offload so forwarded packets actually reach netfilter.
+
2 · FORWARD reject
Insert a blanket REJECT at the top of FORWARD.
+
3 · stop softAP
Best-effort tear the hotspot down so it visibly flips off.
+
+ +
+ after +
+
📱 2nd phone
wants youtube.com
+
→
+
🔁 FORWARD
FOCUS_TETHER_BLOCK is rule #1 → REJECT
+
→
+
🚫 dropped
no internet. bypass closed.
+
+
+ +

Two subtleties make or break this, and both are addressed in the code:

+
    +
  • Ordering. Android's own tethering machinery adds an accept rule to FORWARD (tetherctrl_FORWARD). Our reject only wins if it's evaluated first — so it's inserted at position 1, above Android's rules. Verified on-device: our jump sits on line 2 of FORWARD, Android's on line 6.
  • +
  • Offload. Modern phones can shove forwarded traffic through a hardware/BPF fast path that skips netfilter entirely. If we only added the reject rule, it might never fire. Setting tether_offload_disabled=1 forces the slow path so the rule bites.
  • +
+ +

3 · Play: walk a packet through FORWARD

+

Pick who is sending, toggle focus mode, and step the packet down the chain. Notice that the phone's own traffic never enters FORWARD at all — which is exactly why a blanket reject here is safe.

+ +
+
+ + + +
+
+
+
+
+ + +
+

The FOCUS_TETHER_BLOCK rule only exists while focus mode is on — that's the should_act gate. Turn focus off and the chain reverts to Android's defaults.

+
+ +

4 · Code

+

~690 lines across 9 files, but it groups into four concerns. The heart is the new tether_enforcer.sh; everything else wires it into the existing machinery.

+ +

a · The gate — when to act

+

Like the curfew enforcer, this daemon runs forever but only acts under specific conditions. The gate mirrors curfew_enforcer.sh::should_act, but keyed on focus mode instead of the clock:

+
should_act() {
+    [ "${TETHER_ENFORCER_ENABLED:-0}" = "1" ] || return 1
+    [ -e "$TETHER_OVERRIDE_FILE" ] && return 1     # escape hatch: suspend
+    [ -e "$TETHER_FORCE_FILE" ] && return 0         # daytime test hook
+    at_home                                          # MODE_FILE == "focus"
+}
+

An override file lets you suspend it without killing the daemon (tethering a laptop at home); a force file lets you validate the whole stack in daylight without waiting to be home.

+ +

b · The three levers

+

Offload is snapshotted before we touch it, so leaving home restores whatever value you had (mirrors the curfew enforcer's grayscale snapshot/restore):

+
apply_offload_off() {
+    cur="$(settings get global "$TETHER_OFFLOAD_KEY" 2>/dev/null)"
+    [ "$cur" != "1" ] && settings put global "$TETHER_OFFLOAD_KEY" 1 ...
+}
+

The FORWARD block pins one jump at the top of FORWARD and fills a single-rule reject chain. Crucially, it only rebuilds when something has tampered with it — a lesson paid for in the curfew net layer, where flushing every tick pegged netd and overheated the phone:

+
chain_intact() {                      # cheap check: jump present + exactly 1 rule
+    iptw "$ipt" -C FORWARD -j "$TETHER_IPT_CHAIN" >/dev/null 2>&1 || return 1
+    actual="$(iptw "$ipt" -S "$TETHER_IPT_CHAIN" | grep -c '^-A')"
+    [ "$actual" = "1" ]
+}
+apply_forward_block() {
+    if chain_intact iptables; then :;      # already good — do nothing, no fork
+    elif ensure_chain iptables && fill_chain iptables icmp-port-unreachable; then
+        log "iptables (v4) FORWARD block rebuilt (was missing/tampered)"
+    fi
+    # ... same for ip6tables
+}
+

Stopping the softAP is best-effort and version-gated — cmd wifi stop-softap only exists on Android 11+:

+
stop_softap() {
+    major="$(_android_major "$(getprop ro.build.version.release)")"
+    [ "$major" -ge 11 ] && cmd wifi stop-softap >/dev/null 2>&1 || true
+}
+ +

c · Orchestration — reversible by construction

+

A state marker file means the "engage" work logs once, not every tick, and the "revert" only runs on a real transition. On SIGTERM the cleanup path reverts too, so tether-stop is a clean escape hatch:

+
enter_block() {  # apply all three levers; snapshot offload on first entry
+    [ -e "$TETHER_ENFORCER_STATE" ] || snapshot_offload
+    apply_offload_off; apply_forward_block; stop_softap
+}
+exit_block() {   # restore offload, tear down the chain — only if we had applied
+    [ -e "$TETHER_ENFORCER_STATE" ] || return 0
+    restore_offload; teardown_forward_block
+}
+main() { while true; do
+    if should_act; then enter_block; else exit_block; fi
+    sleep "$TETHER_CHECK_INTERVAL"
+done }
+ +

d · Wiring & proof

+

The rest slots the daemon into the existing lifecycle, following each file's own pattern: config.sh flags; focus_ctl.sh gains tether-status/start/stop/log/test-on/off; magisk_service.sh launches it at boot; deploy.sh pushes, installs and restarts it. Finally lib/tests/test_tether_enforcer.sh runs the real daemon against stubbed Android tools and drives it through all five gate states — 17 assertions, and it's the working sibling of the repo's older, now-stale enforcer tests.

+ +

5 · Quiz

+
Score: 0 / 0
+
+ +

Generated as an /explain-diff walkthrough of commit range HEAD~2..HEAD in testsAndMisc. 2026-07-13.

+
+ + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index e4f4895f..8142f8f6 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -30,8 +30,12 @@
explanations

Change explanations

-

2 explanations · interactive walkthroughs of notable changes, newest first.

+

3 explanations · interactive walkthroughs of notable changes, newest first.