phone_focus_mode: prevent Magisk app from disabling Systemless Hosts module

The Magisk app's Modules tab "Disable" / "Remove" buttons work by
creating marker files (disable, remove) in /data/adb/modules/hosts/.
Tapping Disable in the app on next boot would skip the module's
magic-mount of /system/etc/hosts, silently disabling all hosts-file
blocking.

Defense in depth:

1. deploy.sh chattr +i's the module dir + its hosts file so the
   Magisk app cannot create disable/remove markers (kernel returns
   EPERM). The +i attribute survives reboot.

2. hosts_enforcer.sh adds protect_magisk_module(): every poll cycle
   (and on startup) scans for disable/remove/update markers, deletes
   them, logs TAMPER, and re-asserts +i on the dir. Safety net in
   case the lock is bypassed.

3. sync_magisk_module() now drops +i briefly before its cp and
   re-locks via protect_magisk_module() so workout-state hosts
   swaps still work.

4. deploy.sh detects the previously-silent failure mode of the
   module being enabled on disk but not yet magic-mounted (no
   /system/etc/hosts) and aborts with a clear reboot-required
   message instead of producing a deploy that does nothing.

5. focus_ctl.sh hosts-status now prints the lock state and warns
   about any present markers.

Verified end-to-end on BL9000EEA0000102:
- Pre-reboot: chattr +i set, touch /data/adb/modules/hosts/disable
  returns Operation not permitted.
- Post-reboot: /system/etc/hosts magic-mounted (178303 lines, sha
  matches canonical), lock survives reboot, ping youtube.com -> 127.0.0.1.
- Tamper test: chattr -i + touch disable -> enforcer logs
  'TAMPER: removed Magisk module marker' within 15s and re-locks.

Documented intentional override path inline (focus_ctl.sh hosts-stop;
chattr -i; touch disable).
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-06 21:40:51 +02:00
parent 78c7efbfd8
commit 00c383008a
3 changed files with 123 additions and 13 deletions

View File

@ -381,9 +381,18 @@ PY_EOF
# Without it, no app-level hosts blocking is possible, so we STOP here
# and require user action before the deploy can proceed.
local magisk_hosts_ok=0
local magisk_hosts_state="absent"
if adb_root "test -d /data/adb/modules/hosts" 2>/dev/null; then
if adb_root "test ! -f /data/adb/modules/hosts/disable -a ! -f /data/adb/modules/hosts/remove" 2>/dev/null; then
if adb_root "test -f /data/adb/modules/hosts/disable -o -f /data/adb/modules/hosts/remove" 2>/dev/null; then
magisk_hosts_state="disabled"
elif ! adb_root "test -f /system/etc/hosts" 2>/dev/null; then
# Module dir exists, no disable marker, but the magic-mount
# has not happened yet. Either the user just enabled it but
# has not rebooted, or the module is in a broken state.
magisk_hosts_state="not-mounted"
else
magisk_hosts_ok=1
magisk_hosts_state="ok"
fi
fi
@ -392,25 +401,48 @@ PY_EOF
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ ACTION REQUIRED — Deploy cannot continue ║"
echo "╠══════════════════════════════════════════════════════════════════╣"
echo "║ The Magisk 'Systemless Hosts' module is not enabled. ║"
echo "║ Without it, hosts-file blocking is impossible on this device ║"
echo "║ (the system partition is hardware read-only even with root). ║"
echo "║ ║"
echo "║ Steps to fix: ║"
echo "║ 1. Open the Magisk app on the phone ║"
echo "║ 2. Tap the Modules tab (puzzle-piece icon) ║"
echo "║ 3. Find 'Systemless Hosts' and toggle it ON ║"
echo "║ 4. Reboot the phone when prompted ║"
echo "║ 5. Re-run this deploy command ║"
if [[ "$magisk_hosts_state" == "not-mounted" ]]; then
echo "║ Magisk 'Systemless Hosts' module is enabled on disk but the ║"
echo "║ /system/etc/hosts magic-mount has NOT happened yet. ║"
echo "║ This means the device has not been rebooted since the module ║"
echo "║ was last toggled on. Without the magic-mount, no hosts-file ║"
echo "║ blocking is possible (the partition is hardware read-only). ║"
echo "║ ║"
echo "║ Steps to fix: ║"
echo "║ 1. Reboot the phone now (adb reboot, or hold power) ║"
echo "║ 2. After reboot, re-run this deploy command ║"
else
echo "║ The Magisk 'Systemless Hosts' module is not enabled. ║"
echo "║ Without it, hosts-file blocking is impossible on this device ║"
echo "║ (the system partition is hardware read-only even with root). ║"
echo "║ ║"
echo "║ Steps to fix: ║"
echo "║ 1. Open the Magisk app on the phone ║"
echo "║ 2. Tap the Modules tab (puzzle-piece icon) ║"
echo "║ 3. Find 'Systemless Hosts' and toggle it ON ║"
echo "║ 4. Reboot the phone when prompted ║"
echo "║ 5. Re-run this deploy command ║"
fi
echo "╚══════════════════════════════════════════════════════════════════╝"
echo ""
exit 1
fi
adb_root "mkdir -p /data/adb/modules/hosts/system/etc"
# Drop any +i lock the runtime hosts_enforcer may have set on the
# module dir / hosts file so we can update them. The enforcer will
# re-lock on its next poll cycle. Also pre-emptively delete any
# disable/remove markers that may exist on disk before we start.
adb_root "chattr -i /data/adb/modules/hosts /data/adb/modules/hosts/system/etc/hosts 2>/dev/null; rm -f /data/adb/modules/hosts/disable /data/adb/modules/hosts/remove /data/adb/modules/hosts/update; true"
adb_root "cp $REMOTE_DIR/hosts.canonical /data/adb/modules/hosts/system/etc/hosts"
adb_root "chmod 644 /data/adb/modules/hosts/system/etc/hosts"
echo " Magisk hosts module populated ($(adb_root "wc -l < /data/adb/modules/hosts/system/etc/hosts" 2>/dev/null | tr -d ' ') lines). Reboot to activate /system/etc/hosts."
# Lock the module dir to block the Magisk app's "Disable" / "Remove"
# buttons (they create marker files inside the dir). Files already
# in the dir stay mutable so the runtime enforcer can still update
# the hosts file on workout state changes.
adb_root "chattr +i /data/adb/modules/hosts/system/etc/hosts 2>/dev/null; true"
adb_root "chattr +i /data/adb/modules/hosts 2>/dev/null; true"
echo " Magisk hosts module populated ($(adb_root "wc -l < /data/adb/modules/hosts/system/etc/hosts" 2>/dev/null | tr -d ' ') lines), locked against UI-disable. Reboot to activate /system/etc/hosts."
fi
adb_root "rm -rf /data/local/tmp/focus_stage"

