Skip to content

Support root for latest versions of all Bluestacks editions#27

Merged
RobThePCGuy merged 7 commits into
RobThePCGuy:masterfrom
AndnixSH:fix/root-all-variants
Jul 8, 2026
Merged

Support root for latest versions of all Bluestacks editions#27
RobThePCGuy merged 7 commits into
RobThePCGuy:masterfrom
AndnixSH:fix/root-all-variants

Conversation

@AndnixSH

@AndnixSH AndnixSH commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

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

billede billede

AndnixSH and others added 7 commits July 4, 2026 23:53
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

@gemini-code-assist gemini-code-assist Bot left a comment

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.

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.

Comment thread admin.py
Comment on lines +120 to +124
try:
# ShellExecuteW with the "runas" verb raises the UAC consent dialog.
rc = ctypes.windll.shell32.ShellExecuteW(
None, "runas", executable, params, lp_directory, SW_SHOWNORMAL
)

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 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.

Suggested change
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
)

Comment thread su_patch_offline.py
Comment on lines +427 to +485
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

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

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:

  1. Scan the entries, read the original bytes, and write the backup sidecar file.
  2. 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

Comment thread ext4_symlink.py
Comment on lines +131 to +136
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

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.

medium

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.

Suggested change
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

Comment thread ext4_symlink.py
Comment on lines +139 to +142
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)

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.

medium

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)

Comment thread su_patch_offline.py
Comment on lines +229 to +235
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)

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.

medium

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.

Suggested change
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

Comment thread su_patch_offline.py
Comment on lines +488 to +519
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

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.

medium

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

Comment thread integrity_patch.py
Comment on lines +90 to +115
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

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.

medium

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

Comment thread su_patch.py
Comment on lines +166 to +180
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(' ')}"

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.

medium

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.

Suggested change
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(' ')}"

@RobThePCGuy
RobThePCGuy merged commit 3524dc4 into RobThePCGuy:master Jul 8, 2026
3 checks passed
RobThePCGuy added a commit that referenced this pull request Jul 8, 2026
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.
RobThePCGuy added a commit that referenced this pull request Jul 8, 2026
)

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.
RobThePCGuy added a commit that referenced this pull request Jul 19, 2026
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.
RobThePCGuy added a commit that referenced this pull request Jul 19, 2026
)

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.
RobThePCGuy added a commit that referenced this pull request Jul 19, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants