From 96c80b9b2f6a0294522fe049b6d5101669d7dd33 Mon Sep 17 00:00:00 2001 From: Jordan Ye <79342877+Jordan231111@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:29:41 -0400 Subject: [PATCH 1/2] fix(adb): immune to adb-version conflicts + live-bound port detection Boot-And-Wait was throwing "instance '' did not boot / become adb-reachable within 300 s" on hosts that ALSO have a different-version system adb (Android SDK platform-tools v1.0.41) alongside BlueStacks' HD-Adb v1.0.36. The two kill each other's adb server on the shared default port 5037 ("server version doesn't match this client; killing"), so getprop sys.boot_completed fails forever even though the instance booted fine. Nothing about the instance is wrong. - Pin HD-Adb to its OWN server port (ANDROID_ADB_SERVER_PORT=15037) and only ever use HD-Adb.exe (Resolve-HdAdb; never a system adb.exe). - Merge the live-bound listening port (Get-NetTCPConnection, band 5550-5900) into Get-AdbPortCandidates, AFTER the conf ports, to rescue a stale status.adb_port. Deterministic test seam for the live scan. - Mirror the server isolation in the engine's Connect-WaitBoot. - Run-Resolve-Tests: + probe seam and 3 merge/dedup cases (now 25); re-embedded into blueStackRoot.cmd; Check-Embedded-Sync + Run-Tests (28) pass. Proven live with a v41 server on 5037: isolated getprop 30/30 OK, shared-port control 0/12 (exact reporter error), port detection 20/20 stable, full Boot-And-Wait end-to-end PASS. --- CHANGELOG.md | 28 + blueStackRoot.cmd | 3575 +++++++++++++------------- docs/BLUESTACKS_ROOTING_DEEP_DIVE.md | 1 + docs/RUNBOOK.md | 1 + tests/Run-Resolve-Tests.ps1 | 15 + todolist.md | 8 + tools/bsr_engine.ps1 | 4 + tools/bsr_magisk.ps1 | 73 +- 8 files changed, 1946 insertions(+), 1759 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fff0eda..4ade47f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ Player — from one file, fully automatically. Releases are grouped by the BlueS --- +## v11 — adb robustness: immune to adb-version conflicts + live-bound port detection · 2026-06-02 + +Fixes a report where a **fully-booted** instance (Home visible, Magisk installed) still failed with +`instance '' did not boot / become adb-reachable within 300 s`. Root cause was **not** the instance: +a **system `adb` of a different version** on the host (Android SDK platform-tools **v1.0.41**) and +BlueStacks' bundled **HD-Adb v1.0.36** were killing each other's adb **server** on the shared default port +5037 (*"adb server version doesn't match this client; killing…"*), so the tool's `getprop sys.boot_completed` +calls failed forever and `Boot-And-Wait` timed out. + +- 🛡️ **Version-conflict immunity.** The tool now pins BlueStacks' HD-Adb onto its **own private adb server + port** (`ANDROID_ADB_SERVER_PORT=15037`) and only ever uses `HD-Adb.exe` (never a system `adb.exe`). A + foreign-version adb on 5037 can no longer touch our server. *Proven on this machine:* with a v41 server + deliberately running on 5037, HD-Adb `getprop` on 15037 succeeded **30/30**; on the shared 5037 port it + failed **0/12** with the exact reporter error; the full `Boot-And-Wait` then booted the instance + end-to-end despite the v41 competitor. +- 🎯 **Port detection hardened.** `Get-AdbPortCandidates` now also consults the **actually-bound listening + port** (`Get-NetTCPConnection`, band 5550-5900), merged *after* the `bluestacks.conf` + `status.adb_port`/`adb_port` values (which stay authoritative). This rescues the boot wait when the conf + is stale — verified live: conf said `status.adb_port=5646` while the instance was really on **5645**, and + the live scan found 5645 on **20/20** runs. +- 🧪 **Tests.** `tests/Run-Resolve-Tests.ps1` gains a deterministic seam + 3 new cases for the conf+live + merge/dedup order (25 checks); `Run-Tests.ps1` (28) and `Check-Embedded-Sync.ps1` still pass. Re-embedded + into `blueStackRoot.cmd` (engine + orchestrator back in sync). +- 📄 **No** change to the rooting pipeline, the embedded Magisk APK, or any on-disk format — purely + host-side adb plumbing in `tools/bsr_magisk.ps1` (+ a one-line mirror in `tools/bsr_engine.ps1`). + +--- + ## v10 — Custom Kitsune build: the in-app DenyList now works with ReZygisk · 2026-06-02 Swaps the bundled Magisk for a **custom build of Kitsune Mask v31** (still `31.0-kitsune`, versionCode diff --git a/blueStackRoot.cmd b/blueStackRoot.cmd index c390f87..296e12f 100644 --- a/blueStackRoot.cmd +++ b/blueStackRoot.cmd @@ -271,1070 +271,1074 @@ REM Everything below this line is DATA, never executed by cmd. REM (engine PowerShell, then the gzip+base64 su payload) REM ==================================================================== __BSR_ENGINE_BEGIN__ -<# - bsr_engine.ps1 -- blueStackRoot engine - - Pure-PowerShell, faithful re-implementation of the heavy lifting performed by - BstkRooter.exe (Taaauu "BSTK Rooter" 1.0.1), derived byte-for-byte from - recovered/BstkRooter/BstkRooter_FULL_DERIVATION.md. - - This file is the canonical source. It is embedded verbatim inside - blueStackRoot.cmd (between the engine BEGIN/END marker lines); the .cmd - extracts it to a temp .ps1 at run time and calls it. The test-suite extracts - the embedded copy and runs it, so the .cmd is what is actually tested. - - ACTIONS - Patch Version-proof HD-Player.exe disk-integrity patch (NOP the jz of every - validated CALL ; TEST AL,AL ; JZ site). -Restore reverts from .bak, - -DryRun previews. - Root Offline install of the embedded setuid su into the ext4 inside Root.vhd - (/android/system/xbin/su, mode 0106755, owner 0:0) via debugfs. - Unroot Offline removal of /android/system/xbin/su from Root.vhd. - ExtractSu Decode the embedded su payload to -OutFile (used by tests / debugging). - TestExt4 Run the exact debugfs edit against a plain ext4 image (-Img) -- used by - the test-suite to exercise the ext4 logic with no VHD / no admin. - - NOTHING here depends on BstkRooter.exe. The su payload travels inside the .cmd. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [ValidateSet('Patch', 'Root', 'Unroot', 'ExtractSu', 'TestExt4', 'DiskRW', 'DiskRO', 'ConfRoot', 'ConfUnroot', 'Resolve', 'BaseDir', 'VhdSelfTest', 'AdbRoot', 'AdbUnroot', 'AdbVerify')] - [string]$Action, - - [string]$Exe, # HD-Player.exe (Patch) - [string]$Vhd, # Root.vhd (Root / Unroot) - [string]$Bstk, # .bstk (DiskRW / DiskRO) - [string]$Conf, # bluestacks.conf (ConfRoot / ConfUnroot) - [string]$Instance, # instance name (ConfRoot / ConfUnroot / AdbRoot) - [string]$SelfPath, # the .cmd carrying the embedded su (+debugfs) blobs - [string]$Debugfs, # path to debugfs.exe (offline fallback) - [string]$OutFile, # ExtractSu target - [string]$Img, # TestExt4 target (plain ext4 image) - [string]$DataDir, # BlueStacks DataDir (Resolve / BaseDir) - [string]$UserDef, # BlueStacks UserDefinedDir (Resolve / BaseDir) - [string]$Base, # base version e.g. Rvc64 (Resolve) - [string]$Adb, # HD-Adb.exe (AdbRoot / AdbUnroot) - [string]$Player, # HD-Player.exe (to boot instance) (AdbRoot) - [string]$AdbPort, # instance adb port, e.g. 5555 (AdbRoot) - - [switch]$Restore, - [switch]$DryRun, - [switch]$NoBackup, - [switch]$NoLaunch, # AdbRoot: do not auto-launch the instance (assume already booted) - [switch]$Force # patch even when no anchor string validates a candidate -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version 2 - -# Allow the .cmd to pass path-like inputs via environment variables (avoids batch quoting pain). -function EnvOr([string]$val, [string]$name) { if ($val) { return $val } $e = [Environment]::GetEnvironmentVariable($name); if ($e) { return $e } return $val } -$Exe = EnvOr $Exe 'BSR_EXE' -$Vhd = EnvOr $Vhd 'BSR_VHD' -$Bstk = EnvOr $Bstk 'BSR_BSTK' -$Conf = EnvOr $Conf 'BSR_CONF' -$Instance = EnvOr $Instance 'BSR_INSTANCE' -$SelfPath = EnvOr $SelfPath 'BSR_SELF' -$Debugfs = EnvOr $Debugfs 'BSR_DEBUGFS' -$DataDir = EnvOr $DataDir 'BSR_DATADIR' -$UserDef = EnvOr $UserDef 'BSR_USERDEF' -$Base = EnvOr $Base 'BSR_BASE' -$Adb = EnvOr $Adb 'BSR_ADB' -$Player = EnvOr $Player 'BSR_PLAYER' -$AdbPort = EnvOr $AdbPort 'BSR_ADBPORT' -if (-not $Restore -and $env:BSR_RESTORE -eq '1') { $Restore = $true } -if (-not $NoBackup -and $env:BSR_NOBACKUP -eq '1') { $NoBackup = $true } -if (-not $NoLaunch -and $env:BSR_NOLAUNCH -eq '1') { $NoLaunch = $true } -if (-not $Force -and $env:BSR_FORCE -eq '1') { $Force = $true } - -function Say([string]$m, [string]$c = 'Gray') { Write-Host $m -ForegroundColor $c } - -# Registry discovery (NO hardcoded install/data paths): honour a custom BlueStacks location by reading -# InstallDir/DataDir/UserDefinedDir from the registry -- nxt (BlueStacks 5) then msi5 (MSI App Player), -# native and WOW6432Node views. Must not Write-Host (callers like Resolve/BaseDir parse stdout). -function Get-RegBlueStacks { - foreach ($k in @('HKLM:\SOFTWARE\BlueStacks_nxt', 'HKLM:\SOFTWARE\BlueStacks_msi5', - 'HKLM:\SOFTWARE\WOW6432Node\BlueStacks_nxt', 'HKLM:\SOFTWARE\WOW6432Node\BlueStacks_msi5')) { - try { - $p = Get-ItemProperty -Path $k -ErrorAction Stop - if ($p -and ($p.InstallDir -or $p.DataDir -or $p.UserDefinedDir)) { - return [pscustomobject]@{ InstallDir = $p.InstallDir; DataDir = $p.DataDir; UserDefinedDir = $p.UserDefinedDir } - } - } catch { } - } - return $null -} - -# Expected SHA-256 of the decrypted su ELF (derivation §1). Used as an integrity gate. -$Script:SuSha256 = '185106357CFC0D1DB4B8EFB033DE863F437850437E0EF6B62630C05F291B4902' - -# --------------------------------------------------------------------------- -# Embedded su extraction -# --------------------------------------------------------------------------- -# Markers are built by concatenation so the literal token appears in the file -# ONLY on the real blob lines, never here -- otherwise IndexOf would match this code. -function Get-EmbeddedSu([string]$selfPath) { - if (-not $selfPath -or -not (Test-Path -LiteralPath $selfPath)) { - throw "Embedded-su source not found (SelfPath='$selfPath'). Pass -SelfPath ." - } - $text = [System.IO.File]::ReadAllText($selfPath) - $beg = '__BSR_SU_' + 'BEGIN__' - $end = '__BSR_SU_' + 'END__' - $i = $text.IndexOf($beg) - $j = $text.IndexOf($end) - if ($i -lt 0 -or $j -lt 0 -or $j -le $i) { throw "su payload markers not found in '$selfPath'." } - $i += $beg.Length - $b64 = $text.Substring($i, $j - $i) - # strip all whitespace (line wraps, CR/LF, the marker's own EOL) - $b64 = ($b64 -replace '[^A-Za-z0-9+/=]', '') - if ($b64.Length -lt 64) { throw "su payload is empty -- run tools/embed-su.ps1 to populate it." } - $gz = [Convert]::FromBase64String($b64) - # gunzip via .NET GZipStream (matches the .NET GZipStream used to compress) - $in = New-Object System.IO.MemoryStream(, $gz) - $z = New-Object System.IO.Compression.GZipStream($in, [System.IO.Compression.CompressionMode]::Decompress) - $out = New-Object System.IO.MemoryStream - $buf = New-Object byte[] 65536 - while (($n = $z.Read($buf, 0, $buf.Length)) -gt 0) { $out.Write($buf, 0, $n) } - $z.Close(); $in.Close() - $bytes = $out.ToArray(); $out.Close() - # integrity gate - $sha = (Get-Sha256Hex $bytes) - if ($sha -ne $Script:SuSha256) { - throw "Embedded su FAILED integrity check.`n expected $($Script:SuSha256)`n got $sha" - } - return $bytes -} - -function Get-Sha256Hex([byte[]]$bytes) { - $h = [System.Security.Cryptography.SHA256]::Create() - try { return (($h.ComputeHash($bytes) | ForEach-Object { $_.ToString('X2') }) -join '') } - finally { $h.Dispose() } -} - -# --------------------------------------------------------------------------- -# Embedded debugfs bundle (offline fallback) -- a base64'd .zip of the Cygwin -# debugfs.exe + its 10 DLLs, carried inside the .cmd between __BSR_DFS_* lines. -# Extracted once to %TEMP%\bsr_work\debugfs\ and reused. Returns debugfs.exe -# path, or $null if no bundle is embedded. -# --------------------------------------------------------------------------- -function Expand-EmbeddedDebugfs([string]$selfPath) { - $destDir = Join-Path (Join-Path $env:TEMP 'bsr_work') 'debugfs' - $exe = Join-Path $destDir 'debugfs.exe' - if (Test-Path -LiteralPath $exe) { return $exe } # already extracted this session - if (-not $selfPath -or -not (Test-Path -LiteralPath $selfPath)) { return $null } - $text = [System.IO.File]::ReadAllText($selfPath) - $beg = '__BSR_DFS_' + 'BEGIN__'; $end = '__BSR_DFS_' + 'END__' - $i = $text.IndexOf($beg); $j = $text.IndexOf($end) - if ($i -lt 0 -or $j -le $i) { return $null } # no bundle embedded - $i += $beg.Length - $b64 = ($text.Substring($i, $j - $i) -replace '[^A-Za-z0-9+/=]', '') - if ($b64.Length -lt 1024) { return $null } - if (-not (Test-Path -LiteralPath $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } - $zipPath = Join-Path $destDir '_dfs.zip' - [System.IO.File]::WriteAllBytes($zipPath, [Convert]::FromBase64String($b64)) - try { Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue } catch { } - $za = [System.IO.Compression.ZipFile]::OpenRead($zipPath) - try { - foreach ($e in $za.Entries) { - if (-not $e.Name) { continue } # directory entry - $t = Join-Path $destDir $e.FullName - $d = Split-Path -Parent $t - if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($e, $t, $true) - } - } - finally { $za.Dispose() } - Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue - if (Test-Path -LiteralPath $exe) { return $exe } - return $null -} - -# =========================================================================== -# PATCH -- HD-Player.exe disk-integrity bypass (version proof) -# =========================================================================== -# raw file offset -> RVA across all sections -function RawToRva([int]$raw, $sections) { - foreach ($s in $sections) { - if ($raw -ge $s.RawPtr -and $raw -lt ($s.RawPtr + $s.RawSize)) { return [int]($s.VA + ($raw - $s.RawPtr)) } - } - return -1 -} - -# RVA of every occurrence of an ASCII (NUL-terminated) string -function StringRvas([byte[]]$b, [string]$text, $sections) { - $needle = [System.Text.Encoding]::ASCII.GetBytes($text) - $hits = New-Object System.Collections.Generic.List[int] - $max = $b.Length - $needle.Length - 1 - for ($i = 0; $i -le $max; $i++) { - if ($b[$i] -ne $needle[0]) { continue } - $ok = $true - for ($k = 1; $k -lt $needle.Length; $k++) { if ($b[$i + $k] -ne $needle[$k]) { $ok = $false; break } } - if ($ok -and $b[$i + $needle.Length] -eq 0) { - $rva = RawToRva $i $sections - if ($rva -ge 0) { [void]$hits.Add($rva) } - } - } - return $hits -} - -# Is there a RIP-relative LEA to any of $targetRvas within +/-window of $t (TEST offset)? -function NearAnchor([byte[]]$b, [int]$t, [int]$textVA, [int]$textRaw, [int]$window, $targetSet) { - $lo = [Math]::Max($textRaw, $t - $window); $hi = $t + $window - if ($hi -gt $b.Length - 7) { $hi = $b.Length - 7 } - for ($p = $lo; $p -lt $hi; $p++) { - $rex = $b[$p] - if ($rex -ne 0x48 -and $rex -ne 0x4C -and $rex -ne 0x49 -and $rex -ne 0x4D) { continue } - if ($b[$p + 1] -ne 0x8D) { continue } # LEA - if (($b[$p + 2] -band 0xC7) -ne 0x05) { continue } # mod=00, rm=101 -> [rip+disp32] - $disp = [BitConverter]::ToInt32($b, $p + 3) - $target = $textVA + (($p + 7) - $textRaw) + $disp # RVA after the 7-byte LEA + disp - if ($targetSet.Contains($target)) { return $true } - } - return $false -} - -function Invoke-Patch { - if (-not $Exe) { throw "Patch requires -Exe ." } - if (-not (Test-Path -LiteralPath $Exe)) { throw "HD-Player.exe not found: $Exe" } - $bak = "$Exe.bak" - - if ($Restore) { - if (-not (Test-Path -LiteralPath $bak)) { Say "[!] No backup to restore: $bak" Red; return 1 } - Copy-Item -LiteralPath $bak -Destination $Exe -Force - Say "[+] Restored $Exe from $bak" Green - return 0 - } - - $b = [System.IO.File]::ReadAllBytes($Exe) - Say "[*] Loaded $Exe ($($b.Length) bytes)" - if ($b.Length -lt 0x200) { Say "[!] File too small for a PE." Red; return 1 } - - $e_lfanew = [BitConverter]::ToInt32($b, 0x3C) - if ($e_lfanew -le 0 -or $e_lfanew + 0x40 -ge $b.Length -or $b[$e_lfanew] -ne 0x50 -or $b[$e_lfanew + 1] -ne 0x45) { - Say "[!] Invalid PE header." Red; return 1 - } - $numSections = [BitConverter]::ToUInt16($b, $e_lfanew + 6) - $sizeOptHdr = [BitConverter]::ToUInt16($b, $e_lfanew + 20) - $optHdr = $e_lfanew + 24 - $magic = [BitConverter]::ToUInt16($b, $optHdr) - if ($magic -eq 0x20B) { $imageBase = [BitConverter]::ToUInt64($b, $optHdr + 24) } - else { $imageBase = [BitConverter]::ToUInt32($b, $optHdr + 28) } - - $secTable = $optHdr + $sizeOptHdr - $textRaw = $null; $textRawSize = $null; $textVA = $null - $sections = @() - for ($i = 0; $i -lt $numSections; $i++) { - $s = $secTable + ($i * 40) - $name = ([System.Text.Encoding]::ASCII.GetString($b, $s, 8)).TrimEnd([char]0) - $va = [BitConverter]::ToUInt32($b, $s + 12) - $rs = [BitConverter]::ToUInt32($b, $s + 16) - $pr = [BitConverter]::ToUInt32($b, $s + 20) - $sections += [pscustomobject]@{ Name = $name; VA = $va; RawSize = $rs; RawPtr = $pr } - if ($name -eq '.text') { $textVA = [int]$va; $textRaw = [int]$pr; $textRawSize = [int]$rs } - } - if ($null -eq $textRaw) { Say "[!] .text section not found." Red; return 1 } - - # anchor string RVAs (faithful set + extra-hardening set) - $primaryStr = @('Verified the disk integrity!', 'Failed to verify the disk integrity!') - $fallbackStr = @('plrDiskCheckThreadEntry', - 'Shutting down: disk file have been illegally tampered with!', - 'Failed to verify the file', 'In warmup mode: Stopping player.') - - $primarySet = New-Object System.Collections.Generic.HashSet[int] - foreach ($s in $primaryStr) { foreach ($r in (StringRvas $b $s $sections)) { [void]$primarySet.Add($r) } } - $fallbackSet = New-Object System.Collections.Generic.HashSet[int] - foreach ($r in $primarySet) { [void]$fallbackSet.Add($r) } - foreach ($s in $fallbackStr) { foreach ($r in (StringRvas $b $s $sections)) { [void]$fallbackSet.Add($r) } } - - Say ("[*] anchor strings: {0} primary RVA(s), {1} total" -f $primarySet.Count, $fallbackSet.Count) - - # scan .text for E8 ?? ?? ?? ?? 84 C0 74 ?? (t = offset of TEST AL,AL) - $textStart = $textRaw + 5 - $textEnd = $textRaw + $textRawSize - 3 - if ($textEnd -gt $b.Length - 3) { $textEnd = $b.Length - 3 } - # match E8.. 84 C0 74 ?? (unpatched) OR E8.. 84 C0 90 90 (already patched) - $cands = New-Object System.Collections.Generic.List[int] - for ($t = $textStart; $t -lt $textEnd; $t++) { - if ($b[$t] -eq 0x84 -and $b[$t + 1] -eq 0xC0 -and $b[$t - 5] -eq 0xE8 -and - ($b[$t + 2] -eq 0x74 -or ($b[$t + 2] -eq 0x90 -and $b[$t + 3] -eq 0x90))) { - [void]$cands.Add($t) - } - } - Say "[*] Found $($cands.Count) candidate site(s) (CALL; TEST AL,AL; JZ)" - if ($cands.Count -eq 0) { Say "[!] No candidate sites. BlueStacks build differs too much -- aborting (nothing changed)." Red; return 1 } - - # select sites to patch: primary (verify/fail, tight window) first, then fallback (wider) - $sites = New-Object System.Collections.Generic.List[int] - $how = '' - if ($primarySet.Count -gt 0) { - foreach ($c in $cands) { if (NearAnchor $b $c $textVA $textRaw 0xE0 $primarySet) { [void]$sites.Add($c) } } - if ($sites.Count -gt 0) { $how = 'verify/fail disk-integrity string' } - } - if ($sites.Count -eq 0 -and $fallbackSet.Count -gt 0) { - foreach ($c in $cands) { if (NearAnchor $b $c $textVA $textRaw 0x700 $fallbackSet) { [void]$sites.Add($c) } } - if ($sites.Count -gt 0) { $how = 'fallback anchor (plrDiskCheckThreadEntry/shutdown/per-block/warmup)' } - } - if ($sites.Count -eq 0) { - if ($cands.Count -eq 1 -and $Force) { [void]$sites.Add($cands[0]); $how = 'single candidate (-Force)' } - else { - Say "[!] $($cands.Count) candidate(s) but none validated by an anchor string." Red - Say " Refusing to blind-patch (would risk corrupting HD-Player.exe). Use -Force only if you are sure." Yellow - return 1 - } - } - - $toApply = @(); $already = 0 - foreach ($t in $sites) { - if ($b[$t + 2] -eq 0x90 -and $b[$t + 3] -eq 0x90) { $already++; continue } - if ($b[$t + 2] -ne 0x74) { continue } - $toApply += $t - } - foreach ($t in $sites) { - $rva = RawToRva $t $sections - $va = $imageBase + $rva - Say (" site file=0x{0:X} va=0x{1:X} {2:X2} {3:X2} {4:X2} {5:X2} [{6}]" -f ` - $t, $va, $b[$t], $b[$t + 1], $b[$t + 2], $b[$t + 3], $how) - } - if ($toApply.Count -eq 0) { - if ($already -gt 0) { Say "[~] Already patched ($already site(s)). Nothing to do." Yellow; return 0 } - Say "[~] Nothing to patch." Yellow; return 0 - } - - if ($DryRun) { Say "[+] Dry run -- would NOP $($toApply.Count) site(s). No file written." Yellow; return 0 } - - if (-not $NoBackup) { - if (-not (Test-Path -LiteralPath $bak)) { Copy-Item -LiteralPath $Exe -Destination $bak -Force; Say "[*] Backup created: $bak" } - else { Say "[*] Backup already exists, skipping copy." } - } - foreach ($t in $toApply) { - Say ("[*] Patching at 0x{0:X}: {1:X2} {2:X2} -> 90 90" -f ($t + 2), $b[$t + 2], $b[$t + 3]) Cyan - $b[$t + 2] = 0x90; $b[$t + 3] = 0x90 - } - try { [System.IO.File]::WriteAllBytes($Exe, $b) } - catch { Say "[!] Failed to write -- run as Administrator / close the emulator first." Red; return 1 } - Say "[+] Patched successfully! ($($toApply.Count) site(s), $already already patched)" Green - return 0 -} - -# =========================================================================== -# EXT4 EDIT -- shared debugfs logic (used by Root / Unroot / TestExt4) -# =========================================================================== -function To-DebugfsPath([string]$p) { - # forward slashes are accepted by Win32 CreateFile and avoid debugfs backslash escaping - return ($p -replace '\\', '/') -} - -function Resolve-Debugfs { - if ($Debugfs -and (Test-Path -LiteralPath $Debugfs)) { return (Resolve-Path -LiteralPath $Debugfs).Path } - $c = Get-Command debugfs.exe -ErrorAction SilentlyContinue - if ($c) { return $c.Source } - # fall back to the debugfs bundle embedded in the .cmd itself - $emb = Expand-EmbeddedDebugfs $SelfPath - if ($emb) { Say "[*] using embedded debugfs (extracted from the .cmd)." ; return $emb } - throw @" -debugfs.exe was not found and no embedded debugfs bundle is present. - -The offline ext4 method needs e2fsprogs' debugfs.exe. The single-file build -normally carries one; if you are running the raw engine, pass -Debugfs -or put debugfs.exe in tools\debugfs\ (see tools\debugfs\ in the repo). -"@ -} - -function Run-Debugfs([string]$debugfsExe, [string]$imgPath, [string[]]$cmds, [switch]$Write) { - $script = New-TempFile 'bsr_dfs' '.txt' - Set-Content -LiteralPath $script -Value ($cmds -join "`n") -Encoding ascii -NoNewline - $args = @() - if ($Write) { $args += '-w' } - $args += @('-f', $script, $imgPath) - # debugfs prints its version banner (and many notices) to stderr on EVERY run. - # Under ErrorActionPreference=Stop those stderr lines become terminating errors, - # so drop to Continue for the native call and fold stderr into the captured text. - $old = $ErrorActionPreference; $ErrorActionPreference = 'Continue' - try { $out = & $debugfsExe @args 2>&1 | Out-String } - finally { $ErrorActionPreference = $old } - Remove-Item -LiteralPath $script -Force -ErrorAction SilentlyContinue - return $out -} - -$Script:TempFiles = New-Object System.Collections.Generic.List[string] -function New-TempFile([string]$prefix, [string]$ext) { - $root = Join-Path $env:TEMP 'bsr_work' - if (-not (Test-Path -LiteralPath $root)) { New-Item -ItemType Directory -Path $root -Force | Out-Null } - # no random (deterministic & resume-safe); caller ensures uniqueness by prefix - $f = Join-Path $root ($prefix + $ext) - [void]$Script:TempFiles.Add($f) - return $f -} - -# Edit a plain ext4 image: install (remove=$false) or delete (remove=$true) the su. -# Verifies the result BEFORE returning so callers can refuse to write a bad image back. -function Edit-Ext4([string]$imgPath, [bool]$remove, [byte[]]$suBytes) { - $dfs = Resolve-Debugfs - $imgD = To-DebugfsPath $imgPath - Say "[*] debugfs: $dfs" - - if ($remove) { - Run-Debugfs $dfs $imgPath @('rm /android/system/xbin/su') -Write | Out-Null - $stat = Run-Debugfs $dfs $imgPath @('stat /android/system/xbin/su') - # gone = stat no longer reports an inode for it - if ($stat -notmatch '(?im)Inode:\s*\d') { Say "[+] su removed from ext4." Green; return $true } - Say "[!] su still present after removal:`n$stat" Red; return $false - } - - # install - $suFile = New-TempFile 'su' '' - [System.IO.File]::WriteAllBytes($suFile, $suBytes) - $suD = To-DebugfsPath $suFile - # NOTE: debugfs `write ` does NOT traverse as a path -- it - # creates a file in the CURRENT directory whose name is the literal - # string. So we `cd` into the target dir and write the bare basename, then - # set attributes on the bare basename (relative to cwd). mkdir on dirs that - # already exist (real Root.vhd) just prints "File exists" and is ignored. - $cmds = @( - 'mkdir /android', - 'mkdir /android/system', - 'mkdir /android/system/xbin', - 'cd /android/system/xbin', - 'rm su', - "write $suD su", - 'sif su mode 0106755', - 'sif su uid 0', - 'sif su gid 0', - 'sif su links_count 1' - ) - Run-Debugfs $dfs $imgPath $cmds -Write | Out-Null - $stat = Run-Debugfs $dfs $imgPath @('stat /android/system/xbin/su') - Remove-Item -LiteralPath $suFile -Force -ErrorAction SilentlyContinue - Say "[*] verify:`n$stat" - if ($stat -notmatch '(?im)Inode:\s*\d') { Say "[!] su was not written into ext4." Red; return $false } - # debugfs prints the permission bits only, e.g. "Mode: 06755" (NOT the full i_mode). - # Parse the octal Mode and compare its low 12 bits to 0o6755 (= 0xDED): setuid+setgid+rwxr-xr-x. - $okMode = $false - $mm = [regex]::Match($stat, '(?im)Mode:\s*0*([0-7]{3,6})') - if ($mm.Success) { try { $okMode = (([Convert]::ToInt32($mm.Groups[1].Value, 8)) -band 0xFFF) -eq 0xDED } catch { } } - if (-not $okMode) { Say "[!] su present but mode is not 06755 (setuid/setgid). Not trusting this image." Red; return $false } - Say "[+] su installed: /android/system/xbin/su mode 06755 (setuid root) owner 0:0" Green - return $true -} - -# =========================================================================== -# ROOT / UNROOT -- attach Root.vhd, locate ext4, carve, edit, write back -# =========================================================================== -function Read-DeviceBytes([string]$device, [long]$offset, [int]$count) { - $fs = [System.IO.File]::Open($device, 'Open', 'Read', 'ReadWrite') - try { - # raw-device reads must be sector-aligned: read the 512-byte sector and slice - $secBase = [long]([Math]::Floor($offset / 512) * 512) - $delta = [int]($offset - $secBase) - $need = [int]([Math]::Ceiling(($delta + $count) / 512.0) * 512) - $buf = New-Object byte[] $need - $fs.Position = $secBase - [void]$fs.Read($buf, 0, $need) - $res = New-Object byte[] $count - [Array]::Copy($buf, $delta, $res, 0, $count) - return $res - } finally { $fs.Close() } -} - -function Copy-DeviceToFile([string]$device, [long]$start, [long]$length, [string]$outFile) { - $fs = [System.IO.File]::Open($device, 'Open', 'Read', 'ReadWrite') - try { - $fs.Position = $start - $out = [System.IO.File]::Open($outFile, 'Create', 'Write', 'None') - try { - $buf = New-Object byte[] (16MB) - [long]$remaining = $length - while ($remaining -gt 0) { - $want = [int][Math]::Min([long]$buf.Length, $remaining) - $r = $fs.Read($buf, 0, $want) - if ($r -le 0) { break } - $out.Write($buf, 0, $r) - $remaining -= $r - } - } finally { $out.Close() } - } finally { $fs.Close() } -} - -function Copy-FileToDevice([string]$inFile, [string]$device, [long]$start) { - $fs = [System.IO.File]::Open($device, 'Open', 'ReadWrite', 'ReadWrite') - try { - $fs.Position = $start - $in = [System.IO.File]::OpenRead($inFile) - try { - $buf = New-Object byte[] (16MB) - while (($r = $in.Read($buf, 0, $buf.Length)) -gt 0) { - if (($r % 512) -ne 0) { $r += (512 - ($r % 512)) } # safety pad (img is sector-multiple) - $fs.Write($buf, 0, $r) - } - $fs.Flush() - } finally { $in.Close() } - } finally { $fs.Close() } -} - -function Get-Ext4Target($diskNumber, $physical) { - # Returns @{ Device; Start; Length } for the ext4 region, by probing +0x438 == 0xEF53. - $parts = @(Get-Partition -DiskNumber $diskNumber -ErrorAction SilentlyContinue | Sort-Object Offset) - foreach ($p in $parts) { - $dev = "\\.\Harddisk$($diskNumber)Partition$($p.PartitionNumber)" - try { - $m = Read-DeviceBytes $dev 0x438 2 - if ($m[0] -eq 0x53 -and $m[1] -eq 0xEF) { - return @{ Device = $dev; Start = [long]0; Length = [long]$p.Size; Offset = [long]$p.Offset } - } - } - catch { } # partition device not openable -> skip - # fallback: probe on the physical drive at the partition's absolute offset - try { - $m = Read-DeviceBytes $physical ([long]$p.Offset + 0x438) 2 - if ($m[0] -eq 0x53 -and $m[1] -eq 0xEF) { - return @{ Device = $physical; Start = [long]$p.Offset; Length = [long]$p.Size; Offset = [long]$p.Offset } - } - } - catch { } - } - # superfloppy: ext4 directly at disk offset 0 - try { - $m = Read-DeviceBytes $physical 0x438 2 - if ($m[0] -eq 0x53 -and $m[1] -eq 0xEF) { - $disk = Get-Disk -Number $diskNumber - return @{ Device = $physical; Start = [long]0; Length = [long]$disk.Size; Offset = [long]0 } - } - } - catch { } - return $null -} - -function Invoke-VhdSu([bool]$remove) { - if (-not $Vhd) { throw "Root/Unroot requires -Vhd ." } - if (-not (Test-Path -LiteralPath $Vhd)) { throw "Root.vhd not found: $Vhd" } - $dfs = Resolve-Debugfs # fail fast before we attach anything - - $suBytes = $null - if (-not $remove) { $suBytes = Get-EmbeddedSu $SelfPath; Say "[*] Embedded su OK ($($suBytes.Length) bytes, sha256 verified)." Green } - - # optional safety backup of the whole Root.vhd (once) - if (-not $remove -and -not $NoBackup) { - $vbak = "$Vhd.bsrbak" - if (-not (Test-Path -LiteralPath $vbak)) { - try { - $sz = (Get-Item -LiteralPath $Vhd).Length - $drive = (Get-Item -LiteralPath $Vhd).PSDrive - $free = (Get-PSDrive -Name $drive.Name).Free - if ($free -gt ($sz * 1.1)) { - Say "[*] Backing up Root.vhd -> $vbak (one-time safety copy, $([math]::Round($sz/1GB,2)) GB)..." - Copy-Item -LiteralPath $Vhd -Destination $vbak -Force - Say "[*] Backup done." Green - } - else { Say "[~] Not enough free space for a Root.vhd backup -- proceeding without one (NoRoot copy is your fallback)." Yellow } - } - catch { Say "[~] Could not create Root.vhd backup: $($_.Exception.Message)" Yellow } - } - else { Say "[*] Root.vhd backup already exists: $vbak" } - } - - $attached = $false - try { - Say "[*] Attaching $Vhd (read/write)..." - Mount-DiskImage -ImagePath $Vhd -Access ReadWrite -ErrorAction Stop | Out-Null - $attached = $true - $dn = $null - for ($try = 0; $try -lt 20; $try++) { - $di = Get-DiskImage -ImagePath $Vhd -ErrorAction SilentlyContinue - if ($di -and $di.Number -ne $null) { $dn = $di.Number; break } - Start-Sleep -Milliseconds 250 - } - if ($null -eq $dn) { - $disk = Get-DiskImage -ImagePath $Vhd | Get-Disk -ErrorAction SilentlyContinue - if ($disk) { $dn = $disk.Number } - } - if ($null -eq $dn) { throw "Could not determine the disk number of the attached VHD." } - $physical = "\\.\PhysicalDrive$dn" - Say "[*] Attached as disk $dn ($physical)." - - $tgt = Get-Ext4Target $dn $physical - if ($null -eq $tgt) { throw "No ext4 partition (0xEF53 @ +0x438) found inside $Vhd." } - Say ("[*] ext4 found: device={0} partOffset=0x{1:X} size={2} bytes" -f $tgt.Device, $tgt.Offset, $tgt.Length) - - $img = New-TempFile 'ext4' '.img' - Say "[*] Carving ext4 region to $img ..." - Copy-DeviceToFile $tgt.Device $tgt.Start $tgt.Length $img - - $ok = Edit-Ext4 $img $remove $suBytes - if (-not $ok) { - Say "[!] ext4 edit/verify failed -- NOT writing anything back. Root.vhd is unchanged." Red - return 1 - } - - Say "[*] Writing the modified ext4 region back into the VHD ..." - Copy-FileToDevice $img $tgt.Device $tgt.Start - Remove-Item -LiteralPath $img -Force -ErrorAction SilentlyContinue - if ($remove) { Say "[+] Unrooted successfully! (su removed from Root.vhd)" Green } - else { Say "[+] Rooted successfully! (su installed into Root.vhd)" Green } - return 0 - } - finally { - if ($attached) { - try { Dismount-DiskImage -ImagePath $Vhd -ErrorAction Stop | Out-Null; Say "[*] Detached $Vhd." } - catch { Say "[!] WARNING: failed to detach $Vhd -- detach it manually (Disk Management) before launching BlueStacks." Red } - } - } -} - -# =========================================================================== -# .bstk disk mode -- faithful global regex_replace (derivation §4) -# =========================================================================== -function Backup-Once([string]$path) { - $bak = "$path.bak" - if (-not (Test-Path -LiteralPath $bak)) { - try { attrib -R $path 2>$null | Out-Null } catch { } - Copy-Item -LiteralPath $path -Destination $bak -Force - Say "[*] Backup: $bak" - } -} - -function Invoke-Bstk([bool]$toReadonly) { - if (-not $Bstk) { throw "DiskRW/DiskRO requires -Bstk ." } - if (-not (Test-Path -LiteralPath $Bstk)) { throw ".bstk not found: $Bstk" } - $raw = [System.IO.File]::ReadAllText($Bstk) - # guard exactly like the exe: only touch files that describe the BlueStacks disks - if ($raw -notmatch 'location="fastboot\.vdi"' -and $raw -notmatch 'location="Root\.vhd"') { - Say "[~] $Bstk does not look like a BlueStacks instance disk file -- leaving it untouched." Yellow - return 1 - } - Backup-Once $Bstk - if ($toReadonly) { $new = $raw -replace 'type="Normal"', 'type="Readonly"' } # R/O - else { $new = $raw -replace 'type="Readonly"', 'type="Normal"' } # R/W (case-insensitive: also matches ReadOnly) - if ($new -eq $raw) { Say "[~] .bstk disk mode already set; no change." Yellow; return 0 } - try { attrib -R $Bstk 2>$null | Out-Null } catch { } - [System.IO.File]::WriteAllText($Bstk, $new, (New-Object System.Text.UTF8Encoding($false))) - if ($toReadonly) { Say "[+] Disk reverted to Readonly." Green } else { Say "[+] Disk set to R/W." Green } - return 0 -} - -# =========================================================================== -# bluestacks.conf root flags (hybrid §6a -- works with Magisk + adb) -# =========================================================================== -# Modify an EXISTING key only. Returns $true if found+set, $false if absent. -# We deliberately do NOT add missing keys: BlueStacks 5.22.x validates every conf -# property against its internal "iprop" schema and aborts with -# "prop not found in iprop dir" / "FATAL: configuration init failed" -# if it sees an unknown key. Adding one (e.g. bst.instance..enable_adb_access, -# which is not a valid key on this build) bricks startup. -function Set-ConfKey([System.Collections.Generic.List[string]]$lines, [string]$key, [string]$val) { - $re = '^\s*' + [regex]::Escape($key) + '\s*=' - $done = $false - for ($i = 0; $i -lt $lines.Count; $i++) { - if ($lines[$i] -match $re) { $lines[$i] = "$key=`"$val`""; $done = $true } - } - return $done -} - -function Invoke-Conf([bool]$enable) { - if (-not $Conf) { throw "ConfRoot/ConfUnroot requires -Conf ." } - if (-not $Instance) { throw "ConfRoot/ConfUnroot requires -Instance ." } - if (-not (Test-Path -LiteralPath $Conf)) { throw "bluestacks.conf not found: $Conf" } - Backup-Once $Conf - $val = if ($enable) { '1' } else { '0' } - # CRITICAL: bluestacks.conf must stay UTF-8 *without* a BOM and keep its original - # line endings. PowerShell 5.1 'Set-Content -Encoding utf8' writes a BOM, which - # makes BlueStacks fail with "Failed to read configuration file" -- so read raw, - # edit lines, and write with UTF8Encoding($false) preserving the EOL style. - $raw = [System.IO.File]::ReadAllText($Conf) - $eol = if ($raw -match "`r`n") { "`r`n" } else { "`n" } - $endsNl = $raw.EndsWith("`n") - $lines = New-Object System.Collections.Generic.List[string] - foreach ($l in ($raw -split "`r`n|`n")) { $lines.Add($l) } - if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) } - # Only valid, already-present keys (see Set-ConfKey). Root = per-instance - # enable_root_access + the global rooting feature; adb = the GLOBAL - # bst.enable_adb_access (there is NO valid per-instance enable_adb_access key). - $miss = New-Object System.Collections.Generic.List[string] - if (-not (Set-ConfKey $lines "bst.instance.$Instance.enable_root_access" $val)) { $miss.Add("bst.instance.$Instance.enable_root_access") } - if (-not (Set-ConfKey $lines 'bst.feature.rooting' $val)) { $miss.Add('bst.feature.rooting') } - if (-not (Set-ConfKey $lines 'bst.enable_adb_access' $val)) { $miss.Add('bst.enable_adb_access') } - if ($miss.Count -gt 0) { Say "[~] conf key(s) absent, left as-is (NOT added, would brick startup): $($miss -join ', ')" Yellow } - try { attrib -R $Conf 2>$null | Out-Null } catch { } - $outText = ($lines -join $eol); if ($endsNl) { $outText += $eol } - [System.IO.File]::WriteAllText($Conf, $outText, (New-Object System.Text.UTF8Encoding($false))) - Say "[+] bluestacks.conf updated for '$Instance' (root flags = `"$val`", UTF-8 no BOM)." Green - return 0 -} - -# =========================================================================== -# VhdSelfTest -- exercise the dangerous disk path (attach -> ext4 detect -> -# carve -> write the SAME bytes back -> detach) on a throwaway VHD and prove -# the region is byte-identical afterwards. No debugfs, no su -- pure disk I/O. -# =========================================================================== -function Invoke-VhdSelfTest { - if (-not $Vhd) { throw "VhdSelfTest requires -Vhd." } - if (-not (Test-Path -LiteralPath $Vhd)) { throw "VHD not found: $Vhd" } - $attached = $false - try { - Mount-DiskImage -ImagePath $Vhd -Access ReadWrite -ErrorAction Stop | Out-Null - $attached = $true - $dn = $null - for ($t = 0; $t -lt 20; $t++) { $di = Get-DiskImage -ImagePath $Vhd -EA SilentlyContinue; if ($di -and $di.Number -ne $null) { $dn = $di.Number; break }; Start-Sleep -Milliseconds 250 } - if ($null -eq $dn) { throw "no disk number" } - $physical = "\\.\PhysicalDrive$dn" - $tgt = Get-Ext4Target $dn $physical - if ($null -eq $tgt) { Say "[!] ext4 region not detected." Red; return 1 } - Say ("[*] region: device={0} start=0x{1:X} len={2}" -f $tgt.Device, $tgt.Start, $tgt.Length) - $img1 = New-TempFile 'st1' '.img'; $img2 = New-TempFile 'st2' '.img' - Copy-DeviceToFile $tgt.Device $tgt.Start $tgt.Length $img1 - $h1 = Get-Sha256Hex ([System.IO.File]::ReadAllBytes($img1)) - Copy-FileToDevice $img1 $tgt.Device $tgt.Start - Copy-DeviceToFile $tgt.Device $tgt.Start $tgt.Length $img2 - $h2 = Get-Sha256Hex ([System.IO.File]::ReadAllBytes($img2)) - Remove-Item $img1, $img2 -Force -EA SilentlyContinue - if ($h1 -eq $h2) { Say "[+] carve/write-back is byte-identical (sha256 $($h1.Substring(0,16))...)." Green; return 0 } - Say "[!] MISMATCH after write-back: $h1 vs $h2" Red; return 1 - } - finally { if ($attached) { try { Dismount-DiskImage -ImagePath $Vhd -EA Stop | Out-Null } catch { Say "[!] detach failed for $Vhd" Red } } } -} - -# =========================================================================== -# Resolve -- discover instance / master / .bstk / conf / Root.vhd paths. -# Emits ONLY KEY=VALUE lines on stdout (so a .cmd `for /f` can `set` them). -# NOTE: must never Write-Host here -- it would pollute the captured output. -# =========================================================================== -# Normalize to the folder that actually holds bluestacks.conf + Engine\. -# Newer BlueStacks (e.g. 5.22.169) sets DataDir to ...\BlueStacks_nxt\Engine, -# while UserDefinedDir is the real base ...\BlueStacks_nxt -- handle both. -function Get-BaseDir([string]$dataDir, [string]$userDef) { - $cands = New-Object System.Collections.Generic.List[string] - if ($dataDir) { - if ($dataDir -match '(?i)[\\/]engine[\\/]?$') { [void]$cands.Add(($dataDir -replace '(?i)[\\/]engine[\\/]?$', '')) } - [void]$cands.Add($dataDir) - } - if ($userDef) { [void]$cands.Add($userDef) } - # registry-declared data dir (honours a custom install) before the ProgramData fallback - $reg = Get-RegBlueStacks - if ($reg) { - foreach ($d in @($reg.DataDir, $reg.UserDefinedDir)) { - if ($d) { - if ($d -match '(?i)[\\/]engine[\\/]?$') { [void]$cands.Add(($d -replace '(?i)[\\/]engine[\\/]?$', '')) } - [void]$cands.Add($d) - } - } - } - [void]$cands.Add((Join-Path $env:ProgramData 'BlueStacks_nxt')) - $pick = $null - foreach ($c in $cands) { if ($c -and (Test-Path -LiteralPath (Join-Path $c 'bluestacks.conf'))) { $pick = $c; break } } - if (-not $pick) { foreach ($c in $cands) { if ($c -and (Test-Path -LiteralPath (Join-Path $c 'Engine'))) { $pick = $c; break } } } - if (-not $pick) { $pick = $cands[0] } - return $pick.TrimEnd('\', '/') -} - -function Invoke-Resolve { - if (-not $DataDir -and -not $UserDef) { throw "Resolve requires -DataDir/-UserDef (or BSR_DATADIR/BSR_USERDEF)." } - if (-not $Base) { throw "Resolve requires -Base (or BSR_BASE)." } - $DataDir = Get-BaseDir $DataDir $UserDef # normalize ...\Engine -> base - $instance = $null - $rx = '^' + [regex]::Escape($Base) + '(_\d+)?$' - - # 1) candidates from MimMetaData.json - $cands = @() - $mim = Join-Path $DataDir 'UserData\MimMetaData.json' - if (Test-Path -LiteralPath $mim) { - try { - $m = Select-String -LiteralPath $mim -Pattern '"InstanceName"\s*:\s*"([^"]+)"' -AllMatches - $cands = @($m.Matches | ForEach-Object { $_.Groups[1].Value } | Where-Object { $_ -match $rx } | Sort-Object -Unique) - } - catch { } - } - # 2) the most-recently-launched instance from Player.log (matches the per-instance UX) - $log = Join-Path $DataDir 'Logs\Player.log' - if (Test-Path -LiteralPath $log) { - try { - $tail = Get-Content -LiteralPath $log -Tail 6000 -ErrorAction SilentlyContinue - $hit = $tail | Select-String -Pattern ([regex]::Escape($Base) + '(_\d+)?') -AllMatches | - ForEach-Object { $_.Matches } | ForEach-Object { $_.Value } | - Where-Object { $_ -match $rx } | Select-Object -Last 1 - if ($hit) { $instance = $hit } - } - catch { } - } - # Prefer an instance whose .bstk actually EXISTS on disk -- Player.log may name a - # since-deleted clone (e.g. Rvc64_2). Order: log-most-recent, newest MimMetaData - # candidates, then the bare base. Fall back to the log/candidate name if none exist - # (so the orchestrator can print a helpful "launch it once" message). - $pref = New-Object System.Collections.Generic.List[string] - if ($instance) { [void]$pref.Add($instance) } - for ($k = $cands.Count - 1; $k -ge 0; $k--) { if ($cands[$k]) { [void]$pref.Add($cands[$k]) } } - [void]$pref.Add($Base) - $chosen = $null - foreach ($cand in $pref) { - if (-not $cand) { continue } - if (Test-Path -LiteralPath (Join-Path $DataDir "Engine\$cand\$cand.bstk")) { $chosen = $cand; break } - } - if (-not $chosen) { $chosen = if ($instance) { $instance } elseif ($cands.Count -ge 1) { $cands[-1] } else { $Base } } - $instance = $chosen - - # master = instance with a trailing _ stripped (clones share the master's Root.vhd) - $master = if ($instance -match '^(.+)_\d+$') { $Matches[1] } else { $instance } - - $bstk = Join-Path $DataDir "Engine\$instance\$instance.bstk" - $conf = Join-Path $DataDir 'bluestacks.conf' - - # Root.vhd: prefer the location declared in the .bstk; else master folder; else instance folder - $vhd = $null - if (Test-Path -LiteralPath $bstk) { - try { - $bt = [System.IO.File]::ReadAllText($bstk) - $mm = [regex]::Match($bt, 'location="([^"]*[Rr]oot\.vhd)"') - if ($mm.Success) { - $loc = $mm.Groups[1].Value - if ([System.IO.Path]::IsPathRooted($loc)) { $cand = $loc } - else { $cand = [System.IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $bstk) $loc)) } - if (Test-Path -LiteralPath $cand) { $vhd = $cand } - } - } - catch { } - } - if (-not $vhd) { - $cm = Join-Path $DataDir "Engine\$master\Root.vhd" - $ci = Join-Path $DataDir "Engine\$instance\Root.vhd" - if (Test-Path -LiteralPath $cm) { $vhd = $cm } - elseif (Test-Path -LiteralPath $ci) { $vhd = $ci } - else { $vhd = $cm } # report the most-likely path even if missing - } - - # instance adb port (for the online/adb root path); default 5555 - $adbPort = '5555' - if (Test-Path -LiteralPath $conf) { - try { - $ct = [System.IO.File]::ReadAllText($conf) - $esc = [regex]::Escape($instance) - $pm = [regex]::Match($ct, '(?im)^\s*bst\.instance\.' + $esc + '\.status\.adb_port\s*=\s*"?(\d+)"?') - if (-not $pm.Success) { $pm = [regex]::Match($ct, '(?im)^\s*bst\.instance\.' + $esc + '\.adb_port\s*=\s*"?(\d+)"?') } - if ($pm.Success) { $adbPort = $pm.Groups[1].Value } - } - catch { } - } - - Write-Output "BSR_DATADIR=$DataDir" - Write-Output "BSR_INSTANCE=$instance" - Write-Output "BSR_MASTER=$master" - Write-Output "BSR_BSTK=$bstk" - Write-Output "BSR_CONF=$conf" - Write-Output "BSR_VHD=$vhd" - Write-Output "BSR_ADBPORT=$adbPort" -} - -# =========================================================================== -# ONLINE ROOT via BlueStacks' own adb (HD-Adb.exe) -- PRIMARY path. -# -# Once the disk is Normal + root/adb flags on + integrity bypassed, we boot the -# instance and let ANDROID'S OWN KERNEL write its ext4: push the embedded su and -# drop it into /system using BlueStacks' native su, then prove uid=0. No Windows -# ext4 tooling, no debugfs -- inherently version-proof. Offline debugfs is the -# fallback (Invoke-VhdSu) when the instance can't boot/root. -# =========================================================================== -function Resolve-Adb { - if ($Adb -and (Test-Path -LiteralPath $Adb)) { return (Resolve-Path -LiteralPath $Adb).Path } - $cands = New-Object System.Collections.Generic.List[string] - $reg = Get-RegBlueStacks # registry InstallDir first (custom installs) - if ($reg -and $reg.InstallDir) { [void]$cands.Add((Join-Path ($reg.InstallDir.TrimEnd('\', '/')) 'HD-Adb.exe')) } - foreach ($p in @( - (Join-Path $env:ProgramFiles 'BlueStacks_nxt\HD-Adb.exe'), - (Join-Path ${env:ProgramFiles(x86)} 'BlueStacks_nxt\HD-Adb.exe'), - (Join-Path $env:ProgramFiles 'BlueStacks_msi5\HD-Adb.exe'), - (Join-Path ${env:ProgramFiles(x86)} 'BlueStacks_msi5\HD-Adb.exe'))) { [void]$cands.Add($p) } - foreach ($p in $cands) { if ($p -and (Test-Path -LiteralPath $p)) { return $p } } - foreach ($n in @('HD-Adb.exe', 'adb.exe')) { $c = Get-Command $n -EA SilentlyContinue; if ($c) { return $c.Source } } - throw "HD-Adb.exe not found. Pass -Adb (or BSR_ADB)." -} - -$Script:AdbExe = $null -$Script:Serial = $null - -function AdbRaw([string[]]$a) { - $old = $ErrorActionPreference; $ErrorActionPreference = 'Continue' - try { return (& $Script:AdbExe @a 2>&1 | Out-String) } finally { $ErrorActionPreference = $old } -} -function AdbS([string[]]$a) { return AdbRaw (@('-s', $Script:Serial) + $a) } -function AdbShell([string]$cmd) { return AdbS @('shell', $cmd) } - -# Connect to the instance's adb endpoint and wait until Android finishes booting. -function Connect-WaitBoot([int]$timeoutSec) { - $port = if ($AdbPort) { $AdbPort } else { '5555' } - $Script:Serial = "127.0.0.1:$port" - AdbRaw @('start-server') | Out-Null - $sw = [System.Diagnostics.Stopwatch]::StartNew() - $connected = $false - while ($sw.Elapsed.TotalSeconds -lt $timeoutSec) { - $c = AdbRaw @('connect', $Script:Serial) - if ($c -match '(?i)connected to') { $connected = $true } - if (-not $connected) { - # maybe it registered as emulator-XXXX instead - $dev = AdbRaw @('devices') - $m = [regex]::Match($dev, '(?im)^(emulator-\d+|127\.0\.0\.1:\d+)\s+device\s*$') - if ($m.Success) { $Script:Serial = $m.Groups[1].Value; $connected = $true } - } - if ($connected) { - $b = (AdbShell 'getprop sys.boot_completed').Trim() - if ($b -match '1') { - # give late services (su daemon) a moment - Start-Sleep -Seconds 3 - return $true - } - } - Start-Sleep -Seconds 3 - } - return $false -} - -function Launch-Instance { - if ($NoLaunch) { Say "[*] -NoLaunch: assuming the instance is already running." ; return } - if (-not $Player -or -not (Test-Path -LiteralPath $Player)) { Say "[~] HD-Player.exe not provided; not launching (will try to connect anyway)." Yellow; return } - if (-not $Instance) { throw "AdbRoot requires -Instance to launch." } - $running = @(Get-Process -Name 'HD-Player' -ErrorAction SilentlyContinue) - if ($running.Count -gt 0) { Say "[*] HD-Player already running; not launching a second instance." ; return } - Say "[*] Booting instance '$Instance' ..." - Start-Process -FilePath $Player -ArgumentList @('--instance', $Instance) | Out-Null -} - -# Run a privileged shell script (pushed to the device) as root, trying the su -# styles BlueStacks may expose. Returns the combined output; sets $ok if uid=0. -function Run-AsRoot([string]$deviceScript) { - # opportunistically promote adbd (harmless if unsupported) - AdbS @('root') | Out-Null - Start-Sleep -Seconds 1 - Connect-WaitBoot 30 | Out-Null - $variants = @("su -c 'sh $deviceScript'", "su 0 sh $deviceScript", "su root -c 'sh $deviceScript'", "sh $deviceScript") - $best = '' - foreach ($v in $variants) { - $o = AdbShell $v - $best = $o - if ($o -match 'BSR_ROOT_OK') { return $o } - } - return $best -} - -function Invoke-AdbSu([bool]$remove) { - if (-not $Instance) { throw "AdbRoot/AdbUnroot requires -Instance." } - $Script:AdbExe = Resolve-Adb - Say "[*] adb: $($Script:AdbExe)" - - $suBytes = $null - if (-not $remove) { $suBytes = Get-EmbeddedSu $SelfPath; Say "[*] Embedded su OK ($($suBytes.Length) bytes, sha256 verified)." Green } - - Launch-Instance - Say "[*] Waiting for the instance to finish booting (adb 127.0.0.1:$(if($AdbPort){$AdbPort}else{'5555'})) ..." - if (-not (Connect-WaitBoot 240)) { - Say "[!] Instance did not become adb-reachable / booted in time." Red - return 2 # signal: caller may fall back to offline debugfs - } - Say "[+] Booted. serial=$($Script:Serial)" Green - - # stage a device-side script (avoids all the su/sh quoting pitfalls) - $work = Join-Path (Join-Path $env:TEMP 'bsr_work') 'adb' - if (-not (Test-Path -LiteralPath $work)) { New-Item -ItemType Directory -Path $work -Force | Out-Null } - $sh = Join-Path $work 'bsrdo.sh' - - if ($remove) { - $body = @' -mount -o rw,remount / 2>/dev/null -mount -o rw,remount /system 2>/dev/null -mount -o rw,remount /system_root 2>/dev/null -rm -f /system/xbin/su /system/bin/su 2>/dev/null -sync -if [ ! -e /system/xbin/su ] && [ ! -e /system/bin/su ]; then echo BSR_ROOT_OK_REMOVED; fi -'@ - } - else { - $body = @' -mount -o rw,remount / 2>/dev/null -mount -o rw,remount /system 2>/dev/null -mount -o rw,remount /system_root 2>/dev/null -T="" -for d in /system/xbin /system/bin; do - if [ -d "$d" ]; then - cp /data/local/tmp/bsrsu "$d/su" && chmod 06755 "$d/su" && { chown 0:0 "$d/su" 2>/dev/null || chown 0.0 "$d/su"; } && T="$d/su" - fi -done -sync -ls -l $T 2>/dev/null -if [ -n "$T" ]; then echo BSR_ROOT_OK_INSTALLED $T; fi -'@ - } - # LF line-endings for the device shell - [System.IO.File]::WriteAllText($sh, ($body -replace "`r`n", "`n"), (New-Object System.Text.UTF8Encoding($false))) - AdbS @('push', $sh, '/data/local/tmp/bsrdo.sh') | Out-Null - - if (-not $remove) { - $suTmp = Join-Path $work 'bsrsu' - [System.IO.File]::WriteAllBytes($suTmp, $suBytes) - AdbS @('push', $suTmp, '/data/local/tmp/bsrsu') | Out-Null - Remove-Item -LiteralPath $suTmp -Force -EA SilentlyContinue - } - - $out = Run-AsRoot '/data/local/tmp/bsrdo.sh' - AdbShell 'rm -f /data/local/tmp/bsrdo.sh /data/local/tmp/bsrsu' | Out-Null - - if ($remove) { - if ($out -match 'BSR_ROOT_OK_REMOVED') { Say "[+] su removed from /system via adb." Green; return 0 } - Say "[!] Could not confirm su removal via adb.`n$out" Red; return 1 - } - - if ($out -notmatch 'BSR_ROOT_OK_INSTALLED') { - Say "[!] su install via adb did not confirm (need BlueStacks root/su enabled).`n$out" Red - return 2 # let caller fall back to offline - } - Say "[*] su written:`n$out" - # final proof: a NON-root adb shell calling our setuid su must come back uid=0 - $idOut = AdbShell '/system/xbin/su -c id 2>/dev/null || /system/bin/su -c id 2>/dev/null' - if ($idOut -match 'uid=0') { Say "[+] Rooted online! /system/.../su grants uid=0:`n$idOut" Green; return 0 } - Say "[~] su is in place but 'su -c id' did not report uid=0 (SELinux?). Output:`n$idOut" Yellow - return 0 # file is installed; Magisk's system install can still proceed -} - -function Invoke-AdbVerify { - if (-not $Instance) { throw "AdbVerify requires -Instance." } - $Script:AdbExe = Resolve-Adb - if (-not (Connect-WaitBoot 240)) { Say "[!] not reachable/booted." Red; return 1 } - $id = AdbShell '/system/xbin/su -c id 2>/dev/null || /system/bin/su -c id 2>/dev/null' - $ls = AdbShell 'ls -l /system/xbin/su /system/bin/su 2>/dev/null' - $mg = AdbShell 'magisk -V 2>/dev/null; magisk -c 2>/dev/null' - Say "su id : $($id.Trim())" - Say "su ls : $($ls.Trim())" - Say "magisk: $($mg.Trim())" - if ($id -match 'uid=0') { Say "[+] root verified (uid=0)." Green; return 0 } - Say "[!] root NOT verified." Red; return 1 -} - -# =========================================================================== -# dispatch -# =========================================================================== -switch ($Action) { - 'ExtractSu' { - if (-not $OutFile) { throw "ExtractSu requires -OutFile." } - $bytes = Get-EmbeddedSu $SelfPath - [System.IO.File]::WriteAllBytes($OutFile, $bytes) - Say "[+] su extracted to $OutFile ($($bytes.Length) bytes, sha256 verified)." Green - exit 0 - } - 'TestExt4' { - if (-not $Img) { throw "TestExt4 requires -Img ." } - $suBytes = $null - if (-not $Restore) { $suBytes = Get-EmbeddedSu $SelfPath } # -Restore here means 'remove' - $ok = Edit-Ext4 $Img ([bool]$Restore) $suBytes - exit ([int](-not $ok)) - } - 'Patch' { exit (Invoke-Patch) } - 'Root' { exit (Invoke-VhdSu $false) } - 'Unroot' { exit (Invoke-VhdSu $true) } - 'AdbRoot' { exit (Invoke-AdbSu $false) } - 'AdbUnroot' { exit (Invoke-AdbSu $true) } - 'AdbVerify' { exit (Invoke-AdbVerify) } - 'DiskRW' { exit (Invoke-Bstk $false) } - 'DiskRO' { exit (Invoke-Bstk $true) } - 'ConfRoot' { exit (Invoke-Conf $true) } - 'ConfUnroot' { exit (Invoke-Conf $false) } - 'Resolve' { Invoke-Resolve; exit 0 } - 'BaseDir' { Write-Output (Get-BaseDir $DataDir $UserDef); exit 0 } - 'VhdSelfTest' { exit (Invoke-VhdSelfTest) } +<# + bsr_engine.ps1 -- blueStackRoot engine + + Pure-PowerShell, faithful re-implementation of the heavy lifting performed by + BstkRooter.exe (Taaauu "BSTK Rooter" 1.0.1), derived byte-for-byte from + recovered/BstkRooter/BstkRooter_FULL_DERIVATION.md. + + This file is the canonical source. It is embedded verbatim inside + blueStackRoot.cmd (between the engine BEGIN/END marker lines); the .cmd + extracts it to a temp .ps1 at run time and calls it. The test-suite extracts + the embedded copy and runs it, so the .cmd is what is actually tested. + + ACTIONS + Patch Version-proof HD-Player.exe disk-integrity patch (NOP the jz of every + validated CALL ; TEST AL,AL ; JZ site). -Restore reverts from .bak, + -DryRun previews. + Root Offline install of the embedded setuid su into the ext4 inside Root.vhd + (/android/system/xbin/su, mode 0106755, owner 0:0) via debugfs. + Unroot Offline removal of /android/system/xbin/su from Root.vhd. + ExtractSu Decode the embedded su payload to -OutFile (used by tests / debugging). + TestExt4 Run the exact debugfs edit against a plain ext4 image (-Img) -- used by + the test-suite to exercise the ext4 logic with no VHD / no admin. + + NOTHING here depends on BstkRooter.exe. The su payload travels inside the .cmd. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('Patch', 'Root', 'Unroot', 'ExtractSu', 'TestExt4', 'DiskRW', 'DiskRO', 'ConfRoot', 'ConfUnroot', 'Resolve', 'BaseDir', 'VhdSelfTest', 'AdbRoot', 'AdbUnroot', 'AdbVerify')] + [string]$Action, + + [string]$Exe, # HD-Player.exe (Patch) + [string]$Vhd, # Root.vhd (Root / Unroot) + [string]$Bstk, # .bstk (DiskRW / DiskRO) + [string]$Conf, # bluestacks.conf (ConfRoot / ConfUnroot) + [string]$Instance, # instance name (ConfRoot / ConfUnroot / AdbRoot) + [string]$SelfPath, # the .cmd carrying the embedded su (+debugfs) blobs + [string]$Debugfs, # path to debugfs.exe (offline fallback) + [string]$OutFile, # ExtractSu target + [string]$Img, # TestExt4 target (plain ext4 image) + [string]$DataDir, # BlueStacks DataDir (Resolve / BaseDir) + [string]$UserDef, # BlueStacks UserDefinedDir (Resolve / BaseDir) + [string]$Base, # base version e.g. Rvc64 (Resolve) + [string]$Adb, # HD-Adb.exe (AdbRoot / AdbUnroot) + [string]$Player, # HD-Player.exe (to boot instance) (AdbRoot) + [string]$AdbPort, # instance adb port, e.g. 5555 (AdbRoot) + + [switch]$Restore, + [switch]$DryRun, + [switch]$NoBackup, + [switch]$NoLaunch, # AdbRoot: do not auto-launch the instance (assume already booted) + [switch]$Force # patch even when no anchor string validates a candidate +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2 + +# Allow the .cmd to pass path-like inputs via environment variables (avoids batch quoting pain). +function EnvOr([string]$val, [string]$name) { if ($val) { return $val } $e = [Environment]::GetEnvironmentVariable($name); if ($e) { return $e } return $val } +$Exe = EnvOr $Exe 'BSR_EXE' +$Vhd = EnvOr $Vhd 'BSR_VHD' +$Bstk = EnvOr $Bstk 'BSR_BSTK' +$Conf = EnvOr $Conf 'BSR_CONF' +$Instance = EnvOr $Instance 'BSR_INSTANCE' +$SelfPath = EnvOr $SelfPath 'BSR_SELF' +$Debugfs = EnvOr $Debugfs 'BSR_DEBUGFS' +$DataDir = EnvOr $DataDir 'BSR_DATADIR' +$UserDef = EnvOr $UserDef 'BSR_USERDEF' +$Base = EnvOr $Base 'BSR_BASE' +$Adb = EnvOr $Adb 'BSR_ADB' +$Player = EnvOr $Player 'BSR_PLAYER' +$AdbPort = EnvOr $AdbPort 'BSR_ADBPORT' +if (-not $Restore -and $env:BSR_RESTORE -eq '1') { $Restore = $true } +if (-not $NoBackup -and $env:BSR_NOBACKUP -eq '1') { $NoBackup = $true } +if (-not $NoLaunch -and $env:BSR_NOLAUNCH -eq '1') { $NoLaunch = $true } +if (-not $Force -and $env:BSR_FORCE -eq '1') { $Force = $true } + +function Say([string]$m, [string]$c = 'Gray') { Write-Host $m -ForegroundColor $c } + +# Registry discovery (NO hardcoded install/data paths): honour a custom BlueStacks location by reading +# InstallDir/DataDir/UserDefinedDir from the registry -- nxt (BlueStacks 5) then msi5 (MSI App Player), +# native and WOW6432Node views. Must not Write-Host (callers like Resolve/BaseDir parse stdout). +function Get-RegBlueStacks { + foreach ($k in @('HKLM:\SOFTWARE\BlueStacks_nxt', 'HKLM:\SOFTWARE\BlueStacks_msi5', + 'HKLM:\SOFTWARE\WOW6432Node\BlueStacks_nxt', 'HKLM:\SOFTWARE\WOW6432Node\BlueStacks_msi5')) { + try { + $p = Get-ItemProperty -Path $k -ErrorAction Stop + if ($p -and ($p.InstallDir -or $p.DataDir -or $p.UserDefinedDir)) { + return [pscustomobject]@{ InstallDir = $p.InstallDir; DataDir = $p.DataDir; UserDefinedDir = $p.UserDefinedDir } + } + } catch { } + } + return $null +} + +# Expected SHA-256 of the decrypted su ELF (derivation §1). Used as an integrity gate. +$Script:SuSha256 = '185106357CFC0D1DB4B8EFB033DE863F437850437E0EF6B62630C05F291B4902' + +# --------------------------------------------------------------------------- +# Embedded su extraction +# --------------------------------------------------------------------------- +# Markers are built by concatenation so the literal token appears in the file +# ONLY on the real blob lines, never here -- otherwise IndexOf would match this code. +function Get-EmbeddedSu([string]$selfPath) { + if (-not $selfPath -or -not (Test-Path -LiteralPath $selfPath)) { + throw "Embedded-su source not found (SelfPath='$selfPath'). Pass -SelfPath ." + } + $text = [System.IO.File]::ReadAllText($selfPath) + $beg = '__BSR_SU_' + 'BEGIN__' + $end = '__BSR_SU_' + 'END__' + $i = $text.IndexOf($beg) + $j = $text.IndexOf($end) + if ($i -lt 0 -or $j -lt 0 -or $j -le $i) { throw "su payload markers not found in '$selfPath'." } + $i += $beg.Length + $b64 = $text.Substring($i, $j - $i) + # strip all whitespace (line wraps, CR/LF, the marker's own EOL) + $b64 = ($b64 -replace '[^A-Za-z0-9+/=]', '') + if ($b64.Length -lt 64) { throw "su payload is empty -- run tools/embed-su.ps1 to populate it." } + $gz = [Convert]::FromBase64String($b64) + # gunzip via .NET GZipStream (matches the .NET GZipStream used to compress) + $in = New-Object System.IO.MemoryStream(, $gz) + $z = New-Object System.IO.Compression.GZipStream($in, [System.IO.Compression.CompressionMode]::Decompress) + $out = New-Object System.IO.MemoryStream + $buf = New-Object byte[] 65536 + while (($n = $z.Read($buf, 0, $buf.Length)) -gt 0) { $out.Write($buf, 0, $n) } + $z.Close(); $in.Close() + $bytes = $out.ToArray(); $out.Close() + # integrity gate + $sha = (Get-Sha256Hex $bytes) + if ($sha -ne $Script:SuSha256) { + throw "Embedded su FAILED integrity check.`n expected $($Script:SuSha256)`n got $sha" + } + return $bytes +} + +function Get-Sha256Hex([byte[]]$bytes) { + $h = [System.Security.Cryptography.SHA256]::Create() + try { return (($h.ComputeHash($bytes) | ForEach-Object { $_.ToString('X2') }) -join '') } + finally { $h.Dispose() } +} + +# --------------------------------------------------------------------------- +# Embedded debugfs bundle (offline fallback) -- a base64'd .zip of the Cygwin +# debugfs.exe + its 10 DLLs, carried inside the .cmd between __BSR_DFS_* lines. +# Extracted once to %TEMP%\bsr_work\debugfs\ and reused. Returns debugfs.exe +# path, or $null if no bundle is embedded. +# --------------------------------------------------------------------------- +function Expand-EmbeddedDebugfs([string]$selfPath) { + $destDir = Join-Path (Join-Path $env:TEMP 'bsr_work') 'debugfs' + $exe = Join-Path $destDir 'debugfs.exe' + if (Test-Path -LiteralPath $exe) { return $exe } # already extracted this session + if (-not $selfPath -or -not (Test-Path -LiteralPath $selfPath)) { return $null } + $text = [System.IO.File]::ReadAllText($selfPath) + $beg = '__BSR_DFS_' + 'BEGIN__'; $end = '__BSR_DFS_' + 'END__' + $i = $text.IndexOf($beg); $j = $text.IndexOf($end) + if ($i -lt 0 -or $j -le $i) { return $null } # no bundle embedded + $i += $beg.Length + $b64 = ($text.Substring($i, $j - $i) -replace '[^A-Za-z0-9+/=]', '') + if ($b64.Length -lt 1024) { return $null } + if (-not (Test-Path -LiteralPath $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } + $zipPath = Join-Path $destDir '_dfs.zip' + [System.IO.File]::WriteAllBytes($zipPath, [Convert]::FromBase64String($b64)) + try { Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue } catch { } + $za = [System.IO.Compression.ZipFile]::OpenRead($zipPath) + try { + foreach ($e in $za.Entries) { + if (-not $e.Name) { continue } # directory entry + $t = Join-Path $destDir $e.FullName + $d = Split-Path -Parent $t + if (-not (Test-Path -LiteralPath $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($e, $t, $true) + } + } + finally { $za.Dispose() } + Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $exe) { return $exe } + return $null +} + +# =========================================================================== +# PATCH -- HD-Player.exe disk-integrity bypass (version proof) +# =========================================================================== +# raw file offset -> RVA across all sections +function RawToRva([int]$raw, $sections) { + foreach ($s in $sections) { + if ($raw -ge $s.RawPtr -and $raw -lt ($s.RawPtr + $s.RawSize)) { return [int]($s.VA + ($raw - $s.RawPtr)) } + } + return -1 +} + +# RVA of every occurrence of an ASCII (NUL-terminated) string +function StringRvas([byte[]]$b, [string]$text, $sections) { + $needle = [System.Text.Encoding]::ASCII.GetBytes($text) + $hits = New-Object System.Collections.Generic.List[int] + $max = $b.Length - $needle.Length - 1 + for ($i = 0; $i -le $max; $i++) { + if ($b[$i] -ne $needle[0]) { continue } + $ok = $true + for ($k = 1; $k -lt $needle.Length; $k++) { if ($b[$i + $k] -ne $needle[$k]) { $ok = $false; break } } + if ($ok -and $b[$i + $needle.Length] -eq 0) { + $rva = RawToRva $i $sections + if ($rva -ge 0) { [void]$hits.Add($rva) } + } + } + return $hits +} + +# Is there a RIP-relative LEA to any of $targetRvas within +/-window of $t (TEST offset)? +function NearAnchor([byte[]]$b, [int]$t, [int]$textVA, [int]$textRaw, [int]$window, $targetSet) { + $lo = [Math]::Max($textRaw, $t - $window); $hi = $t + $window + if ($hi -gt $b.Length - 7) { $hi = $b.Length - 7 } + for ($p = $lo; $p -lt $hi; $p++) { + $rex = $b[$p] + if ($rex -ne 0x48 -and $rex -ne 0x4C -and $rex -ne 0x49 -and $rex -ne 0x4D) { continue } + if ($b[$p + 1] -ne 0x8D) { continue } # LEA + if (($b[$p + 2] -band 0xC7) -ne 0x05) { continue } # mod=00, rm=101 -> [rip+disp32] + $disp = [BitConverter]::ToInt32($b, $p + 3) + $target = $textVA + (($p + 7) - $textRaw) + $disp # RVA after the 7-byte LEA + disp + if ($targetSet.Contains($target)) { return $true } + } + return $false +} + +function Invoke-Patch { + if (-not $Exe) { throw "Patch requires -Exe ." } + if (-not (Test-Path -LiteralPath $Exe)) { throw "HD-Player.exe not found: $Exe" } + $bak = "$Exe.bak" + + if ($Restore) { + if (-not (Test-Path -LiteralPath $bak)) { Say "[!] No backup to restore: $bak" Red; return 1 } + Copy-Item -LiteralPath $bak -Destination $Exe -Force + Say "[+] Restored $Exe from $bak" Green + return 0 + } + + $b = [System.IO.File]::ReadAllBytes($Exe) + Say "[*] Loaded $Exe ($($b.Length) bytes)" + if ($b.Length -lt 0x200) { Say "[!] File too small for a PE." Red; return 1 } + + $e_lfanew = [BitConverter]::ToInt32($b, 0x3C) + if ($e_lfanew -le 0 -or $e_lfanew + 0x40 -ge $b.Length -or $b[$e_lfanew] -ne 0x50 -or $b[$e_lfanew + 1] -ne 0x45) { + Say "[!] Invalid PE header." Red; return 1 + } + $numSections = [BitConverter]::ToUInt16($b, $e_lfanew + 6) + $sizeOptHdr = [BitConverter]::ToUInt16($b, $e_lfanew + 20) + $optHdr = $e_lfanew + 24 + $magic = [BitConverter]::ToUInt16($b, $optHdr) + if ($magic -eq 0x20B) { $imageBase = [BitConverter]::ToUInt64($b, $optHdr + 24) } + else { $imageBase = [BitConverter]::ToUInt32($b, $optHdr + 28) } + + $secTable = $optHdr + $sizeOptHdr + $textRaw = $null; $textRawSize = $null; $textVA = $null + $sections = @() + for ($i = 0; $i -lt $numSections; $i++) { + $s = $secTable + ($i * 40) + $name = ([System.Text.Encoding]::ASCII.GetString($b, $s, 8)).TrimEnd([char]0) + $va = [BitConverter]::ToUInt32($b, $s + 12) + $rs = [BitConverter]::ToUInt32($b, $s + 16) + $pr = [BitConverter]::ToUInt32($b, $s + 20) + $sections += [pscustomobject]@{ Name = $name; VA = $va; RawSize = $rs; RawPtr = $pr } + if ($name -eq '.text') { $textVA = [int]$va; $textRaw = [int]$pr; $textRawSize = [int]$rs } + } + if ($null -eq $textRaw) { Say "[!] .text section not found." Red; return 1 } + + # anchor string RVAs (faithful set + extra-hardening set) + $primaryStr = @('Verified the disk integrity!', 'Failed to verify the disk integrity!') + $fallbackStr = @('plrDiskCheckThreadEntry', + 'Shutting down: disk file have been illegally tampered with!', + 'Failed to verify the file', 'In warmup mode: Stopping player.') + + $primarySet = New-Object System.Collections.Generic.HashSet[int] + foreach ($s in $primaryStr) { foreach ($r in (StringRvas $b $s $sections)) { [void]$primarySet.Add($r) } } + $fallbackSet = New-Object System.Collections.Generic.HashSet[int] + foreach ($r in $primarySet) { [void]$fallbackSet.Add($r) } + foreach ($s in $fallbackStr) { foreach ($r in (StringRvas $b $s $sections)) { [void]$fallbackSet.Add($r) } } + + Say ("[*] anchor strings: {0} primary RVA(s), {1} total" -f $primarySet.Count, $fallbackSet.Count) + + # scan .text for E8 ?? ?? ?? ?? 84 C0 74 ?? (t = offset of TEST AL,AL) + $textStart = $textRaw + 5 + $textEnd = $textRaw + $textRawSize - 3 + if ($textEnd -gt $b.Length - 3) { $textEnd = $b.Length - 3 } + # match E8.. 84 C0 74 ?? (unpatched) OR E8.. 84 C0 90 90 (already patched) + $cands = New-Object System.Collections.Generic.List[int] + for ($t = $textStart; $t -lt $textEnd; $t++) { + if ($b[$t] -eq 0x84 -and $b[$t + 1] -eq 0xC0 -and $b[$t - 5] -eq 0xE8 -and + ($b[$t + 2] -eq 0x74 -or ($b[$t + 2] -eq 0x90 -and $b[$t + 3] -eq 0x90))) { + [void]$cands.Add($t) + } + } + Say "[*] Found $($cands.Count) candidate site(s) (CALL; TEST AL,AL; JZ)" + if ($cands.Count -eq 0) { Say "[!] No candidate sites. BlueStacks build differs too much -- aborting (nothing changed)." Red; return 1 } + + # select sites to patch: primary (verify/fail, tight window) first, then fallback (wider) + $sites = New-Object System.Collections.Generic.List[int] + $how = '' + if ($primarySet.Count -gt 0) { + foreach ($c in $cands) { if (NearAnchor $b $c $textVA $textRaw 0xE0 $primarySet) { [void]$sites.Add($c) } } + if ($sites.Count -gt 0) { $how = 'verify/fail disk-integrity string' } + } + if ($sites.Count -eq 0 -and $fallbackSet.Count -gt 0) { + foreach ($c in $cands) { if (NearAnchor $b $c $textVA $textRaw 0x700 $fallbackSet) { [void]$sites.Add($c) } } + if ($sites.Count -gt 0) { $how = 'fallback anchor (plrDiskCheckThreadEntry/shutdown/per-block/warmup)' } + } + if ($sites.Count -eq 0) { + if ($cands.Count -eq 1 -and $Force) { [void]$sites.Add($cands[0]); $how = 'single candidate (-Force)' } + else { + Say "[!] $($cands.Count) candidate(s) but none validated by an anchor string." Red + Say " Refusing to blind-patch (would risk corrupting HD-Player.exe). Use -Force only if you are sure." Yellow + return 1 + } + } + + $toApply = @(); $already = 0 + foreach ($t in $sites) { + if ($b[$t + 2] -eq 0x90 -and $b[$t + 3] -eq 0x90) { $already++; continue } + if ($b[$t + 2] -ne 0x74) { continue } + $toApply += $t + } + foreach ($t in $sites) { + $rva = RawToRva $t $sections + $va = $imageBase + $rva + Say (" site file=0x{0:X} va=0x{1:X} {2:X2} {3:X2} {4:X2} {5:X2} [{6}]" -f ` + $t, $va, $b[$t], $b[$t + 1], $b[$t + 2], $b[$t + 3], $how) + } + if ($toApply.Count -eq 0) { + if ($already -gt 0) { Say "[~] Already patched ($already site(s)). Nothing to do." Yellow; return 0 } + Say "[~] Nothing to patch." Yellow; return 0 + } + + if ($DryRun) { Say "[+] Dry run -- would NOP $($toApply.Count) site(s). No file written." Yellow; return 0 } + + if (-not $NoBackup) { + if (-not (Test-Path -LiteralPath $bak)) { Copy-Item -LiteralPath $Exe -Destination $bak -Force; Say "[*] Backup created: $bak" } + else { Say "[*] Backup already exists, skipping copy." } + } + foreach ($t in $toApply) { + Say ("[*] Patching at 0x{0:X}: {1:X2} {2:X2} -> 90 90" -f ($t + 2), $b[$t + 2], $b[$t + 3]) Cyan + $b[$t + 2] = 0x90; $b[$t + 3] = 0x90 + } + try { [System.IO.File]::WriteAllBytes($Exe, $b) } + catch { Say "[!] Failed to write -- run as Administrator / close the emulator first." Red; return 1 } + Say "[+] Patched successfully! ($($toApply.Count) site(s), $already already patched)" Green + return 0 +} + +# =========================================================================== +# EXT4 EDIT -- shared debugfs logic (used by Root / Unroot / TestExt4) +# =========================================================================== +function To-DebugfsPath([string]$p) { + # forward slashes are accepted by Win32 CreateFile and avoid debugfs backslash escaping + return ($p -replace '\\', '/') +} + +function Resolve-Debugfs { + if ($Debugfs -and (Test-Path -LiteralPath $Debugfs)) { return (Resolve-Path -LiteralPath $Debugfs).Path } + $c = Get-Command debugfs.exe -ErrorAction SilentlyContinue + if ($c) { return $c.Source } + # fall back to the debugfs bundle embedded in the .cmd itself + $emb = Expand-EmbeddedDebugfs $SelfPath + if ($emb) { Say "[*] using embedded debugfs (extracted from the .cmd)." ; return $emb } + throw @" +debugfs.exe was not found and no embedded debugfs bundle is present. + +The offline ext4 method needs e2fsprogs' debugfs.exe. The single-file build +normally carries one; if you are running the raw engine, pass -Debugfs +or put debugfs.exe in tools\debugfs\ (see tools\debugfs\ in the repo). +"@ +} + +function Run-Debugfs([string]$debugfsExe, [string]$imgPath, [string[]]$cmds, [switch]$Write) { + $script = New-TempFile 'bsr_dfs' '.txt' + Set-Content -LiteralPath $script -Value ($cmds -join "`n") -Encoding ascii -NoNewline + $args = @() + if ($Write) { $args += '-w' } + $args += @('-f', $script, $imgPath) + # debugfs prints its version banner (and many notices) to stderr on EVERY run. + # Under ErrorActionPreference=Stop those stderr lines become terminating errors, + # so drop to Continue for the native call and fold stderr into the captured text. + $old = $ErrorActionPreference; $ErrorActionPreference = 'Continue' + try { $out = & $debugfsExe @args 2>&1 | Out-String } + finally { $ErrorActionPreference = $old } + Remove-Item -LiteralPath $script -Force -ErrorAction SilentlyContinue + return $out +} + +$Script:TempFiles = New-Object System.Collections.Generic.List[string] +function New-TempFile([string]$prefix, [string]$ext) { + $root = Join-Path $env:TEMP 'bsr_work' + if (-not (Test-Path -LiteralPath $root)) { New-Item -ItemType Directory -Path $root -Force | Out-Null } + # no random (deterministic & resume-safe); caller ensures uniqueness by prefix + $f = Join-Path $root ($prefix + $ext) + [void]$Script:TempFiles.Add($f) + return $f +} + +# Edit a plain ext4 image: install (remove=$false) or delete (remove=$true) the su. +# Verifies the result BEFORE returning so callers can refuse to write a bad image back. +function Edit-Ext4([string]$imgPath, [bool]$remove, [byte[]]$suBytes) { + $dfs = Resolve-Debugfs + $imgD = To-DebugfsPath $imgPath + Say "[*] debugfs: $dfs" + + if ($remove) { + Run-Debugfs $dfs $imgPath @('rm /android/system/xbin/su') -Write | Out-Null + $stat = Run-Debugfs $dfs $imgPath @('stat /android/system/xbin/su') + # gone = stat no longer reports an inode for it + if ($stat -notmatch '(?im)Inode:\s*\d') { Say "[+] su removed from ext4." Green; return $true } + Say "[!] su still present after removal:`n$stat" Red; return $false + } + + # install + $suFile = New-TempFile 'su' '' + [System.IO.File]::WriteAllBytes($suFile, $suBytes) + $suD = To-DebugfsPath $suFile + # NOTE: debugfs `write ` does NOT traverse as a path -- it + # creates a file in the CURRENT directory whose name is the literal + # string. So we `cd` into the target dir and write the bare basename, then + # set attributes on the bare basename (relative to cwd). mkdir on dirs that + # already exist (real Root.vhd) just prints "File exists" and is ignored. + $cmds = @( + 'mkdir /android', + 'mkdir /android/system', + 'mkdir /android/system/xbin', + 'cd /android/system/xbin', + 'rm su', + "write $suD su", + 'sif su mode 0106755', + 'sif su uid 0', + 'sif su gid 0', + 'sif su links_count 1' + ) + Run-Debugfs $dfs $imgPath $cmds -Write | Out-Null + $stat = Run-Debugfs $dfs $imgPath @('stat /android/system/xbin/su') + Remove-Item -LiteralPath $suFile -Force -ErrorAction SilentlyContinue + Say "[*] verify:`n$stat" + if ($stat -notmatch '(?im)Inode:\s*\d') { Say "[!] su was not written into ext4." Red; return $false } + # debugfs prints the permission bits only, e.g. "Mode: 06755" (NOT the full i_mode). + # Parse the octal Mode and compare its low 12 bits to 0o6755 (= 0xDED): setuid+setgid+rwxr-xr-x. + $okMode = $false + $mm = [regex]::Match($stat, '(?im)Mode:\s*0*([0-7]{3,6})') + if ($mm.Success) { try { $okMode = (([Convert]::ToInt32($mm.Groups[1].Value, 8)) -band 0xFFF) -eq 0xDED } catch { } } + if (-not $okMode) { Say "[!] su present but mode is not 06755 (setuid/setgid). Not trusting this image." Red; return $false } + Say "[+] su installed: /android/system/xbin/su mode 06755 (setuid root) owner 0:0" Green + return $true +} + +# =========================================================================== +# ROOT / UNROOT -- attach Root.vhd, locate ext4, carve, edit, write back +# =========================================================================== +function Read-DeviceBytes([string]$device, [long]$offset, [int]$count) { + $fs = [System.IO.File]::Open($device, 'Open', 'Read', 'ReadWrite') + try { + # raw-device reads must be sector-aligned: read the 512-byte sector and slice + $secBase = [long]([Math]::Floor($offset / 512) * 512) + $delta = [int]($offset - $secBase) + $need = [int]([Math]::Ceiling(($delta + $count) / 512.0) * 512) + $buf = New-Object byte[] $need + $fs.Position = $secBase + [void]$fs.Read($buf, 0, $need) + $res = New-Object byte[] $count + [Array]::Copy($buf, $delta, $res, 0, $count) + return $res + } finally { $fs.Close() } +} + +function Copy-DeviceToFile([string]$device, [long]$start, [long]$length, [string]$outFile) { + $fs = [System.IO.File]::Open($device, 'Open', 'Read', 'ReadWrite') + try { + $fs.Position = $start + $out = [System.IO.File]::Open($outFile, 'Create', 'Write', 'None') + try { + $buf = New-Object byte[] (16MB) + [long]$remaining = $length + while ($remaining -gt 0) { + $want = [int][Math]::Min([long]$buf.Length, $remaining) + $r = $fs.Read($buf, 0, $want) + if ($r -le 0) { break } + $out.Write($buf, 0, $r) + $remaining -= $r + } + } finally { $out.Close() } + } finally { $fs.Close() } +} + +function Copy-FileToDevice([string]$inFile, [string]$device, [long]$start) { + $fs = [System.IO.File]::Open($device, 'Open', 'ReadWrite', 'ReadWrite') + try { + $fs.Position = $start + $in = [System.IO.File]::OpenRead($inFile) + try { + $buf = New-Object byte[] (16MB) + while (($r = $in.Read($buf, 0, $buf.Length)) -gt 0) { + if (($r % 512) -ne 0) { $r += (512 - ($r % 512)) } # safety pad (img is sector-multiple) + $fs.Write($buf, 0, $r) + } + $fs.Flush() + } finally { $in.Close() } + } finally { $fs.Close() } +} + +function Get-Ext4Target($diskNumber, $physical) { + # Returns @{ Device; Start; Length } for the ext4 region, by probing +0x438 == 0xEF53. + $parts = @(Get-Partition -DiskNumber $diskNumber -ErrorAction SilentlyContinue | Sort-Object Offset) + foreach ($p in $parts) { + $dev = "\\.\Harddisk$($diskNumber)Partition$($p.PartitionNumber)" + try { + $m = Read-DeviceBytes $dev 0x438 2 + if ($m[0] -eq 0x53 -and $m[1] -eq 0xEF) { + return @{ Device = $dev; Start = [long]0; Length = [long]$p.Size; Offset = [long]$p.Offset } + } + } + catch { } # partition device not openable -> skip + # fallback: probe on the physical drive at the partition's absolute offset + try { + $m = Read-DeviceBytes $physical ([long]$p.Offset + 0x438) 2 + if ($m[0] -eq 0x53 -and $m[1] -eq 0xEF) { + return @{ Device = $physical; Start = [long]$p.Offset; Length = [long]$p.Size; Offset = [long]$p.Offset } + } + } + catch { } + } + # superfloppy: ext4 directly at disk offset 0 + try { + $m = Read-DeviceBytes $physical 0x438 2 + if ($m[0] -eq 0x53 -and $m[1] -eq 0xEF) { + $disk = Get-Disk -Number $diskNumber + return @{ Device = $physical; Start = [long]0; Length = [long]$disk.Size; Offset = [long]0 } + } + } + catch { } + return $null +} + +function Invoke-VhdSu([bool]$remove) { + if (-not $Vhd) { throw "Root/Unroot requires -Vhd ." } + if (-not (Test-Path -LiteralPath $Vhd)) { throw "Root.vhd not found: $Vhd" } + $dfs = Resolve-Debugfs # fail fast before we attach anything + + $suBytes = $null + if (-not $remove) { $suBytes = Get-EmbeddedSu $SelfPath; Say "[*] Embedded su OK ($($suBytes.Length) bytes, sha256 verified)." Green } + + # optional safety backup of the whole Root.vhd (once) + if (-not $remove -and -not $NoBackup) { + $vbak = "$Vhd.bsrbak" + if (-not (Test-Path -LiteralPath $vbak)) { + try { + $sz = (Get-Item -LiteralPath $Vhd).Length + $drive = (Get-Item -LiteralPath $Vhd).PSDrive + $free = (Get-PSDrive -Name $drive.Name).Free + if ($free -gt ($sz * 1.1)) { + Say "[*] Backing up Root.vhd -> $vbak (one-time safety copy, $([math]::Round($sz/1GB,2)) GB)..." + Copy-Item -LiteralPath $Vhd -Destination $vbak -Force + Say "[*] Backup done." Green + } + else { Say "[~] Not enough free space for a Root.vhd backup -- proceeding without one (NoRoot copy is your fallback)." Yellow } + } + catch { Say "[~] Could not create Root.vhd backup: $($_.Exception.Message)" Yellow } + } + else { Say "[*] Root.vhd backup already exists: $vbak" } + } + + $attached = $false + try { + Say "[*] Attaching $Vhd (read/write)..." + Mount-DiskImage -ImagePath $Vhd -Access ReadWrite -ErrorAction Stop | Out-Null + $attached = $true + $dn = $null + for ($try = 0; $try -lt 20; $try++) { + $di = Get-DiskImage -ImagePath $Vhd -ErrorAction SilentlyContinue + if ($di -and $di.Number -ne $null) { $dn = $di.Number; break } + Start-Sleep -Milliseconds 250 + } + if ($null -eq $dn) { + $disk = Get-DiskImage -ImagePath $Vhd | Get-Disk -ErrorAction SilentlyContinue + if ($disk) { $dn = $disk.Number } + } + if ($null -eq $dn) { throw "Could not determine the disk number of the attached VHD." } + $physical = "\\.\PhysicalDrive$dn" + Say "[*] Attached as disk $dn ($physical)." + + $tgt = Get-Ext4Target $dn $physical + if ($null -eq $tgt) { throw "No ext4 partition (0xEF53 @ +0x438) found inside $Vhd." } + Say ("[*] ext4 found: device={0} partOffset=0x{1:X} size={2} bytes" -f $tgt.Device, $tgt.Offset, $tgt.Length) + + $img = New-TempFile 'ext4' '.img' + Say "[*] Carving ext4 region to $img ..." + Copy-DeviceToFile $tgt.Device $tgt.Start $tgt.Length $img + + $ok = Edit-Ext4 $img $remove $suBytes + if (-not $ok) { + Say "[!] ext4 edit/verify failed -- NOT writing anything back. Root.vhd is unchanged." Red + return 1 + } + + Say "[*] Writing the modified ext4 region back into the VHD ..." + Copy-FileToDevice $img $tgt.Device $tgt.Start + Remove-Item -LiteralPath $img -Force -ErrorAction SilentlyContinue + if ($remove) { Say "[+] Unrooted successfully! (su removed from Root.vhd)" Green } + else { Say "[+] Rooted successfully! (su installed into Root.vhd)" Green } + return 0 + } + finally { + if ($attached) { + try { Dismount-DiskImage -ImagePath $Vhd -ErrorAction Stop | Out-Null; Say "[*] Detached $Vhd." } + catch { Say "[!] WARNING: failed to detach $Vhd -- detach it manually (Disk Management) before launching BlueStacks." Red } + } + } +} + +# =========================================================================== +# .bstk disk mode -- faithful global regex_replace (derivation §4) +# =========================================================================== +function Backup-Once([string]$path) { + $bak = "$path.bak" + if (-not (Test-Path -LiteralPath $bak)) { + try { attrib -R $path 2>$null | Out-Null } catch { } + Copy-Item -LiteralPath $path -Destination $bak -Force + Say "[*] Backup: $bak" + } +} + +function Invoke-Bstk([bool]$toReadonly) { + if (-not $Bstk) { throw "DiskRW/DiskRO requires -Bstk ." } + if (-not (Test-Path -LiteralPath $Bstk)) { throw ".bstk not found: $Bstk" } + $raw = [System.IO.File]::ReadAllText($Bstk) + # guard exactly like the exe: only touch files that describe the BlueStacks disks + if ($raw -notmatch 'location="fastboot\.vdi"' -and $raw -notmatch 'location="Root\.vhd"') { + Say "[~] $Bstk does not look like a BlueStacks instance disk file -- leaving it untouched." Yellow + return 1 + } + Backup-Once $Bstk + if ($toReadonly) { $new = $raw -replace 'type="Normal"', 'type="Readonly"' } # R/O + else { $new = $raw -replace 'type="Readonly"', 'type="Normal"' } # R/W (case-insensitive: also matches ReadOnly) + if ($new -eq $raw) { Say "[~] .bstk disk mode already set; no change." Yellow; return 0 } + try { attrib -R $Bstk 2>$null | Out-Null } catch { } + [System.IO.File]::WriteAllText($Bstk, $new, (New-Object System.Text.UTF8Encoding($false))) + if ($toReadonly) { Say "[+] Disk reverted to Readonly." Green } else { Say "[+] Disk set to R/W." Green } + return 0 +} + +# =========================================================================== +# bluestacks.conf root flags (hybrid §6a -- works with Magisk + adb) +# =========================================================================== +# Modify an EXISTING key only. Returns $true if found+set, $false if absent. +# We deliberately do NOT add missing keys: BlueStacks 5.22.x validates every conf +# property against its internal "iprop" schema and aborts with +# "prop not found in iprop dir" / "FATAL: configuration init failed" +# if it sees an unknown key. Adding one (e.g. bst.instance..enable_adb_access, +# which is not a valid key on this build) bricks startup. +function Set-ConfKey([System.Collections.Generic.List[string]]$lines, [string]$key, [string]$val) { + $re = '^\s*' + [regex]::Escape($key) + '\s*=' + $done = $false + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match $re) { $lines[$i] = "$key=`"$val`""; $done = $true } + } + return $done +} + +function Invoke-Conf([bool]$enable) { + if (-not $Conf) { throw "ConfRoot/ConfUnroot requires -Conf ." } + if (-not $Instance) { throw "ConfRoot/ConfUnroot requires -Instance ." } + if (-not (Test-Path -LiteralPath $Conf)) { throw "bluestacks.conf not found: $Conf" } + Backup-Once $Conf + $val = if ($enable) { '1' } else { '0' } + # CRITICAL: bluestacks.conf must stay UTF-8 *without* a BOM and keep its original + # line endings. PowerShell 5.1 'Set-Content -Encoding utf8' writes a BOM, which + # makes BlueStacks fail with "Failed to read configuration file" -- so read raw, + # edit lines, and write with UTF8Encoding($false) preserving the EOL style. + $raw = [System.IO.File]::ReadAllText($Conf) + $eol = if ($raw -match "`r`n") { "`r`n" } else { "`n" } + $endsNl = $raw.EndsWith("`n") + $lines = New-Object System.Collections.Generic.List[string] + foreach ($l in ($raw -split "`r`n|`n")) { $lines.Add($l) } + if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') { $lines.RemoveAt($lines.Count - 1) } + # Only valid, already-present keys (see Set-ConfKey). Root = per-instance + # enable_root_access + the global rooting feature; adb = the GLOBAL + # bst.enable_adb_access (there is NO valid per-instance enable_adb_access key). + $miss = New-Object System.Collections.Generic.List[string] + if (-not (Set-ConfKey $lines "bst.instance.$Instance.enable_root_access" $val)) { $miss.Add("bst.instance.$Instance.enable_root_access") } + if (-not (Set-ConfKey $lines 'bst.feature.rooting' $val)) { $miss.Add('bst.feature.rooting') } + if (-not (Set-ConfKey $lines 'bst.enable_adb_access' $val)) { $miss.Add('bst.enable_adb_access') } + if ($miss.Count -gt 0) { Say "[~] conf key(s) absent, left as-is (NOT added, would brick startup): $($miss -join ', ')" Yellow } + try { attrib -R $Conf 2>$null | Out-Null } catch { } + $outText = ($lines -join $eol); if ($endsNl) { $outText += $eol } + [System.IO.File]::WriteAllText($Conf, $outText, (New-Object System.Text.UTF8Encoding($false))) + Say "[+] bluestacks.conf updated for '$Instance' (root flags = `"$val`", UTF-8 no BOM)." Green + return 0 +} + +# =========================================================================== +# VhdSelfTest -- exercise the dangerous disk path (attach -> ext4 detect -> +# carve -> write the SAME bytes back -> detach) on a throwaway VHD and prove +# the region is byte-identical afterwards. No debugfs, no su -- pure disk I/O. +# =========================================================================== +function Invoke-VhdSelfTest { + if (-not $Vhd) { throw "VhdSelfTest requires -Vhd." } + if (-not (Test-Path -LiteralPath $Vhd)) { throw "VHD not found: $Vhd" } + $attached = $false + try { + Mount-DiskImage -ImagePath $Vhd -Access ReadWrite -ErrorAction Stop | Out-Null + $attached = $true + $dn = $null + for ($t = 0; $t -lt 20; $t++) { $di = Get-DiskImage -ImagePath $Vhd -EA SilentlyContinue; if ($di -and $di.Number -ne $null) { $dn = $di.Number; break }; Start-Sleep -Milliseconds 250 } + if ($null -eq $dn) { throw "no disk number" } + $physical = "\\.\PhysicalDrive$dn" + $tgt = Get-Ext4Target $dn $physical + if ($null -eq $tgt) { Say "[!] ext4 region not detected." Red; return 1 } + Say ("[*] region: device={0} start=0x{1:X} len={2}" -f $tgt.Device, $tgt.Start, $tgt.Length) + $img1 = New-TempFile 'st1' '.img'; $img2 = New-TempFile 'st2' '.img' + Copy-DeviceToFile $tgt.Device $tgt.Start $tgt.Length $img1 + $h1 = Get-Sha256Hex ([System.IO.File]::ReadAllBytes($img1)) + Copy-FileToDevice $img1 $tgt.Device $tgt.Start + Copy-DeviceToFile $tgt.Device $tgt.Start $tgt.Length $img2 + $h2 = Get-Sha256Hex ([System.IO.File]::ReadAllBytes($img2)) + Remove-Item $img1, $img2 -Force -EA SilentlyContinue + if ($h1 -eq $h2) { Say "[+] carve/write-back is byte-identical (sha256 $($h1.Substring(0,16))...)." Green; return 0 } + Say "[!] MISMATCH after write-back: $h1 vs $h2" Red; return 1 + } + finally { if ($attached) { try { Dismount-DiskImage -ImagePath $Vhd -EA Stop | Out-Null } catch { Say "[!] detach failed for $Vhd" Red } } } +} + +# =========================================================================== +# Resolve -- discover instance / master / .bstk / conf / Root.vhd paths. +# Emits ONLY KEY=VALUE lines on stdout (so a .cmd `for /f` can `set` them). +# NOTE: must never Write-Host here -- it would pollute the captured output. +# =========================================================================== +# Normalize to the folder that actually holds bluestacks.conf + Engine\. +# Newer BlueStacks (e.g. 5.22.169) sets DataDir to ...\BlueStacks_nxt\Engine, +# while UserDefinedDir is the real base ...\BlueStacks_nxt -- handle both. +function Get-BaseDir([string]$dataDir, [string]$userDef) { + $cands = New-Object System.Collections.Generic.List[string] + if ($dataDir) { + if ($dataDir -match '(?i)[\\/]engine[\\/]?$') { [void]$cands.Add(($dataDir -replace '(?i)[\\/]engine[\\/]?$', '')) } + [void]$cands.Add($dataDir) + } + if ($userDef) { [void]$cands.Add($userDef) } + # registry-declared data dir (honours a custom install) before the ProgramData fallback + $reg = Get-RegBlueStacks + if ($reg) { + foreach ($d in @($reg.DataDir, $reg.UserDefinedDir)) { + if ($d) { + if ($d -match '(?i)[\\/]engine[\\/]?$') { [void]$cands.Add(($d -replace '(?i)[\\/]engine[\\/]?$', '')) } + [void]$cands.Add($d) + } + } + } + [void]$cands.Add((Join-Path $env:ProgramData 'BlueStacks_nxt')) + $pick = $null + foreach ($c in $cands) { if ($c -and (Test-Path -LiteralPath (Join-Path $c 'bluestacks.conf'))) { $pick = $c; break } } + if (-not $pick) { foreach ($c in $cands) { if ($c -and (Test-Path -LiteralPath (Join-Path $c 'Engine'))) { $pick = $c; break } } } + if (-not $pick) { $pick = $cands[0] } + return $pick.TrimEnd('\', '/') +} + +function Invoke-Resolve { + if (-not $DataDir -and -not $UserDef) { throw "Resolve requires -DataDir/-UserDef (or BSR_DATADIR/BSR_USERDEF)." } + if (-not $Base) { throw "Resolve requires -Base (or BSR_BASE)." } + $DataDir = Get-BaseDir $DataDir $UserDef # normalize ...\Engine -> base + $instance = $null + $rx = '^' + [regex]::Escape($Base) + '(_\d+)?$' + + # 1) candidates from MimMetaData.json + $cands = @() + $mim = Join-Path $DataDir 'UserData\MimMetaData.json' + if (Test-Path -LiteralPath $mim) { + try { + $m = Select-String -LiteralPath $mim -Pattern '"InstanceName"\s*:\s*"([^"]+)"' -AllMatches + $cands = @($m.Matches | ForEach-Object { $_.Groups[1].Value } | Where-Object { $_ -match $rx } | Sort-Object -Unique) + } + catch { } + } + # 2) the most-recently-launched instance from Player.log (matches the per-instance UX) + $log = Join-Path $DataDir 'Logs\Player.log' + if (Test-Path -LiteralPath $log) { + try { + $tail = Get-Content -LiteralPath $log -Tail 6000 -ErrorAction SilentlyContinue + $hit = $tail | Select-String -Pattern ([regex]::Escape($Base) + '(_\d+)?') -AllMatches | + ForEach-Object { $_.Matches } | ForEach-Object { $_.Value } | + Where-Object { $_ -match $rx } | Select-Object -Last 1 + if ($hit) { $instance = $hit } + } + catch { } + } + # Prefer an instance whose .bstk actually EXISTS on disk -- Player.log may name a + # since-deleted clone (e.g. Rvc64_2). Order: log-most-recent, newest MimMetaData + # candidates, then the bare base. Fall back to the log/candidate name if none exist + # (so the orchestrator can print a helpful "launch it once" message). + $pref = New-Object System.Collections.Generic.List[string] + if ($instance) { [void]$pref.Add($instance) } + for ($k = $cands.Count - 1; $k -ge 0; $k--) { if ($cands[$k]) { [void]$pref.Add($cands[$k]) } } + [void]$pref.Add($Base) + $chosen = $null + foreach ($cand in $pref) { + if (-not $cand) { continue } + if (Test-Path -LiteralPath (Join-Path $DataDir "Engine\$cand\$cand.bstk")) { $chosen = $cand; break } + } + if (-not $chosen) { $chosen = if ($instance) { $instance } elseif ($cands.Count -ge 1) { $cands[-1] } else { $Base } } + $instance = $chosen + + # master = instance with a trailing _ stripped (clones share the master's Root.vhd) + $master = if ($instance -match '^(.+)_\d+$') { $Matches[1] } else { $instance } + + $bstk = Join-Path $DataDir "Engine\$instance\$instance.bstk" + $conf = Join-Path $DataDir 'bluestacks.conf' + + # Root.vhd: prefer the location declared in the .bstk; else master folder; else instance folder + $vhd = $null + if (Test-Path -LiteralPath $bstk) { + try { + $bt = [System.IO.File]::ReadAllText($bstk) + $mm = [regex]::Match($bt, 'location="([^"]*[Rr]oot\.vhd)"') + if ($mm.Success) { + $loc = $mm.Groups[1].Value + if ([System.IO.Path]::IsPathRooted($loc)) { $cand = $loc } + else { $cand = [System.IO.Path]::GetFullPath((Join-Path (Split-Path -Parent $bstk) $loc)) } + if (Test-Path -LiteralPath $cand) { $vhd = $cand } + } + } + catch { } + } + if (-not $vhd) { + $cm = Join-Path $DataDir "Engine\$master\Root.vhd" + $ci = Join-Path $DataDir "Engine\$instance\Root.vhd" + if (Test-Path -LiteralPath $cm) { $vhd = $cm } + elseif (Test-Path -LiteralPath $ci) { $vhd = $ci } + else { $vhd = $cm } # report the most-likely path even if missing + } + + # instance adb port (for the online/adb root path); default 5555 + $adbPort = '5555' + if (Test-Path -LiteralPath $conf) { + try { + $ct = [System.IO.File]::ReadAllText($conf) + $esc = [regex]::Escape($instance) + $pm = [regex]::Match($ct, '(?im)^\s*bst\.instance\.' + $esc + '\.status\.adb_port\s*=\s*"?(\d+)"?') + if (-not $pm.Success) { $pm = [regex]::Match($ct, '(?im)^\s*bst\.instance\.' + $esc + '\.adb_port\s*=\s*"?(\d+)"?') } + if ($pm.Success) { $adbPort = $pm.Groups[1].Value } + } + catch { } + } + + Write-Output "BSR_DATADIR=$DataDir" + Write-Output "BSR_INSTANCE=$instance" + Write-Output "BSR_MASTER=$master" + Write-Output "BSR_BSTK=$bstk" + Write-Output "BSR_CONF=$conf" + Write-Output "BSR_VHD=$vhd" + Write-Output "BSR_ADBPORT=$adbPort" +} + +# =========================================================================== +# ONLINE ROOT via BlueStacks' own adb (HD-Adb.exe) -- PRIMARY path. +# +# Once the disk is Normal + root/adb flags on + integrity bypassed, we boot the +# instance and let ANDROID'S OWN KERNEL write its ext4: push the embedded su and +# drop it into /system using BlueStacks' native su, then prove uid=0. No Windows +# ext4 tooling, no debugfs -- inherently version-proof. Offline debugfs is the +# fallback (Invoke-VhdSu) when the instance can't boot/root. +# =========================================================================== +function Resolve-Adb { + if ($Adb -and (Test-Path -LiteralPath $Adb)) { return (Resolve-Path -LiteralPath $Adb).Path } + $cands = New-Object System.Collections.Generic.List[string] + $reg = Get-RegBlueStacks # registry InstallDir first (custom installs) + if ($reg -and $reg.InstallDir) { [void]$cands.Add((Join-Path ($reg.InstallDir.TrimEnd('\', '/')) 'HD-Adb.exe')) } + foreach ($p in @( + (Join-Path $env:ProgramFiles 'BlueStacks_nxt\HD-Adb.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'BlueStacks_nxt\HD-Adb.exe'), + (Join-Path $env:ProgramFiles 'BlueStacks_msi5\HD-Adb.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'BlueStacks_msi5\HD-Adb.exe'))) { [void]$cands.Add($p) } + foreach ($p in $cands) { if ($p -and (Test-Path -LiteralPath $p)) { return $p } } + foreach ($n in @('HD-Adb.exe', 'adb.exe')) { $c = Get-Command $n -EA SilentlyContinue; if ($c) { return $c.Source } } + throw "HD-Adb.exe not found. Pass -Adb (or BSR_ADB)." +} + +$Script:AdbExe = $null +$Script:Serial = $null + +function AdbRaw([string[]]$a) { + $old = $ErrorActionPreference; $ErrorActionPreference = 'Continue' + try { return (& $Script:AdbExe @a 2>&1 | Out-String) } finally { $ErrorActionPreference = $old } +} +function AdbS([string[]]$a) { return AdbRaw (@('-s', $Script:Serial) + $a) } +function AdbShell([string]$cmd) { return AdbS @('shell', $cmd) } + +# Connect to the instance's adb endpoint and wait until Android finishes booting. +function Connect-WaitBoot([int]$timeoutSec) { + $port = if ($AdbPort) { $AdbPort } else { '5555' } + $Script:Serial = "127.0.0.1:$port" + # Isolate HD-Adb on its own server port so a different-version system adb (e.g. Android SDK + # platform-tools) on the default 5037 can't kill our server mid-run (the version-mismatch churn + # that makes getprop/shell calls fail and a booted instance look "not adb-reachable"). + if (-not $env:ANDROID_ADB_SERVER_PORT) { $env:ANDROID_ADB_SERVER_PORT = '15037' } + AdbRaw @('start-server') | Out-Null + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $connected = $false + while ($sw.Elapsed.TotalSeconds -lt $timeoutSec) { + $c = AdbRaw @('connect', $Script:Serial) + if ($c -match '(?i)connected to') { $connected = $true } + if (-not $connected) { + # maybe it registered as emulator-XXXX instead + $dev = AdbRaw @('devices') + $m = [regex]::Match($dev, '(?im)^(emulator-\d+|127\.0\.0\.1:\d+)\s+device\s*$') + if ($m.Success) { $Script:Serial = $m.Groups[1].Value; $connected = $true } + } + if ($connected) { + $b = (AdbShell 'getprop sys.boot_completed').Trim() + if ($b -match '1') { + # give late services (su daemon) a moment + Start-Sleep -Seconds 3 + return $true + } + } + Start-Sleep -Seconds 3 + } + return $false +} + +function Launch-Instance { + if ($NoLaunch) { Say "[*] -NoLaunch: assuming the instance is already running." ; return } + if (-not $Player -or -not (Test-Path -LiteralPath $Player)) { Say "[~] HD-Player.exe not provided; not launching (will try to connect anyway)." Yellow; return } + if (-not $Instance) { throw "AdbRoot requires -Instance to launch." } + $running = @(Get-Process -Name 'HD-Player' -ErrorAction SilentlyContinue) + if ($running.Count -gt 0) { Say "[*] HD-Player already running; not launching a second instance." ; return } + Say "[*] Booting instance '$Instance' ..." + Start-Process -FilePath $Player -ArgumentList @('--instance', $Instance) | Out-Null +} + +# Run a privileged shell script (pushed to the device) as root, trying the su +# styles BlueStacks may expose. Returns the combined output; sets $ok if uid=0. +function Run-AsRoot([string]$deviceScript) { + # opportunistically promote adbd (harmless if unsupported) + AdbS @('root') | Out-Null + Start-Sleep -Seconds 1 + Connect-WaitBoot 30 | Out-Null + $variants = @("su -c 'sh $deviceScript'", "su 0 sh $deviceScript", "su root -c 'sh $deviceScript'", "sh $deviceScript") + $best = '' + foreach ($v in $variants) { + $o = AdbShell $v + $best = $o + if ($o -match 'BSR_ROOT_OK') { return $o } + } + return $best +} + +function Invoke-AdbSu([bool]$remove) { + if (-not $Instance) { throw "AdbRoot/AdbUnroot requires -Instance." } + $Script:AdbExe = Resolve-Adb + Say "[*] adb: $($Script:AdbExe)" + + $suBytes = $null + if (-not $remove) { $suBytes = Get-EmbeddedSu $SelfPath; Say "[*] Embedded su OK ($($suBytes.Length) bytes, sha256 verified)." Green } + + Launch-Instance + Say "[*] Waiting for the instance to finish booting (adb 127.0.0.1:$(if($AdbPort){$AdbPort}else{'5555'})) ..." + if (-not (Connect-WaitBoot 240)) { + Say "[!] Instance did not become adb-reachable / booted in time." Red + return 2 # signal: caller may fall back to offline debugfs + } + Say "[+] Booted. serial=$($Script:Serial)" Green + + # stage a device-side script (avoids all the su/sh quoting pitfalls) + $work = Join-Path (Join-Path $env:TEMP 'bsr_work') 'adb' + if (-not (Test-Path -LiteralPath $work)) { New-Item -ItemType Directory -Path $work -Force | Out-Null } + $sh = Join-Path $work 'bsrdo.sh' + + if ($remove) { + $body = @' +mount -o rw,remount / 2>/dev/null +mount -o rw,remount /system 2>/dev/null +mount -o rw,remount /system_root 2>/dev/null +rm -f /system/xbin/su /system/bin/su 2>/dev/null +sync +if [ ! -e /system/xbin/su ] && [ ! -e /system/bin/su ]; then echo BSR_ROOT_OK_REMOVED; fi +'@ + } + else { + $body = @' +mount -o rw,remount / 2>/dev/null +mount -o rw,remount /system 2>/dev/null +mount -o rw,remount /system_root 2>/dev/null +T="" +for d in /system/xbin /system/bin; do + if [ -d "$d" ]; then + cp /data/local/tmp/bsrsu "$d/su" && chmod 06755 "$d/su" && { chown 0:0 "$d/su" 2>/dev/null || chown 0.0 "$d/su"; } && T="$d/su" + fi +done +sync +ls -l $T 2>/dev/null +if [ -n "$T" ]; then echo BSR_ROOT_OK_INSTALLED $T; fi +'@ + } + # LF line-endings for the device shell + [System.IO.File]::WriteAllText($sh, ($body -replace "`r`n", "`n"), (New-Object System.Text.UTF8Encoding($false))) + AdbS @('push', $sh, '/data/local/tmp/bsrdo.sh') | Out-Null + + if (-not $remove) { + $suTmp = Join-Path $work 'bsrsu' + [System.IO.File]::WriteAllBytes($suTmp, $suBytes) + AdbS @('push', $suTmp, '/data/local/tmp/bsrsu') | Out-Null + Remove-Item -LiteralPath $suTmp -Force -EA SilentlyContinue + } + + $out = Run-AsRoot '/data/local/tmp/bsrdo.sh' + AdbShell 'rm -f /data/local/tmp/bsrdo.sh /data/local/tmp/bsrsu' | Out-Null + + if ($remove) { + if ($out -match 'BSR_ROOT_OK_REMOVED') { Say "[+] su removed from /system via adb." Green; return 0 } + Say "[!] Could not confirm su removal via adb.`n$out" Red; return 1 + } + + if ($out -notmatch 'BSR_ROOT_OK_INSTALLED') { + Say "[!] su install via adb did not confirm (need BlueStacks root/su enabled).`n$out" Red + return 2 # let caller fall back to offline + } + Say "[*] su written:`n$out" + # final proof: a NON-root adb shell calling our setuid su must come back uid=0 + $idOut = AdbShell '/system/xbin/su -c id 2>/dev/null || /system/bin/su -c id 2>/dev/null' + if ($idOut -match 'uid=0') { Say "[+] Rooted online! /system/.../su grants uid=0:`n$idOut" Green; return 0 } + Say "[~] su is in place but 'su -c id' did not report uid=0 (SELinux?). Output:`n$idOut" Yellow + return 0 # file is installed; Magisk's system install can still proceed +} + +function Invoke-AdbVerify { + if (-not $Instance) { throw "AdbVerify requires -Instance." } + $Script:AdbExe = Resolve-Adb + if (-not (Connect-WaitBoot 240)) { Say "[!] not reachable/booted." Red; return 1 } + $id = AdbShell '/system/xbin/su -c id 2>/dev/null || /system/bin/su -c id 2>/dev/null' + $ls = AdbShell 'ls -l /system/xbin/su /system/bin/su 2>/dev/null' + $mg = AdbShell 'magisk -V 2>/dev/null; magisk -c 2>/dev/null' + Say "su id : $($id.Trim())" + Say "su ls : $($ls.Trim())" + Say "magisk: $($mg.Trim())" + if ($id -match 'uid=0') { Say "[+] root verified (uid=0)." Green; return 0 } + Say "[!] root NOT verified." Red; return 1 +} + +# =========================================================================== +# dispatch +# =========================================================================== +switch ($Action) { + 'ExtractSu' { + if (-not $OutFile) { throw "ExtractSu requires -OutFile." } + $bytes = Get-EmbeddedSu $SelfPath + [System.IO.File]::WriteAllBytes($OutFile, $bytes) + Say "[+] su extracted to $OutFile ($($bytes.Length) bytes, sha256 verified)." Green + exit 0 + } + 'TestExt4' { + if (-not $Img) { throw "TestExt4 requires -Img ." } + $suBytes = $null + if (-not $Restore) { $suBytes = Get-EmbeddedSu $SelfPath } # -Restore here means 'remove' + $ok = Edit-Ext4 $Img ([bool]$Restore) $suBytes + exit ([int](-not $ok)) + } + 'Patch' { exit (Invoke-Patch) } + 'Root' { exit (Invoke-VhdSu $false) } + 'Unroot' { exit (Invoke-VhdSu $true) } + 'AdbRoot' { exit (Invoke-AdbSu $false) } + 'AdbUnroot' { exit (Invoke-AdbSu $true) } + 'AdbVerify' { exit (Invoke-AdbVerify) } + 'DiskRW' { exit (Invoke-Bstk $false) } + 'DiskRO' { exit (Invoke-Bstk $true) } + 'ConfRoot' { exit (Invoke-Conf $true) } + 'ConfUnroot' { exit (Invoke-Conf $false) } + 'Resolve' { Invoke-Resolve; exit 0 } + 'BaseDir' { Write-Output (Get-BaseDir $DataDir $UserDef); exit 0 } + 'VhdSelfTest' { exit (Invoke-VhdSelfTest) } } __BSR_ENGINE_END__ __BSR_SU_BEGIN__ @@ -38028,696 +38032,759 @@ p/M8fTed+2Quz2J88qdzzPSlbnsSUX9e8Kfv9Qz677iI/4vIkf8wnjfo/xvHhQcmvr+vCfGr/w9B/w7B OZKTeFD5WODF+f8Fe05s2WgTAAA= __BSR_BSRSU_END__ __BSR_MAGISK_BEGIN__ -<# bsr_magisk.ps1 -- Make Magisk the SOLE, self-sustaining root on BlueStacks 5 (rvc/Android 11), - with no traces of any bootstrap su. Minimal read/writes. Built from the proven workflow in - docs/BLUESTACKS_ROOTING_DEEP_DIVE.md (sect. 6). - - Pipeline (no DiskRW; Root.vhd edited offline at file level; only /data written at runtime): - Prep [offline] HD-Player anti-tamper patch (via bsr_engine.ps1) + conf enable_root_access=1 - + ONE Root.vhd carve writing: Magisk /system files + hijacked bootanim.rc - + bootstrap su (bsr_su) + hijacked bindmount. - Data [online ] boot, adb install Magisk APK, then via bootstrap su populate /data/adb/magisk - (busybox + ABI binaries + scripts) and set the grant policy. - Clean [offline] remove bsr_su + restore the stock bindmount. - Finalize [conf ] enable_root_access=0 ("turn off emulator root"). - Verify [online ] cold boot; confirm Magisk-only root, no traces. - Auto Prep -> boot -> Data -> Clean -> Finalize -> Verify (the whole thing). - - Inputs: -MagiskApk (manager app + every Magisk binary; the one external file) - -Vhd -Conf -Instance -Install -#> -[CmdletBinding()] -param( - [ValidateSet('Prep','Data','Clean','Finalize','Verify','Auto','Undo')] - [string]$Action = 'Auto', - [string]$Vhd, - [string]$Conf, - [string]$Instance = 'Rvc64', - [string]$Install, # BlueStacks install dir; resolved from the registry if omitted - [string]$MagiskApk, - [string]$Engine, # bsr_engine.ps1 (for the HD-Player patch); auto-detected if omitted - [string]$Debugfs, # debugfs.exe; auto-detected if omitted - [string]$BsrSuPath, # bootstrap su binary; auto-detected if omitted - [string]$SelfCmd, # path to blueStackRoot.cmd (embedded mode: self-extract debugfs + bsr_su) - [switch]$NoBackup, - [switch]$Full # Undo: also scrub the shared master + un-patch HD-Player (unroots ALL instances) -) -# NOTE: 'Continue' (not 'Stop') because native tools (adb, debugfs) write normal output to -# stderr, which under 'Stop' becomes a fatal NativeCommandError. Every critical step below has -# an explicit success check + throw, and disk cmdlets use -ErrorAction Stop, so failures still abort. -$ErrorActionPreference = 'Continue' -$Self = $MyInvocation.MyCommand.Path -$Here = Split-Path -Parent $Self - -# --------- the bsr_su grant policy / known signatures ---------- -$BSR_SU_SHA = '7eb6380ee26ce0b68d9f3f23ac04f50e0dfdd49359ef17d1a4978be1795913dd' - -function Say($m,$c='Gray'){ Write-Host $m -ForegroundColor $c } -function Fwd($p){ $p -replace '\\','/' } - -# --------- normalize incoming paths (callers may pass a trailing '\' or a stray '"') ---------- -# A registry InstallDir like C:\Program Files\BlueStacks_nxt\ becomes -Install "...\nxt\" on the -# command line, and PowerShell -File treats the \" as an escaped quote -> the value arrives as -# '...\nxt"'. Strip stray quotes and any trailing slash/space so Join-Path stays clean. -function Clean-Path($p){ if($null -eq $p){return $p}; ($p -replace '"','').Trim().TrimEnd('\') } -$Install = Clean-Path $Install -$Engine = Clean-Path $Engine -$Debugfs = Clean-Path $Debugfs -$Vhd = Clean-Path $Vhd -$Conf = Clean-Path $Conf -$SelfCmd = Clean-Path $SelfCmd - -# --------- registry discovery (NO hardcoded install/data paths) ---------- -# BlueStacks records its real install/data folders in the registry; honour a custom install location by -# reading them instead of assuming C:\Program Files\.. / C:\ProgramData\.. . nxt (BlueStacks 5) first, -# then msi5 (MSI App Player), under both the native and WOW6432Node views. -function Get-RegBlueStacks{ - foreach($k in @('HKLM:\SOFTWARE\BlueStacks_nxt','HKLM:\SOFTWARE\BlueStacks_msi5', - 'HKLM:\SOFTWARE\WOW6432Node\BlueStacks_nxt','HKLM:\SOFTWARE\WOW6432Node\BlueStacks_msi5')){ - try{ - $p=Get-ItemProperty -Path $k -ErrorAction Stop - if($p -and ($p.InstallDir -or $p.DataDir -or $p.UserDefinedDir)){ - return [pscustomobject]@{ InstallDir=$p.InstallDir; DataDir=$p.DataDir; UserDefinedDir=$p.UserDefinedDir } - } - }catch{} - } - return $null -} -# Folder that actually holds bluestacks.conf + Engine\. Newer BlueStacks reports DataDir as ...\Engine. -function Get-DataRoot($reg){ - $d = if($reg){ if($reg.DataDir){$reg.DataDir}elseif($reg.UserDefinedDir){$reg.UserDefinedDir}else{$null} } else { $null } - if(-not $d){ $d = Join-Path $env:ProgramData 'BlueStacks_nxt' } - if($d -match '(?i)[\\/]engine[\\/]?$'){ $d = $d -replace '(?i)[\\/]engine[\\/]?$','' } - $d.TrimEnd('\','/') -} - -$reg = Get-RegBlueStacks -if (-not $Install) { $Install = if($reg -and $reg.InstallDir){ $reg.InstallDir.TrimEnd('\') } else { Join-Path $env:ProgramFiles 'BlueStacks_nxt' } } -$DataRoot = Get-DataRoot $reg -if (-not $Engine) { $Engine = Join-Path $Here 'bsr_engine.ps1' } -if (-not $Debugfs) { - foreach($c in @((Join-Path $Here 'debugfs\debugfs.exe'), (Join-Path $env:TEMP 'bsr_work\debugfs\debugfs.exe'))){ if(Test-Path $c){ $Debugfs=$c; break } } -} -if (-not $Conf) { $Conf = Join-Path $DataRoot 'bluestacks.conf' } -if (-not $Vhd -and $Instance) { $Vhd = Join-Path $DataRoot "Engine\$Instance\Root.vhd" } -$Adb = Join-Path $Install 'HD-Adb.exe' -$Player = Join-Path $Install 'HD-Player.exe' -$BsrSu = if($BsrSuPath){$BsrSuPath}else{ Join-Path $Here 'su_src\bsr_su' } # the setuid bootstrap su (4968 B) - -# ---- embedded-payload self-extraction (only used when -SelfCmd is given) ---- -function Extract-Block($cmdPath,$begTok,$endTok){ - $t=[System.IO.File]::ReadAllText($cmdPath) - $b="__BSR_${begTok}_"+"BEGIN__"; $e="__BSR_${endTok}_"+"END__" - $i=$t.IndexOf($b); $j=$t.IndexOf($e) - if($i -lt 0 -or $j -le $i){ return $null } - $i=$t.IndexOf([char]10,$i)+1 - $t.Substring($i,$j-$i) -} -function Ensure-Debugfs { - if($script:Debugfs -and (Test-Path $script:Debugfs)){ return } - foreach($c in @((Join-Path $Here 'debugfs\debugfs.exe'), (Join-Path $env:TEMP 'bsr_work\debugfs\debugfs.exe'))){ if(Test-Path $c){ $script:Debugfs=$c; return } } - if($SelfCmd -and (Test-Path $SelfCmd)){ - $b64=Extract-Block $SelfCmd 'DFS' 'DFS' - if($b64){ $b64=($b64 -replace '[^A-Za-z0-9+/=]',''); $d=Join-Path $env:TEMP 'bsr_work\debugfs'; New-Item -ItemType Directory -Path $d -Force | Out-Null; $zip=Join-Path $d '_d.zip'; [System.IO.File]::WriteAllBytes($zip,[Convert]::FromBase64String($b64)); Add-Type -AssemblyName System.IO.Compression.FileSystem; $za=[System.IO.Compression.ZipFile]::OpenRead($zip); try{ foreach($en in $za.Entries){ if(-not $en.Name){continue}; $tp=Join-Path $d $en.FullName; $dd=Split-Path -Parent $tp; if(-not(Test-Path $dd)){New-Item -ItemType Directory -Path $dd -Force|Out-Null}; [System.IO.Compression.ZipFileExtensions]::ExtractToFile($en,$tp,$true) } } finally { $za.Dispose() }; Remove-Item $zip -Force -EA SilentlyContinue; $exe=Join-Path $d 'debugfs.exe'; if(Test-Path $exe){ $script:Debugfs=$exe } } - } - if(-not ($script:Debugfs -and (Test-Path $script:Debugfs))){ throw "debugfs.exe not found (pass -Debugfs or -SelfCmd)." } -} -function Ensure-BsrSu { - if($script:BsrSu -and (Test-Path $script:BsrSu)){ return } - if($SelfCmd -and (Test-Path $SelfCmd)){ - $b64=Extract-Block $SelfCmd 'BSRSU' 'BSRSU' - if($b64){ $b64=($b64 -replace '[^A-Za-z0-9+/=]',''); $gz=[Convert]::FromBase64String($b64); $in=New-Object System.IO.MemoryStream(,$gz); $z=New-Object System.IO.Compression.GZipStream($in,[System.IO.Compression.CompressionMode]::Decompress); $out=New-Object System.IO.MemoryStream; $buf=New-Object byte[] 65536; while(($n=$z.Read($buf,0,$buf.Length)) -gt 0){ $out.Write($buf,0,$n) }; $z.Close(); $in.Close(); $d=Join-Path $env:TEMP 'bsr_work'; New-Item -ItemType Directory -Path $d -Force | Out-Null; $p=Join-Path $d 'bsr_su'; [System.IO.File]::WriteAllBytes($p,$out.ToArray()); $out.Close(); $script:BsrSu=$p } - } - if(-not ($script:BsrSu -and (Test-Path $script:BsrSu))){ throw "bootstrap su (bsr_su) not found (pass -BsrSuPath or -SelfCmd)." } -} -function Ensure-MagiskApk { - if($script:MagiskApk -and (Test-Path $script:MagiskApk)){ return } - # an external APK next to the .cmd already resolved by the caller; otherwise extract the EMBEDDED one - if($SelfCmd -and (Test-Path $SelfCmd)){ - $b64=Extract-Block $SelfCmd 'APK' 'APK' - if($b64){ $b64=($b64 -replace '[^A-Za-z0-9+/=]',''); $d=Join-Path $env:TEMP 'bsr_work'; New-Item -ItemType Directory -Path $d -Force | Out-Null; $p=Join-Path $d 'magisk.apk'; [System.IO.File]::WriteAllBytes($p,[Convert]::FromBase64String($b64)); $script:MagiskApk=$p; Say "[*] using embedded Magisk APK ($([Math]::Round((Get-Item $p).Length/1MB,1)) MB)." DarkGray } - } - if(-not ($script:MagiskApk -and (Test-Path $script:MagiskApk))){ throw "Magisk APK not found (pass -MagiskApk or -SelfCmd with an embedded APK)." } -} - -# ==================================================================== -# Embedded text templates (LF; written verbatim into ext4 / used at runtime) -# ==================================================================== -# GATED bootanim.rc: the shared master Root.vhd is used by ALL instances, so Magisk's boot hooks -# must be PER-INSTANCE. Each stage execs bsr_boot.sh, which no-ops unless THIS instance carries -# /data/adb/.bsr_root (its own /data). Unrooted instances -> no magiskd, no su, no app, no leak. -$BOOTANIM_RC = @' -service bootanim /system/bin/bootanimation - class core animation - user graphics - group graphics audio - disabled - oneshot - ioprio rt 0 - task_profiles MaxPerformance -on post-fs-data - start logd - exec u:r:su:s0 root root -- /system/etc/init/magisk/bsr_boot.sh post-fs-data -on nonencrypted - exec u:r:su:s0 root root -- /system/etc/init/magisk/bsr_boot.sh service -on property:vold.decrypt=trigger_restart_framework - exec u:r:su:s0 root root -- /system/etc/init/magisk/bsr_boot.sh service -on property:sys.boot_completed=1 - exec u:r:su:s0 root root -- /system/etc/init/magisk/bsr_boot.sh boot-complete -on property:init.svc.zygote=restarting - exec u:r:su:s0 root root -- /system/etc/init/magisk/bsr_boot.sh zygote-restart -on property:init.svc.zygote=stopped - exec u:r:su:s0 root root -- /system/etc/init/magisk/bsr_boot.sh zygote-restart -'@ -replace "`r`n","`n" - -# Per-instance Magisk gate. SELinux is disabled on BlueStacks so one context (root) suffices. -$BSR_BOOT_SH = @' -#!/system/bin/sh -# BSR per-instance Magisk gate. Activates Magisk ONLY if THIS instance (its own /data) is flagged. -[ -f /data/adb/.bsr_root ] || exit 0 -M=/system/etc/init/magisk -case "$1" in - post-fs-data) - "$M/magiskpolicy" --live --magisk 2>/dev/null - "$M/magisk64" --auto-selinux --setup-sbin "$M" /sbin 2>/dev/null - /sbin/magisk --auto-selinux --post-fs-data 2>/dev/null - ;; - service) /sbin/magisk --auto-selinux --service 2>/dev/null ;; - boot-complete) mkdir -p /data/adb/magisk; /sbin/magisk --auto-selinux --boot-complete 2>/dev/null ;; - zygote-restart) /sbin/magisk --auto-selinux --zygote-restart 2>/dev/null ;; -esac -exit 0 -'@ -replace "`r`n","`n" - -$MAGISK_CONFIG = "SYSTEMMODE=true`nRECOVERYMODE=false`n" - -# bootstrap bindmount: stock behaviour + bind our setuid su over xbin/su AFTER the .xb overmount -$BINDMOUNT_MOD = @' -#!/system/bin/sh -# Rooting helper: bindmount /data/downloads/.xb over /system/xbin, then bind our setuid su. -MAXSIZE=100000 -MARKER_FILE="/data/downloads/.bm" -to_mount=$(getprop bst.config.bindmount) -echo "to_mount=$to_mount" > /dev/kmsg -mounted=`mountpoint -q /system/xbin && echo "1" || echo "0"` -echo "mounted=$mounted" > /dev/kmsg -FILESIZE=$(stat -c%s "$MARKER_FILE") -echo "Size of $MARKER_FILE = $FILESIZE bytes." > /dev/kmsg -if (( FILESIZE > MAXSIZE )); then - rm $MARKER_FILE - touch $MARKER_FILE -fi -if [ $to_mount -gt 0 ] && [ $mounted -le 0 ] && [ -d /data/downloads/.xb ]; then - echo "Bind mounting..." > /dev/kmsg - mount -o bind /data/downloads/.xb/ /system/xbin/ > /dev/kmsg - echo "`date` bindmount" >> $MARKER_FILE - if [ -f /system/etc/bsr_su ]; then - mount -o bind /system/etc/bsr_su /system/xbin/su - mount -o bind /system/etc/bsr_su /system/xbin/bstk/su - echo "bsr: ungated setuid su bind-mounted" > /dev/kmsg - fi - /system/xbin/su --auto-daemon & -elif [ $to_mount -le 0 ] && [ $mounted -gt 0 ]; then - for pid in `pgrep daemonsu` - do - kill -9 $pid - done - sleep 3 - echo "`date` unbindmount" >> $MARKER_FILE - umount /system/xbin/su 2>/dev/null - umount /system/xbin/bstk/su 2>/dev/null - umount /system/xbin/ > /dev/kmsg -fi -'@ -replace "`r`n","`n" - -# stock bindmount restored at Clean (genuine factory file, extracted from bsrbak) -$BINDMOUNT_ORIG = @' -#!/system/bin/sh -# This script helps in rooting/unrooting the app-player by bindmounting/unmounting the .xb folder and xbin. - -#================ ROOT/UNROOT =====================# - -MAXSIZE=100000 -MARKER_FILE="/data/downloads/.bm" -to_mount=$(getprop bst.config.bindmount) -echo "to_mount=$to_mount" > /dev/kmsg - -mounted=`mountpoint -q /system/xbin && echo "1" || echo "0"` -echo "mounted=$mounted" > /dev/kmsg - -# Get marker file size -FILESIZE=$(stat -c%s "$MARKER_FILE") -# Checkpoint -echo "Size of $MARKER_FILE = $FILESIZE bytes." > /dev/kmsg - -if (( FILESIZE > MAXSIZE )); then - echo "Removing and creating new $MARKER_FILE" > /dev/kmsg - rm $MARKER_FILE - touch $MARKER_FILE -fi - -if [ $to_mount -gt 0 ] && [ $mounted -le 0 ] && [ -d /data/downloads/.xb ]; then - echo "Bind mounting..." > /dev/kmsg - mount -o bind /data/downloads/.xb/ /system/xbin/ > /dev/kmsg - echo "`date` bindmount" >> $MARKER_FILE - /system/xbin/su --auto-daemon & -elif [ $to_mount -le 0 ] && [ $mounted -gt 0 ]; then - echo "Bind Unmounting..." > /dev/kmsg - for pid in `pgrep daemonsu` - do - echo "Killing Process $pid" > /dev/kmsg - kill -9 $pid - done - sleep 3 - echo "`date` unbindmount" >> $MARKER_FILE - umount /system/xbin/ > /dev/kmsg - if [ "$?" -ne 0 ]; then - echo "Unmount failed..." > /dev/kmsg - fi -fi - -'@ -replace "`r`n","`n" - -# ==================================================================== -# Raw-device / ext4 helpers (proven; same as the engine) -# ==================================================================== -function Read-DeviceBytes($dev,$off,$cnt){ $fs=[System.IO.File]::Open($dev,'Open','Read','ReadWrite'); try{ $sb=[long]([Math]::Floor($off/512)*512); $d=[int]($off-$sb); $need=[int]([Math]::Ceiling(($d+$cnt)/512.0)*512); $b=New-Object byte[] $need; $fs.Position=$sb; [void]$fs.Read($b,0,$need); $r=New-Object byte[] $cnt; [Array]::Copy($b,$d,$r,0,$cnt); $r } finally{ $fs.Close() } } -function Copy-DeviceToFile($dev,$start,$len,$out){ $fs=[System.IO.File]::Open($dev,'Open','Read','ReadWrite'); try{ $fs.Position=$start; $o=[System.IO.File]::Open($out,'Create','Write','None'); try{ $buf=New-Object byte[] (16MB); [long]$rem=$len; while($rem -gt 0){ $w=[int][Math]::Min([long]$buf.Length,$rem); $n=$fs.Read($buf,0,$w); if($n -le 0){break}; $o.Write($buf,0,$n); $rem-=$n } } finally{ $o.Close() } } finally{ $fs.Close() } } -function Copy-FileToDevice($inf,$dev,$start){ $fs=[System.IO.File]::Open($dev,'Open','ReadWrite','ReadWrite'); try{ $fs.Position=$start; $i=[System.IO.File]::OpenRead($inf); try{ $buf=New-Object byte[] (16MB); while(($n=$i.Read($buf,0,$buf.Length)) -gt 0){ if(($n%512)-ne 0){$n+=(512-($n%512))}; $fs.Write($buf,0,$n) }; $fs.Flush() } finally{ $i.Close() } } finally{ $fs.Close() } } - -function Kill-BlueStacks { - # Kill ONLY BlueStacks-owned processes (names start with HD-, Bstk, or BlueStacks): - # HD-Player/Adb/Agent/MultiInstanceManager/CommonLoader, BstkSVC, BlueStacksHelper/Web/Services... - # This is scoped (no unrelated services are touched) and complete (BstkSVC holds the .bstk/conf - # lock, so it MUST go or config edits won't persist -- verified). BlueStacks 5 has no auto-restart - # Windows service, so killing the processes is sufficient. - $killed = Get-Process -EA SilentlyContinue | Where-Object { $_.Name -match '^(HD-|Bstk|BlueStacks)' } - if($killed){ $killed | Stop-Process -Force -EA SilentlyContinue } - Start-Sleep 4 -} - -# Run a debugfs command-list against an ext4 image file; returns combined output. -function Invoke-Debugfs($img,[string[]]$cmds){ - if(-not $Debugfs -or -not (Test-Path $Debugfs)){ throw "debugfs.exe not found (pass -Debugfs)." } - $scr = Join-Path $env:TEMP ("bsr_work\dfs_{0}.txt" -f (Get-Random)) - New-Item -ItemType Directory -Path (Split-Path $scr) -Force | Out-Null - Set-Content -LiteralPath $scr -Value ($cmds -join "`n") -Encoding ascii -NoNewline - $eap=$ErrorActionPreference; $ErrorActionPreference='Continue' - $out = & $Debugfs @('-w','-f',$scr,(Fwd $img)) 2>&1 | Out-String - $ErrorActionPreference=$eap - Remove-Item $scr -Force -EA SilentlyContinue - $out -} - -# Attach Root.vhd, carve ext4 -> temp img, run $editScriptBlock(img), then (optionally) write back. -function With-RootVhdExt4([scriptblock]$edit,[bool]$writeBack){ - if(-not (Test-Path $Vhd)){ throw "Root.vhd not found: $Vhd" } - $attached=$false - try{ - Say "[*] attach $Vhd (RW)..." Cyan - Mount-DiskImage -ImagePath $Vhd -Access ReadWrite -ErrorAction Stop | Out-Null; $attached=$true - $dn=$null; for($i=0;$i -lt 20;$i++){ $di=Get-DiskImage -ImagePath $Vhd -EA SilentlyContinue; if($di.Number -ne $null){$dn=$di.Number;break}; Start-Sleep -Milliseconds 250 } - if($null -eq $dn){ throw 'no disk number' } - $tgt=$null - foreach($p in @(Get-Partition -DiskNumber $dn | Sort-Object Offset)){ $d="\\.\Harddisk$($dn)Partition$($p.PartitionNumber)"; try{ $m=Read-DeviceBytes $d 0x438 2; if($m[0]-eq 0x53 -and $m[1]-eq 0xEF){ $tgt=@{Device=$d;Start=[long]0;Length=[long]$p.Size}; break } }catch{} } - if(-not $tgt){ throw 'no ext4 partition (0xEF53) in Root.vhd' } - Say "[*] ext4 device=$($tgt.Device) size=$([Math]::Round($tgt.Length/1GB,2))GB" - $img=Join-Path $env:TEMP 'bsr_work\rootvhd_ext4.img'; New-Item -ItemType Directory -Path (Split-Path $img) -Force | Out-Null - Say "[*] carve ext4 region (~1-2 min)..." Cyan - Copy-DeviceToFile $tgt.Device $tgt.Start $tgt.Length $img - $ok = & $edit $img - if($writeBack -and $ok){ - Say "[*] write modified ext4 back into Root.vhd..." Cyan - Copy-FileToDevice $img $tgt.Device $tgt.Start - Say "[+] Root.vhd updated." Green - } elseif($writeBack){ - Say "[!] edit reported failure -- NOT writing back (Root.vhd unchanged)." Red - } - Remove-Item $img -Force -EA SilentlyContinue - return $ok - } finally { - if($attached){ try{ Dismount-DiskImage -ImagePath $Vhd | Out-Null; Say "[*] detached Root.vhd" }catch{ Say 'WARN detach failed' Red } } - } -} - -# Extract the canonical /data/adb/magisk + /system/etc/init/magisk file set from a Magisk APK. -function Extract-MagiskApk($apk,$dst){ - if(-not (Test-Path $apk)){ throw "Magisk APK not found: $apk" } - Add-Type -AssemblyName System.IO.Compression.FileSystem - if(Test-Path $dst){ Remove-Item $dst -Recurse -Force } - New-Item -ItemType Directory -Path $dst -Force | Out-Null - $zip=[System.IO.Compression.ZipFile]::OpenRead($apk) - try{ - # lib name -> output name (x86_64 set, plus magisk32 from x86) - $map=@{ - 'lib/x86_64/libbusybox.so'='busybox'; 'lib/x86_64/libmagisk64.so'='magisk64'; - 'lib/x86_64/libmagiskboot.so'='magiskboot'; 'lib/x86_64/libmagiskinit.so'='magiskinit'; - 'lib/x86_64/libmagiskpolicy.so'='magiskpolicy'; 'lib/x86/libmagisk32.so'='magisk32'; - 'assets/stub.apk'='stub.apk'; 'assets/util_functions.sh'='util_functions.sh'; - 'assets/boot_patch.sh'='boot_patch.sh'; 'assets/addon.d.sh'='addon.d.sh' - } - foreach($e in $zip.Entries){ - if($map.ContainsKey($e.FullName)){ - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($e, (Join-Path $dst $map[$e.FullName]), $true) - } - } - } finally { $zip.Dispose() } - $need=@('busybox','magisk32','magisk64','magiskboot','magiskinit','magiskpolicy','stub.apk','util_functions.sh','boot_patch.sh','addon.d.sh') - $missing = $need | Where-Object { -not (Test-Path (Join-Path $dst $_)) } - if($missing){ throw "APK missing expected members: $($missing -join ', ')" } - Say "[+] extracted Magisk databin ($($need.Count) files) from APK." Green -} - -# ---- conf edit: set a per-instance key (modify-only, UTF-8 no BOM) ---- -function Set-ConfKey($key,$val){ - if(-not (Test-Path $Conf)){ throw "conf not found: $Conf" } - $raw=[System.IO.File]::ReadAllText($Conf) - $pat = [regex]::Escape($key) + '="\d"' - if($raw -notmatch $pat){ Say "[~] conf key $key not present; leaving conf unchanged." Yellow; return } - $new=[regex]::Replace($raw, $pat, ($key + '="' + $val + '"')) - if($new -ne $raw){ [System.IO.File]::WriteAllText($Conf,$new,(New-Object System.Text.UTF8Encoding($false))); Say "[+] conf: $key=$val" Green } - else { Say "[*] conf: $key already $val" DarkGray } -} - -# ---- adb helpers ---- -function Adb([string[]]$a){ (& $Adb @a 2>&1 | Out-String) } -# Candidate adb ports for THIS instance, in priority order, from BlueStacks' OWN conf -- NEVER hardcoded -# to 5555. status.adb_port is the runtime port BlueStacks writes on boot; adb_port is the Multi-Instance -# Manager's assigned port (clones get 5585/5595/...); 5555 is only a last-resort fallback. Boot-And-Wait -# tries each AND verifies identity, so a stale status value or a foreign emulator on a port can't mislead. -function Get-AdbPortCandidates{ - $cands=New-Object System.Collections.Generic.List[string] - if($Conf -and (Test-Path $Conf)){ - try{ - $ct=[IO.File]::ReadAllText($Conf); $esc=[regex]::Escape($Instance) - foreach($key in @('status\.adb_port','adb_port')){ - $m=[regex]::Match($ct,'(?im)^\s*bst\.instance\.'+$esc+'\.'+$key+'\s*=\s*"?(\d+)"?') - if($m.Success){ [void]$cands.Add($m.Groups[1].Value) } - } - }catch{} - } - [void]$cands.Add('5555') - $seen=@{}; $out=@(); foreach($c in $cands){ if(-not $seen.ContainsKey($c)){ $seen[$c]=$true; $out+=$c } } - ,$out -} -# Is the device on $serial actually a BlueStacks instance (vs a foreign emulator squatting the port)? -# BlueStacks exposes bst.* props + the bst service manager (init.svc.bstsvcmgrtest); a stock AVD has neither. -function Is-BlueStacks([string]$serial){ - $all=(& $Adb @('-s',$serial,'shell','getprop') 2>&1 | Out-String) - return ($all -match '\[(bst\.|init\.svc\.bst|ro\.bst)') -} -$Script:AdbSerial = $null # the pinned 127.0.0.1: transport for the current boot -function AdbConnect{ $s = if($Script:AdbSerial){$Script:AdbSerial}else{"127.0.0.1:$((Get-AdbPortCandidates)[0])"}; & $Adb @('connect',$s) *>$null } -# False if adb reported a transient transport/device error (common while a freshly-booted instance -# is still restarting adbd). Such output should be retried after a reconnect, not trusted. -function AdbOk([string]$o){ -not ($o -match "device '.*' not found" -or $o -match 'device .* not found' -or $o -match 'no devices/emulators found' -or $o -match 'device offline' -or $o -match 'error: closed') } -# Run an adb shell command, reconnecting + retrying on a dropped transport. -function AdbShellRetry([string]$serial,[string]$cmd,[int]$tries=6){ - $o='' - for($k=0;$k -lt $tries;$k++){ - $o=(& $Adb @('-s',$serial,'shell',$cmd) 2>&1 | Out-String) - if(AdbOk $o){ return $o } - Start-Sleep 3; & $Adb @('start-server') *>$null; AdbConnect - } - $o -} -# Run any adb subcommand (install/push/...) with the same reconnect-on-drop retry. -function AdbTry([string[]]$a,[int]$tries=4){ - $o='' - for($k=0;$k -lt $tries;$k++){ - $o=(& $Adb @($a) 2>&1 | Out-String) - if(AdbOk $o){ return $o } - Start-Sleep 3; & $Adb @('start-server') *>$null; AdbConnect - } - $o -} -function AdbSu([string]$serial,[string]$cmd){ AdbShellRetry $serial "/system/xbin/su -c '$cmd'" } -function Boot-And-Wait([int]$timeoutSec=300){ - Say "[*] launching instance $Instance ..." Cyan - Start-Process -FilePath $Player -ArgumentList @('--instance',$Instance) | Out-Null - # Find the adb endpoint from BlueStacks' OWN per-instance conf ports (re-read each pass: BlueStacks - # writes the actual bound port during boot). Try each candidate, require boot_completed=1, and confirm - # it is really our BlueStacks instance -- so neither a stale port nor a foreign emulator on 5555 can - # mislead us. We pin to that 127.0.0.1: transport (never the transient emulator-XXXX serial). - $serial=$null; $fallback=$null - for($i=0;$i -lt ($timeoutSec/3) -and -not $serial;$i++){ - Start-Sleep 3; & $Adb @('start-server') *>$null - foreach($port in (Get-AdbPortCandidates)){ - $cand="127.0.0.1:$port" - & $Adb @('connect',$cand) *>$null - # boot_completed must be EXACTLY "1" on its own line -- a "device '...:port' not found" error - # contains the port digits and would false-positive a naive -match '1'. - $out=(& $Adb @('-s',$cand,'shell','getprop','sys.boot_completed') 2>&1 | Out-String) - if(-not (($out -split "`n" | ForEach-Object { $_.Trim() }) -contains '1')){ continue } - if(Is-BlueStacks $cand){ $serial=$cand; break } # confirmed: our instance - elseif(-not $fallback){ $fallback=$cand } # booted, but identity unconfirmed - } - } - if(-not $serial -and $fallback){ Say "[~] using booted device $fallback (BlueStacks marker not seen)." Yellow; $serial=$fallback } - if(-not $serial){ throw "instance '$Instance' did not boot / become adb-reachable within $timeoutSec s" } - $Script:AdbSerial=$serial - # Stabilize: a freshly-booted instance (esp. a first boot) restarts adbd a few times, which drops the - # transport -> the next call fails with "device '127.0.0.1:' not found". Wait until a plain shell - # is reliably reachable (3 consecutive hits, reconnecting each time) before handing the serial to callers. - $stable=0 - for($s=0;$s -lt 30 -and $stable -lt 3;$s++){ - AdbConnect - $t=(& $Adb @('-s',$serial,'shell','echo BSR_RDY') 2>&1 | Out-String) - if($t -match 'BSR_RDY'){ $stable++ } else { $stable=0; Start-Sleep 3 } - } - Say "[+] booted: $serial" Green - Start-Sleep 4 - $serial -} - -# ==================================================================== -# ACTIONS -# ==================================================================== -function Do-Prep { - Ensure-MagiskApk; Ensure-BsrSu; Ensure-Debugfs - Say '==== PREP (offline) ====' Cyan - Kill-BlueStacks - - # 0) one-time pristine safety backup (so Undo can fully restore) - $bak = "$Vhd.bsrbak" - if(-not $NoBackup -and -not (Test-Path $bak)){ - try { Say "[*] one-time pristine backup -> $bak ..." ; Copy-Item -LiteralPath $Vhd -Destination $bak -Force; Say "[+] backup done." Green } - catch { Say "[~] could not create backup: $($_.Exception.Message)" Yellow } - } else { Say "[*] pristine backup present (or -NoBackup): $bak" DarkGray } - - # 1) HD-Player anti-tamper patch (proven, via engine; engine Patch requires -Exe) - Say '[*] HD-Player anti-tamper patch (engine Patch)...' - $hdp = Join-Path $Install 'HD-Player.exe' - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Engine -Action Patch -Exe $hdp 2>&1 | ForEach-Object { Say " $_" DarkGray } - - # 2) conf: emulator root ON (so the bootstrap bindmount runs) + adb on - Set-ConfKey "bst.instance.$Instance.enable_root_access" 1 - Set-ConfKey "bst.enable_adb_access" 1 - - # 3) stage files - $stage = Join-Path $env:TEMP 'bsr_work\databin' - Extract-MagiskApk $MagiskApk $stage - $tmpDir = Join-Path $env:TEMP 'bsr_work\sysfiles'; New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null - [System.IO.File]::WriteAllText((Join-Path $tmpDir 'bootanim.rc'), $BOOTANIM_RC, (New-Object System.Text.UTF8Encoding($false))) - [System.IO.File]::WriteAllText((Join-Path $tmpDir 'config'), $MAGISK_CONFIG, (New-Object System.Text.UTF8Encoding($false))) - [System.IO.File]::WriteAllText((Join-Path $tmpDir 'bindmount'), $BINDMOUNT_MOD, (New-Object System.Text.UTF8Encoding($false))) - [System.IO.File]::WriteAllText((Join-Path $tmpDir 'bsr_boot.sh'), $BSR_BOOT_SH, (New-Object System.Text.UTF8Encoding($false))) - - # 4) ONE offline carve: Magisk /system files + bootanim hijack + bootstrap su + hijacked bindmount - $ok = With-RootVhdExt4 { - param($img) - $sysM = Fwd (Join-Path $stage 'magisk32'); $null=$sysM - $cmds = @( - 'mkdir /android','mkdir /android/system','mkdir /android/system/etc','mkdir /android/system/etc/init', - 'mkdir /android/system/etc/init/magisk','cd /android/system/etc/init/magisk' - ) - foreach($f in 'magisk32','magisk64','magiskinit','magiskpolicy','stub.apk'){ - $cmds += @("rm $f","write $(Fwd (Join-Path $stage $f)) $f","sif $f mode 0100700","sif $f uid 0","sif $f gid 0","sif $f links_count 1") - } - $cmds += @("rm config","write $(Fwd (Join-Path $tmpDir 'config')) config","sif config mode 0100700","sif config uid 0","sif config gid 0","sif config links_count 1") - # per-instance gate script (root-owned 0700, alongside the magisk binaries) - $cmds += @("rm bsr_boot.sh","write $(Fwd (Join-Path $tmpDir 'bsr_boot.sh')) bsr_boot.sh","sif bsr_boot.sh mode 0100700","sif bsr_boot.sh uid 0","sif bsr_boot.sh gid 0","sif bsr_boot.sh links_count 1") - # hijack bootanim.rc (system:system 0664) -- now the GATED version - $cmds += @('cd /android/system/etc/init','rm bootanim.rc',"write $(Fwd (Join-Path $tmpDir 'bootanim.rc')) bootanim.rc",'sif bootanim.rc mode 0100664','sif bootanim.rc uid 1000','sif bootanim.rc gid 1000','sif bootanim.rc links_count 1') - # bootstrap su template (setuid root) - $cmds += @('cd /android/system/etc','rm bsr_su',"write $(Fwd $BsrSu) bsr_su",'sif bsr_su mode 0106755','sif bsr_su uid 0','sif bsr_su gid 0','sif bsr_su links_count 1') - # hijacked bindmount - $cmds += @('cd /android/system/bin','rm bindmount',"write $(Fwd (Join-Path $tmpDir 'bindmount')) bindmount",'sif bindmount mode 0100755','sif bindmount uid 0','sif bindmount gid 0','sif bindmount links_count 1') - # verify - $cmds += @('stat /android/system/etc/init/magisk/magisk64','stat /android/system/etc/init/bootanim.rc','stat /android/system/etc/bsr_su','stat /android/system/bin/bindmount') - $out = Invoke-Debugfs $img $cmds - Say $out DarkGray - $good = ($out -match '(?s)bsr_su.*?Inode:\s*\d') -and ($out -match '(?s)bootanim\.rc.*?Inode:\s*\d') -and ($out -match '(?s)magisk64.*?Inode:\s*\d') - if(-not $good){ Say '[!] prep verify FAILED' Red } - return $good - } $true - if(-not $ok){ throw "Prep failed (Root.vhd not modified)." } - Say '[+] PREP complete.' Green -} - -function Do-Data { - Ensure-MagiskApk; Ensure-Debugfs - Say '==== DATA (online, bootstrap su) ====' Cyan - $stage = Join-Path $env:TEMP 'bsr_work\databin' - if(-not (Test-Path (Join-Path $stage 'busybox'))){ Extract-MagiskApk $MagiskApk $stage } - $serial = Boot-And-Wait - # sanity: bootstrap su works - $id = (AdbSu $serial 'id').Trim() - if($id -notmatch 'uid=0'){ throw "bootstrap su not root (got '$id'). Prep/patch/conf issue." } - Say "[+] bootstrap su OK: $id" Green - # install Magisk manager app - Say '[*] adb install Magisk APK...' - Say (" " + (AdbTry @('-s',$serial,'install','-r',$MagiskApk)).Trim()) DarkGray - # push databin to /data/local/tmp then su-copy into /data/adb/magisk - AdbTry @('-s',$serial,'shell','rm -rf /data/local/tmp/bsrmbin; mkdir -p /data/local/tmp/bsrmbin') | Out-Null - @((AdbTry @('-s',$serial,'push',(Fwd "$stage\."),'/data/local/tmp/bsrmbin/')) -split "`n" | Where-Object { $_.Trim() }) | Select-Object -Last 1 | ForEach-Object { Say " $($_.Trim())" DarkGray } - $script = @' -set -e -mkdir -p /data/adb/magisk /data/adb/modules /data/adb/post-fs-data.d /data/adb/service.d -# per-instance ROOT FLAG: bsr_boot.sh on the shared master only activates Magisk on instances that -# carry this file on their OWN /data. This is what makes THIS instance rooted while others stay clean. -touch /data/adb/.bsr_root; chmod 600 /data/adb/.bsr_root -cp -f /data/local/tmp/bsrmbin/* /data/adb/magisk/ -chown -R 0:0 /data/adb/magisk -chmod 0755 /data/adb/magisk/busybox /data/adb/magisk/magisk32 /data/adb/magisk/magisk64 /data/adb/magisk/magiskboot /data/adb/magisk/magiskinit /data/adb/magisk/magiskpolicy /data/adb/magisk/*.sh -chmod 0644 /data/adb/magisk/stub.apk -restorecon -R /data/adb 2>/dev/null || true -rm -rf /data/local/tmp/bsrmbin -sync -echo BSR_DATA_OK -'@ -replace "`r`n","`n" - $script | Set-Content -LiteralPath (Join-Path $env:TEMP 'bsr_work\datapop.sh') -Encoding ascii -NoNewline - AdbTry @('-s',$serial,'push',(Fwd (Join-Path $env:TEMP 'bsr_work\datapop.sh')),'/data/local/tmp/bsr_pop.sh') | Out-Null - $r = AdbSu $serial 'sh /data/local/tmp/bsr_pop.sh; rm -f /data/local/tmp/bsr_pop.sh' - Say $r DarkGray - if($r -notmatch 'BSR_DATA_OK'){ throw "/data/adb/magisk populate failed." } - Say '[+] /data/adb/magisk populated. Rebooting so magiskd initializes (env now complete)...' Green - & $Adb @('-s',$serial,'shell','sync') *>$null; Start-Sleep 2; Kill-BlueStacks - - # second boot: magiskd should now start; bsr_su is still present so we can set the grant policy. - $serial = Boot-And-Wait - Start-Sleep 5 - $mg = (AdbSu $serial 'ps -A | grep -c magiskd').Trim() - Say " magiskd processes: $mg" $(if($mg -match '[1-9]'){'Green'}else{'Yellow'}) - # grant policy (allow shell uid 2000). The SQL has parens, which break through nested su -c '...' - # quoting -> push it as a script FILE and run that (robust, same pattern as the populate step). - $polSh = 'magisk --sqlite "REPLACE INTO policies (uid,policy,until,logging,notification) VALUES(2000,2,0,0,0)"' + "`n" + 'echo POL_RC=$?' + "`n" - [System.IO.File]::WriteAllText((Join-Path $env:TEMP 'bsr_work\polset.sh'), $polSh, (New-Object System.Text.UTF8Encoding($false))) - AdbTry @('-s',$serial,'push',(Fwd (Join-Path $env:TEMP 'bsr_work\polset.sh')),'/data/local/tmp/bsr_pol.sh') | Out-Null - $pol = AdbSu $serial 'sh /data/local/tmp/bsr_pol.sh; rm -f /data/local/tmp/bsr_pol.sh' - Say " policy: $(($pol -replace "`r?`n",' ').Trim())" DarkGray - $mc = (AdbSu $serial 'magisk -c').Trim(); Say " magisk -c: $mc" - AdbSu $serial 'sync' | Out-Null - if($mg -notmatch '[1-9]'){ Say '[!] magiskd not detected after populate+reboot -- check /cache/magisk.log' Yellow } - Say '[+] DATA complete (/data/adb/magisk populated, magiskd up, policy set).' Green - & $Adb @('-s',$serial,'shell','sync') *>$null; Start-Sleep 2; Kill-BlueStacks -} - -function Do-Clean { - Ensure-Debugfs - Say '==== CLEAN (offline: erase bootstrap su, restore stock bindmount) ====' Cyan - Kill-BlueStacks - $tmpDir = Join-Path $env:TEMP 'bsr_work\sysfiles'; New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null - [System.IO.File]::WriteAllText((Join-Path $tmpDir 'bindmount.orig'), $BINDMOUNT_ORIG, (New-Object System.Text.UTF8Encoding($false))) - $ok = With-RootVhdExt4 { - param($img) - $cmds = @( - 'cd /android/system/etc','rm bsr_su', - 'cd /android/system/bin','rm bindmount',"write $(Fwd (Join-Path $tmpDir 'bindmount.orig')) bindmount", - 'sif bindmount mode 0100775','sif bindmount uid 1000','sif bindmount gid 1000','sif bindmount links_count 1', - 'stat /android/system/bin/bindmount','stat /android/system/etc/bsr_su' - ) - $out = Invoke-Debugfs $img $cmds - Say $out DarkGray - # stock bindmount is ~1339B; accept the byte-exact range (template is 1339, tolerate +/-2) - $bmOk = ($out -match '(?s)/android/system/bin/bindmount[\s\S]*?Size:\s*(133[0-9]|134[01])') - $suGone = ($out -match 'bsr_su:\s*File not found') -or ($out -match 'File not found by ext2_lookup') - if(-not $bmOk){ Say '[!] stock bindmount not detected after restore' Red } - return ($bmOk -and $suGone) - } $true - if(-not $ok){ throw "Clean failed." } - Say '[+] CLEAN complete (bsr_su removed, stock bindmount restored).' Green -} - -function Do-Finalize { - Say '==== FINALIZE (emulator root OFF + shareable master) ====' Cyan - Set-ConfKey "bst.instance.$Instance.enable_root_access" 0 - # Ensure the shared master Root.vhd + fastboot.vdi are Readonly so MULTIPLE instances can attach - # them at once (type="Normal" is exclusive -> a 2nd instance fails with VBOX_E_INVALID_OBJECT_STATE). - # Data.vhdx stays Normal (per-instance, writable). This is the factory layout. - $bstk = Join-Path (Split-Path $Vhd) ("$Instance.bstk") - if(Test-Path $bstk){ - $t=[System.IO.File]::ReadAllText($bstk); $o=$t - $t=[regex]::Replace($t,'(location="Root\.vhd"[^>]*?type=")Normal(")','${1}Readonly${2}') - $t=[regex]::Replace($t,'(location="fastboot\.vdi"[^>]*?type=")Normal(")','${1}Readonly${2}') - if($t -ne $o){ [System.IO.File]::WriteAllText($bstk,$t); Say '[+] master Root.vhd + fastboot.vdi set Readonly (multi-instance shareable).' Green } - else { Say '[*] master disks already Readonly (or not declared in this .bstk).' DarkGray } - } - Say '[+] FINALIZE complete.' Green -} - -function Do-Verify { - Say '==== VERIFY (cold boot) ====' Cyan - $serial = Boot-And-Wait - $id = (& $Adb @('-s',$serial,'shell','su -c id') 2>&1 | Out-String).Trim() - $whi = (& $Adb @('-s',$serial,'shell','readlink /system/bin/su') 2>&1 | Out-String).Trim() - $xb = (& $Adb @('-s',$serial,'shell','su -c "ls /system/xbin/su 2>&1"') 2>&1 | Out-String).Trim() - $sweep = (& $Adb @('-s',$serial,'shell',"su -c `"find /system /data/adb /data/downloads -type f -size 4968c 2>/dev/null | while read f; do [ \`"`$(sha256sum `$f|cut -d' ' -f1)\`" = '$BSR_SU_SHA' ] && echo TRACE:`$f; done; echo SWEEPDONE`"") 2>&1 | Out-String) - Say (" su -c id : {0}" -f $id) $(if($id -match 'uid=0'){'Green'}else{'Red'}) - Say (" /system/bin/su -> : {0}" -f $whi) - Say (" /system/xbin/su : {0} (want: not found)" -f $xb) - Say (" bsr_su sweep : {0}" -f ($sweep -replace "`r?`n",' ').Trim()) - if($id -match 'uid=0' -and $whi -match 'magisk' -and $sweep -notmatch 'TRACE:'){ Say '[+] VERIFY PASS: Magisk is the sole root; no bsr_su traces.' Green } - else { Say '[!] VERIFY: review the above.' Yellow } -} - -function Do-Undo { - # PER-INSTANCE unroot (multi-instance safe): just drop THIS instance's root flag + /data Magisk - # state + app. The shared master /system and HD-Player patch are LEFT INTACT so any OTHER rooted - # instances keep working. Use -Full to also scrub the master + un-patch (unroots ALL instances). - Say "==== UNDO ($Instance) ====" Cyan - Kill-BlueStacks; Start-Sleep 2 - if(-not (Test-Path $Player)){ Say "[!] HD-Player.exe not found at '$Player'." Red; return } - try { - $serial = Boot-And-Wait 240 - & $Adb @('-s',$serial,'uninstall','io.github.huskydg.magisk') 2>&1 | Out-Null - # while the flag is still present this instance has working Magisk root, so its su can wipe /data/adb - $rm = 'rm -f /data/adb/.bsr_root; rm -rf /data/adb/magisk /data/adb/magisk.db /data/adb/modules /data/adb/post-fs-data.d /data/adb/service.d 2>/dev/null; sync; echo BSR_RM_OK' - $o1 = (& $Adb @('-s',$serial,'shell',"su -c '$rm'") 2>&1 | Out-String) - if($o1 -notmatch 'BSR_RM_OK'){ AdbSu $serial $rm | Out-Null } - & $Adb @('-s',$serial,'shell','sync') *>$null; Start-Sleep 2 - Say "[+] $Instance unrooted (flag + /data Magisk state removed, app uninstalled)." Green - } catch { Say "[~] could not boot to unroot /data: $($_.Exception.Message)" Yellow } - Kill-BlueStacks - Set-ConfKey "bst.instance.$Instance.enable_root_access" 0 - - if($Full){ - Say '[*] -Full: scrubbing shared master + un-patching HD-Player (unroots ALL instances)...' Yellow - $bak = "$Vhd.bsrbak" - if(Test-Path $bak){ Copy-Item -LiteralPath $bak -Destination $Vhd -Force; Say '[+] master Root.vhd restored to factory.' Green } - else { Say '[~] no Root.vhd.bsrbak; master left as-is (gated Magisk stays dormant).' Yellow } - $hdp = Join-Path $Install 'HD-Player.exe' - Say '[*] un-patching HD-Player.exe (-Exe last to avoid arg-glue)...' - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Engine -Action Patch -Restore -Exe $hdp 2>&1 | ForEach-Object { Say " $_" DarkGray } - Say '[+] FULL host scrub complete (no instance rooted; HD-Player factory).' Green - } else { - Say "[+] UNDO complete. Shared master + HD-Player patch left intact so other rooted instances keep working." Green - Say " For a full host scrub (no instances rooted, HD-Player un-patched), re-run Undo with -Full." DarkGray - } -} - -# Only dispatch when run normally (-File / &). When DOT-SOURCED (. bsr_magisk.ps1) -- e.g. by the test -# suite to unit-test the resolver functions -- skip the pipeline so nothing boots or writes. -if ($MyInvocation.InvocationName -ne '.') { - switch($Action){ - 'Prep' { Do-Prep } - 'Data' { Do-Data } - 'Clean' { Do-Clean } - 'Finalize' { Do-Finalize } - 'Verify' { Do-Verify } - 'Auto' { Do-Prep; Do-Data; Do-Clean; Do-Finalize; Do-Verify } - 'Undo' { Do-Undo } - } +<# bsr_magisk.ps1 -- Make Magisk the SOLE, self-sustaining root on BlueStacks 5 (rvc/Android 11), + with no traces of any bootstrap su. Minimal read/writes. Built from the proven workflow in + docs/BLUESTACKS_ROOTING_DEEP_DIVE.md (sect. 6). + + Pipeline (no DiskRW; Root.vhd edited offline at file level; only /data written at runtime): + Prep [offline] HD-Player anti-tamper patch (via bsr_engine.ps1) + conf enable_root_access=1 + + ONE Root.vhd carve writing: Magisk /system files + hijacked bootanim.rc + + bootstrap su (bsr_su) + hijacked bindmount. + Data [online ] boot, adb install Magisk APK, then via bootstrap su populate /data/adb/magisk + (busybox + ABI binaries + scripts) and set the grant policy. + Clean [offline] remove bsr_su + restore the stock bindmount. + Finalize [conf ] enable_root_access=0 ("turn off emulator root"). + Verify [online ] cold boot; confirm Magisk-only root, no traces. + Auto Prep -> boot -> Data -> Clean -> Finalize -> Verify (the whole thing). + + Inputs: -MagiskApk (manager app + every Magisk binary; the one external file) + -Vhd -Conf -Instance -Install +#> +[CmdletBinding()] +param( + [ValidateSet('Prep','Data','Clean','Finalize','Verify','Auto','Undo')] + [string]$Action = 'Auto', + [string]$Vhd, + [string]$Conf, + [string]$Instance = 'Rvc64', + [string]$Install, # BlueStacks install dir; resolved from the registry if omitted + [string]$MagiskApk, + [string]$Engine, # bsr_engine.ps1 (for the HD-Player patch); auto-detected if omitted + [string]$Debugfs, # debugfs.exe; auto-detected if omitted + [string]$BsrSuPath, # bootstrap su binary; auto-detected if omitted + [string]$SelfCmd, # path to blueStackRoot.cmd (embedded mode: self-extract debugfs + bsr_su) + [switch]$NoBackup, + [switch]$Full # Undo: also scrub the shared master + un-patch HD-Player (unroots ALL instances) +) +# NOTE: 'Continue' (not 'Stop') because native tools (adb, debugfs) write normal output to +# stderr, which under 'Stop' becomes a fatal NativeCommandError. Every critical step below has +# an explicit success check + throw, and disk cmdlets use -ErrorAction Stop, so failures still abort. +$ErrorActionPreference = 'Continue' +$Self = $MyInvocation.MyCommand.Path +$Here = Split-Path -Parent $Self + +# --------- the bsr_su grant policy / known signatures ---------- +$BSR_SU_SHA = '7eb6380ee26ce0b68d9f3f23ac04f50e0dfdd49359ef17d1a4978be1795913dd' + +function Say($m,$c='Gray'){ Write-Host $m -ForegroundColor $c } +function Fwd($p){ $p -replace '\\','/' } + +# --------- normalize incoming paths (callers may pass a trailing '\' or a stray '"') ---------- +# A registry InstallDir like C:\Program Files\BlueStacks_nxt\ becomes -Install "...\nxt\" on the +# command line, and PowerShell -File treats the \" as an escaped quote -> the value arrives as +# '...\nxt"'. Strip stray quotes and any trailing slash/space so Join-Path stays clean. +function Clean-Path($p){ if($null -eq $p){return $p}; ($p -replace '"','').Trim().TrimEnd('\') } +$Install = Clean-Path $Install +$Engine = Clean-Path $Engine +$Debugfs = Clean-Path $Debugfs +$Vhd = Clean-Path $Vhd +$Conf = Clean-Path $Conf +$SelfCmd = Clean-Path $SelfCmd + +# --------- registry discovery (NO hardcoded install/data paths) ---------- +# BlueStacks records its real install/data folders in the registry; honour a custom install location by +# reading them instead of assuming C:\Program Files\.. / C:\ProgramData\.. . nxt (BlueStacks 5) first, +# then msi5 (MSI App Player), under both the native and WOW6432Node views. +function Get-RegBlueStacks{ + foreach($k in @('HKLM:\SOFTWARE\BlueStacks_nxt','HKLM:\SOFTWARE\BlueStacks_msi5', + 'HKLM:\SOFTWARE\WOW6432Node\BlueStacks_nxt','HKLM:\SOFTWARE\WOW6432Node\BlueStacks_msi5')){ + try{ + $p=Get-ItemProperty -Path $k -ErrorAction Stop + if($p -and ($p.InstallDir -or $p.DataDir -or $p.UserDefinedDir)){ + return [pscustomobject]@{ InstallDir=$p.InstallDir; DataDir=$p.DataDir; UserDefinedDir=$p.UserDefinedDir } + } + }catch{} + } + return $null +} +# Folder that actually holds bluestacks.conf + Engine\. Newer BlueStacks reports DataDir as ...\Engine. +function Get-DataRoot($reg){ + $d = if($reg){ if($reg.DataDir){$reg.DataDir}elseif($reg.UserDefinedDir){$reg.UserDefinedDir}else{$null} } else { $null } + if(-not $d){ $d = Join-Path $env:ProgramData 'BlueStacks_nxt' } + if($d -match '(?i)[\\/]engine[\\/]?$'){ $d = $d -replace '(?i)[\\/]engine[\\/]?$','' } + $d.TrimEnd('\','/') +} + +$reg = Get-RegBlueStacks +if (-not $Install) { $Install = if($reg -and $reg.InstallDir){ $reg.InstallDir.TrimEnd('\') } else { Join-Path $env:ProgramFiles 'BlueStacks_nxt' } } +$DataRoot = Get-DataRoot $reg +if (-not $Engine) { $Engine = Join-Path $Here 'bsr_engine.ps1' } +if (-not $Debugfs) { + foreach($c in @((Join-Path $Here 'debugfs\debugfs.exe'), (Join-Path $env:TEMP 'bsr_work\debugfs\debugfs.exe'))){ if(Test-Path $c){ $Debugfs=$c; break } } +} +if (-not $Conf) { $Conf = Join-Path $DataRoot 'bluestacks.conf' } +if (-not $Vhd -and $Instance) { $Vhd = Join-Path $DataRoot "Engine\$Instance\Root.vhd" } +# Resolve BlueStacks' OWN adb (HD-Adb.exe). We deliberately do NOT fall back to a system adb.exe: +# mixing a system adb (e.g. Android SDK platform-tools v1.0.41) with BlueStacks' HD-Adb (v1.0.36) +# triggers the "adb server version doesn't match this client; killing..." war -- the two kill each +# other's server, getprop/shell calls fail intermittently, and a fully-booted instance can still +# fail Boot-And-Wait with "did not become adb-reachable". So we pin HD-Adb.exe specifically. +function Resolve-HdAdb { + $c = New-Object System.Collections.Generic.List[string] + if($Install){ [void]$c.Add((Join-Path $Install 'HD-Adb.exe')) } + if($reg -and $reg.InstallDir){ [void]$c.Add((Join-Path ($reg.InstallDir.TrimEnd('\','/')) 'HD-Adb.exe')) } + foreach($p in @((Join-Path $env:ProgramFiles 'BlueStacks_nxt\HD-Adb.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'BlueStacks_nxt\HD-Adb.exe'), + (Join-Path $env:ProgramFiles 'BlueStacks_msi5\HD-Adb.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'BlueStacks_msi5\HD-Adb.exe'))){ [void]$c.Add($p) } + foreach($p in $c){ if($p -and (Test-Path -LiteralPath $p)){ return (Resolve-Path -LiteralPath $p).Path } } + $g = Get-Command 'HD-Adb.exe' -ErrorAction SilentlyContinue; if($g){ return $g.Source } + return (Join-Path $Install 'HD-Adb.exe') # most-likely path even if absent (a later check reports it) +} +$Adb = Resolve-HdAdb +$Player = Join-Path $Install 'HD-Player.exe' +$BsrSu = if($BsrSuPath){$BsrSuPath}else{ Join-Path $Here 'su_src\bsr_su' } # the setuid bootstrap su (4968 B) + +# ---- embedded-payload self-extraction (only used when -SelfCmd is given) ---- +function Extract-Block($cmdPath,$begTok,$endTok){ + $t=[System.IO.File]::ReadAllText($cmdPath) + $b="__BSR_${begTok}_"+"BEGIN__"; $e="__BSR_${endTok}_"+"END__" + $i=$t.IndexOf($b); $j=$t.IndexOf($e) + if($i -lt 0 -or $j -le $i){ return $null } + $i=$t.IndexOf([char]10,$i)+1 + $t.Substring($i,$j-$i) +} +function Ensure-Debugfs { + if($script:Debugfs -and (Test-Path $script:Debugfs)){ return } + foreach($c in @((Join-Path $Here 'debugfs\debugfs.exe'), (Join-Path $env:TEMP 'bsr_work\debugfs\debugfs.exe'))){ if(Test-Path $c){ $script:Debugfs=$c; return } } + if($SelfCmd -and (Test-Path $SelfCmd)){ + $b64=Extract-Block $SelfCmd 'DFS' 'DFS' + if($b64){ $b64=($b64 -replace '[^A-Za-z0-9+/=]',''); $d=Join-Path $env:TEMP 'bsr_work\debugfs'; New-Item -ItemType Directory -Path $d -Force | Out-Null; $zip=Join-Path $d '_d.zip'; [System.IO.File]::WriteAllBytes($zip,[Convert]::FromBase64String($b64)); Add-Type -AssemblyName System.IO.Compression.FileSystem; $za=[System.IO.Compression.ZipFile]::OpenRead($zip); try{ foreach($en in $za.Entries){ if(-not $en.Name){continue}; $tp=Join-Path $d $en.FullName; $dd=Split-Path -Parent $tp; if(-not(Test-Path $dd)){New-Item -ItemType Directory -Path $dd -Force|Out-Null}; [System.IO.Compression.ZipFileExtensions]::ExtractToFile($en,$tp,$true) } } finally { $za.Dispose() }; Remove-Item $zip -Force -EA SilentlyContinue; $exe=Join-Path $d 'debugfs.exe'; if(Test-Path $exe){ $script:Debugfs=$exe } } + } + if(-not ($script:Debugfs -and (Test-Path $script:Debugfs))){ throw "debugfs.exe not found (pass -Debugfs or -SelfCmd)." } +} +function Ensure-BsrSu { + if($script:BsrSu -and (Test-Path $script:BsrSu)){ return } + if($SelfCmd -and (Test-Path $SelfCmd)){ + $b64=Extract-Block $SelfCmd 'BSRSU' 'BSRSU' + if($b64){ $b64=($b64 -replace '[^A-Za-z0-9+/=]',''); $gz=[Convert]::FromBase64String($b64); $in=New-Object System.IO.MemoryStream(,$gz); $z=New-Object System.IO.Compression.GZipStream($in,[System.IO.Compression.CompressionMode]::Decompress); $out=New-Object System.IO.MemoryStream; $buf=New-Object byte[] 65536; while(($n=$z.Read($buf,0,$buf.Length)) -gt 0){ $out.Write($buf,0,$n) }; $z.Close(); $in.Close(); $d=Join-Path $env:TEMP 'bsr_work'; New-Item -ItemType Directory -Path $d -Force | Out-Null; $p=Join-Path $d 'bsr_su'; [System.IO.File]::WriteAllBytes($p,$out.ToArray()); $out.Close(); $script:BsrSu=$p } + } + if(-not ($script:BsrSu -and (Test-Path $script:BsrSu))){ throw "bootstrap su (bsr_su) not found (pass -BsrSuPath or -SelfCmd)." } +} +function Ensure-MagiskApk { + if($script:MagiskApk -and (Test-Path $script:MagiskApk)){ return } + # an external APK next to the .cmd already resolved by the caller; otherwise extract the EMBEDDED one + if($SelfCmd -and (Test-Path $SelfCmd)){ + $b64=Extract-Block $SelfCmd 'APK' 'APK' + if($b64){ $b64=($b64 -replace '[^A-Za-z0-9+/=]',''); $d=Join-Path $env:TEMP 'bsr_work'; New-Item -ItemType Directory -Path $d -Force | Out-Null; $p=Join-Path $d 'magisk.apk'; [System.IO.File]::WriteAllBytes($p,[Convert]::FromBase64String($b64)); $script:MagiskApk=$p; Say "[*] using embedded Magisk APK ($([Math]::Round((Get-Item $p).Length/1MB,1)) MB)." DarkGray } + } + if(-not ($script:MagiskApk -and (Test-Path $script:MagiskApk))){ throw "Magisk APK not found (pass -MagiskApk or -SelfCmd with an embedded APK)." } +} + +# ==================================================================== +# Embedded text templates (LF; written verbatim into ext4 / used at runtime) +# ==================================================================== +# GATED bootanim.rc: the shared master Root.vhd is used by ALL instances, so Magisk's boot hooks +# must be PER-INSTANCE. Each stage execs bsr_boot.sh, which no-ops unless THIS instance carries +# /data/adb/.bsr_root (its own /data). Unrooted instances -> no magiskd, no su, no app, no leak. +$BOOTANIM_RC = @' +service bootanim /system/bin/bootanimation + class core animation + user graphics + group graphics audio + disabled + oneshot + ioprio rt 0 + task_profiles MaxPerformance +on post-fs-data + start logd + exec u:r:su:s0 root root -- /system/etc/init/magisk/bsr_boot.sh post-fs-data +on nonencrypted + exec u:r:su:s0 root root -- /system/etc/init/magisk/bsr_boot.sh service +on property:vold.decrypt=trigger_restart_framework + exec u:r:su:s0 root root -- /system/etc/init/magisk/bsr_boot.sh service +on property:sys.boot_completed=1 + exec u:r:su:s0 root root -- /system/etc/init/magisk/bsr_boot.sh boot-complete +on property:init.svc.zygote=restarting + exec u:r:su:s0 root root -- /system/etc/init/magisk/bsr_boot.sh zygote-restart +on property:init.svc.zygote=stopped + exec u:r:su:s0 root root -- /system/etc/init/magisk/bsr_boot.sh zygote-restart +'@ -replace "`r`n","`n" + +# Per-instance Magisk gate. SELinux is disabled on BlueStacks so one context (root) suffices. +$BSR_BOOT_SH = @' +#!/system/bin/sh +# BSR per-instance Magisk gate. Activates Magisk ONLY if THIS instance (its own /data) is flagged. +[ -f /data/adb/.bsr_root ] || exit 0 +M=/system/etc/init/magisk +case "$1" in + post-fs-data) + "$M/magiskpolicy" --live --magisk 2>/dev/null + "$M/magisk64" --auto-selinux --setup-sbin "$M" /sbin 2>/dev/null + /sbin/magisk --auto-selinux --post-fs-data 2>/dev/null + ;; + service) /sbin/magisk --auto-selinux --service 2>/dev/null ;; + boot-complete) mkdir -p /data/adb/magisk; /sbin/magisk --auto-selinux --boot-complete 2>/dev/null ;; + zygote-restart) /sbin/magisk --auto-selinux --zygote-restart 2>/dev/null ;; +esac +exit 0 +'@ -replace "`r`n","`n" + +$MAGISK_CONFIG = "SYSTEMMODE=true`nRECOVERYMODE=false`n" + +# bootstrap bindmount: stock behaviour + bind our setuid su over xbin/su AFTER the .xb overmount +$BINDMOUNT_MOD = @' +#!/system/bin/sh +# Rooting helper: bindmount /data/downloads/.xb over /system/xbin, then bind our setuid su. +MAXSIZE=100000 +MARKER_FILE="/data/downloads/.bm" +to_mount=$(getprop bst.config.bindmount) +echo "to_mount=$to_mount" > /dev/kmsg +mounted=`mountpoint -q /system/xbin && echo "1" || echo "0"` +echo "mounted=$mounted" > /dev/kmsg +FILESIZE=$(stat -c%s "$MARKER_FILE") +echo "Size of $MARKER_FILE = $FILESIZE bytes." > /dev/kmsg +if (( FILESIZE > MAXSIZE )); then + rm $MARKER_FILE + touch $MARKER_FILE +fi +if [ $to_mount -gt 0 ] && [ $mounted -le 0 ] && [ -d /data/downloads/.xb ]; then + echo "Bind mounting..." > /dev/kmsg + mount -o bind /data/downloads/.xb/ /system/xbin/ > /dev/kmsg + echo "`date` bindmount" >> $MARKER_FILE + if [ -f /system/etc/bsr_su ]; then + mount -o bind /system/etc/bsr_su /system/xbin/su + mount -o bind /system/etc/bsr_su /system/xbin/bstk/su + echo "bsr: ungated setuid su bind-mounted" > /dev/kmsg + fi + /system/xbin/su --auto-daemon & +elif [ $to_mount -le 0 ] && [ $mounted -gt 0 ]; then + for pid in `pgrep daemonsu` + do + kill -9 $pid + done + sleep 3 + echo "`date` unbindmount" >> $MARKER_FILE + umount /system/xbin/su 2>/dev/null + umount /system/xbin/bstk/su 2>/dev/null + umount /system/xbin/ > /dev/kmsg +fi +'@ -replace "`r`n","`n" + +# stock bindmount restored at Clean (genuine factory file, extracted from bsrbak) +$BINDMOUNT_ORIG = @' +#!/system/bin/sh +# This script helps in rooting/unrooting the app-player by bindmounting/unmounting the .xb folder and xbin. + +#================ ROOT/UNROOT =====================# + +MAXSIZE=100000 +MARKER_FILE="/data/downloads/.bm" +to_mount=$(getprop bst.config.bindmount) +echo "to_mount=$to_mount" > /dev/kmsg + +mounted=`mountpoint -q /system/xbin && echo "1" || echo "0"` +echo "mounted=$mounted" > /dev/kmsg + +# Get marker file size +FILESIZE=$(stat -c%s "$MARKER_FILE") +# Checkpoint +echo "Size of $MARKER_FILE = $FILESIZE bytes." > /dev/kmsg + +if (( FILESIZE > MAXSIZE )); then + echo "Removing and creating new $MARKER_FILE" > /dev/kmsg + rm $MARKER_FILE + touch $MARKER_FILE +fi + +if [ $to_mount -gt 0 ] && [ $mounted -le 0 ] && [ -d /data/downloads/.xb ]; then + echo "Bind mounting..." > /dev/kmsg + mount -o bind /data/downloads/.xb/ /system/xbin/ > /dev/kmsg + echo "`date` bindmount" >> $MARKER_FILE + /system/xbin/su --auto-daemon & +elif [ $to_mount -le 0 ] && [ $mounted -gt 0 ]; then + echo "Bind Unmounting..." > /dev/kmsg + for pid in `pgrep daemonsu` + do + echo "Killing Process $pid" > /dev/kmsg + kill -9 $pid + done + sleep 3 + echo "`date` unbindmount" >> $MARKER_FILE + umount /system/xbin/ > /dev/kmsg + if [ "$?" -ne 0 ]; then + echo "Unmount failed..." > /dev/kmsg + fi +fi + +'@ -replace "`r`n","`n" + +# ==================================================================== +# Raw-device / ext4 helpers (proven; same as the engine) +# ==================================================================== +function Read-DeviceBytes($dev,$off,$cnt){ $fs=[System.IO.File]::Open($dev,'Open','Read','ReadWrite'); try{ $sb=[long]([Math]::Floor($off/512)*512); $d=[int]($off-$sb); $need=[int]([Math]::Ceiling(($d+$cnt)/512.0)*512); $b=New-Object byte[] $need; $fs.Position=$sb; [void]$fs.Read($b,0,$need); $r=New-Object byte[] $cnt; [Array]::Copy($b,$d,$r,0,$cnt); $r } finally{ $fs.Close() } } +function Copy-DeviceToFile($dev,$start,$len,$out){ $fs=[System.IO.File]::Open($dev,'Open','Read','ReadWrite'); try{ $fs.Position=$start; $o=[System.IO.File]::Open($out,'Create','Write','None'); try{ $buf=New-Object byte[] (16MB); [long]$rem=$len; while($rem -gt 0){ $w=[int][Math]::Min([long]$buf.Length,$rem); $n=$fs.Read($buf,0,$w); if($n -le 0){break}; $o.Write($buf,0,$n); $rem-=$n } } finally{ $o.Close() } } finally{ $fs.Close() } } +function Copy-FileToDevice($inf,$dev,$start){ $fs=[System.IO.File]::Open($dev,'Open','ReadWrite','ReadWrite'); try{ $fs.Position=$start; $i=[System.IO.File]::OpenRead($inf); try{ $buf=New-Object byte[] (16MB); while(($n=$i.Read($buf,0,$buf.Length)) -gt 0){ if(($n%512)-ne 0){$n+=(512-($n%512))}; $fs.Write($buf,0,$n) }; $fs.Flush() } finally{ $i.Close() } } finally{ $fs.Close() } } + +function Kill-BlueStacks { + # Kill ONLY BlueStacks-owned processes (names start with HD-, Bstk, or BlueStacks): + # HD-Player/Adb/Agent/MultiInstanceManager/CommonLoader, BstkSVC, BlueStacksHelper/Web/Services... + # This is scoped (no unrelated services are touched) and complete (BstkSVC holds the .bstk/conf + # lock, so it MUST go or config edits won't persist -- verified). BlueStacks 5 has no auto-restart + # Windows service, so killing the processes is sufficient. + $killed = Get-Process -EA SilentlyContinue | Where-Object { $_.Name -match '^(HD-|Bstk|BlueStacks)' } + if($killed){ $killed | Stop-Process -Force -EA SilentlyContinue } + Start-Sleep 4 +} + +# Run a debugfs command-list against an ext4 image file; returns combined output. +function Invoke-Debugfs($img,[string[]]$cmds){ + if(-not $Debugfs -or -not (Test-Path $Debugfs)){ throw "debugfs.exe not found (pass -Debugfs)." } + $scr = Join-Path $env:TEMP ("bsr_work\dfs_{0}.txt" -f (Get-Random)) + New-Item -ItemType Directory -Path (Split-Path $scr) -Force | Out-Null + Set-Content -LiteralPath $scr -Value ($cmds -join "`n") -Encoding ascii -NoNewline + $eap=$ErrorActionPreference; $ErrorActionPreference='Continue' + $out = & $Debugfs @('-w','-f',$scr,(Fwd $img)) 2>&1 | Out-String + $ErrorActionPreference=$eap + Remove-Item $scr -Force -EA SilentlyContinue + $out +} + +# Attach Root.vhd, carve ext4 -> temp img, run $editScriptBlock(img), then (optionally) write back. +function With-RootVhdExt4([scriptblock]$edit,[bool]$writeBack){ + if(-not (Test-Path $Vhd)){ throw "Root.vhd not found: $Vhd" } + $attached=$false + try{ + Say "[*] attach $Vhd (RW)..." Cyan + Mount-DiskImage -ImagePath $Vhd -Access ReadWrite -ErrorAction Stop | Out-Null; $attached=$true + $dn=$null; for($i=0;$i -lt 20;$i++){ $di=Get-DiskImage -ImagePath $Vhd -EA SilentlyContinue; if($di.Number -ne $null){$dn=$di.Number;break}; Start-Sleep -Milliseconds 250 } + if($null -eq $dn){ throw 'no disk number' } + $tgt=$null + foreach($p in @(Get-Partition -DiskNumber $dn | Sort-Object Offset)){ $d="\\.\Harddisk$($dn)Partition$($p.PartitionNumber)"; try{ $m=Read-DeviceBytes $d 0x438 2; if($m[0]-eq 0x53 -and $m[1]-eq 0xEF){ $tgt=@{Device=$d;Start=[long]0;Length=[long]$p.Size}; break } }catch{} } + if(-not $tgt){ throw 'no ext4 partition (0xEF53) in Root.vhd' } + Say "[*] ext4 device=$($tgt.Device) size=$([Math]::Round($tgt.Length/1GB,2))GB" + $img=Join-Path $env:TEMP 'bsr_work\rootvhd_ext4.img'; New-Item -ItemType Directory -Path (Split-Path $img) -Force | Out-Null + Say "[*] carve ext4 region (~1-2 min)..." Cyan + Copy-DeviceToFile $tgt.Device $tgt.Start $tgt.Length $img + $ok = & $edit $img + if($writeBack -and $ok){ + Say "[*] write modified ext4 back into Root.vhd..." Cyan + Copy-FileToDevice $img $tgt.Device $tgt.Start + Say "[+] Root.vhd updated." Green + } elseif($writeBack){ + Say "[!] edit reported failure -- NOT writing back (Root.vhd unchanged)." Red + } + Remove-Item $img -Force -EA SilentlyContinue + return $ok + } finally { + if($attached){ try{ Dismount-DiskImage -ImagePath $Vhd | Out-Null; Say "[*] detached Root.vhd" }catch{ Say 'WARN detach failed' Red } } + } +} + +# Extract the canonical /data/adb/magisk + /system/etc/init/magisk file set from a Magisk APK. +function Extract-MagiskApk($apk,$dst){ + if(-not (Test-Path $apk)){ throw "Magisk APK not found: $apk" } + Add-Type -AssemblyName System.IO.Compression.FileSystem + if(Test-Path $dst){ Remove-Item $dst -Recurse -Force } + New-Item -ItemType Directory -Path $dst -Force | Out-Null + $zip=[System.IO.Compression.ZipFile]::OpenRead($apk) + try{ + # lib name -> output name (x86_64 set, plus magisk32 from x86) + $map=@{ + 'lib/x86_64/libbusybox.so'='busybox'; 'lib/x86_64/libmagisk64.so'='magisk64'; + 'lib/x86_64/libmagiskboot.so'='magiskboot'; 'lib/x86_64/libmagiskinit.so'='magiskinit'; + 'lib/x86_64/libmagiskpolicy.so'='magiskpolicy'; 'lib/x86/libmagisk32.so'='magisk32'; + 'assets/stub.apk'='stub.apk'; 'assets/util_functions.sh'='util_functions.sh'; + 'assets/boot_patch.sh'='boot_patch.sh'; 'assets/addon.d.sh'='addon.d.sh' + } + foreach($e in $zip.Entries){ + if($map.ContainsKey($e.FullName)){ + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($e, (Join-Path $dst $map[$e.FullName]), $true) + } + } + } finally { $zip.Dispose() } + $need=@('busybox','magisk32','magisk64','magiskboot','magiskinit','magiskpolicy','stub.apk','util_functions.sh','boot_patch.sh','addon.d.sh') + $missing = $need | Where-Object { -not (Test-Path (Join-Path $dst $_)) } + if($missing){ throw "APK missing expected members: $($missing -join ', ')" } + Say "[+] extracted Magisk databin ($($need.Count) files) from APK." Green +} + +# ---- conf edit: set a per-instance key (modify-only, UTF-8 no BOM) ---- +function Set-ConfKey($key,$val){ + if(-not (Test-Path $Conf)){ throw "conf not found: $Conf" } + $raw=[System.IO.File]::ReadAllText($Conf) + $pat = [regex]::Escape($key) + '="\d"' + if($raw -notmatch $pat){ Say "[~] conf key $key not present; leaving conf unchanged." Yellow; return } + $new=[regex]::Replace($raw, $pat, ($key + '="' + $val + '"')) + if($new -ne $raw){ [System.IO.File]::WriteAllText($Conf,$new,(New-Object System.Text.UTF8Encoding($false))); Say "[+] conf: $key=$val" Green } + else { Say "[*] conf: $key already $val" DarkGray } +} + +# ---- adb helpers ---- +function Adb([string[]]$a){ (& $Adb @a 2>&1 | Out-String) } + +# Force HD-Adb onto its OWN private server port so a DIFFERENT-version system adb on the default 5037 +# (e.g. Android SDK platform-tools, which is v1.0.41 vs BlueStacks' HD-Adb v1.0.36) can't kill our +# server mid-run. That version clash ("server version doesn't match; killing...") silently breaks +# getprop/shell calls -- which is exactly how a fully-booted instance still trips Boot-And-Wait's +# "did not become adb-reachable" throw. A private port ALSO gives us a clean transport table (no stale +# 'offline' devices left by other adb sessions). HD-Adb v1.0.36 honours ANDROID_ADB_SERVER_PORT. +$Script:AdbServerInit = $false +function Initialize-AdbServer{ + if(-not $env:ANDROID_ADB_SERVER_PORT){ $env:ANDROID_ADB_SERVER_PORT = '15037' } + if(-not (Test-Path -LiteralPath $Adb)){ return } + if(-not $Script:AdbServerInit){ & $Adb @('kill-server') *>$null; $Script:AdbServerInit=$true } # clean slate on OUR port + & $Adb @('start-server') *>$null # idempotent; also revives the server after Kill-BlueStacks nukes HD-Adb +} + +# Test seam: the unit tests dot-source this file and set $Script:LiveAdbPortProbe to a scriptblock so +# the live scan is deterministic (it otherwise depends on what is really listening on the host). +$Script:LiveAdbPortProbe = $null +# Ports actually LISTENING in the BlueStacks adb band right now. This catches what the conf can get +# wrong: a clone can rebind a port that differs from BOTH status.adb_port and adb_port (verified in the +# wild -- when an instance's usual port is held by a leftover socket, BlueStacks records one port but +# binds another). We do NOT filter by owning process (the forwarder may be a privileged VM proc we +# can't name without elevation); every candidate is still verified by getprop + Is-BlueStacks in +# Boot-And-Wait, so a non-instance port here is harmless. Band 5550-5900 spans BlueStacks' clone ports +# (base 5555, clones +10 per instance => _9 = 5645, etc.). +function Get-LiveAdbPorts{ + if($Script:LiveAdbPortProbe){ return @(& $Script:LiveAdbPortProbe) } + $out=New-Object System.Collections.Generic.List[string] + try{ + Get-NetTCPConnection -State Listen -ErrorAction Stop | + Where-Object { $_.LocalPort -ge 5550 -and $_.LocalPort -le 5900 } | + ForEach-Object { [void]$out.Add([string]$_.LocalPort) } + }catch{ + try{ # fallback for hosts without the NetTCPIP module + foreach($ln in (netstat -ano -p tcp 2>$null)){ + if($ln -match 'LISTENING' -and $ln -match ':(\d{4,5})\b'){ $p=[int]$Matches[1]; if($p -ge 5550 -and $p -le 5900){ [void]$out.Add([string]$p) } } + } + }catch{} + } + return @($out | Sort-Object -Unique) +} + +# Candidate adb ports for THIS instance, in priority order. status.adb_port is the runtime port +# BlueStacks writes on boot; adb_port is the Multi-Instance Manager's assigned port (clones get +# 5585/5595/...). Those (from BlueStacks' OWN conf) come FIRST -- authoritative when fresh. Then the +# actually-bound listening ports, which rescue us when the conf is stale. 5555 is the last resort. +# NEVER hardcoded to a single value; Boot-And-Wait tries each AND verifies identity, so a stale conf +# value or a foreign emulator on a port can't mislead. +function Get-AdbPortCandidates{ + $cands=New-Object System.Collections.Generic.List[string] + if($Conf -and (Test-Path $Conf)){ + try{ + $ct=[IO.File]::ReadAllText($Conf); $esc=[regex]::Escape($Instance) + foreach($key in @('status\.adb_port','adb_port')){ + $m=[regex]::Match($ct,'(?im)^\s*bst\.instance\.'+$esc+'\.'+$key+'\s*=\s*"?(\d+)"?') + if($m.Success){ [void]$cands.Add($m.Groups[1].Value) } + } + }catch{} + } + foreach($lp in @(Get-LiveAdbPorts)){ [void]$cands.Add($lp) } # live bound ports rescue a stale conf + [void]$cands.Add('5555') + $seen=@{}; $out=@(); foreach($c in $cands){ if(-not $seen.ContainsKey($c)){ $seen[$c]=$true; $out+=$c } } + ,$out +} +# Is the device on $serial actually a BlueStacks instance (vs a foreign emulator squatting the port)? +# BlueStacks exposes bst.* props + the bst service manager (init.svc.bstsvcmgrtest); a stock AVD has neither. +function Is-BlueStacks([string]$serial){ + $all=(& $Adb @('-s',$serial,'shell','getprop') 2>&1 | Out-String) + return ($all -match '\[(bst\.|init\.svc\.bst|ro\.bst)') +} +$Script:AdbSerial = $null # the pinned 127.0.0.1: transport for the current boot +function AdbConnect{ $s = if($Script:AdbSerial){$Script:AdbSerial}else{"127.0.0.1:$((Get-AdbPortCandidates)[0])"}; & $Adb @('connect',$s) *>$null } +# False if adb reported a transient transport/device error (common while a freshly-booted instance +# is still restarting adbd). Such output should be retried after a reconnect, not trusted. +function AdbOk([string]$o){ -not ($o -match "device '.*' not found" -or $o -match 'device .* not found' -or $o -match 'no devices/emulators found' -or $o -match 'device offline' -or $o -match 'error: closed') } +# Run an adb shell command, reconnecting + retrying on a dropped transport. +function AdbShellRetry([string]$serial,[string]$cmd,[int]$tries=6){ + $o='' + for($k=0;$k -lt $tries;$k++){ + $o=(& $Adb @('-s',$serial,'shell',$cmd) 2>&1 | Out-String) + if(AdbOk $o){ return $o } + Start-Sleep 3; & $Adb @('start-server') *>$null; AdbConnect + } + $o +} +# Run any adb subcommand (install/push/...) with the same reconnect-on-drop retry. +function AdbTry([string[]]$a,[int]$tries=4){ + $o='' + for($k=0;$k -lt $tries;$k++){ + $o=(& $Adb @($a) 2>&1 | Out-String) + if(AdbOk $o){ return $o } + Start-Sleep 3; & $Adb @('start-server') *>$null; AdbConnect + } + $o +} +function AdbSu([string]$serial,[string]$cmd){ AdbShellRetry $serial "/system/xbin/su -c '$cmd'" } +function Boot-And-Wait([int]$timeoutSec=300){ + Initialize-AdbServer # pin HD-Adb to its private server port BEFORE any connect (version-conflict immunity) + Say "[*] launching instance $Instance ..." Cyan + Start-Process -FilePath $Player -ArgumentList @('--instance',$Instance) | Out-Null + # Find the adb endpoint from BlueStacks' OWN per-instance conf ports (re-read each pass: BlueStacks + # writes the actual bound port during boot). Try each candidate, require boot_completed=1, and confirm + # it is really our BlueStacks instance -- so neither a stale port nor a foreign emulator on 5555 can + # mislead us. We pin to that 127.0.0.1: transport (never the transient emulator-XXXX serial). + $serial=$null; $fallback=$null + for($i=0;$i -lt ($timeoutSec/3) -and -not $serial;$i++){ + Start-Sleep 3; & $Adb @('start-server') *>$null + foreach($port in (Get-AdbPortCandidates)){ + $cand="127.0.0.1:$port" + & $Adb @('connect',$cand) *>$null + # boot_completed must be EXACTLY "1" on its own line -- a "device '...:port' not found" error + # contains the port digits and would false-positive a naive -match '1'. + $out=(& $Adb @('-s',$cand,'shell','getprop','sys.boot_completed') 2>&1 | Out-String) + if(-not (($out -split "`n" | ForEach-Object { $_.Trim() }) -contains '1')){ continue } + if(Is-BlueStacks $cand){ $serial=$cand; break } # confirmed: our instance + elseif(-not $fallback){ $fallback=$cand } # booted, but identity unconfirmed + } + } + if(-not $serial -and $fallback){ Say "[~] using booted device $fallback (BlueStacks marker not seen)." Yellow; $serial=$fallback } + if(-not $serial){ throw "instance '$Instance' did not boot / become adb-reachable within $timeoutSec s" } + $Script:AdbSerial=$serial + # Stabilize: a freshly-booted instance (esp. a first boot) restarts adbd a few times, which drops the + # transport -> the next call fails with "device '127.0.0.1:' not found". Wait until a plain shell + # is reliably reachable (3 consecutive hits, reconnecting each time) before handing the serial to callers. + $stable=0 + for($s=0;$s -lt 30 -and $stable -lt 3;$s++){ + AdbConnect + $t=(& $Adb @('-s',$serial,'shell','echo BSR_RDY') 2>&1 | Out-String) + if($t -match 'BSR_RDY'){ $stable++ } else { $stable=0; Start-Sleep 3 } + } + Say "[+] booted: $serial" Green + Start-Sleep 4 + $serial +} + +# ==================================================================== +# ACTIONS +# ==================================================================== +function Do-Prep { + Ensure-MagiskApk; Ensure-BsrSu; Ensure-Debugfs + Say '==== PREP (offline) ====' Cyan + Kill-BlueStacks + + # 0) one-time pristine safety backup (so Undo can fully restore) + $bak = "$Vhd.bsrbak" + if(-not $NoBackup -and -not (Test-Path $bak)){ + try { Say "[*] one-time pristine backup -> $bak ..." ; Copy-Item -LiteralPath $Vhd -Destination $bak -Force; Say "[+] backup done." Green } + catch { Say "[~] could not create backup: $($_.Exception.Message)" Yellow } + } else { Say "[*] pristine backup present (or -NoBackup): $bak" DarkGray } + + # 1) HD-Player anti-tamper patch (proven, via engine; engine Patch requires -Exe) + Say '[*] HD-Player anti-tamper patch (engine Patch)...' + $hdp = Join-Path $Install 'HD-Player.exe' + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Engine -Action Patch -Exe $hdp 2>&1 | ForEach-Object { Say " $_" DarkGray } + + # 2) conf: emulator root ON (so the bootstrap bindmount runs) + adb on + Set-ConfKey "bst.instance.$Instance.enable_root_access" 1 + Set-ConfKey "bst.enable_adb_access" 1 + + # 3) stage files + $stage = Join-Path $env:TEMP 'bsr_work\databin' + Extract-MagiskApk $MagiskApk $stage + $tmpDir = Join-Path $env:TEMP 'bsr_work\sysfiles'; New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $tmpDir 'bootanim.rc'), $BOOTANIM_RC, (New-Object System.Text.UTF8Encoding($false))) + [System.IO.File]::WriteAllText((Join-Path $tmpDir 'config'), $MAGISK_CONFIG, (New-Object System.Text.UTF8Encoding($false))) + [System.IO.File]::WriteAllText((Join-Path $tmpDir 'bindmount'), $BINDMOUNT_MOD, (New-Object System.Text.UTF8Encoding($false))) + [System.IO.File]::WriteAllText((Join-Path $tmpDir 'bsr_boot.sh'), $BSR_BOOT_SH, (New-Object System.Text.UTF8Encoding($false))) + + # 4) ONE offline carve: Magisk /system files + bootanim hijack + bootstrap su + hijacked bindmount + $ok = With-RootVhdExt4 { + param($img) + $sysM = Fwd (Join-Path $stage 'magisk32'); $null=$sysM + $cmds = @( + 'mkdir /android','mkdir /android/system','mkdir /android/system/etc','mkdir /android/system/etc/init', + 'mkdir /android/system/etc/init/magisk','cd /android/system/etc/init/magisk' + ) + foreach($f in 'magisk32','magisk64','magiskinit','magiskpolicy','stub.apk'){ + $cmds += @("rm $f","write $(Fwd (Join-Path $stage $f)) $f","sif $f mode 0100700","sif $f uid 0","sif $f gid 0","sif $f links_count 1") + } + $cmds += @("rm config","write $(Fwd (Join-Path $tmpDir 'config')) config","sif config mode 0100700","sif config uid 0","sif config gid 0","sif config links_count 1") + # per-instance gate script (root-owned 0700, alongside the magisk binaries) + $cmds += @("rm bsr_boot.sh","write $(Fwd (Join-Path $tmpDir 'bsr_boot.sh')) bsr_boot.sh","sif bsr_boot.sh mode 0100700","sif bsr_boot.sh uid 0","sif bsr_boot.sh gid 0","sif bsr_boot.sh links_count 1") + # hijack bootanim.rc (system:system 0664) -- now the GATED version + $cmds += @('cd /android/system/etc/init','rm bootanim.rc',"write $(Fwd (Join-Path $tmpDir 'bootanim.rc')) bootanim.rc",'sif bootanim.rc mode 0100664','sif bootanim.rc uid 1000','sif bootanim.rc gid 1000','sif bootanim.rc links_count 1') + # bootstrap su template (setuid root) + $cmds += @('cd /android/system/etc','rm bsr_su',"write $(Fwd $BsrSu) bsr_su",'sif bsr_su mode 0106755','sif bsr_su uid 0','sif bsr_su gid 0','sif bsr_su links_count 1') + # hijacked bindmount + $cmds += @('cd /android/system/bin','rm bindmount',"write $(Fwd (Join-Path $tmpDir 'bindmount')) bindmount",'sif bindmount mode 0100755','sif bindmount uid 0','sif bindmount gid 0','sif bindmount links_count 1') + # verify + $cmds += @('stat /android/system/etc/init/magisk/magisk64','stat /android/system/etc/init/bootanim.rc','stat /android/system/etc/bsr_su','stat /android/system/bin/bindmount') + $out = Invoke-Debugfs $img $cmds + Say $out DarkGray + $good = ($out -match '(?s)bsr_su.*?Inode:\s*\d') -and ($out -match '(?s)bootanim\.rc.*?Inode:\s*\d') -and ($out -match '(?s)magisk64.*?Inode:\s*\d') + if(-not $good){ Say '[!] prep verify FAILED' Red } + return $good + } $true + if(-not $ok){ throw "Prep failed (Root.vhd not modified)." } + Say '[+] PREP complete.' Green +} + +function Do-Data { + Ensure-MagiskApk; Ensure-Debugfs + Say '==== DATA (online, bootstrap su) ====' Cyan + $stage = Join-Path $env:TEMP 'bsr_work\databin' + if(-not (Test-Path (Join-Path $stage 'busybox'))){ Extract-MagiskApk $MagiskApk $stage } + $serial = Boot-And-Wait + # sanity: bootstrap su works + $id = (AdbSu $serial 'id').Trim() + if($id -notmatch 'uid=0'){ throw "bootstrap su not root (got '$id'). Prep/patch/conf issue." } + Say "[+] bootstrap su OK: $id" Green + # install Magisk manager app + Say '[*] adb install Magisk APK...' + Say (" " + (AdbTry @('-s',$serial,'install','-r',$MagiskApk)).Trim()) DarkGray + # push databin to /data/local/tmp then su-copy into /data/adb/magisk + AdbTry @('-s',$serial,'shell','rm -rf /data/local/tmp/bsrmbin; mkdir -p /data/local/tmp/bsrmbin') | Out-Null + @((AdbTry @('-s',$serial,'push',(Fwd "$stage\."),'/data/local/tmp/bsrmbin/')) -split "`n" | Where-Object { $_.Trim() }) | Select-Object -Last 1 | ForEach-Object { Say " $($_.Trim())" DarkGray } + $script = @' +set -e +mkdir -p /data/adb/magisk /data/adb/modules /data/adb/post-fs-data.d /data/adb/service.d +# per-instance ROOT FLAG: bsr_boot.sh on the shared master only activates Magisk on instances that +# carry this file on their OWN /data. This is what makes THIS instance rooted while others stay clean. +touch /data/adb/.bsr_root; chmod 600 /data/adb/.bsr_root +cp -f /data/local/tmp/bsrmbin/* /data/adb/magisk/ +chown -R 0:0 /data/adb/magisk +chmod 0755 /data/adb/magisk/busybox /data/adb/magisk/magisk32 /data/adb/magisk/magisk64 /data/adb/magisk/magiskboot /data/adb/magisk/magiskinit /data/adb/magisk/magiskpolicy /data/adb/magisk/*.sh +chmod 0644 /data/adb/magisk/stub.apk +restorecon -R /data/adb 2>/dev/null || true +rm -rf /data/local/tmp/bsrmbin +sync +echo BSR_DATA_OK +'@ -replace "`r`n","`n" + $script | Set-Content -LiteralPath (Join-Path $env:TEMP 'bsr_work\datapop.sh') -Encoding ascii -NoNewline + AdbTry @('-s',$serial,'push',(Fwd (Join-Path $env:TEMP 'bsr_work\datapop.sh')),'/data/local/tmp/bsr_pop.sh') | Out-Null + $r = AdbSu $serial 'sh /data/local/tmp/bsr_pop.sh; rm -f /data/local/tmp/bsr_pop.sh' + Say $r DarkGray + if($r -notmatch 'BSR_DATA_OK'){ throw "/data/adb/magisk populate failed." } + Say '[+] /data/adb/magisk populated. Rebooting so magiskd initializes (env now complete)...' Green + & $Adb @('-s',$serial,'shell','sync') *>$null; Start-Sleep 2; Kill-BlueStacks + + # second boot: magiskd should now start; bsr_su is still present so we can set the grant policy. + $serial = Boot-And-Wait + Start-Sleep 5 + $mg = (AdbSu $serial 'ps -A | grep -c magiskd').Trim() + Say " magiskd processes: $mg" $(if($mg -match '[1-9]'){'Green'}else{'Yellow'}) + # grant policy (allow shell uid 2000). The SQL has parens, which break through nested su -c '...' + # quoting -> push it as a script FILE and run that (robust, same pattern as the populate step). + $polSh = 'magisk --sqlite "REPLACE INTO policies (uid,policy,until,logging,notification) VALUES(2000,2,0,0,0)"' + "`n" + 'echo POL_RC=$?' + "`n" + [System.IO.File]::WriteAllText((Join-Path $env:TEMP 'bsr_work\polset.sh'), $polSh, (New-Object System.Text.UTF8Encoding($false))) + AdbTry @('-s',$serial,'push',(Fwd (Join-Path $env:TEMP 'bsr_work\polset.sh')),'/data/local/tmp/bsr_pol.sh') | Out-Null + $pol = AdbSu $serial 'sh /data/local/tmp/bsr_pol.sh; rm -f /data/local/tmp/bsr_pol.sh' + Say " policy: $(($pol -replace "`r?`n",' ').Trim())" DarkGray + $mc = (AdbSu $serial 'magisk -c').Trim(); Say " magisk -c: $mc" + AdbSu $serial 'sync' | Out-Null + if($mg -notmatch '[1-9]'){ Say '[!] magiskd not detected after populate+reboot -- check /cache/magisk.log' Yellow } + Say '[+] DATA complete (/data/adb/magisk populated, magiskd up, policy set).' Green + & $Adb @('-s',$serial,'shell','sync') *>$null; Start-Sleep 2; Kill-BlueStacks +} + +function Do-Clean { + Ensure-Debugfs + Say '==== CLEAN (offline: erase bootstrap su, restore stock bindmount) ====' Cyan + Kill-BlueStacks + $tmpDir = Join-Path $env:TEMP 'bsr_work\sysfiles'; New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $tmpDir 'bindmount.orig'), $BINDMOUNT_ORIG, (New-Object System.Text.UTF8Encoding($false))) + $ok = With-RootVhdExt4 { + param($img) + $cmds = @( + 'cd /android/system/etc','rm bsr_su', + 'cd /android/system/bin','rm bindmount',"write $(Fwd (Join-Path $tmpDir 'bindmount.orig')) bindmount", + 'sif bindmount mode 0100775','sif bindmount uid 1000','sif bindmount gid 1000','sif bindmount links_count 1', + 'stat /android/system/bin/bindmount','stat /android/system/etc/bsr_su' + ) + $out = Invoke-Debugfs $img $cmds + Say $out DarkGray + # stock bindmount is ~1339B; accept the byte-exact range (template is 1339, tolerate +/-2) + $bmOk = ($out -match '(?s)/android/system/bin/bindmount[\s\S]*?Size:\s*(133[0-9]|134[01])') + $suGone = ($out -match 'bsr_su:\s*File not found') -or ($out -match 'File not found by ext2_lookup') + if(-not $bmOk){ Say '[!] stock bindmount not detected after restore' Red } + return ($bmOk -and $suGone) + } $true + if(-not $ok){ throw "Clean failed." } + Say '[+] CLEAN complete (bsr_su removed, stock bindmount restored).' Green +} + +function Do-Finalize { + Say '==== FINALIZE (emulator root OFF + shareable master) ====' Cyan + Set-ConfKey "bst.instance.$Instance.enable_root_access" 0 + # Ensure the shared master Root.vhd + fastboot.vdi are Readonly so MULTIPLE instances can attach + # them at once (type="Normal" is exclusive -> a 2nd instance fails with VBOX_E_INVALID_OBJECT_STATE). + # Data.vhdx stays Normal (per-instance, writable). This is the factory layout. + $bstk = Join-Path (Split-Path $Vhd) ("$Instance.bstk") + if(Test-Path $bstk){ + $t=[System.IO.File]::ReadAllText($bstk); $o=$t + $t=[regex]::Replace($t,'(location="Root\.vhd"[^>]*?type=")Normal(")','${1}Readonly${2}') + $t=[regex]::Replace($t,'(location="fastboot\.vdi"[^>]*?type=")Normal(")','${1}Readonly${2}') + if($t -ne $o){ [System.IO.File]::WriteAllText($bstk,$t); Say '[+] master Root.vhd + fastboot.vdi set Readonly (multi-instance shareable).' Green } + else { Say '[*] master disks already Readonly (or not declared in this .bstk).' DarkGray } + } + Say '[+] FINALIZE complete.' Green +} + +function Do-Verify { + Say '==== VERIFY (cold boot) ====' Cyan + $serial = Boot-And-Wait + $id = (& $Adb @('-s',$serial,'shell','su -c id') 2>&1 | Out-String).Trim() + $whi = (& $Adb @('-s',$serial,'shell','readlink /system/bin/su') 2>&1 | Out-String).Trim() + $xb = (& $Adb @('-s',$serial,'shell','su -c "ls /system/xbin/su 2>&1"') 2>&1 | Out-String).Trim() + $sweep = (& $Adb @('-s',$serial,'shell',"su -c `"find /system /data/adb /data/downloads -type f -size 4968c 2>/dev/null | while read f; do [ \`"`$(sha256sum `$f|cut -d' ' -f1)\`" = '$BSR_SU_SHA' ] && echo TRACE:`$f; done; echo SWEEPDONE`"") 2>&1 | Out-String) + Say (" su -c id : {0}" -f $id) $(if($id -match 'uid=0'){'Green'}else{'Red'}) + Say (" /system/bin/su -> : {0}" -f $whi) + Say (" /system/xbin/su : {0} (want: not found)" -f $xb) + Say (" bsr_su sweep : {0}" -f ($sweep -replace "`r?`n",' ').Trim()) + if($id -match 'uid=0' -and $whi -match 'magisk' -and $sweep -notmatch 'TRACE:'){ Say '[+] VERIFY PASS: Magisk is the sole root; no bsr_su traces.' Green } + else { Say '[!] VERIFY: review the above.' Yellow } +} + +function Do-Undo { + # PER-INSTANCE unroot (multi-instance safe): just drop THIS instance's root flag + /data Magisk + # state + app. The shared master /system and HD-Player patch are LEFT INTACT so any OTHER rooted + # instances keep working. Use -Full to also scrub the master + un-patch (unroots ALL instances). + Say "==== UNDO ($Instance) ====" Cyan + Kill-BlueStacks; Start-Sleep 2 + if(-not (Test-Path $Player)){ Say "[!] HD-Player.exe not found at '$Player'." Red; return } + try { + $serial = Boot-And-Wait 240 + & $Adb @('-s',$serial,'uninstall','io.github.huskydg.magisk') 2>&1 | Out-Null + # while the flag is still present this instance has working Magisk root, so its su can wipe /data/adb + $rm = 'rm -f /data/adb/.bsr_root; rm -rf /data/adb/magisk /data/adb/magisk.db /data/adb/modules /data/adb/post-fs-data.d /data/adb/service.d 2>/dev/null; sync; echo BSR_RM_OK' + $o1 = (& $Adb @('-s',$serial,'shell',"su -c '$rm'") 2>&1 | Out-String) + if($o1 -notmatch 'BSR_RM_OK'){ AdbSu $serial $rm | Out-Null } + & $Adb @('-s',$serial,'shell','sync') *>$null; Start-Sleep 2 + Say "[+] $Instance unrooted (flag + /data Magisk state removed, app uninstalled)." Green + } catch { Say "[~] could not boot to unroot /data: $($_.Exception.Message)" Yellow } + Kill-BlueStacks + Set-ConfKey "bst.instance.$Instance.enable_root_access" 0 + + if($Full){ + Say '[*] -Full: scrubbing shared master + un-patching HD-Player (unroots ALL instances)...' Yellow + $bak = "$Vhd.bsrbak" + if(Test-Path $bak){ Copy-Item -LiteralPath $bak -Destination $Vhd -Force; Say '[+] master Root.vhd restored to factory.' Green } + else { Say '[~] no Root.vhd.bsrbak; master left as-is (gated Magisk stays dormant).' Yellow } + $hdp = Join-Path $Install 'HD-Player.exe' + Say '[*] un-patching HD-Player.exe (-Exe last to avoid arg-glue)...' + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Engine -Action Patch -Restore -Exe $hdp 2>&1 | ForEach-Object { Say " $_" DarkGray } + Say '[+] FULL host scrub complete (no instance rooted; HD-Player factory).' Green + } else { + Say "[+] UNDO complete. Shared master + HD-Player patch left intact so other rooted instances keep working." Green + Say " For a full host scrub (no instances rooted, HD-Player un-patched), re-run Undo with -Full." DarkGray + } +} + +# Only dispatch when run normally (-File / &). When DOT-SOURCED (. bsr_magisk.ps1) -- e.g. by the test +# suite to unit-test the resolver functions -- skip the pipeline so nothing boots or writes. +if ($MyInvocation.InvocationName -ne '.') { + switch($Action){ + 'Prep' { Do-Prep } + 'Data' { Do-Data } + 'Clean' { Do-Clean } + 'Finalize' { Do-Finalize } + 'Verify' { Do-Verify } + 'Auto' { Do-Prep; Do-Data; Do-Clean; Do-Finalize; Do-Verify } + 'Undo' { Do-Undo } + } } __BSR_MAGISK_END__ diff --git a/docs/BLUESTACKS_ROOTING_DEEP_DIVE.md b/docs/BLUESTACKS_ROOTING_DEEP_DIVE.md index 8624e72..362f482 100644 --- a/docs/BLUESTACKS_ROOTING_DEEP_DIVE.md +++ b/docs/BLUESTACKS_ROOTING_DEEP_DIVE.md @@ -296,6 +296,7 @@ Per‑instance root = presence of `/data/adb/.bsr_root`. **Proven:** `Rvc64` (fl | Undo left HD‑Player patched | `-Exe "" -Restore` glued `-Restore` onto the path | pass `-Exe` **last** | | 2nd instance "couldn't launch" | shared master `Root.vhd` was `type="Normal"` (exclusive) | set master **Readonly** (Finalize) | | Kitsune Mask leaked to unrooted instances | shared `/system` ⇒ `magiskd` ran everywhere | per‑instance gate (`bsr_boot.sh` + `/data/adb/.bsr_root`) | +| `did not become adb‑reachable` though the instance booted | a system `adb` (Android SDK **v1.0.41**) and BlueStacks `HD‑Adb` (**v1.0.36**) fight over the default 5037 server (*"server version doesn't match; killing…"*) → `getprop` fails forever | pin HD‑Adb to a **private** `ANDROID_ADB_SERVER_PORT=15037` (proven: 30/30 getprop OK with a v41 server on 5037; 0/12 on the shared port; full `Boot‑And‑Wait` end‑to‑end PASS) + add **live‑bound‑port** discovery (`Get-NetTCPConnection`) so a stale `status.adb_port` can't strand the boot wait | --- diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 49092e7..90b7ab5 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -155,6 +155,7 @@ device after completion: **only Magisk's**. No DiskRW, no engine‑su, no daemon | Magisk app: *Abnormal State — su not from Magisk* | a stray `/system/xbin/su` (old engine `Root`) | `bsr_engine.ps1 -Action Unroot -Vhd ` (or re‑run **Clean**) | | `su` returns nothing / `uid=2000` after Finalize | shell took BlueStacks' gated su; Magisk daemon down | check `/cache/magisk.log`; ensure `/data/adb/magisk/busybox` exists | | Instance won't boot after edits | HD‑Player patch missing | restore `HD-Player.exe.bak`, re‑apply patch, retry | +| `instance '' did not boot / become adb‑reachable within N s` — **but the instance is up** (Home visible, Magisk installed) | a **system `adb` of a different version** (e.g. Android SDK platform‑tools **v1.0.41**) keeps killing BlueStacks' **HD‑Adb v1.0.36** server on the shared port 5037 — *"adb server version doesn't match this client; killing…"* — so `getprop` calls fail | **fixed in v11**: the tool pins HD‑Adb to its own server port (`ANDROID_ADB_SERVER_PORT=15037`) so the two never collide, and also tries the **live‑bound** adb port, not just `bluestacks.conf`. Update the tool. (Diagnose: compare `adb version` on `PATH` vs `"…\BlueStacks_nxt\HD-Adb.exe" version`.) | --- diff --git a/tests/Run-Resolve-Tests.ps1 b/tests/Run-Resolve-Tests.ps1 index aaee014..a0eed3d 100644 --- a/tests/Run-Resolve-Tests.ps1 +++ b/tests/Run-Resolve-Tests.ps1 @@ -99,6 +99,9 @@ Eq 'engine: defaults to 5555 when neither present' '5555' (Resolve-Map (New-Fake Write-Host "`n=== ADB PORT candidates (orchestrator, dot-sourced) ===" -ForegroundColor Cyan . $Magisk # dispatch guard => nothing boots; functions become available +# Stub the live-bound-port scan so these conf-only cases are deterministic regardless of what is +# actually listening on the test host (the seam Get-LiveAdbPorts checks first). +$script:LiveAdbPortProbe = { @() } function Cands([string]$dataDir, [string]$inst) { $script:Conf = Join-Path $dataDir 'bluestacks.conf' $script:Instance = $inst @@ -111,6 +114,18 @@ Eq 'cands: adb_port used when status absent' '5595,5555' ((Cands (New-Fak Eq 'cands: stale status + real adb_port both tried' '5555,5595' ((Cands (New-FakeData 'Rvc64_4' @{ 'status.adb_port' = '5555'; 'adb_port' = '5595' } 'c') 'Rvc64_4') -join ',') Eq 'cands: no keys -> just the 5555 fallback' '5555' ((Cands (New-FakeData 'Foo' @{} 'c') 'Foo') -join ',') +Write-Host "`n=== ADB PORT candidates: live-bound-port merge (rescues a stale conf) ===" -ForegroundColor Cyan +# conf ports stay FIRST (authoritative when fresh); the live-bound port is appended; 5555 last. +$script:LiveAdbPortProbe = { @('5646') } +Eq 'cands: live bound port appended after conf' '5645,5646,5555' ((Cands (New-FakeData 'Rvc64_9' @{ 'status.adb_port' = '5645' } 'c') 'Rvc64_9') -join ',') +# the real-world failure mode: conf records NEITHER the bound port -> live scan is the only rescue. +$script:LiveAdbPortProbe = { @('5646') } +Eq 'cands: live bound port used when conf has none' '5646,5555' ((Cands (New-FakeData 'Bar' @{} 'c') 'Bar') -join ',') +# a live port that equals a conf port must NOT be duplicated. +$script:LiveAdbPortProbe = { @('5645', '5646') } +Eq 'cands: dedup live vs conf (5645 not repeated)' '5645,5646,5555' ((Cands (New-FakeData 'Rvc64_9' @{ 'status.adb_port' = '5645' } 'c') 'Rvc64_9') -join ',') +$script:LiveAdbPortProbe = { @() } # reset so later sections are unaffected + Write-Host "`n=== DataRoot resolution (orchestrator Get-DataRoot, custom/registry) ===" -ForegroundColor Cyan Eq 'DataRoot: ...\Engine is normalized to base' 'X:\Custom\BS' (Get-DataRoot ([pscustomobject]@{ DataDir = 'X:\Custom\BS\Engine'; UserDefinedDir = $null })) Eq 'DataRoot: plain data dir kept as-is' 'X:\Custom\Data' (Get-DataRoot ([pscustomobject]@{ DataDir = 'X:\Custom\Data'; UserDefinedDir = $null })) diff --git a/todolist.md b/todolist.md index 80ea9de..249e38e 100644 --- a/todolist.md +++ b/todolist.md @@ -1,6 +1,14 @@ # Todo List ## Done +- [x] **adb robustness: immune to adb-version conflicts + live-bound port detection** — `Boot-And-Wait` + was timing out ("did not become adb-reachable") on hosts that also have a *different-version* system + `adb` (Android SDK v1.0.41 vs BlueStacks HD-Adb v1.0.36) fighting over the default server port 5037 + (*"server version doesn't match; killing…"*). Fixed by pinning HD-Adb to a private + `ANDROID_ADB_SERVER_PORT=15037` + only ever using `HD-Adb.exe`, and by merging the **live-bound** + listening port into `Get-AdbPortCandidates` (rescues a stale `status.adb_port`). Proven live: 30/30 + isolated getprop OK with a v41 server on 5037 (0/12 on the shared port), 20/20 stable port detection, + full Boot-And-Wait end-to-end PASS. +3 resolve tests (25), re-embedded, all suites green (28+25). - [x] **bundled a custom Kitsune v31 build so the in-app DenyList works with ReZygisk/NeoZygisk** — the deny module now stores entries in the `denylist` table (not `hidelist`), built from `Jordan231111/KitsuneMagisk@25fa2159f`. Re-embedded (`reembed-apk.ps1`, SHA `fac319d2…`, round-trip OK), diff --git a/tools/bsr_engine.ps1 b/tools/bsr_engine.ps1 index 7a3cdae..5e93581 100644 --- a/tools/bsr_engine.ps1 +++ b/tools/bsr_engine.ps1 @@ -887,6 +887,10 @@ function AdbShell([string]$cmd) { return AdbS @('shell', $cmd) } function Connect-WaitBoot([int]$timeoutSec) { $port = if ($AdbPort) { $AdbPort } else { '5555' } $Script:Serial = "127.0.0.1:$port" + # Isolate HD-Adb on its own server port so a different-version system adb (e.g. Android SDK + # platform-tools) on the default 5037 can't kill our server mid-run (the version-mismatch churn + # that makes getprop/shell calls fail and a booted instance look "not adb-reachable"). + if (-not $env:ANDROID_ADB_SERVER_PORT) { $env:ANDROID_ADB_SERVER_PORT = '15037' } AdbRaw @('start-server') | Out-Null $sw = [System.Diagnostics.Stopwatch]::StartNew() $connected = $false diff --git a/tools/bsr_magisk.ps1 b/tools/bsr_magisk.ps1 index ba4869f..0e250cb 100644 --- a/tools/bsr_magisk.ps1 +++ b/tools/bsr_magisk.ps1 @@ -90,7 +90,24 @@ if (-not $Debugfs) { } if (-not $Conf) { $Conf = Join-Path $DataRoot 'bluestacks.conf' } if (-not $Vhd -and $Instance) { $Vhd = Join-Path $DataRoot "Engine\$Instance\Root.vhd" } -$Adb = Join-Path $Install 'HD-Adb.exe' +# Resolve BlueStacks' OWN adb (HD-Adb.exe). We deliberately do NOT fall back to a system adb.exe: +# mixing a system adb (e.g. Android SDK platform-tools v1.0.41) with BlueStacks' HD-Adb (v1.0.36) +# triggers the "adb server version doesn't match this client; killing..." war -- the two kill each +# other's server, getprop/shell calls fail intermittently, and a fully-booted instance can still +# fail Boot-And-Wait with "did not become adb-reachable". So we pin HD-Adb.exe specifically. +function Resolve-HdAdb { + $c = New-Object System.Collections.Generic.List[string] + if($Install){ [void]$c.Add((Join-Path $Install 'HD-Adb.exe')) } + if($reg -and $reg.InstallDir){ [void]$c.Add((Join-Path ($reg.InstallDir.TrimEnd('\','/')) 'HD-Adb.exe')) } + foreach($p in @((Join-Path $env:ProgramFiles 'BlueStacks_nxt\HD-Adb.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'BlueStacks_nxt\HD-Adb.exe'), + (Join-Path $env:ProgramFiles 'BlueStacks_msi5\HD-Adb.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'BlueStacks_msi5\HD-Adb.exe'))){ [void]$c.Add($p) } + foreach($p in $c){ if($p -and (Test-Path -LiteralPath $p)){ return (Resolve-Path -LiteralPath $p).Path } } + $g = Get-Command 'HD-Adb.exe' -ErrorAction SilentlyContinue; if($g){ return $g.Source } + return (Join-Path $Install 'HD-Adb.exe') # most-likely path even if absent (a later check reports it) +} +$Adb = Resolve-HdAdb $Player = Join-Path $Install 'HD-Player.exe' $BsrSu = if($BsrSuPath){$BsrSuPath}else{ Join-Path $Here 'su_src\bsr_su' } # the setuid bootstrap su (4968 B) @@ -371,10 +388,54 @@ function Set-ConfKey($key,$val){ # ---- adb helpers ---- function Adb([string[]]$a){ (& $Adb @a 2>&1 | Out-String) } -# Candidate adb ports for THIS instance, in priority order, from BlueStacks' OWN conf -- NEVER hardcoded -# to 5555. status.adb_port is the runtime port BlueStacks writes on boot; adb_port is the Multi-Instance -# Manager's assigned port (clones get 5585/5595/...); 5555 is only a last-resort fallback. Boot-And-Wait -# tries each AND verifies identity, so a stale status value or a foreign emulator on a port can't mislead. + +# Force HD-Adb onto its OWN private server port so a DIFFERENT-version system adb on the default 5037 +# (e.g. Android SDK platform-tools, which is v1.0.41 vs BlueStacks' HD-Adb v1.0.36) can't kill our +# server mid-run. That version clash ("server version doesn't match; killing...") silently breaks +# getprop/shell calls -- which is exactly how a fully-booted instance still trips Boot-And-Wait's +# "did not become adb-reachable" throw. A private port ALSO gives us a clean transport table (no stale +# 'offline' devices left by other adb sessions). HD-Adb v1.0.36 honours ANDROID_ADB_SERVER_PORT. +$Script:AdbServerInit = $false +function Initialize-AdbServer{ + if(-not $env:ANDROID_ADB_SERVER_PORT){ $env:ANDROID_ADB_SERVER_PORT = '15037' } + if(-not (Test-Path -LiteralPath $Adb)){ return } + if(-not $Script:AdbServerInit){ & $Adb @('kill-server') *>$null; $Script:AdbServerInit=$true } # clean slate on OUR port + & $Adb @('start-server') *>$null # idempotent; also revives the server after Kill-BlueStacks nukes HD-Adb +} + +# Test seam: the unit tests dot-source this file and set $Script:LiveAdbPortProbe to a scriptblock so +# the live scan is deterministic (it otherwise depends on what is really listening on the host). +$Script:LiveAdbPortProbe = $null +# Ports actually LISTENING in the BlueStacks adb band right now. This catches what the conf can get +# wrong: a clone can rebind a port that differs from BOTH status.adb_port and adb_port (verified in the +# wild -- when an instance's usual port is held by a leftover socket, BlueStacks records one port but +# binds another). We do NOT filter by owning process (the forwarder may be a privileged VM proc we +# can't name without elevation); every candidate is still verified by getprop + Is-BlueStacks in +# Boot-And-Wait, so a non-instance port here is harmless. Band 5550-5900 spans BlueStacks' clone ports +# (base 5555, clones +10 per instance => _9 = 5645, etc.). +function Get-LiveAdbPorts{ + if($Script:LiveAdbPortProbe){ return @(& $Script:LiveAdbPortProbe) } + $out=New-Object System.Collections.Generic.List[string] + try{ + Get-NetTCPConnection -State Listen -ErrorAction Stop | + Where-Object { $_.LocalPort -ge 5550 -and $_.LocalPort -le 5900 } | + ForEach-Object { [void]$out.Add([string]$_.LocalPort) } + }catch{ + try{ # fallback for hosts without the NetTCPIP module + foreach($ln in (netstat -ano -p tcp 2>$null)){ + if($ln -match 'LISTENING' -and $ln -match ':(\d{4,5})\b'){ $p=[int]$Matches[1]; if($p -ge 5550 -and $p -le 5900){ [void]$out.Add([string]$p) } } + } + }catch{} + } + return @($out | Sort-Object -Unique) +} + +# Candidate adb ports for THIS instance, in priority order. status.adb_port is the runtime port +# BlueStacks writes on boot; adb_port is the Multi-Instance Manager's assigned port (clones get +# 5585/5595/...). Those (from BlueStacks' OWN conf) come FIRST -- authoritative when fresh. Then the +# actually-bound listening ports, which rescue us when the conf is stale. 5555 is the last resort. +# NEVER hardcoded to a single value; Boot-And-Wait tries each AND verifies identity, so a stale conf +# value or a foreign emulator on a port can't mislead. function Get-AdbPortCandidates{ $cands=New-Object System.Collections.Generic.List[string] if($Conf -and (Test-Path $Conf)){ @@ -386,6 +447,7 @@ function Get-AdbPortCandidates{ } }catch{} } + foreach($lp in @(Get-LiveAdbPorts)){ [void]$cands.Add($lp) } # live bound ports rescue a stale conf [void]$cands.Add('5555') $seen=@{}; $out=@(); foreach($c in $cands){ if(-not $seen.ContainsKey($c)){ $seen[$c]=$true; $out+=$c } } ,$out @@ -423,6 +485,7 @@ function AdbTry([string[]]$a,[int]$tries=4){ } function AdbSu([string]$serial,[string]$cmd){ AdbShellRetry $serial "/system/xbin/su -c '$cmd'" } function Boot-And-Wait([int]$timeoutSec=300){ + Initialize-AdbServer # pin HD-Adb to its private server port BEFORE any connect (version-conflict immunity) Say "[*] launching instance $Instance ..." Cyan Start-Process -FilePath $Player -ArgumentList @('--instance',$Instance) | Out-Null # Find the adb endpoint from BlueStacks' OWN per-instance conf ports (re-read each pass: BlueStacks From 3490727a22059fc73085b950c84e0e38cbf7425a Mon Sep 17 00:00:00 2001 From: Jordan Ye <79342877+Jordan231111@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:43:14 -0400 Subject: [PATCH 2/2] fix(adb): pick a FREE private server port + release it on exit; README->v11 Addresses "what if 15037 is already in use before my session": - Resolve-AdbServerPort probes 15037..15057 and uses the first FREE port (reusing our own HD-Adb server if already up), so a non-adb app or a foreign-version adb already on 15037 is skipped, never fought. Explicit ANDROID_ADB_SERVER_PORT still wins. Mirrored in bsr_engine.ps1. - Release our private adb server on exit (kill-server in a finally), so nothing of ours lingers on the port after the tool finishes. - Run-Resolve-Tests: +6 free-port cases (now 31). Re-embedded; suites green. - README: download link v9 -> v11; BlueStacks version 5.22.169 -> 5.22.210 (the version we ran the boot/adb path on this session). Verified live (real, non-stubbed probe): non-adb listener on 15037 -> tool picks 15038; our own HD-Adb server on 15037 -> reused. --- CHANGELOG.md | 16 ++++--- README.md | 12 ++--- blueStackRoot.cmd | 90 ++++++++++++++++++++++++++++++------- tests/Run-Resolve-Tests.ps1 | 19 ++++++++ todolist.md | 6 ++- tools/bsr_engine.ps1 | 26 ++++++++++- tools/bsr_magisk.ps1 | 64 +++++++++++++++++++------- 7 files changed, 188 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ade47f..0851557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,17 @@ BlueStacks' bundled **HD-Adb v1.0.36** were killing each other's adb **server** calls failed forever and `Boot-And-Wait` timed out. - 🛡️ **Version-conflict immunity.** The tool now pins BlueStacks' HD-Adb onto its **own private adb server - port** (`ANDROID_ADB_SERVER_PORT=15037`) and only ever uses `HD-Adb.exe` (never a system `adb.exe`). A - foreign-version adb on 5037 can no longer touch our server. *Proven on this machine:* with a v41 server - deliberately running on 5037, HD-Adb `getprop` on 15037 succeeded **30/30**; on the shared 5037 port it - failed **0/12** with the exact reporter error; the full `Boot-And-Wait` then booted the instance - end-to-end despite the v41 competitor. + port** and only ever uses `HD-Adb.exe` (never a system `adb.exe`). A foreign-version adb on 5037 can no + longer touch our server. *Proven on this machine:* with a v41 server deliberately running on 5037, HD-Adb + `getprop` on the private port succeeded **30/30**; on the shared 5037 port it failed **0/12** with the + exact reporter error; the full `Boot-And-Wait` then booted the instance end-to-end despite the v41 + competitor. +- 🔌 **Free-port selection (no new collisions).** The private port is **chosen free** from `15037–15057`: + if something already holds `15037` before the run — a non-adb app *or* a foreign-version adb — the tool + steps to the next free port instead of colliding with it (and reuses its own HD-Adb server if one is + already up). An explicit `ANDROID_ADB_SERVER_PORT` always wins. *Verified live:* a non-adb listener on + 15037 → tool picks 15038; its own server on 15037 → reused. The private server is **released when the + tool exits** (`kill-server` in a `finally`), so nothing of ours lingers on the port. - 🎯 **Port detection hardened.** `Get-AdbPortCandidates` now also consults the **actually-bound listening port** (`Get-NetTCPConnection`, band 5550-5900), merged *after* the `bluestacks.conf` `status.adb_port`/`adb_port` values (which stay authoritative). This rescues the boot wait when the conf diff --git a/README.md b/README.md index b1aabc6..6aff698 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

