Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions adb_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
109 changes: 86 additions & 23 deletions magisk_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"``)."""
Expand Down Expand Up @@ -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))
Comment on lines +433 to +436

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

On a fresh or newly cloned BlueStacks instance, the /data/adb directory (which maps to /adb in the ext4 filesystem of Data.vhdx) does not exist by default. If /adb is missing, both mkdir /adb/magisk (in _write_commands) and mkdir /adb/service.d (in _service_d_grant_commands) will fail because debugfs's mkdir is not recursive (it does not behave like mkdir -p).

To prevent staging failures on fresh instances, we should check if /adb exists and create it first if it is missing.

            adb_exists = "Inode:" in _es._stat_path(dev, "/adb", env)
            adb_cmds = []
            if not adb_exists:
                adb_cmds = ["mkdir /adb", "sif /adb mode 040700",
                            "sif /adb uid 0", "sif /adb gid 0",
                            "ea_set /adb security.selinux %s" % _SELINUX_CTX]
            svc_exists = adb_exists and "Inode:" in _es._stat_path(dev, _SERVICE_D, env)
            script = (adb_cmds
                      + _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]:
Expand All @@ -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):
Expand Down
38 changes: 38 additions & 0 deletions tests/test_magisk_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down