From 00c383008a53981857aa0360119257ffcf468dc2 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 6 May 2026 21:40:51 +0200 Subject: [PATCH] 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). --- phone_focus_mode/deploy.sh | 56 ++++++++++++++++++++++------ phone_focus_mode/focus_ctl.sh | 20 ++++++++++ phone_focus_mode/hosts_enforcer.sh | 60 +++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 13 deletions(-) diff --git a/phone_focus_mode/deploy.sh b/phone_focus_mode/deploy.sh index 9817fd6..989989d 100755 --- a/phone_focus_mode/deploy.sh +++ b/phone_focus_mode/deploy.sh @@ -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" diff --git a/phone_focus_mode/focus_ctl.sh b/phone_focus_mode/focus_ctl.sh index 7bc2cda..40c850b 100755 --- a/phone_focus_mode/focus_ctl.sh +++ b/phone_focus_mode/focus_ctl.sh @@ -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: " + fi } cmd_hosts_start() { diff --git a/phone_focus_mode/hosts_enforcer.sh b/phone_focus_mode/hosts_enforcer.sh index 858b59c..eb59673 100755 --- a/phone_focus_mode/hosts_enforcer.sh +++ b/phone_focus_mode/hosts_enforcer.sh @@ -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