Stars Forks - BlueStacks + BlueStacks Magisk License

@@ -11,7 +11,7 @@ **Root BlueStacks 5 / MSI App Player with real Magisk — from one file, with no traces left behind.** **Run `blueStackRoot.cmd` as administrator, pick your Android version, and you're rooted.** **A Magisk Delta (Kitsune v31) build is now bundled inside the `.cmd` itself** — no separate Magisk -download, no other files, nothing to install. Works on the latest BlueStacks (5.22.169). +download, no other files, nothing to install. Works on the latest BlueStacks (5.22.210). --- @@ -19,7 +19,7 @@ download, no other files, nothing to install. Works on the latest BlueStacks (5. Works on the 64-bit BlueStacks instances — **Android 9, 11, and 13**. -**⬇️ [Download `blueStackRoot.cmd`](https://github.com/Jordan231111/BluestacksRoot/releases/download/v9/blueStackRoot.cmd)** — one file (~20 MB) with the **real Magisk APK embedded inside** — nothing else to download. *(All versions: [Releases page](https://github.com/Jordan231111/BluestacksRoot/releases).)* +**⬇️ [Download `blueStackRoot.cmd`](https://github.com/Jordan231111/BluestacksRoot/releases/download/v11/blueStackRoot.cmd)** — one file (~20 MB) with the **real Magisk APK embedded inside** — nothing else to download. *(All versions: [Releases page](https://github.com/Jordan231111/BluestacksRoot/releases).)* 1. **First, open the exact instance you want to root** — launch it from the Multi-Instance Manager and let it boot once. The tool roots the instance of your chosen Android version that you **opened most @@ -123,7 +123,7 @@ keeps closing** the moment you enable root, that's the **disk-integrity / anti-t have to.** `blueStackRoot` applies a **one-byte HD-Player anti-tamper patch** that bypasses the disk-integrity check, -so you can **root the *latest* BlueStacks (5.22.169) without downgrading** — and it installs **real Magisk** +so you can **root the *latest* BlueStacks (5.22.210) without downgrading** — and it installs **real Magisk** with **no traces**, not a detectable classic `su`. If `bst.feature.rooting` keeps **reverting to `0`** on launch, that's the same anti-tamper system, and this tool handles it for you. Full technical breakdown: [`docs/BLUESTACKS_ROOTING_DEEP_DIVE.md`](docs/BLUESTACKS_ROOTING_DEEP_DIVE.md) §2 (the single-byte patch). @@ -140,7 +140,7 @@ System* button also leaves `/data/adb/magisk` **empty**, so the daemon aborts wi incomplete". This tool solves all of that: 1. **One-byte patch** on `HD-Player.exe` (NOP the disk-integrity `JZ`, `74 5B → 90 90`) so a modified - `Root.vhd` is accepted and a tampered `/system` boots. It's a version-proof byte-scan; verified on 5.22.169. + `Root.vhd` is accepted and a tampered `/system` boots. It's a **version-proof byte-scan**, so it tracks new BlueStacks builds — run on 5.22.169 and the current **5.22.210**. 2. **Offline write:** using the embedded `debugfs`, it writes Magisk's `/system` payload + a **gated** `bootanim.rc` directly into `Root.vhd` — no Windows ext4 driver needed. 3. **The breakthrough:** it boots once with a tiny **bootstrap su** to populate **`/data/adb/magisk`** (the @@ -246,4 +246,4 @@ BlueStacks illegally tampered, Android system doesn't meet security requirements detected, BlueStacks instance keeps closing after root, BlueStacks disk integrity check bypass, root latest BlueStacks without downgrading, fix illegally tampered BlueStacks, bst.feature.rooting reverts to 0, "disk file have been illegally tampered with", Verified the disk integrity, BlueStacks disk integrity check, -root BlueStacks 5.22.169, root BlueStacks latest version 2026. +root BlueStacks 5.22.169, root BlueStacks 5.22.210, root BlueStacks latest version 2026. diff --git a/blueStackRoot.cmd b/blueStackRoot.cmd index 296e12f..f83ca41 100644 --- a/blueStackRoot.cmd +++ b/blueStackRoot.cmd @@ -1156,6 +1156,30 @@ function AdbRaw([string[]]$a) { function AdbS([string[]]$a) { return AdbRaw (@('-s', $Script:Serial) + $a) } function AdbShell([string]$cmd) { return AdbS @('shell', $cmd) } +# Map listening ports in our private band to 'ours' (a reusable HD-Adb.exe server) or 'other' (a non-adb +# app, or a foreign-version adb we must not fight). Absent = free. (Mirrors tools\bsr_magisk.ps1.) +function Get-AdbServerPortState { + $state = @{} + try { + foreach ($c in (Get-NetTCPConnection -State Listen -ErrorAction Stop)) { + $p = [int]$c.LocalPort; if ($p -lt 15037 -or $p -gt 15057) { continue } + $path = $null; try { $path = (Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).Path } catch { } + $state[$p] = if ($path -and ((Split-Path -Leaf $path) -ieq 'HD-Adb.exe')) { 'ours' } else { 'other' } + } + } catch { + try { foreach ($ln in (netstat -ano -p tcp 2>$null)) { if ($ln -match 'LISTENING' -and $ln -match ':(\d{4,5})\b') { $p = [int]$Matches[1]; if ($p -ge 15037 -and $p -le 15057 -and -not $state.ContainsKey($p)) { $state[$p] = 'other' } } } } catch { } + } + return $state +} +# Pick a private adb-server port that is FREE (or already hosts our own HD-Adb server), so we never +# collide with something already using 15037 before this run. Explicit env override wins. +function Resolve-AdbServerPort { + if ($env:ANDROID_ADB_SERVER_PORT) { return $env:ANDROID_ADB_SERVER_PORT } + $state = Get-AdbServerPortState + foreach ($p in 15037..15057) { $s = $state[$p]; if (-not $s -or $s -eq 'ours') { return "$p" } } + return '15037' +} + # Connect to the instance's adb endpoint and wait until Android finishes booting. function Connect-WaitBoot([int]$timeoutSec) { $port = if ($AdbPort) { $AdbPort } else { '5555' } @@ -1163,7 +1187,7 @@ function Connect-WaitBoot([int]$timeoutSec) { # Isolate HD-Adb on its own server port so a different-version system adb (e.g. Android SDK # platform-tools) on the default 5037 can't kill our server mid-run (the version-mismatch churn # that makes getprop/shell calls fail and a booted instance look "not adb-reachable"). - if (-not $env:ANDROID_ADB_SERVER_PORT) { $env:ANDROID_ADB_SERVER_PORT = '15037' } + if (-not $env:ANDROID_ADB_SERVER_PORT) { $env:ANDROID_ADB_SERVER_PORT = (Resolve-AdbServerPort) } AdbRaw @('start-server') | Out-Null $sw = [System.Diagnostics.Stopwatch]::StartNew() $connected = $false @@ -38423,15 +38447,42 @@ function Set-ConfKey($key,$val){ # ---- adb helpers ---- function Adb([string[]]$a){ (& $Adb @a 2>&1 | Out-String) } -# Force HD-Adb onto its OWN private server port so a DIFFERENT-version system adb on the default 5037 -# (e.g. Android SDK platform-tools, which is v1.0.41 vs BlueStacks' HD-Adb v1.0.36) can't kill our -# server mid-run. That version clash ("server version doesn't match; killing...") silently breaks -# getprop/shell calls -- which is exactly how a fully-booted instance still trips Boot-And-Wait's -# "did not become adb-reachable" throw. A private port ALSO gives us a clean transport table (no stale -# 'offline' devices left by other adb sessions). HD-Adb v1.0.36 honours ANDROID_ADB_SERVER_PORT. +# --- adb server isolation + private-port selection (version-conflict immunity) --- $Script:AdbServerInit = $false +# Test seam: tests set this to a scriptblock returning @{ = 'ours'|'other' } (absent key = free). +$Script:AdbServerPortProbe = $null +# Map listening ports in our private band to 'ours' (an HD-Adb.exe server we can safely reuse) or +# 'other' (anything else -- a non-adb app, OR a foreign-version adb we must NOT fight). Absent = free. +function Get-AdbServerPortState{ + $state=@{} + try{ + foreach($c in (Get-NetTCPConnection -State Listen -ErrorAction Stop)){ + $p=[int]$c.LocalPort; if($p -lt 15037 -or $p -gt 15057){ continue } + $path=$null; try{ $path=(Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).Path }catch{} + $state[$p] = if($path -and ((Split-Path -Leaf $path) -ieq 'HD-Adb.exe')){ 'ours' } else { 'other' } + } + }catch{ # no NetTCPIP module: fall back to netstat (owner unknown -> treat any listener as 'other') + try{ foreach($ln in (netstat -ano -p tcp 2>$null)){ if($ln -match 'LISTENING' -and $ln -match ':(\d{4,5})\b'){ $p=[int]$Matches[1]; if($p -ge 15037 -and $p -le 15057 -and -not $state.ContainsKey($p)){ $state[$p]='other' } } } }catch{} + } + return $state +} +# Pick a private adb-server port that is FREE (or already hosts our own HD-Adb server). THIS is what +# handles "something is already using 15037 before my session": a non-adb app or a foreign-version adb +# on a candidate port is skipped, so we never collide with it and never kill it. An explicitly-set +# ANDROID_ADB_SERVER_PORT always wins (escape hatch). 15037..15057 gives 21 ports of headroom. +function Resolve-AdbServerPort{ + if($env:ANDROID_ADB_SERVER_PORT){ return $env:ANDROID_ADB_SERVER_PORT } + $state = if($Script:AdbServerPortProbe){ & $Script:AdbServerPortProbe } else { Get-AdbServerPortState } + foreach($p in 15037..15057){ $s=$state[$p]; if(-not $s -or $s -eq 'ours'){ return "$p" } } + return '15037' # band fully occupied (extreme) -- proceed; a later adb error would surface it clearly +} +# Force HD-Adb onto our resolved private server port so a DIFFERENT-version system adb on the default +# 5037 (Android SDK platform-tools v1.0.41 vs HD-Adb v1.0.36) can't kill our server mid-run. The clash +# ("server version doesn't match; killing...") silently breaks getprop/shell calls -- exactly how a +# fully-booted instance still trips Boot-And-Wait's "did not become adb-reachable" throw. A private +# port also gives us a clean transport table (no stale 'offline' devices). HD-Adb honours the env var. function Initialize-AdbServer{ - if(-not $env:ANDROID_ADB_SERVER_PORT){ $env:ANDROID_ADB_SERVER_PORT = '15037' } + if(-not $env:ANDROID_ADB_SERVER_PORT){ $env:ANDROID_ADB_SERVER_PORT = (Resolve-AdbServerPort) } if(-not (Test-Path -LiteralPath $Adb)){ return } if(-not $Script:AdbServerInit){ & $Adb @('kill-server') *>$null; $Script:AdbServerInit=$true } # clean slate on OUR port & $Adb @('start-server') *>$null # idempotent; also revives the server after Kill-BlueStacks nukes HD-Adb @@ -38776,14 +38827,21 @@ function Do-Undo { # Only dispatch when run normally (-File / &). When DOT-SOURCED (. bsr_magisk.ps1) -- e.g. by the test # suite to unit-test the resolver functions -- skip the pipeline so nothing boots or writes. if ($MyInvocation.InvocationName -ne '.') { - switch($Action){ - 'Prep' { Do-Prep } - 'Data' { Do-Data } - 'Clean' { Do-Clean } - 'Finalize' { Do-Finalize } - 'Verify' { Do-Verify } - 'Auto' { Do-Prep; Do-Data; Do-Clean; Do-Finalize; Do-Verify } - 'Undo' { Do-Undo } + try { + switch($Action){ + 'Prep' { Do-Prep } + 'Data' { Do-Data } + 'Clean' { Do-Clean } + 'Finalize' { Do-Finalize } + 'Verify' { Do-Verify } + 'Auto' { Do-Prep; Do-Data; Do-Clean; Do-Finalize; Do-Verify } + 'Undo' { Do-Undo } + } + } finally { + # Free our private adb-server port on the way out. adb normally leaves its server running + # forever; we only ever started one if Initialize-AdbServer ran (an online action), so tidy it + # up so nothing of ours lingers on the port after the tool exits. (runs even if an action threw) + if ($Script:AdbServerInit -and (Test-Path -LiteralPath $Adb)) { & $Adb @('kill-server') *>$null } } } __BSR_MAGISK_END__ diff --git a/tests/Run-Resolve-Tests.ps1 b/tests/Run-Resolve-Tests.ps1 index a0eed3d..b806cdd 100644 --- a/tests/Run-Resolve-Tests.ps1 +++ b/tests/Run-Resolve-Tests.ps1 @@ -126,6 +126,25 @@ $script:LiveAdbPortProbe = { @('5645', '5646') } Eq 'cands: dedup live vs conf (5645 not repeated)' '5645,5646,5555' ((Cands (New-FakeData 'Rvc64_9' @{ 'status.adb_port' = '5645' } 'c') 'Rvc64_9') -join ',') $script:LiveAdbPortProbe = { @() } # reset so later sections are unaffected +Write-Host "`n=== adb server port: free-port probe (handles a port already in use) ===" -ForegroundColor Cyan +$savedPort = $env:ANDROID_ADB_SERVER_PORT +Remove-Item Env:\ANDROID_ADB_SERVER_PORT -ErrorAction SilentlyContinue +$script:AdbServerPortProbe = { @{} } # nothing listening -> base port +Eq 'adb-port: all free -> base 15037' '15037' (Resolve-AdbServerPort) +$script:AdbServerPortProbe = { @{ 15037 = 'other' } } # a stranger holds 15037 +Eq 'adb-port: 15037 taken by a stranger -> 15038' '15038' (Resolve-AdbServerPort) +$script:AdbServerPortProbe = { @{ 15037 = 'other'; 15038 = 'other' } } # two strangers -> skip both +Eq 'adb-port: skips two taken ports -> 15039' '15039' (Resolve-AdbServerPort) +$script:AdbServerPortProbe = { @{ 15037 = 'ours' } } # our own HD-Adb server -> reuse it +Eq 'adb-port: our own HD-Adb server -> reuse 15037' '15037' (Resolve-AdbServerPort) +$script:AdbServerPortProbe = { @{ 15037 = 'other'; 15039 = 'ours' } } # first FREE wins over a later reusable +Eq 'adb-port: stranger on 15037 -> first free 15038' '15038' (Resolve-AdbServerPort) +$env:ANDROID_ADB_SERVER_PORT = '5037' # explicit override is honoured +Eq 'adb-port: explicit env override is respected' '5037' (Resolve-AdbServerPort) +$script:AdbServerPortProbe = $null +Remove-Item Env:\ANDROID_ADB_SERVER_PORT -ErrorAction SilentlyContinue +if ($savedPort) { $env:ANDROID_ADB_SERVER_PORT = $savedPort } + Write-Host "`n=== DataRoot resolution (orchestrator Get-DataRoot, custom/registry) ===" -ForegroundColor Cyan Eq 'DataRoot: ...\Engine is normalized to base' 'X:\Custom\BS' (Get-DataRoot ([pscustomobject]@{ DataDir = 'X:\Custom\BS\Engine'; UserDefinedDir = $null })) Eq 'DataRoot: plain data dir kept as-is' 'X:\Custom\Data' (Get-DataRoot ([pscustomobject]@{ DataDir = 'X:\Custom\Data'; UserDefinedDir = $null })) diff --git a/todolist.md b/todolist.md index 249e38e..33a92b6 100644 --- a/todolist.md +++ b/todolist.md @@ -5,10 +5,12 @@ was timing out ("did not become adb-reachable") on hosts that also have a *different-version* system `adb` (Android SDK v1.0.41 vs BlueStacks HD-Adb v1.0.36) fighting over the default server port 5037 (*"server version doesn't match; killing…"*). Fixed by pinning HD-Adb to a private - `ANDROID_ADB_SERVER_PORT=15037` + only ever using `HD-Adb.exe`, and by merging the **live-bound** + a private `ANDROID_ADB_SERVER_PORT` (auto-picked FREE from 15037-15057, so it never collides with + something already on 15037) + only ever using `HD-Adb.exe`, and by merging the **live-bound** listening port into `Get-AdbPortCandidates` (rescues a stale `status.adb_port`). Proven live: 30/30 isolated getprop OK with a v41 server on 5037 (0/12 on the shared port), 20/20 stable port detection, - full Boot-And-Wait end-to-end PASS. +3 resolve tests (25), re-embedded, all suites green (28+25). + free-port probe steps 15037->15038 around a stranger, full Boot-And-Wait end-to-end PASS. +9 resolve + tests (31), re-embedded, all suites green (28+31). - [x] **bundled a custom Kitsune v31 build so the in-app DenyList works with ReZygisk/NeoZygisk** — the deny module now stores entries in the `denylist` table (not `hidelist`), built from `Jordan231111/KitsuneMagisk@25fa2159f`. Re-embedded (`reembed-apk.ps1`, SHA `fac319d2…`, round-trip OK), diff --git a/tools/bsr_engine.ps1 b/tools/bsr_engine.ps1 index 5e93581..19716e8 100644 --- a/tools/bsr_engine.ps1 +++ b/tools/bsr_engine.ps1 @@ -883,6 +883,30 @@ function AdbRaw([string[]]$a) { function AdbS([string[]]$a) { return AdbRaw (@('-s', $Script:Serial) + $a) } function AdbShell([string]$cmd) { return AdbS @('shell', $cmd) } +# Map listening ports in our private band to 'ours' (a reusable HD-Adb.exe server) or 'other' (a non-adb +# app, or a foreign-version adb we must not fight). Absent = free. (Mirrors tools\bsr_magisk.ps1.) +function Get-AdbServerPortState { + $state = @{} + try { + foreach ($c in (Get-NetTCPConnection -State Listen -ErrorAction Stop)) { + $p = [int]$c.LocalPort; if ($p -lt 15037 -or $p -gt 15057) { continue } + $path = $null; try { $path = (Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).Path } catch { } + $state[$p] = if ($path -and ((Split-Path -Leaf $path) -ieq 'HD-Adb.exe')) { 'ours' } else { 'other' } + } + } catch { + try { foreach ($ln in (netstat -ano -p tcp 2>$null)) { if ($ln -match 'LISTENING' -and $ln -match ':(\d{4,5})\b') { $p = [int]$Matches[1]; if ($p -ge 15037 -and $p -le 15057 -and -not $state.ContainsKey($p)) { $state[$p] = 'other' } } } } catch { } + } + return $state +} +# Pick a private adb-server port that is FREE (or already hosts our own HD-Adb server), so we never +# collide with something already using 15037 before this run. Explicit env override wins. +function Resolve-AdbServerPort { + if ($env:ANDROID_ADB_SERVER_PORT) { return $env:ANDROID_ADB_SERVER_PORT } + $state = Get-AdbServerPortState + foreach ($p in 15037..15057) { $s = $state[$p]; if (-not $s -or $s -eq 'ours') { return "$p" } } + return '15037' +} + # Connect to the instance's adb endpoint and wait until Android finishes booting. function Connect-WaitBoot([int]$timeoutSec) { $port = if ($AdbPort) { $AdbPort } else { '5555' } @@ -890,7 +914,7 @@ function Connect-WaitBoot([int]$timeoutSec) { # Isolate HD-Adb on its own server port so a different-version system adb (e.g. Android SDK # platform-tools) on the default 5037 can't kill our server mid-run (the version-mismatch churn # that makes getprop/shell calls fail and a booted instance look "not adb-reachable"). - if (-not $env:ANDROID_ADB_SERVER_PORT) { $env:ANDROID_ADB_SERVER_PORT = '15037' } + if (-not $env:ANDROID_ADB_SERVER_PORT) { $env:ANDROID_ADB_SERVER_PORT = (Resolve-AdbServerPort) } AdbRaw @('start-server') | Out-Null $sw = [System.Diagnostics.Stopwatch]::StartNew() $connected = $false diff --git a/tools/bsr_magisk.ps1 b/tools/bsr_magisk.ps1 index 0e250cb..69c8fb3 100644 --- a/tools/bsr_magisk.ps1 +++ b/tools/bsr_magisk.ps1 @@ -389,15 +389,42 @@ function Set-ConfKey($key,$val){ # ---- adb helpers ---- function Adb([string[]]$a){ (& $Adb @a 2>&1 | Out-String) } -# Force HD-Adb onto its OWN private server port so a DIFFERENT-version system adb on the default 5037 -# (e.g. Android SDK platform-tools, which is v1.0.41 vs BlueStacks' HD-Adb v1.0.36) can't kill our -# server mid-run. That version clash ("server version doesn't match; killing...") silently breaks -# getprop/shell calls -- which is exactly how a fully-booted instance still trips Boot-And-Wait's -# "did not become adb-reachable" throw. A private port ALSO gives us a clean transport table (no stale -# 'offline' devices left by other adb sessions). HD-Adb v1.0.36 honours ANDROID_ADB_SERVER_PORT. +# --- adb server isolation + private-port selection (version-conflict immunity) --- $Script:AdbServerInit = $false +# Test seam: tests set this to a scriptblock returning @{ = 'ours'|'other' } (absent key = free). +$Script:AdbServerPortProbe = $null +# Map listening ports in our private band to 'ours' (an HD-Adb.exe server we can safely reuse) or +# 'other' (anything else -- a non-adb app, OR a foreign-version adb we must NOT fight). Absent = free. +function Get-AdbServerPortState{ + $state=@{} + try{ + foreach($c in (Get-NetTCPConnection -State Listen -ErrorAction Stop)){ + $p=[int]$c.LocalPort; if($p -lt 15037 -or $p -gt 15057){ continue } + $path=$null; try{ $path=(Get-Process -Id $c.OwningProcess -ErrorAction SilentlyContinue).Path }catch{} + $state[$p] = if($path -and ((Split-Path -Leaf $path) -ieq 'HD-Adb.exe')){ 'ours' } else { 'other' } + } + }catch{ # no NetTCPIP module: fall back to netstat (owner unknown -> treat any listener as 'other') + try{ foreach($ln in (netstat -ano -p tcp 2>$null)){ if($ln -match 'LISTENING' -and $ln -match ':(\d{4,5})\b'){ $p=[int]$Matches[1]; if($p -ge 15037 -and $p -le 15057 -and -not $state.ContainsKey($p)){ $state[$p]='other' } } } }catch{} + } + return $state +} +# Pick a private adb-server port that is FREE (or already hosts our own HD-Adb server). THIS is what +# handles "something is already using 15037 before my session": a non-adb app or a foreign-version adb +# on a candidate port is skipped, so we never collide with it and never kill it. An explicitly-set +# ANDROID_ADB_SERVER_PORT always wins (escape hatch). 15037..15057 gives 21 ports of headroom. +function Resolve-AdbServerPort{ + if($env:ANDROID_ADB_SERVER_PORT){ return $env:ANDROID_ADB_SERVER_PORT } + $state = if($Script:AdbServerPortProbe){ & $Script:AdbServerPortProbe } else { Get-AdbServerPortState } + foreach($p in 15037..15057){ $s=$state[$p]; if(-not $s -or $s -eq 'ours'){ return "$p" } } + return '15037' # band fully occupied (extreme) -- proceed; a later adb error would surface it clearly +} +# Force HD-Adb onto our resolved private server port so a DIFFERENT-version system adb on the default +# 5037 (Android SDK platform-tools v1.0.41 vs HD-Adb v1.0.36) can't kill our server mid-run. The clash +# ("server version doesn't match; killing...") silently breaks getprop/shell calls -- exactly how a +# fully-booted instance still trips Boot-And-Wait's "did not become adb-reachable" throw. A private +# port also gives us a clean transport table (no stale 'offline' devices). HD-Adb honours the env var. function Initialize-AdbServer{ - if(-not $env:ANDROID_ADB_SERVER_PORT){ $env:ANDROID_ADB_SERVER_PORT = '15037' } + if(-not $env:ANDROID_ADB_SERVER_PORT){ $env:ANDROID_ADB_SERVER_PORT = (Resolve-AdbServerPort) } if(-not (Test-Path -LiteralPath $Adb)){ return } if(-not $Script:AdbServerInit){ & $Adb @('kill-server') *>$null; $Script:AdbServerInit=$true } # clean slate on OUR port & $Adb @('start-server') *>$null # idempotent; also revives the server after Kill-BlueStacks nukes HD-Adb @@ -742,13 +769,20 @@ function Do-Undo { # Only dispatch when run normally (-File / &). When DOT-SOURCED (. bsr_magisk.ps1) -- e.g. by the test # suite to unit-test the resolver functions -- skip the pipeline so nothing boots or writes. if ($MyInvocation.InvocationName -ne '.') { - switch($Action){ - 'Prep' { Do-Prep } - 'Data' { Do-Data } - 'Clean' { Do-Clean } - 'Finalize' { Do-Finalize } - 'Verify' { Do-Verify } - 'Auto' { Do-Prep; Do-Data; Do-Clean; Do-Finalize; Do-Verify } - 'Undo' { Do-Undo } + try { + switch($Action){ + 'Prep' { Do-Prep } + 'Data' { Do-Data } + 'Clean' { Do-Clean } + 'Finalize' { Do-Finalize } + 'Verify' { Do-Verify } + 'Auto' { Do-Prep; Do-Data; Do-Clean; Do-Finalize; Do-Verify } + 'Undo' { Do-Undo } + } + } finally { + # Free our private adb-server port on the way out. adb normally leaves its server running + # forever; we only ever started one if Initialize-AdbServer ran (an online action), so tidy it + # up so nothing of ours lingers on the port after the tool exits. (runs even if an action threw) + if ($Script:AdbServerInit -and (Test-Path -LiteralPath $Adb)) { & $Adb @('kill-server') *>$null } } }