View File

@ -351,6 +351,26 @@ cmd_hosts_status() {
else
echo "Canonical hosts file missing - run deploy.sh"
fi
# Magisk Systemless Hosts module protection state.
local module_dir="/data/adb/modules/hosts"
if [ -d "$module_dir" ]; then
local lock_state="UNLOCKED (Magisk app can disable!)"
if lsattr -d "$module_dir" 2>/dev/null | awk '{print $1}' | grep -q i; then
lock_state="LOCKED (chattr +i)"
fi
echo "Magisk dir: $module_dir [$lock_state]"
local marker_warn=""
for marker in disable remove update; do
if [ -e "$module_dir/$marker" ]; then
marker_warn="$marker_warn $marker"
fi
done
if [ -n "$marker_warn" ]; then
echo "WARN: Magisk markers present:$marker_warn (will be auto-removed by hosts_enforcer)"
fi
else
echo "Magisk dir: <missing - module not installed>"
fi
}
cmd_hosts_start() {

View File

@ -187,15 +187,71 @@ assert_bind_mount() {
sync_magisk_module() {
local canonical="$1"
[ -n "$canonical" ] && [ -f "$canonical" ] || return 0
[ -d "$(dirname "$HOSTS_MAGISK_MODULE_FILE")" ] || return 0
local module_dir
module_dir="$(dirname "$(dirname "$(dirname "$HOSTS_MAGISK_MODULE_FILE")")")"
[ -d "$module_dir" ] || return 0
local module_hash canonical_hash
module_hash="$(sha256_of "$HOSTS_MAGISK_MODULE_FILE")"
canonical_hash="$(sha256_of "$canonical")"
if [ "$module_hash" != "$canonical_hash" ]; then
# Drop +i on the module dir + file long enough to update, then re-lock.
chattr -i "$module_dir" 2>/dev/null || true
chattr -i "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || true
cp "$canonical" "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || return 0
chmod 644 "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || true
chattr +i "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || true
log "Synced Magisk module hosts to $(basename "$canonical")"
fi
# Always re-assert dir-level lock at the end so a partial earlier failure
# leaves us in the locked state on the next iteration.
protect_magisk_module
}
# Defense against the user disabling the Magisk Systemless Hosts module via
# the Magisk app UI. The "Disable" / "Remove" buttons work by creating a
# marker file inside the module directory:
#
# /data/adb/modules/hosts/disable # disable on next reboot
# /data/adb/modules/hosts/remove # uninstall on next reboot
#
# We do TWO things every poll cycle:
# 1. Delete those markers if they appeared (so a reboot still loads us)
# 2. Set chattr +i on the module directory itself so the Magisk app cannot
# create those markers in the first place. The +i flag on a directory
# blocks adding/removing/renaming entries inside it, which is exactly
# what `touch disable` does. Files already inside remain mutable, so
# sync_magisk_module() can still rewrite the hosts file (it briefly
# drops the lock above to handle the rare case where it must).
#
# To intentionally disable the module for maintenance, run from a root
# shell on the phone:
# focus_ctl.sh hosts-stop
# chattr -i /data/adb/modules/hosts
# touch /data/adb/modules/hosts/disable
protect_magisk_module() {
local module_dir
module_dir="$(dirname "$(dirname "$(dirname "$HOSTS_MAGISK_MODULE_FILE")")")"
[ -d "$module_dir" ] || return 0
# Step 1: nuke any disable/remove markers that may have been created
# since the last poll. We have to chattr -i first because either of the
# two locks below may already be in effect.
local removed=0
for marker in disable remove update; do
local f="$module_dir/$marker"
if [ -e "$f" ]; then
chattr -i "$module_dir" 2>/dev/null || true
chattr -i "$f" 2>/dev/null || true
if rm -f "$f" 2>/dev/null; then
removed=$((removed + 1))
log "TAMPER: removed Magisk module marker $f"
fi
fi
done
# Step 2: re-lock the module dir so the Magisk app cannot recreate them
# via its UI. Best-effort - if the kernel/fs rejects +i, the runtime
# delete loop above is still our safety net.
chattr +i "$module_dir" 2>/dev/null || true
return $removed
}
ensure_canonical_immutable() {
@ -267,6 +323,7 @@ main() {
log "hosts_enforcer started (PID=$$)"
ensure_canonical_immutable
protect_magisk_module
# Initial assertion
assert_bind_mount || true
@ -284,6 +341,7 @@ main() {
while true; do
verify_and_restore
protect_magisk_module
rotate_log
sleep "$HOSTS_CHECK_INTERVAL"
done