From 751b2553dbe7c7c6fbe3722b5079797aa4c1be1a Mon Sep 17 00:00:00 2001 From: ZeroOneZero Date: Mon, 20 Jul 2026 21:39:20 -0500 Subject: [PATCH] Force-allow the ADB shell at boot so module flashes are never auto-denied The offline installer now plants a Magisk service.d script (00-bsrgui-adbgrant.sh) that runs as root at every boot and sets a permanent "allow" Superuser policy for the shell uid (2000). This fixes the "permission denied" seen when flashing ReZygisk/LSPosed over ADB: Magisk prompts for su, and if the prompt isn't tapped in time it auto-DENIES and caches that deny across reboots, permanently locking out ADB module installs. The shell can't set this policy itself (it needs the very su it's being denied), so the grant is planted offline via debugfs during stage_databin and executed by magiskd at late_start. Scoped to the shell uid only -- apps still prompt as normal. Verified live on Android 13: a fresh install grants the shell from first boot with no manager and no tap, and a missed prompt can no longer lock it out. - magisk_system: _service_d_grant_commands + _grant_script_tempfile, wired into stage_databin (verified + rolled back with the DATABIN) and removed on unstage; creates /data/adb/service.d on a fresh instance. - adb_handler: _ensure_su_policy demoted to a silent re-affirm; the flash message no longer tells the user to tap Grant. - Version -> 3.3.0. --- adb_handler.py | 23 +++++--- constants.py | 2 +- magisk_system.py | 109 ++++++++++++++++++++++++++++-------- tests/test_magisk_system.py | 38 +++++++++++++ 4 files changed, 140 insertions(+), 32 deletions(-) diff --git a/adb_handler.py b/adb_handler.py index b860e92..d4aa779 100644 --- a/adb_handler.py +++ b/adb_handler.py @@ -125,11 +125,17 @@ def _resolve_serial(adb_exe: str, port: Optional[int], runner: Runner) -> str: def _ensure_su_policy(adb_exe: str, serial: str, runner: Runner) -> None: - """Persist a permanent 'allow' Superuser policy for the shell uid (2000) so - magiskd stops prompting -- and, if the prompt isn't tapped fast enough, - auto-DENYING -- ``su`` during a flash. The very first ``su`` here may still - pop the grant once; after it's recorded, every later flash is automatic - (this is the "flip auto-grant before flashing" step, done for you). + """Re-affirm the permanent 'allow' Superuser policy for the shell uid (2000) + so magiskd never prompts -- and, on a missed prompt, auto-DENIES -- ``su`` + during a flash. + + The real fix lives offline: the installer plants a Magisk ``service.d`` + script (magisk_system) that runs as root at every boot and sets this same + policy, so a fresh instance grants the shell from first boot with no tap and + a missed prompt can never lock it out. This online call is a silent + belt-and-suspenders re-affirm for instances that already have su; it can't + bootstrap a shell that's currently denied (it would need the very su it's + being denied), which is exactly why the grant is planted offline. Best-effort: any failure is swallowed so it never aborts the flash itself. """ @@ -164,9 +170,10 @@ def _p(msg): _p("Connecting to the instance...") serial = _resolve_serial(adb_exe, port, runner) - # Pre-authorize shell root so the flash isn't auto-denied. The first flash - # of a fresh install may still prompt once (tap Grant); after that it sticks. - _p("Authorizing root (tap Grant in the instance if a prompt appears)...") + # Re-affirm shell root so the flash isn't auto-denied. On a tool-installed + # instance the offline service.d grant already did this at boot, so no prompt + # appears; this is a silent belt-and-suspenders confirm. + _p("Confirming ADB root access...") _ensure_su_policy(adb_exe, serial, runner) tmp = "/data/local/tmp/" + name diff --git a/constants.py b/constants.py index c3870ea..0617304 100644 --- a/constants.py +++ b/constants.py @@ -101,7 +101,7 @@ def parse_version(s): PROCESS_POST_KILL_WAIT_S = 2 -APP_VERSION = "3.2.0" +APP_VERSION = "3.3.0" APP_ID = f"RobThePCGuy.BlueStacksRootGUI.{APP_VERSION}" APP_NAME = "BlueStacks Root GUI" ICON_FILENAME = "favicon.ico" diff --git a/magisk_system.py b/magisk_system.py index 060908d..881d48e 100644 --- a/magisk_system.py +++ b/magisk_system.py @@ -66,6 +66,26 @@ # that are data, not executables -- everything else in the DATABIN is 0755. _DATABIN_DATA_FILES = ("stub.apk", "kernel.keyblock", "kernel_data_key.vbprivk") +# service.d auto-grant. Magisk runs /data/adb/service.d/*.sh as root at +# late_start (every boot). We plant a tiny script that force-allows the ADB +# shell (uid 2000) in Magisk's policy DB, so a su prompt the user never sees +# can't auto-DENY -- and permanently lock out -- ADB module flashing. The shell +# itself can't set this (it needs the su it's being denied); service.d runs as +# root, so it can. Scoped to the shell uid; apps still prompt. Live-proven on +# BlueStacks A13: survives a cold boot, re-grants silently, no tap. +_SERVICE_D = "/adb/service.d" # ext4 path inside Data.vhdx (fs root == guest /data) +_ADB_GRANT_SCRIPT = "00-bsrgui-adbgrant.sh" +_ADB_GRANT_BODY = ( + "#!/system/bin/sh\n" + "# BlueStacks-Root-GUI: force-allow the ADB shell (uid 2000) at boot so\n" + "# module flashes over ADB are never auto-denied (the missed-prompt -> deny\n" + "# trap). Runs as root via Magisk service.d; scoped to the shell uid only.\n" + "for i in 1 2 3 4 5 6 7 8 9 10; do\n" + ' magisk --sqlite "REPLACE INTO policies (uid,policy,until,logging,notification) VALUES(2000,2,0,0,0)" && break\n' + " sleep 2\n" + "done\n" +) + def _databin_mode(basename: str) -> str: """debugfs ``sif mode`` for a DATABIN file: scripts/binaries are 0755, the @@ -261,6 +281,35 @@ def _databin_extra_commands(extras: dict[str, str]) -> list[str]: return cmds +def _grant_script_tempfile() -> str: + """Write the service.d auto-grant script to a temp file (LF endings for the + guest shell) and return its path. The caller removes it after staging.""" + fd, path = tempfile.mkstemp(suffix="-" + _ADB_GRANT_SCRIPT) + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: + f.write(_ADB_GRANT_BODY) + return path + + +def _service_d_grant_commands(script_hostpath: str, dir_exists: bool) -> list[str]: + """Pure debugfs commands to install the ADB-shell auto-grant script into + ``/data/adb/service.d`` root-owned + executable (0755). Creates service.d + first on a fresh (never-booted-with-Magisk) instance that lacks it. ``write`` + won't overwrite, so a leftover copy is ``rm``'d first (a harmless "not found" + when absent, same pattern as the bootanim.rc.gz rewrite).""" + dst = "%s/%s" % (_SERVICE_D, _ADB_GRANT_SCRIPT) + cmds: list[str] = [] + if not dir_exists: + cmds += ["mkdir %s" % _SERVICE_D, "sif %s mode 040700" % _SERVICE_D, + "sif %s uid 0" % _SERVICE_D, "sif %s gid 0" % _SERVICE_D, + "ea_set %s security.selinux %s" % (_SERVICE_D, _SELINUX_CTX)] + cmds += ["cd %s" % _SERVICE_D, + "rm %s" % _ADB_GRANT_SCRIPT, # write won't overwrite; harmless if absent + "write %s %s" % (_dq(_cygpath(script_hostpath)), _ADB_GRANT_SCRIPT), # quoted src, bare dest + "sif %s mode 0100755" % dst, "sif %s uid 0" % dst, "sif %s gid 0" % dst, + "ea_set %s security.selinux %s" % (dst, _SELINUX_CTX)] + return cmds + + def _stat_is_regular_root(st: str, want_mode: str) -> bool: """True if a debugfs stat shows a regular file, root-owned, at ``want_mode`` (e.g. ``"0755"``).""" @@ -375,33 +424,46 @@ def _p(msg: str) -> None: env = _es._tool_env() total = len(tools) + len(extras) + grant_script = _grant_script_tempfile() # service.d ADB auto-grant; removed below _p("Attaching Data.vhdx (staging Magisk binaries)...") - with _es._Attached(vhdx) as att: - dev = att.device - _p("Writing %d DATABIN files into %s..." % (total, _DATABIN)) - script = (_clean_dir_commands(dev, _DATABIN, env) - + _write_commands(tools) + _databin_extra_commands(extras)) - out = _es._run_script(dev, script, env) - try: - bad = _verify_staged(dev, tools, env, extras) - if bad: - raise RuntimeError( - "staging incomplete -- not correctly written: %s (debugfs: %s)" - % (", ".join(sorted(bad)), _errtail(out))) - _p("Verifying filesystem (e2fsck)...") - if not _es._fsck_ok(dev, env): - raise RuntimeError("e2fsck reported errors after staging") - except Exception: - _p("Staging failed -- rolling back /data/adb/magisk...") + try: + with _es._Attached(vhdx) as att: + dev = att.device + _p("Writing %d DATABIN files into %s..." % (total, _DATABIN)) + svc_exists = "Inode:" in _es._stat_path(dev, _SERVICE_D, env) + script = (_clean_dir_commands(dev, _DATABIN, env) + + _write_commands(tools) + _databin_extra_commands(extras) + + _service_d_grant_commands(grant_script, svc_exists)) + out = _es._run_script(dev, script, env) try: - _es._run_script(dev, _clean_dir_commands(dev, _DATABIN, env), env) + bad = _verify_staged(dev, tools, env, extras) + grant_st = _es._stat_path(dev, "%s/%s" % (_SERVICE_D, _ADB_GRANT_SCRIPT), env) + if not _stat_is_regular_root(grant_st, "0755"): + bad.append("service.d/%s" % _ADB_GRANT_SCRIPT) + if bad: + raise RuntimeError( + "staging incomplete -- not correctly written: %s (debugfs: %s)" + % (", ".join(sorted(bad)), _errtail(out))) + _p("Verifying filesystem (e2fsck)...") + if not _es._fsck_ok(dev, env): + raise RuntimeError("e2fsck reported errors after staging") except Exception: - logger.exception("rollback cleanup also failed") - raise + _p("Staging failed -- rolling back /data/adb/magisk...") + try: + _es._run_script(dev, _clean_dir_commands(dev, _DATABIN, env) + + ["rm %s/%s" % (_SERVICE_D, _ADB_GRANT_SCRIPT)], env) + except Exception: + logger.exception("rollback cleanup also failed") + raise + finally: + try: + os.unlink(grant_script) + except OSError: + pass _write_manifest(instance_dir, ["databin"]) gate = "busybox + util_functions.sh" if "util_functions.sh" in extras else "busybox gate" - return ["Staged %d DATABIN files into /data/adb/magisk (all verified, %s OK)" - % (total, gate)] + return ["Staged %d DATABIN files into /data/adb/magisk (+ ADB auto-grant, all " + "verified, %s OK)" % (total, gate)] def unstage_databin(instance_dir: str, progress=None) -> list[str]: @@ -418,7 +480,8 @@ def _p(msg: str) -> None: _p("Attaching Data.vhdx (removing Magisk binaries)...") with _es._Attached(vhdx) as att: dev = att.device - _es._run_script(dev, _clean_dir_commands(dev, _DATABIN, env), env) # enumerate-based removal + _es._run_script(dev, _clean_dir_commands(dev, _DATABIN, env) + + ["rm %s/%s" % (_SERVICE_D, _ADB_GRANT_SCRIPT)], env) # DATABIN + auto-grant if "Inode:" in _es._stat_path(dev, _DATABIN, env): raise RuntimeError("failed to remove %s" % _DATABIN) if not _es._fsck_ok(dev, env): diff --git a/tests/test_magisk_system.py b/tests/test_magisk_system.py index 65e26f1..42729c6 100644 --- a/tests/test_magisk_system.py +++ b/tests/test_magisk_system.py @@ -144,6 +144,44 @@ def test_databin_extra_commands_writes_scripts_and_chromeos_subdir(): assert "sif %s/kernel.keyblock mode 0100644" % cs in cmds # key -> data +def test_service_d_grant_commands_creates_dir_when_absent(): + cmds = ms._service_d_grant_commands(r"C:\w\00-bsrgui-adbgrant.sh", dir_exists=False) + sd = ms._SERVICE_D + dst = "%s/%s" % (sd, ms._ADB_GRANT_SCRIPT) + # fresh instance: service.d is created 0700 root before the script write + assert "mkdir %s" % sd in cmds + assert "sif %s mode 040700" % sd in cmds + assert cmds.index("mkdir %s" % sd) < cmds.index("cd %s" % sd) + # rm-before-write (write won't overwrite), quoted src + bare dest, 0755 root + assert cmds.index("rm %s" % ms._ADB_GRANT_SCRIPT) < cmds.index( + 'write "/cygdrive/c/w/00-bsrgui-adbgrant.sh" %s' % ms._ADB_GRANT_SCRIPT) + assert "sif %s mode 0100755" % dst in cmds + assert "sif %s uid 0" % dst in cmds and "sif %s gid 0" % dst in cmds + assert "ea_set %s security.selinux %s" % (dst, ms._SELINUX_CTX) in cmds + + +def test_service_d_grant_commands_skips_mkdir_when_present(): + cmds = ms._service_d_grant_commands(r"C:\w\00-bsrgui-adbgrant.sh", dir_exists=True) + # booted-once instance already has service.d: don't recreate it + assert not any(c.startswith("mkdir ") for c in cmds) + assert "cd %s" % ms._SERVICE_D in cmds + assert any(c.startswith("write ") and c.endswith(ms._ADB_GRANT_SCRIPT) for c in cmds) + + +def test_grant_script_tempfile_is_lf_and_grants_shell(): + path = ms._grant_script_tempfile() + try: + raw = open(path, "rb").read() + assert b"\r\n" not in raw, "guest sh needs LF endings, not CRLF" + assert raw.startswith(b"#!/system/bin/sh") + text = raw.decode("utf-8") + # force-allow (policy 2) the shell uid (2000), forever (until 0) + assert "REPLACE INTO policies" in text + assert "VALUES(2000,2,0,0,0)" in text + finally: + os.unlink(path) + + def test_verify_staged_checks_extras_mode_and_owner(monkeypatch): stats = { "busybox": "Inode: 5 Type: regular Mode: 0755\nUser: 0 Group: 0",