Hardening a rooted Android device against app detection
June 10, 2026 · 10 min read

The logcat leak

You hid the packages and the features. Then you notice fifteen apps quietly holding READ_LOGS — reading the whole device log, where every stray Magisk and lineage string is sitting in plain text.

androidlogcatread_logsselinux
Cover illustration for “The logcat leak”

I hid the packages and features, but then I audited permissions and noticed fifteen third-party apps holding READ_LOGS. They were reading the global log, where Magisk daemon stderr and LSPosed module traces sat in plain text.

Logcat is system-wide. A normal app can’t read another app’s memory, but an app holding READ_LOGS reads the system log buffers in plain text. I discovered that on this LineageOS build, READ_LOGS was auto-granted at install. Its protection level is signature|privileged|development. The development flag allows a shell to grant it, and this ROM was silently auto-granting it upon manifest declaration.

I built a three-layer defense. First, I revoked the permission from all third-party apps using a root script:

for p in $(pm list packages -3 | cut -d: -f2); do
  if dumpsys package "$p" 2>/dev/null | grep -q "android.permission.READ_LOGS: granted=true"; then
    pm revoke "$p" android.permission.READ_LOGS
  fi
done

Second, I default-denied the runtime path. I hooked LogcatManagerService inside system_server. Since the decision runs on a handler thread instead of a Binder transaction, I couldn’t use Binder.getCallingUid(). I extracted the UID from the LogAccessRequest object.

XposedBridge.hookAllMethods(lms, "processNewLogAccessRequest", new XC_MethodHook() {
    @Override protected void beforeHookedMethod(MethodHookParam p) {
        try {
            Object req = (p.args != null && p.args.length > 0) ? p.args[0] : null;
            if (req == null) return;
            int uid = XposedHelpers.getIntField(req, "mUid");
            if (!isThirdPartyAppId(uid)) return;
            XposedHelpers.callMethod(p.thisObject, "declineRequest", req);
            p.setResult(null);
        } catch (Throwable ignored) {}
    }
});

Finally, I stripped all my own XposedBridge.log statements from my modules.

I almost fell into a verification trap. Running su 10253 -c 'logcat' returned 329 lines, making it look like the revoke failed. It hadn’t. su <uid> keeps the privileged SELinux domain, bypassing logd’s check. Forcing the real app context revealed the truth:

su 10253 -z u:r:untrusted_app:s0 -c 'logcat -d -t 200' | wc -l   ->   0   (denied)

Logd enforces access via SELinux and caller UID. The untrusted_app domain is what logd actually gates. Testing from the app’s actual SELinux domain is the only way to measure physical limits.

Comments