Support root for latest versions of all Bluestacks editions#27
Conversation
It requires patching the EXE file to bypass the integrity check and patching the SU file, as well as modifying the disk.
App root on these builds needs BOTH halves, so the per-instance toggle now does both: - enable_root_access=1 so BlueStacks exposes /system/xbin/su, and - the guest su binary patched (isDeveloperMode -> true) inside Data.vhdx. Apps (untrusted_app) can't open /dev/bstvmsg, so the host-side developer-mode escape (the engine patch) only grants a root shell; the su binary itself must be patched for apps to be granted. su_patch_offline: - Add DynamicVHDX read/write and an open_disk() factory; retarget the offline su patch from Root.vhd to the writable Data.vhdx (/system/xbin overlay), where the live su actually resides on 5.22.150.1014+. - Unified, segment-correct su scanner: classifies patched/unpatched and handles 32/64-bit plus page-aligned multi-segment (A11/A13) binaries. - Scan progress no longer floods the console (GUI-only, deduped). main.py: - patch-mode root toggle sets enable_root_access (+ feature.rooting) and patches the guest su; root status keyed on the su-patch sidecar; async progress detail. .gitignore: ignore RE working dir, *.prepatch.bak, *.i64. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01442AJf9tjfSoQmV1i2UovD
_OpWorker uses QObject/pyqtSignal/pyqtSlot but main.py only imported Qt, QTimer, QThread from PyQt5.QtCore, so the module raised NameError at import time and the app never launched -- no instance could be toggled on any BlueStacks edition. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01442AJf9tjfSoQmV1i2UovD
registry_handler only scanned BlueStacks_nxt and BlueStacks_msi5, so the China edition (SOFTWARE\BlueStacks_nxt_cn) was never listed and could not be rooted. Its HD-Player uses the same integrity/developer-mode mechanism as mainline, so the existing engine + Data.vhdx su patches work on it unchanged once it is detected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01442AJf9tjfSoQmV1i2UovD
On classic (pre-5.22.150) builds the Root status was feature.rooting AND enable_root_access, but BlueStacks resets the global bst.feature.rooting to 0 on every launch while root stays live via the per-instance enable_root_access flag. That made a rooted MSI instance read as "Root: Off". Base the classic-mode status on the persistent per-instance flag instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01442AJf9tjfSoQmV1i2UovD
…y matrix The compatibility table still declared 5.22.0.1102+ as "No - Play Integrity enforcement blocks root" and told users to downgrade to 5.21. That is no longer accurate for this fork: the engine integrity patch clears the "security" popup and, on 5.22.150.1014+, the Data.vhdx guest-su patch enables app root. Replace the stale table with the current mechanism (classic vs patch mode) and add a verified matrix covering all three editions - Normal (BlueStacks_nxt), China (BlueStacks_nxt_cn), and MSI (BlueStacks_msi5) - each rooted to uid=0 across Android 7/9/11/13. Also surface China in the auto-detection feature and the registry-keys troubleshooting note, matching the nxt_cn detection support. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WFsBPkFhTYXkqXyud5FV6g
Classic (MSI / pre-5.22.150) builds expose root only as /system/xbin/bstk/su, which is not on the app PATH -- so root-checker *apps* report "not rooted" even though a shell gets uid=0. (enable_root_access alone, and the patch-mode su_patch, don't help here: the su lives in the hypervisor-read-only Root.vhd, not Data.vhdx, and is a classic bstk su with no isDeveloperMode gate.) Add ext4_symlink.py: while the instance is shut down it diskpart-attaches Root.vhd, then uses debugfs to create a /system/xbin/su -> bstk/su symlink in the ext4 /system image (e2fsck-verified), and detaches. bstk su does grant app-UID root once it is reachable on PATH, so a normal Root Checker app then passes. Hooked into _toggle_root_conf as a best-effort step (add on enable, remove on disable); if the tools are missing or the layout is unexpected, conf-based shell root still applies. Patch-mode (NXT/CN) app root is unchanged (su_patch_offline). ext4 has htree directories and gdt_csum/metadata_csum checksums that vary by Android version, so rather than hand-write a fragile ext4 mutator we bundle the battle-tested e2fsprogs debugfs/e2fsck (1.44.5) + their Cygwin runtime DLLs under tools/e2fsprogs/ (5.5 MB, GPL/LGPL, provenance in its README). No Cygwin or WSL install is required by the end user; PyInstaller ships the folder in the exe. Verified on MSI Android 7/9/13: /system/xbin/su resolves, su -c id = uid=0, and Root Checker reports "Root access is properly installed". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WFsBPkFhTYXkqXyud5FV6g
There was a problem hiding this comment.
Code Review
This pull request adds support for rooting BlueStacks 5.22+ builds by introducing binary patching for the disk-integrity check, root persistence, and offline guest-su modifications, alongside Windows UAC elevation helpers and UI updates. The review feedback highlights several critical areas for improvement, including preventing 64-bit handle truncation in ShellExecuteW, ensuring transactional safety by writing backup sidecars before modifying virtual disks, escaping single quotes in PowerShell commands, and correctly mapping Cygwin drive letters on systems with 26 or more disks. Additionally, the feedback recommends wrapping file I/O and PE parsing in robust error-handling blocks, specifying explicit UTF-8 encoding, and using with statements to guarantee proper file handle cleanup.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| try: | ||
| # ShellExecuteW with the "runas" verb raises the UAC consent dialog. | ||
| rc = ctypes.windll.shell32.ShellExecuteW( | ||
| None, "runas", executable, params, lp_directory, SW_SHOWNORMAL | ||
| ) |
There was a problem hiding this comment.
On 64-bit Windows, ShellExecuteW returns a 64-bit HINSTANCE handle. Since ctypes defaults to a 32-bit c_int return type, the returned handle will be truncated to 32 bits. If the lower 32 bits of the 64-bit pointer happen to be 32 or less (or 0), the check int(rc) > 32 will falsely evaluate to False, causing the application to report that elevation failed or was declined even when it succeeded.
To prevent truncation, explicitly set the return type of ShellExecuteW to ctypes.c_size_t before calling it.
| try: | |
| # ShellExecuteW with the "runas" verb raises the UAC consent dialog. | |
| rc = ctypes.windll.shell32.ShellExecuteW( | |
| None, "runas", executable, params, lp_directory, SW_SHOWNORMAL | |
| ) | |
| try: | |
| # ShellExecuteW with the "runas" verb raises the UAC consent dialog. | |
| ctypes.windll.shell32.ShellExecuteW.restype = ctypes.c_size_t | |
| rc = ctypes.windll.shell32.ShellExecuteW( | |
| None, "runas", executable, params, lp_directory, SW_SHOWNORMAL | |
| ) |
| def enable(vhd_path: str, progress=None) -> List[str]: | ||
| """Patch every gated su to grant app root; back up originals to the sidecar. | ||
|
|
||
| ``progress`` (optional) is called with a status string for each step. | ||
| """ | ||
| def _p(msg): | ||
| logger.info(msg) | ||
| if progress: | ||
| progress(msg) | ||
|
|
||
| results: List[str] = [] | ||
| merged: Dict[int, str] = {} | ||
| sc = _sidecar(vhd_path) | ||
| if os.path.isfile(sc): | ||
| for p in json.load(open(sc)).get("patches", []): | ||
| if p.get("orig"): | ||
| merged[p["offset"]] = p["orig"] | ||
| # Progress-only percentage reporter: updates the GUI status label, deduped so | ||
| # it fires only when the integer %% changes, and NEVER goes to the logger -- | ||
| # the per-block scan ticks thousands of times and would flood the console. | ||
| _last_pc = [-1] | ||
| def _pct(pc): | ||
| if progress and pc != _last_pc[0]: | ||
| _last_pc[0] = pc | ||
| progress("Scanning /system for su... %d%%" % pc) | ||
|
|
||
| _p("Opening %s" % os.path.basename(vhd_path)) | ||
| vhd = open_disk(vhd_path, writable=True) | ||
| try: | ||
| _p("Scanning /system for su binaries...") | ||
| entries = _scan_su_entries(vhd, _pct) | ||
| logger.info("Found %d gated su entr%s", len(entries), "y" if len(entries) == 1 else "ies") | ||
| n = len(entries) | ||
| for i, (off, patched, is64) in enumerate(entries, 1): | ||
| orig_hex = "53 48 8d" if is64 else "55 89 e5" | ||
| cur = vhd.read(off, 3) | ||
| if patched or cur == su_patch.PATCH: | ||
| merged.setdefault(off, orig_hex) # track the already-rooted copy | ||
| results.append("su@0x%X already rooted" % off) | ||
| continue | ||
| _p("Patching su %d/%d (offset 0x%X)..." % (i, n, off)) | ||
| merged.setdefault(off, cur.hex(" ")) # remember the original bytes | ||
| vhd.write(off, su_patch.PATCH) | ||
| ok = vhd.read(off, 3) == su_patch.PATCH | ||
| line = "su@0x%X %s (%s -> %s)" % (off, "rooted" if ok else "write-verify FAILED", | ||
| cur.hex(" "), su_patch.PATCH.hex(" ")) | ||
| logger.info(line) | ||
| results.append(line) | ||
| finally: | ||
| vhd.close() | ||
| if merged: | ||
| _p("Writing backup sidecar...") | ||
| json.dump({"patches": [{"offset": o, "orig": h} for o, h in merged.items()]}, | ||
| open(sc, "w"), indent=2) | ||
| elif not results: | ||
| results.append("no gated su found -- boot the instance once so Android " | ||
| "populates /system/xbin/su in Data.vhdx, then shut it " | ||
| "down and retry") | ||
| return results |
There was a problem hiding this comment.
In the current implementation, the virtual disk is modified (patched) before the backup sidecar JSON file is written. If writing the sidecar file fails (e.g., due to disk space exhaustion, directory permission issues, or other I/O errors), the virtual disk will be left in a modified (rooted) state with no backup, making it impossible for the user to restore (un-root) the disk later.
To ensure transactional safety and prevent un-restorable modifications, refactor the enable function to perform the operation in two passes:
- Scan the entries, read the original bytes, and write the backup sidecar file.
- Only after the sidecar file is successfully written, proceed to write the patches to the virtual disk.
def enable(vhd_path: str, progress=None) -> List[str]:
"""Patch every gated su to grant app root; back up originals to the sidecar.
``progress`` (optional) is called with a status string for each step.
"""
def _p(msg):
logger.info(msg)
if progress:
progress(msg)
results: List[str] = []
merged: Dict[int, str] = {}
sc = _sidecar(vhd_path)
if os.path.isfile(sc):
try:
with open(sc, "r", encoding="utf-8") as f:
for p in json.load(f).get("patches", []):
if p.get("orig"):
merged[p["offset"]] = p["orig"]
except Exception:
pass
# Progress-only percentage reporter: updates the GUI status label, deduped so
# it fires only when the integer %% changes, and NEVER goes to the logger --
# the per-block scan ticks thousands of times and would flood the console.
_last_pc = [-1]
def _pct(pc):
if progress and pc != _last_pc[0]:
_last_pc[0] = pc
progress("Scanning /system for su... %d%%" % pc)
_p("Opening %s" % os.path.basename(vhd_path))
vhd = open_disk(vhd_path, writable=True)
try:
_p("Scanning /system for su binaries...")
entries = _scan_su_entries(vhd, _pct)
logger.info("Found %d gated su entr%s", len(entries), "y" if len(entries) == 1 else "ies")
to_patch = []
for off, patched, is64 in entries:
orig_hex = "53 48 8d" if is64 else "55 89 e5"
cur = vhd.read(off, 3)
if patched or cur == su_patch.PATCH:
merged.setdefault(off, orig_hex)
results.append("su@0x%X already rooted" % off)
continue
merged.setdefault(off, cur.hex(" "))
to_patch.append((off, cur))
if merged:
_p("Writing backup sidecar...")
with open(sc, "w", encoding="utf-8") as f:
json.dump({"patches": [{"offset": o, "orig": h} for o, h in merged.items()]},
f, indent=2)
for i, (off, cur) in enumerate(to_patch, 1):
_p("Patching su %d/%d (offset 0x%X)..." % (i, len(to_patch), off))
vhd.write(off, su_patch.PATCH)
ok = vhd.read(off, 3) == su_patch.PATCH
line = "su@0x%X %s (%s -> %s)" % (off, "rooted" if ok else "write-verify FAILED",
cur.hex(" "), su_patch.PATCH.hex(" "))
logger.info(line)
results.append(line)
finally:
vhd.close()
if not entries:
results.append("no gated su found -- boot the instance once so Android "
"populates /system/xbin/su in Data.vhdx, then shut it "
"down and retry")
return results| def _disk_number(vhd_path: str) -> Optional[int]: | ||
| """OS disk number of the attached VHD (== PhysicalDriveN index).""" | ||
| r = _run(["powershell", "-NoProfile", "-Command", | ||
| "(Get-Disk | Where-Object { $_.Location -eq '%s' }).Number" % vhd_path]) | ||
| out = (r.stdout or "").strip() | ||
| return int(out) if out.isdigit() else None |
There was a problem hiding this comment.
If the vhd_path contains a single quote (which is common in Windows user names or custom folder names, e.g., O'Connor), formatting it directly into the PowerShell single-quoted string will result in an unbalanced quote and a PowerShell syntax error. This will cause _disk_number to return None and the entire attachment process to fail.
To prevent this, escape single quotes in the path by doubling them (i.e., replacing ' with '') before formatting it into the PowerShell command.
| def _disk_number(vhd_path: str) -> Optional[int]: | |
| """OS disk number of the attached VHD (== PhysicalDriveN index).""" | |
| r = _run(["powershell", "-NoProfile", "-Command", | |
| "(Get-Disk | Where-Object { $_.Location -eq '%s' }).Number" % vhd_path]) | |
| out = (r.stdout or "").strip() | |
| return int(out) if out.isdigit() else None | |
| def _disk_number(vhd_path: str) -> Optional[int]: | |
| """OS disk number of the attached VHD (== PhysicalDriveN index).""" | |
| escaped_path = vhd_path.replace("'", "''") | |
| r = _run(["powershell", "-NoProfile", "-Command", | |
| "(Get-Disk | Where-Object { $_.Location -eq '%s' }).Number" % escaped_path]) | |
| out = (r.stdout or "").strip() | |
| return int(out) if out.isdigit() else None |
| def _cyg_device(disk_number: int, offset: int) -> str: | ||
| # Cygwin maps PhysicalDriveN -> /dev/sd<a+N>; ?offset= selects the ext4 | ||
| # partition without relying on Cygwin's own partition detection. | ||
| return "/dev/sd%s?offset=%d" % (chr(ord("a") + disk_number), offset) |
There was a problem hiding this comment.
The mapping chr(ord("a") + disk_number) assumes that the disk number is always less than 26. On systems with 26 or more disks (which is common in environments with many virtual disks, USB drives, or storage arrays), this will produce invalid characters (e.g., [ or {) instead of the standard Cygwin multi-letter drive sequence (e.g., aa, ab).
To support systems with 26 or more disks, convert the disk number to an Excel-style column name sequence (e.g., 0 -> a, 25 -> z, 26 -> aa).
def _cyg_device(disk_number: int, offset: int) -> str:
# Cygwin maps PhysicalDriveN -> /dev/sd<a+N>; ?offset= selects the ext4
# partition without relying on Cygwin's own partition detection.
n = disk_number
letters = ""
while True:
letters = chr(ord("a") + (n % 26)) + letters
n = n // 26 - 1
if n < 0:
break
return "/dev/sd%s?offset=%d" % (letters, offset)| def open_disk(path: str, writable: bool = False): | ||
| """Open a Data.vhdx (VHDX) or Root.vhd (legacy dynamic VHD) transparently.""" | ||
| with open(path, "rb") as fh: | ||
| sig = fh.read(8) | ||
| if sig == VHDX_SIGNATURE: | ||
| return DynamicVHDX(path, writable=writable) | ||
| return DynamicVHD(path, writable=writable) |
There was a problem hiding this comment.
If the target disk file (Data.vhdx or Root.vhd) has the read-only attribute set (e.g., if the user previously toggled R/W to 'Off'), or if the file is locked by a running BlueStacks process, open() will raise a generic PermissionError.
To improve defensive programming and provide a clear, actionable error message to the user, wrap the file opening in a try-except block and raise a descriptive PermissionError explaining that the file might be read-only or locked by BlueStacks.
| def open_disk(path: str, writable: bool = False): | |
| """Open a Data.vhdx (VHDX) or Root.vhd (legacy dynamic VHD) transparently.""" | |
| with open(path, "rb") as fh: | |
| sig = fh.read(8) | |
| if sig == VHDX_SIGNATURE: | |
| return DynamicVHDX(path, writable=writable) | |
| return DynamicVHD(path, writable=writable) | |
| def open_disk(path: str, writable: bool = False): | |
| """Open a Data.vhdx (VHDX) or Root.vhd (legacy dynamic VHD) transparently.""" | |
| try: | |
| with open(path, "rb") as fh: | |
| sig = fh.read(8) | |
| except PermissionError as exc: | |
| raise PermissionError( | |
| f"Permission denied opening {path}. Ensure the file is not read-only " | |
| f"and BlueStacks is completely closed." | |
| ) from exc | |
| try: | |
| if sig == VHDX_SIGNATURE: | |
| return DynamicVHDX(path, writable=writable) | |
| return DynamicVHD(path, writable=writable) | |
| except PermissionError as exc: | |
| raise PermissionError( | |
| f"Permission denied opening {path} for writing. Ensure the file is not read-only " | |
| f"and BlueStacks is completely closed." | |
| ) from exc |
| def disable(vhd_path: str, progress=None) -> List[str]: | ||
| """Restore original su bytes from the sidecar (un-root).""" | ||
| def _p(msg): | ||
| logger.info(msg) | ||
| if progress: | ||
| progress(msg) | ||
|
|
||
| sc = _sidecar(vhd_path) | ||
| if not os.path.isfile(sc): | ||
| return ["no backup sidecar -- nothing to restore"] | ||
| patches = json.load(open(sc)).get("patches", []) | ||
| results: List[str] = [] | ||
| _p("Opening %s" % os.path.basename(vhd_path)) | ||
| vhd = open_disk(vhd_path, writable=True) | ||
| try: | ||
| for i, p in enumerate(patches, 1): | ||
| _p("Restoring su %d/%d..." % (i, len(patches))) | ||
| off = p["offset"] | ||
| orig = bytes(int(x, 16) for x in p["orig"].split()) | ||
| cur = vhd.read(off, 3) | ||
| if cur == orig: | ||
| results.append("su@0x%X already original" % off) | ||
| continue | ||
| vhd.write(off, orig) | ||
| results.append("su@0x%X restored (%s -> %s)" % (off, cur.hex(" "), orig.hex(" "))) | ||
| finally: | ||
| vhd.close() | ||
| try: | ||
| os.remove(sc) | ||
| except OSError: | ||
| pass | ||
| return results |
There was a problem hiding this comment.
In disable(), the backup sidecar file is opened without specifying an explicit encoding, and reading/parsing the JSON is not wrapped in a try-except block. If the file is corrupted or unreadable, it will raise an unhandled exception and crash the background thread.
Additionally, always specify encoding="utf-8" when opening text files to ensure platform-independent behavior.
def disable(vhd_path: str, progress=None) -> List[str]:
"""Restore original su bytes from the sidecar (un-root)."""
def _p(msg):
logger.info(msg)
if progress:
progress(msg)
sc = _sidecar(vhd_path)
if not os.path.isfile(sc):
return ["no backup sidecar -- nothing to restore"]
try:
with open(sc, "r", encoding="utf-8") as f:
patches = json.load(f).get("patches", [])
except Exception as exc:
return [f"failed to read backup sidecar: {exc}"]
results: List[str] = []
_p("Opening %s" % os.path.basename(vhd_path))
vhd = open_disk(vhd_path, writable=True)
try:
for i, p in enumerate(patches, 1):
_p("Restoring su %d/%d..." % (i, len(patches)))
off = p["offset"]
orig = bytes(int(x, 16) for x in p["orig"].split())
cur = vhd.read(off, 3)
if cur == orig:
results.append("su@0x%X already original" % off)
continue
vhd.write(off, orig)
results.append("su@0x%X restored (%s -> %s)" % (off, cur.hex(" "), orig.hex(" ")))
finally:
vhd.close()
try:
os.remove(sc)
except OSError:
pass
return results| def pe_image_base_and_sections(data: bytes) -> Tuple[int, List[Tuple[int, int, int, int]]]: | ||
| """Parse just enough PE to map file offsets <-> RVAs. | ||
|
|
||
| Returns ``(image_base, sections)`` where each section is | ||
| ``(virtual_address, virtual_size, pointer_to_raw, size_of_raw)``. | ||
| """ | ||
| e_lfanew = struct.unpack_from("<I", data, 0x3C)[0] | ||
| if data[e_lfanew:e_lfanew + 4] != b"PE\x00\x00": | ||
| raise ValueError("not a PE file") | ||
| coff = e_lfanew + 4 | ||
| num_sections = struct.unpack_from("<H", data, coff + 2)[0] | ||
| size_opt = struct.unpack_from("<H", data, coff + 16)[0] | ||
| opt = coff + 20 | ||
| magic = struct.unpack_from("<H", data, opt)[0] | ||
| image_base = (struct.unpack_from("<Q", data, opt + 24)[0] if magic == 0x20B | ||
| else struct.unpack_from("<I", data, opt + 28)[0]) | ||
| sec_table = opt + size_opt | ||
| sections = [] | ||
| for i in range(num_sections): | ||
| o = sec_table + i * 40 | ||
| vsize = struct.unpack_from("<I", data, o + 8)[0] | ||
| va = struct.unpack_from("<I", data, o + 12)[0] | ||
| sraw = struct.unpack_from("<I", data, o + 16)[0] | ||
| praw = struct.unpack_from("<I", data, o + 20)[0] | ||
| sections.append((va, vsize, praw, sraw)) | ||
| return image_base, sections |
There was a problem hiding this comment.
If the binary file being parsed is truncated, corrupted, or not a valid PE file, struct.unpack_from can raise struct.error (e.g., if the file is too small to contain the PE headers), or list slicing can raise IndexError. Since the callers (_locate_isdiskverify and _locate_enable_root_write) only catch ValueError, any other exception raised here will propagate and crash the application.
To make PE parsing robust, wrap the parsing logic in a try-except block and raise a ValueError if any exception occurs.
def pe_image_base_and_sections(data: bytes) -> Tuple[int, List[Tuple[int, int, int, int]]]:
"""Parse just enough PE to map file offsets <-> RVAs.
Returns ``(image_base, sections)`` where each section is
``(virtual_address, virtual_size, pointer_to_raw, size_of_raw)``.
"""
try:
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
if data[e_lfanew:e_lfanew + 4] != b"PE\x00\x00":
raise ValueError("not a PE file")
coff = e_lfanew + 4
num_sections = struct.unpack_from("<H", data, coff + 2)[0]
size_opt = struct.unpack_from("<H", data, coff + 16)[0]
opt = coff + 20
magic = struct.unpack_from("<H", data, opt)[0]
image_base = (struct.unpack_from("<Q", data, opt + 24)[0] if magic == 0x20B
else struct.unpack_from("<I", data, opt + 28)[0])
sec_table = opt + size_opt
sections = []
for i in range(num_sections):
o = sec_table + i * 40
vsize = struct.unpack_from("<I", data, o + 8)[0]
va = struct.unpack_from("<I", data, o + 12)[0]
sraw = struct.unpack_from("<I", data, o + 16)[0]
praw = struct.unpack_from("<I", data, o + 20)[0]
sections.append((va, vsize, praw, sraw))
return image_base, sections
except Exception as exc:
raise ValueError("not a valid PE file") from exc| def patch_su(path: str, make_backup: bool = True) -> str: | ||
| data = bytearray(open(path, "rb").read()) | ||
| if data[:4] != b"\x7fELF": | ||
| return "skip (not an ELF su)" | ||
| entry = _find_isdevmode_entry(data) | ||
| if entry is None: | ||
| return "skip (no isDeveloperMode gate — likely an older/open su)" | ||
| cur = bytes(data[entry:entry + 3]) | ||
| if cur == PATCH: | ||
| return f"already patched (isDeveloperMode@0x{entry:X})" | ||
| data[entry:entry + 3] = PATCH | ||
| if make_backup and not os.path.exists(path + ".orig"): | ||
| shutil.copy2(path, path + ".orig") | ||
| open(path, "wb").write(data) | ||
| return f"patched isDeveloperMode@0x{entry:X}: {cur.hex(' ')} -> {PATCH.hex(' ')}" |
There was a problem hiding this comment.
Opening files using open(path, ...).read() or open(path, ...).write() without a with statement leaves the file handle open until garbage collection runs. On Windows, this can cause file locking issues and subsequent PermissionError exceptions when other parts of the application try to access the same file.
Use a with statement to ensure file handles are closed immediately.
| def patch_su(path: str, make_backup: bool = True) -> str: | |
| data = bytearray(open(path, "rb").read()) | |
| if data[:4] != b"\x7fELF": | |
| return "skip (not an ELF su)" | |
| entry = _find_isdevmode_entry(data) | |
| if entry is None: | |
| return "skip (no isDeveloperMode gate — likely an older/open su)" | |
| cur = bytes(data[entry:entry + 3]) | |
| if cur == PATCH: | |
| return f"already patched (isDeveloperMode@0x{entry:X})" | |
| data[entry:entry + 3] = PATCH | |
| if make_backup and not os.path.exists(path + ".orig"): | |
| shutil.copy2(path, path + ".orig") | |
| open(path, "wb").write(data) | |
| return f"patched isDeveloperMode@0x{entry:X}: {cur.hex(' ')} -> {PATCH.hex(' ')}" | |
| def patch_su(path: str, make_backup: bool = True) -> str: | |
| with open(path, "rb") as f: | |
| data = bytearray(f.read()) | |
| if data[:4] != b"\x7fELF": | |
| return "skip (not an ELF su)" | |
| entry = _find_isdevmode_entry(data) | |
| if entry is None: | |
| return "skip (no isDeveloperMode gate — likely an older/open su)" | |
| cur = bytes(data[entry:entry + 3]) | |
| if cur == PATCH: | |
| return f"already patched (isDeveloperMode@0x{entry:X})" | |
| data[entry:entry + 3] = PATCH | |
| if make_backup and not os.path.exists(path + ".orig"): | |
| shutil.copy2(path, path + ".orig") | |
| with open(path, "wb") as f: | |
| f.write(data) | |
| return f"patched isDeveloperMode@0x{entry:X}: {cur.hex(' ')} -> {PATCH.hex(' ')}" |
Validated against a live 5.22.232.1002 / Android 13 root test: - constants: add HD-MultiInstanceManager.exe to the terminate list. The engine patch rewrites that binary, but it was never killed first, so patching failed with 'Permission denied' whenever the Manager window was open. Confirmed fixed live (tool now terminates PID before patching). - integrity_patch: restore_file() records the patched-file SHA-256 and refuses to restore a stale .prepatch.bak over a binary BlueStacks has since updated, preventing a version-mismatch break. Unit-tested. - tools/e2fsprogs: document the pre-inclusion binary audit (clean PE imports, version 1.44.5 self-report) + a SHA-256 manifest.
) Validated against a live 5.22.232.1002 / Android 13 root test: - constants: add HD-MultiInstanceManager.exe to the terminate list. The engine patch rewrites that binary, but it was never killed first, so patching failed with 'Permission denied' whenever the Manager window was open. Confirmed fixed live (tool now terminates PID before patching). - integrity_patch: restore_file() records the patched-file SHA-256 and refuses to restore a stale .prepatch.bak over a binary BlueStacks has since updated, preventing a version-mismatch break. Unit-tested. - tools/e2fsprogs: document the pre-inclusion binary audit (clean PE imports, version 1.44.5 self-report) + a SHA-256 manifest.
AndnixSH's engine-patch + offline su-patch approach. Tested working end-to-end on 5.22.232.1002 / Android 13: engine patched (HD-Player + HD-MultiInstanceManager), both guest su binaries patched in Data.vhdx, instance booted with no tamper shutdown, Root Checker confirmed root. Maintainer follow-up (separate PR) adds: auto-kill HD-MultiInstanceManager before patching, restore brick-guard, and a binary-provenance audit.
) Validated against a live 5.22.232.1002 / Android 13 root test: - constants: add HD-MultiInstanceManager.exe to the terminate list. The engine patch rewrites that binary, but it was never killed first, so patching failed with 'Permission denied' whenever the Manager window was open. Confirmed fixed live (tool now terminates PID before patching). - integrity_patch: restore_file() records the patched-file SHA-256 and refuses to restore a stale .prepatch.bak over a binary BlueStacks has since updated, preventing a version-mismatch break. Unit-tested. - tools/e2fsprogs: document the pre-inclusion binary audit (clean PE imports, version 1.44.5 self-report) + a SHA-256 manifest.
…ode root Update README to the shipped v3.1.0 app: nav-rail layout (Dashboard/ Instances/Modules), patch-gating banner, update-revert alert, real progress bar, display-name/root-highlight instance list, theme toggle. Refresh the usage guide and troubleshooting to route steps to their pages, and rebuild the Development structure for the views/ package, adb_handler.py, and the pytest/pytest-qt suite. Add visible attribution for the patch-mode rooting method (engine patch + offline Data.vhdx guest-su patch) contributed by @AndnixSH in PR #27: a note in How It Works and a dedicated Credits section.
Added support rooting Bluestacks Global and China 5.22.150.1014 and above. It now requires admin rights to patch the exe files and SU files to disable anti-tampering (Issues #13, #15)
MSI App Player requires bundled debugfs (taken from Cygwin) to root properly
This is all done using AI. You are welcome to reject this PR if you don't like AI
Proof of concept: Rooted Bluestacks Global 5.22.232.1002 Android 13 (beta). Both build-in SU and Kitsume Magisk works