diff --git a/CHANGELOG.md b/CHANGELOG.md index 0851557..5dc40d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ Player — from one file, fully automatically. Releases are grouped by the BlueS --- +## v12 — Magisk is the SOLE root: scrub any competing su + Verify fails on one · 2026-06-02 + +Fixes Magisk reporting **"Abnormal State — a su binary not from Magisk has been detected"** on an instance +whose shared master `Root.vhd` still carried a **classic/engine `su`** at `/system/xbin/su` (the old +non-Magisk root method — e.g. left behind by the legacy live-E2E harness). The Magisk pipeline scrubbed +its own *bootstrap* su but never removed a pre-existing `/system/xbin/su`, and **Verify only swept for the +bootstrap su's hash**, so a competing su passed silently. (Git shows **no commit ever changed su handling**; +the v11 adb fix simply let the pipeline *complete*, so Magisk finally booted and flagged the leftover.) + +- 🧹 **Prep + Clean now scrub `/system/xbin/su`** (and `daemonsu`) from the master, so Magisk's own su + (`/system/bin/su` → magisk, `/sbin/su` → magisk) is the only one. **Re-running Clean repairs an + already-rooted instance** — verified live: the stray su was removed and the instance returned to a clean + state. +- 🔎 **Verify now FAILS on ANY competing su.** It enumerates every su in the standard PATH dirs and flags + anything that isn't a symlink to magisk (new pure, unit-tested `Find-StraySu`). No more silent PASS with + a foreign su present. +- 🧪 **Live-E2E rewritten + de-footgunned.** `tests/Run-Live-E2E.ps1` used to root via the engine's + **legacy classic-su path** (`-Action AdbRoot`) — which *installs* a competing `/system/xbin/su` and is + not what the tool ships. It now drives the real `bsr_magisk.ps1 -Action Auto` pipeline, **asserts VERIFY + PASS + no competing su** (across a reboot), uses the correct package `io.github.huskydg.magisk`, and + reverts via Magisk **Undo**. +- ✅ **Tests:** `Run-Resolve-Tests.ps1` +7 `Find-StraySu` cases (38 total); `Run-Tests.ps1` (28) and + `Check-Embedded-Sync.ps1` green; re-embedded into `blueStackRoot.cmd`. + +--- + ## 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 diff --git a/blueStackRoot.cmd b/blueStackRoot.cmd index f83ca41..b41eec1 100644 --- a/blueStackRoot.cmd +++ b/blueStackRoot.cmd @@ -271,1098 +271,1098 @@ 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) } - -# 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' } - $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 = (Resolve-AdbServerPort) } - 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) } + +# 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' } + $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 = (Resolve-AdbServerPort) } + 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__ @@ -38056,793 +38056,843 @@ 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" } -# 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) } - -# --- 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 = (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 -} - -# 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 '.') { - 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.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) } + +# --- 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 = (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 +} + +# 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') + # scrub any pre-existing CLASSIC/engine su from the shared master. An older non-Magisk root + # (engine Root/AdbRoot -- e.g. the legacy live-E2E) leaves a setuid /system/xbin/su, which makes + # Magisk report "Abnormal State -- a su binary not from Magisk has been detected". Magisk's own su + # live at /system/bin/su -> magisk and /sbin/su -> magisk, so /system/xbin/su is never ours. + $cmds += @('cd /android/system/xbin','rm su','rm daemonsu') + # 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','stat /android/system/xbin/su') + $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', + # also scrub any leftover CLASSIC/engine su so Magisk is the SOLE root (no "Abnormal State") + 'cd /android/system/xbin','rm su','rm daemonsu', + 'stat /android/system/bin/bindmount','stat /android/system/etc/bsr_su','stat /android/system/xbin/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 '(?im)bsr_su:?\s*File not found') + $xbinSuGone = ($out -match '(?im)xbin/su:?\s*File not found') -or ($out -match '(?im)/android/system/xbin/su[^\n]*not found') + if(-not $bmOk){ Say '[!] stock bindmount not detected after restore' Red } + if(-not $xbinSuGone){ Say '[!] /system/xbin/su still present after scrub' Red } + return ($bmOk -and $suGone -and $xbinSuGone) + } $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 +} + +# Classify a device su inventory (lines "|link|" or "|file|") and return the +# NON-Magisk su paths. Magisk's own su are symlinks whose target contains 'magisk'; a real su binary +# (or a symlink pointing elsewhere) is a COMPETING root -- that is exactly what trips Magisk's +# "Abnormal State -- a su binary not from Magisk has been detected". Pure/string-only => unit-testable. +function Find-StraySu([string]$scan){ + $stray=@() + foreach($line in ($scan -split "`n")){ + $line=$line.Trim(); if(-not $line){ continue } + $p=$line.Split('|') + if($p.Count -lt 2){ continue } + $path=$p[0].Trim(); $kind=$p[1].Trim(); $target= if($p.Count -ge 3){ $p[2].Trim() } else { '' } + if(-not $path){ continue } + if($kind -eq 'link'){ if($target -notmatch 'magisk'){ $stray+=$path } } + elseif($kind -eq 'file'){ $stray+=$path } + } + ,$stray +} + +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() + # Enumerate EVERY su in the standard PATH dirs and classify each: a symlink to magisk is ours, + # anything else is a competing root (the cause of Magisk's "Abnormal State"). Pushed as a script + # file (not inline su -c '...') because the loop's semicolons don't survive PS -> adb -> device quoting. + $scanSh = @' +for f in /system/bin/su /system/xbin/su /sbin/su /vendor/bin/su /odm/bin/su /system_ext/bin/su /product/bin/su /debug_ramdisk/su; do + if [ -L "$f" ]; then echo "$f|link|$(readlink "$f")" + elif [ -e "$f" ]; then echo "$f|file|" + fi +done +'@ -replace "`r`n","`n" + $scanFile = Join-Path $env:TEMP 'bsr_work\bsr_suscan.sh' + New-Item -ItemType Directory -Path (Split-Path $scanFile) -Force | Out-Null + [System.IO.File]::WriteAllText($scanFile,$scanSh,(New-Object System.Text.UTF8Encoding($false))) + AdbTry @('-s',$serial,'push',(Fwd $scanFile),'/data/local/tmp/bsr_suscan.sh') | Out-Null + $scan = (& $Adb @('-s',$serial,'shell','su -c "sh /data/local/tmp/bsr_suscan.sh"') 2>&1 | Out-String) + & $Adb @('-s',$serial,'shell','rm -f /data/local/tmp/bsr_suscan.sh') *>$null + $stray = Find-StraySu $scan + $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 " su inventory :" + foreach($l in ($scan -split "`n")){ $l=$l.Trim(); if($l){ Say " $l" DarkGray } } + if($stray.Count){ Say (" competing su : {0} <-- NOT from Magisk" -f ($stray -join ', ')) Red } + else { Say " competing su : none (good)" Green } + Say (" bsr_su sweep : {0}" -f ($sweep -replace "`r?`n",' ').Trim()) + if($id -match 'uid=0' -and $whi -match 'magisk' -and $stray.Count -eq 0 -and $sweep -notmatch 'TRACE:'){ + Say '[+] VERIFY PASS: Magisk is the sole root; no competing su, no bsr_su traces.' Green + } else { + Say '[!] VERIFY FAIL: review the above.' Red + if($stray.Count){ Say ' -> a competing su is present; re-run Clean (it now scrubs /system/xbin/su) then reboot.' 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 '.') { + 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/docs/BLUESTACKS_ROOTING_DEEP_DIVE.md b/docs/BLUESTACKS_ROOTING_DEEP_DIVE.md index 362f482..4709ef5 100644 --- a/docs/BLUESTACKS_ROOTING_DEEP_DIVE.md +++ b/docs/BLUESTACKS_ROOTING_DEEP_DIVE.md @@ -297,6 +297,7 @@ Per‑instance root = presence of `/data/adb/.bsr_root`. **Proven:** `Rvc64` (fl | 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 | +| Magisk "Abnormal State — su not from Magisk" though Verify "passed" | a classic/engine `su` left at `/system/xbin/su` on the **shared master** (old non‑Magisk root / the legacy classic‑su live‑E2E); Prep/Clean never scrubbed it and Verify only swept the *bootstrap* su's hash | Prep + Clean now `rm /system/xbin/su` (+`daemonsu`); Verify enumerates **every** su and FAILS on any non‑Magisk one (`Find-StraySu`, unit‑tested); the live‑E2E now drives the **Magisk pipeline** (not classic su) and asserts no competing su — verified live (stray su scrubbed → VERIFY PASS) | --- diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 90b7ab5..e392a18 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -152,7 +152,7 @@ device after completion: **only Magisk's**. No DiskRW, no engine‑su, no daemon |---|---|---| | `bootstrap su not root` during **Data** | patch didn't apply, or `enable_root_access≠1`, or `bindmount` not hijacked | re‑run **Prep**; confirm `HD-Player.exe` = `84 C0 90 90` | | Magisk app: *Magisk environment incomplete* | `/data/adb/magisk` not populated | re‑run **Data** (it populates it) | -| 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**) | +| Magisk app: *Abnormal State — a su binary not from Magisk* | a **competing `/system/xbin/su`** on the shared master (an old non‑Magisk/classic root, e.g. a prior engine root or the legacy live‑E2E) — Magisk's own su are `/system/bin/su`→magisk + `/sbin/su`→magisk | **fixed in v12**: Prep/Clean now scrub `/system/xbin/su` and Verify FAILS on any non‑Magisk su. To repair an existing instance, **re‑run Clean** (it removes the stray su) and reboot | | `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-Live-E2E.ps1 b/tests/Run-Live-E2E.ps1 index 2f8424e..0854ff9 100644 --- a/tests/Run-Live-E2E.ps1 +++ b/tests/Run-Live-E2E.ps1 @@ -1,57 +1,56 @@ <# - Run-Live-E2E.ps1 -- REAL end-to-end proof against a LIVE BlueStacks instance. - - This actually roots an instance and proves Magisk works across a reboot, the way - a human would, but scripted + screenshotted so the result is visible and checkable. - - !! THIS MODIFIES A REAL INSTANCE. Use a THROWAWAY CLONE you created in the - !! Multi-Instance Manager. Do NOT point it at an instance you care about. - !! (Pass -Revert afterwards, or use the cmd's Undo, to put it back.) - - What it does, in order (Action = Root, the default): - 1. kill emulator procs, resolve paths for -Instance - 2. .bstk -> Normal, conf root/adb flags -> 1, HD-Player integrity bypass - 3. boot the instance, wait for Android, push the embedded su over HD-Adb - (engine Action AdbRoot) and prove `su -c id` => uid=0 - 4. adb install the Magisk APK, launch it, screenshot the app - 5. adb reboot, wait for boot, prove su STILL gives uid=0, read `magisk -V`, - screenshot the Magisk app again - Screenshots land in -Shots (default tests\live-shots\). Read them to SEE the - Magisk "Installed" state before/after reboot. - - After you have visually confirmed Magisk's "Direct Install to system" + the - SU-conflict message, run with -Revert to undo (or use the .cmd Undo menu). + Run-Live-E2E.ps1 -- REAL end-to-end proof against a LIVE BlueStacks instance, through the + SHIPPED Magisk pipeline (the same `-Action Auto` the .cmd runs). It proves Magisk ends up as the + SOLE root with NO competing su -- i.e. it would FAIL on the "Abnormal State -- a su binary not from + Magisk has been detected" regression -- and that this survives a reboot. + + !! THIS MODIFIES A REAL INSTANCE. Use a THROWAWAY CLONE you created in the Multi-Instance Manager. + !! Do NOT point it at an instance you care about. Run with -Revert afterwards (Magisk Undo). + + History note: a previous version of this harness rooted via the engine's *legacy classic-su* path + (`-Action AdbRoot`), which installs a setuid /system/xbin/su -- a competing root that makes Magisk + report "Abnormal State". That was the wrong thing to test (it is not what the tool ships) and it + left that su behind on the shared master. This version drives the actual Magisk pipeline and asserts + the no-competing-su invariant instead. + + What it does (default): + 1. resolve paths for -Instance (engine Resolve) + 2. run bsr_magisk.ps1 -Action Auto (Prep -> Data -> Clean -> Finalize -> Verify), self-extracting + the embedded debugfs / bootstrap su / Magisk APK from the .cmd -- exactly the shipped path + 3. ASSERT the pipeline reached "VERIFY PASS" and reported NO competing su + 4. independently re-check over adb: uid=0, /system/bin/su -> magisk, NO /system/xbin/su, + `magisk -c` is the kitsune build, the manager package is installed; screenshot the app + 5. reboot and re-assert uid=0 + still NO /system/xbin/su (persistence) Usage (Administrator): - powershell -NoProfile -ExecutionPolicy Bypass -File tests\Run-Live-E2E.ps1 -Instance Rvc64_2 - ... -Revert -Instance Rvc64_2 + powershell -NoProfile -ExecutionPolicy Bypass -File tests\Run-Live-E2E.ps1 -Instance Tiramisu64_9 + ... -Revert -Instance Tiramisu64_9 #> [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Instance, - [string]$Engine, [string]$Cmd, + [string]$Engine, [string]$Adb, [string]$Player, [string]$InstallDir, [string]$DataDir, - [string]$Magisk, [string]$Shots, [int]$BootTimeout = 300, - [switch]$Revert, - [switch]$SkipMagisk, - [switch]$Unattended # don't pause for the manual Magisk "Direct Install" GUI tap + [switch]$Revert ) $ErrorActionPreference = 'Stop' $here = if ($PSScriptRoot) { $PSScriptRoot } elseif ($PSCommandPath) { Split-Path -Parent $PSCommandPath } else { (Get-Location).Path } $repo = Split-Path -Parent $here -if (-not $Engine) { $Engine = Join-Path $repo 'tools\bsr_engine.ps1' } if (-not $Cmd) { $Cmd = Join-Path $repo 'blueStackRoot.cmd' } +if (-not $Engine) { $Engine = Join-Path $repo 'tools\bsr_engine.ps1' } +$Magisk = Join-Path $repo 'tools\bsr_magisk.ps1' if (-not $Shots) { $Shots = Join-Path $here 'live-shots' } if (-not (Test-Path $Shots)) { New-Item -ItemType Directory -Path $Shots -Force | Out-Null } +$PKG = 'io.github.huskydg.magisk' # the bundled Kitsune Mask package (NOT com.topjohnwu.magisk) # ---- registry discovery (nxt then msi5) ---- -function Reg1($key, $name) { try { (Get-ItemProperty -Path $key -Name $name -EA Stop).$name } catch { $null } } +function Reg1($k, $n) { try { (Get-ItemProperty -Path $k -Name $n -EA Stop).$n } catch { $null } } if (-not $InstallDir -or -not $DataDir) { foreach ($k in @('HKLM:\SOFTWARE\BlueStacks_nxt', 'HKLM:\SOFTWARE\BlueStacks_msi5')) { if (Test-Path $k) { @@ -62,17 +61,11 @@ if (-not $InstallDir -or -not $DataDir) { } } if (-not $InstallDir) { $InstallDir = 'C:\Program Files\BlueStacks_nxt' } +if (-not $DataDir) { $DataDir = Join-Path $env:ProgramData 'BlueStacks_nxt' } if (-not $Adb) { $Adb = Join-Path $InstallDir 'HD-Adb.exe' } if (-not $Player) { $Player = Join-Path $InstallDir 'HD-Player.exe' } -if (-not $Magisk) { - foreach ($m in @((Join-Path $repo 'magiskkitsune.apk'), (Join-Path $repo 'Working Example & Fix\MagiskMyStableBuild.apk'))) { - if (Test-Path -LiteralPath $m) { $Magisk = $m; break } - } -} -foreach ($f in @($Engine, $Adb, $Player)) { if (-not (Test-Path -LiteralPath $f)) { throw "missing: $f" } } - -$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) -if (-not $isAdmin) { throw "Run elevated (Administrator)." } +foreach ($f in @($Cmd, $Engine, $Magisk, $Adb)) { if (-not (Test-Path -LiteralPath $f)) { throw "missing: $f" } } +if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)) { throw 'Run elevated (Administrator).' } $pass = 0; $fail = 0 function Ok($m) { Write-Host " [PASS] $m" -ForegroundColor Green; $script:pass++ } @@ -80,147 +73,95 @@ function No($m) { Write-Host " [FAIL] $m" -ForegroundColor Red; $script:fail++ function Info($m) { Write-Host " [..] $m" -ForegroundColor DarkGray } function Step($m) { Write-Host "`n=== $m ===" -ForegroundColor Cyan } -# child PS with the engine, env-driven, capturing exit + text (stderr won't throw here) -function Eng([hashtable]$envv, [string[]]$a, [string]$label) { - foreach ($k in $envv.Keys) { Set-Item -Path "Env:$k" -Value $envv[$k] } - $old = $ErrorActionPreference; $ErrorActionPreference = 'Continue' - try { $o = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Engine @a 2>&1 | Out-String } - finally { $ErrorActionPreference = $old } - if ($label) { Write-Host ("--- {0} (rc={1}) ---" -f $label, $LASTEXITCODE) -ForegroundColor DarkGray; Write-Host $o.Trim() } - return [pscustomobject]@{ Code = $LASTEXITCODE; Out = $o } -} -function Adb([string[]]$a) { - $old = $ErrorActionPreference; $ErrorActionPreference = 'Continue' - try { return (& $Adb @a 2>&1 | Out-String) } finally { $ErrorActionPreference = $old } -} +# isolate HD-Adb on its own server port (immune to a different-version system adb on 5037) +if (-not $env:ANDROID_ADB_SERVER_PORT) { $env:ANDROID_ADB_SERVER_PORT = '15037' } +function Adb([string[]]$a) { $o = $ErrorActionPreference; $ErrorActionPreference = 'Continue'; try { (& $Adb @a 2>&1 | Out-String) } finally { $ErrorActionPreference = $o } } +function Ps([string[]]$a) { $o = $ErrorActionPreference; $ErrorActionPreference = 'Continue'; try { & powershell.exe -NoProfile -ExecutionPolicy Bypass -File @a 2>&1 | Out-String } finally { $ErrorActionPreference = $o } } -# resolve instance paths via the engine (master/.bstk/conf/vhd/adbport) +# resolve instance paths via the engine $base = $Instance -replace '_\d+$', '' -$res = Eng @{ BSR_DATADIR = $DataDir; BSR_BASE = $base } @('-Action', 'Resolve') '' -$paths = @{} -foreach ($l in ($res.Out -split "`r?`n")) { if ($l -match '^(BSR_\w+)=(.*)$') { $paths[$Matches[1]] = $Matches[2] } } -$dataBase = $paths['BSR_DATADIR'] -$bstk = Join-Path $dataBase "Engine\$Instance\$Instance.bstk" -$conf = Join-Path $dataBase 'bluestacks.conf' +$res = Ps @($Engine, '-Action', 'Resolve', '-DataDir', $DataDir, '-Base', $base) +$paths = @{}; foreach ($l in ($res -split "`r?`n")) { if ("$l" -match '^(BSR_\w+)=(.*)$') { $paths[$Matches[1]] = $Matches[2] } } +$conf = if ($paths['BSR_CONF']) { $paths['BSR_CONF'] } else { Join-Path $DataDir 'bluestacks.conf' } $vhd = $paths['BSR_VHD'] -$adbPort = if ($paths['BSR_ADBPORT']) { $paths['BSR_ADBPORT'] } else { '5555' } -# Resolve matches the base (may pick a clone) -- read the EXACT instance's port from conf +# the EXACT instance's adb port from conf (Resolve matches the base, which may be a different clone) +$adbPort = '5555' if (Test-Path -LiteralPath $conf) { - try { - $ct = Get-Content -Raw -LiteralPath $conf - $esc = [regex]::Escape($Instance) - $m = [regex]::Match($ct, '(?im)^\s*bst\.instance\.' + $esc + '\.status\.adb_port\s*=\s*"?(\d+)"?') - if (-not $m.Success) { $m = [regex]::Match($ct, '(?im)^\s*bst\.instance\.' + $esc + '\.adb_port\s*=\s*"?(\d+)"?') } - if ($m.Success) { $adbPort = $m.Groups[1].Value } - } - catch { } -} -# rooting the master writes its OWN Root.vhd -$vhd = Join-Path $dataBase "Engine\$($Instance -replace '_\d+$','')\Root.vhd" -Write-Host "instance=$Instance bstk=$bstk vhd=$vhd adb=127.0.0.1:$adbPort" -ForegroundColor DarkGray -if (-not (Test-Path -LiteralPath $bstk)) { throw ".bstk not found for '$Instance': $bstk (create the throwaway clone first)" } - -function Kill-Bst { foreach ($p in 'HD-Player', 'HD-MultiInstanceManager', 'BstkSVC', 'BlueStacksHelper') { Get-Process -Name $p -EA SilentlyContinue | Stop-Process -Force -EA SilentlyContinue }; Start-Sleep 2 } -$commonEnv = @{ BSR_BSTK = $bstk; BSR_CONF = $conf; BSR_INSTANCE = $Instance; BSR_VHD = $vhd; - BSR_EXE = (Join-Path $InstallDir 'HD-Player.exe'); BSR_ADB = $Adb; BSR_PLAYER = $Player; BSR_ADBPORT = $adbPort; BSR_SELF = $Cmd -} - -# ---------------------------------------------------------------- REVERT -if ($Revert) { - Step "REVERT '$Instance'" - Kill-Bst - Eng $commonEnv @('-Action', 'Unroot') 'Unroot (remove su offline)' | Out-Null - Eng $commonEnv @('-Action', 'Patch', '-Restore') 'Restore HD-Player.exe' | Out-Null - Eng $commonEnv @('-Action', 'ConfUnroot') 'conf flags -> 0' | Out-Null - Eng $commonEnv @('-Action', 'DiskRO') '.bstk -> Readonly' | Out-Null - Write-Host "`nReverted. (Root.vhd backup, if made, is at $vhd.bsrbak)" -ForegroundColor Green - exit 0 + $ct = [IO.File]::ReadAllText($conf); $esc = [regex]::Escape($Instance) + $m = [regex]::Match($ct, '(?im)^\s*bst\.instance\.' + $esc + '\.status\.adb_port\s*=\s*"?(\d+)"?') + if (-not $m.Success) { $m = [regex]::Match($ct, '(?im)^\s*bst\.instance\.' + $esc + '\.adb_port\s*=\s*"?(\d+)"?') } + if ($m.Success) { $adbPort = $m.Groups[1].Value } } +$script:serial = "127.0.0.1:$adbPort" +Write-Host "instance=$Instance master.vhd=$vhd adb=$script:serial pkg=$PKG" -ForegroundColor DarkGray +if (-not (Test-Path -LiteralPath $vhd)) { throw "master Root.vhd not found: $vhd (create the throwaway clone + open it once first)" } -# ---------------------------------------------------------------- ROOT + PROVE -Step "1) prepare disk / conf / integrity bypass" -Kill-Bst -$r1 = Eng $commonEnv @('-Action', 'DiskRW') '.bstk -> Normal' -$r2 = Eng $commonEnv @('-Action', 'ConfRoot') 'conf root/adb flags -> 1' -$r3 = Eng $commonEnv @('-Action', 'Patch') 'HD-Player integrity bypass' -if ($r1.Code -eq 0) { Ok "disk set R/W" } else { No "DiskRW failed" } -if ($r2.Code -eq 0) { Ok "conf flags set" } else { No "ConfRoot failed" } -if ($r3.Code -eq 0) { Ok "integrity bypass applied" } else { No "Patch failed" } - -Step "2) boot + install su over adb (engine AdbRoot)" -$r4 = Eng $commonEnv @('-Action', 'AdbRoot') 'AdbRoot (boot, push su, verify uid=0)' -if ($r4.Code -eq 0 -and $r4.Out -match 'uid=0') { Ok "ONLINE root: su -c id => uid=0" } -elseif ($r4.Code -eq 0) { Ok "su installed online (uid=0 not echoed; will re-check)" } -else { - No "online adb root did not confirm (rc=$($r4.Code)); trying OFFLINE debugfs fallback" - Kill-Bst - $r4b = Eng $commonEnv @('-Action', 'Root') 'Root (offline debugfs into Root.vhd)' - if ($r4b.Code -eq 0) { Ok "OFFLINE root: su injected into Root.vhd" } else { No "offline root failed too" } - # need the instance running for the rest - Start-Process -FilePath $Player -ArgumentList @('--instance', $Instance) | Out-Null -} - -$serial = "127.0.0.1:$adbPort" +# Wait for boot, trying the conf port AND any live-bound port in the BlueStacks band; pins $serial. function Wait-Boot([int]$sec) { Adb @('start-server') | Out-Null $sw = [Diagnostics.Stopwatch]::StartNew() while ($sw.Elapsed.TotalSeconds -lt $sec) { - Adb @('connect', $serial) | Out-Null - if ((Adb @('-s', $serial, 'shell', 'getprop', 'sys.boot_completed')).Trim() -match '1') { Start-Sleep 3; return $true } + $cands = @($adbPort) + @(Get-NetTCPConnection -State Listen -EA SilentlyContinue | Where-Object { $_.LocalPort -ge 5550 -and $_.LocalPort -le 5900 } | Select-Object -Expand LocalPort) + foreach ($p in ($cands | Select-Object -Unique)) { + $s = "127.0.0.1:$p"; Adb @('connect', $s) | Out-Null + if ((Adb @('-s', $s, 'shell', 'getprop', 'sys.boot_completed')).Trim() -match '1') { $script:serial = $s; Start-Sleep 3; return $true } + } Start-Sleep 3 } return $false } function Shot([string]$name) { - $png = Join-Path $Shots $name - $old = $ErrorActionPreference; $ErrorActionPreference = 'Continue' - try { & $Adb -s $serial exec-out screencap -p > $png } finally { $ErrorActionPreference = $old } - if ((Test-Path $png) -and (Get-Item $png).Length -gt 1000) { Info "screenshot -> $png" } else { Info "screenshot failed ($name)" } + $png = Join-Path $Shots $name; $o = $ErrorActionPreference; $ErrorActionPreference = 'Continue' + try { & $Adb -s $script:serial exec-out screencap -p > $png } finally { $ErrorActionPreference = $o } + if ((Test-Path $png) -and (Get-Item $png).Length -gt 1000) { Info "shot -> $png" } else { Info "screenshot failed ($name)" } } -Step "3) verify root via adb" -if (-not (Wait-Boot $BootTimeout)) { No "instance not reachable"; } -$id = Adb @('-s', $serial, 'shell', '/system/xbin/su -c id 2>/dev/null || /system/bin/su -c id 2>/dev/null') -Write-Host " su -c id => $($id.Trim())" -if ($id -match 'uid=0') { Ok "ROOT CONFIRMED: uid=0 from a normal adb shell" } else { No "uid=0 not returned (SELinux/su style?)" } -$ls = Adb @('-s', $serial, 'shell', 'ls -lZ /system/xbin/su /system/bin/su 2>/dev/null') -Write-Host " su file: $($ls.Trim())" - -if (-not $SkipMagisk -and $Magisk -and (Test-Path -LiteralPath $Magisk)) { - Step "4) install Magisk + screenshot" - Info "installing $Magisk" - Write-Host (Adb @('-s', $serial, 'install', '-r', '-g', $Magisk)).Trim() - $pkg = 'com.topjohnwu.magisk' - Adb @('-s', $serial, 'shell', "monkey -p $pkg -c android.intent.category.LAUNCHER 1") | Out-Null - Start-Sleep 8 - Shot 'magisk_before_reboot.png' - $mv = Adb @('-s', $serial, 'shell', 'magisk -V 2>/dev/null; magisk -c 2>/dev/null') - Write-Host " magisk -V/-c => $($mv.Trim())" - - if (-not $Unattended) { - Write-Host "`n >>> NOW (in the emulator window): open Magisk -> Install -> Direct Install (to /system)." -ForegroundColor Yellow - Write-Host " >>> Wait for it to finish, then press ENTER here to reboot + verify." -ForegroundColor Yellow - [void](Read-Host) - } - else { Write-Host " [unattended] Magisk APK installed + screenshotted; skipping the GUI 'Direct Install' tap. Do it later, then reboot." -ForegroundColor Yellow } +# ---------------------------------------------------------------- REVERT (Magisk Undo) +if ($Revert) { + Step "REVERT '$Instance' (Magisk Undo)" + Ps @($Magisk, '-Action', 'Undo', '-Instance', $Instance, '-SelfCmd', $Cmd, '-Vhd', $vhd, '-Conf', $conf, '-Install', $InstallDir) | Write-Host + Write-Host "`nReverted ($Instance). The shared master + HD-Player patch are left intact unless you passed -Full to Undo." -ForegroundColor Green + exit 0 } -Step "5) reboot + re-verify persistence" -Adb @('-s', $serial, 'reboot') | Out-Null -Start-Sleep 8 +# ---------------------------------------------------------------- ROOT via the SHIPPED Magisk pipeline +Step "1) run the FULL Magisk pipeline (bsr_magisk.ps1 -Action Auto; embedded debugfs/su/APK)" +$autoOut = Ps @($Magisk, '-Action', 'Auto', '-Instance', $Instance, '-SelfCmd', $Cmd, '-Engine', $Engine, '-Vhd', $vhd, '-Conf', $conf, '-Install', $InstallDir) +$autoRc = $LASTEXITCODE +Write-Host $autoOut +if ($autoRc -eq 0) { Ok "pipeline exited 0" } else { No "pipeline exit code = $autoRc" } +if ($autoOut -match 'VERIFY PASS') { Ok "pipeline reached VERIFY PASS (Magisk sole root, no competing su, no bsr_su traces)" } else { No "pipeline did NOT print VERIFY PASS" } +if ($autoOut -match 'competing su\s*:\s*none') { Ok "pipeline reported NO competing su" } +elseif ($autoOut -match 'competing su\s*:\s*\S') { No "pipeline reported a COMPETING su (Abnormal-State regression)" } + +# ---------------------------------------------------------------- independent adb re-check +Step "2) independent verification over adb" +if (-not (Wait-Boot $BootTimeout)) { No "instance not reachable" } +$id = (Adb @('-s', $script:serial, 'shell', 'su -c id')).Trim() +if ($id -match 'uid=0') { Ok "su -c id => $id" } else { No "uid=0 not returned ($id)" } +$binsu = (Adb @('-s', $script:serial, 'shell', 'readlink /system/bin/su')).Trim() +if ($binsu -match 'magisk') { Ok "/system/bin/su -> $binsu" } else { No "/system/bin/su not -> magisk ($binsu)" } +$xbin = (Adb @('-s', $script:serial, 'shell', 'su -c "ls -l /system/xbin/su 2>&1"')).Trim() +if ($xbin -match 'No such file|not found') { Ok "NO /system/xbin/su (competing su is gone)" } else { No "competing /system/xbin/su present: $xbin" } +$mv = (Adb @('-s', $script:serial, 'shell', 'su -c "magisk -c"')).Trim() +if ($mv -match 'kitsune') { Ok "magisk -c => $mv" } else { No "magisk version unexpected ($mv)" } +$pkg = (Adb @('-s', $script:serial, 'shell', "pm path $PKG")).Trim() +if ($pkg -match 'package:') { Ok "manager installed: $pkg" } else { Info "manager package not found via pm path ($pkg)" } +Shot 'magisk_e2e.png' + +# ---------------------------------------------------------------- reboot persistence +Step "3) reboot + re-assert (persistence)" +Adb @('-s', $script:serial, 'reboot') | Out-Null; Start-Sleep 8 if (-not (Wait-Boot $BootTimeout)) { No "did not come back after reboot" } else { - $id2 = Adb @('-s', $serial, 'shell', '/system/xbin/su -c id 2>/dev/null || /system/bin/su -c id 2>/dev/null') - Write-Host " after reboot, su -c id => $($id2.Trim())" - if ($id2 -match 'uid=0') { Ok "ROOT PERSISTS after reboot (uid=0)" } else { No "root did NOT persist after reboot" } - $mv2 = Adb @('-s', $serial, 'shell', 'magisk -V 2>/dev/null; magisk -c 2>/dev/null') - Write-Host " after reboot, magisk -V/-c => $($mv2.Trim())" - if ($mv2.Trim()) { Ok "Magisk reports a version after reboot (installed)" } - if (-not $SkipMagisk) { $pkg = 'com.topjohnwu.magisk'; Adb @('-s', $serial, 'shell', "monkey -p $pkg -c android.intent.category.LAUNCHER 1") | Out-Null; Start-Sleep 8; Shot 'magisk_after_reboot.png' } + $id2 = (Adb @('-s', $script:serial, 'shell', 'su -c id')).Trim() + if ($id2 -match 'uid=0') { Ok "root PERSISTS after reboot (uid=0)" } else { No "root lost after reboot ($id2)" } + $xbin2 = (Adb @('-s', $script:serial, 'shell', 'su -c "ls -l /system/xbin/su 2>&1"')).Trim() + if ($xbin2 -match 'No such file|not found') { Ok "still NO competing /system/xbin/su after reboot" } else { No "competing su reappeared: $xbin2" } + Shot 'magisk_e2e_after_reboot.png' } Write-Host "`n================ LIVE E2E SUMMARY ================" -ForegroundColor Cyan -$col = if ($fail) { 'Red' } else { 'Green' } -Write-Host (" PASS=$pass FAIL=$fail screenshots in $Shots") -ForegroundColor $col -Write-Host " When satisfied, undo with: -Revert -Instance $Instance" -ForegroundColor DarkGray +Write-Host (" PASS=$pass FAIL=$fail screenshots in $Shots") -ForegroundColor $(if ($fail) { 'Red' } else { 'Green' }) +Write-Host " Undo with: -Revert -Instance $Instance" -ForegroundColor DarkGray exit ([int]($fail -gt 0)) diff --git a/tests/Run-Resolve-Tests.ps1 b/tests/Run-Resolve-Tests.ps1 index b806cdd..7050f02 100644 --- a/tests/Run-Resolve-Tests.ps1 +++ b/tests/Run-Resolve-Tests.ps1 @@ -145,6 +145,16 @@ $script:AdbServerPortProbe = $null Remove-Item Env:\ANDROID_ADB_SERVER_PORT -ErrorAction SilentlyContinue if ($savedPort) { $env:ANDROID_ADB_SERVER_PORT = $savedPort } +Write-Host "`n=== competing-su detection (Find-StraySu -- what Verify fails on) ===" -ForegroundColor Cyan +# Magisk's own su are symlinks to magisk; anything else is a competing root (Magisk "Abnormal State"). +Eq 'stray: clean Magisk (all -> magisk) = none' '' (((Find-StraySu "/system/bin/su|link|./magisk`n/sbin/su|link|./magisk")) -join ',') +Eq 'stray: the engine-su leak at /system/xbin/su' '/system/xbin/su' (((Find-StraySu "/system/bin/su|link|./magisk`n/system/xbin/su|file|")) -join ',') +Eq 'stray: a symlink NOT to magisk is competing' '/system/xbin/su' (((Find-StraySu "/system/xbin/su|link|/data/local/tmp/su")) -join ',') +Eq 'stray: real su in two dirs (both flagged)' '/system/xbin/su,/vendor/bin/su' (((Find-StraySu "/system/bin/su|link|/system/bin/magisk`n/system/xbin/su|file|`n/vendor/bin/su|file|")) -join ',') +Eq 'stray: empty inventory = none' '' (((Find-StraySu '')) -join ',') +Eq 'stray: malformed lines ignored' '' (((Find-StraySu "garbage`n|||`n/system/bin/su|link|./magisk")) -join ',') +Eq 'stray: magisk in a deep path target = ours' '' (((Find-StraySu "/system/bin/su|link|/sbin/.magisk/busybox/magisk")) -join ',') + 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 33a92b6..f3ddb98 100644 --- a/todolist.md +++ b/todolist.md @@ -1,6 +1,15 @@ # Todo List ## Done +- [x] **Magisk is the SOLE root: scrub any competing su + Verify catches it** — an instance could show + "Abnormal State — su not from Magisk" when the shared master still carried a classic/engine + `/system/xbin/su` (old non-Magisk root / the **legacy classic-su live-E2E**, which injected one). + Git shows no commit changed su handling; the v11 adb fix just let the pipeline complete so Magisk + booted and flagged the leftover. Fix: **Prep + Clean now scrub `/system/xbin/su`** (+daemonsu); + **Verify enumerates every su and FAILS on any non-Magisk one** (pure `Find-StraySu`, +7 unit tests + → 38). Rewrote `tests/Run-Live-E2E.ps1` to drive the real Magisk pipeline (`-Action Auto`) and + assert no competing su across a reboot (it previously ran the classic-su path and planted the su). + Live-verified on Tiramisu64_9: stray su scrubbed → **VERIFY PASS**. Re-embedded; all suites green (28+38). - [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 diff --git a/tools/bsr_magisk.ps1 b/tools/bsr_magisk.ps1 index 69c8fb3..1404b15 100644 --- a/tools/bsr_magisk.ps1 +++ b/tools/bsr_magisk.ps1 @@ -603,8 +603,13 @@ function Do-Prep { $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') + # scrub any pre-existing CLASSIC/engine su from the shared master. An older non-Magisk root + # (engine Root/AdbRoot -- e.g. the legacy live-E2E) leaves a setuid /system/xbin/su, which makes + # Magisk report "Abnormal State -- a su binary not from Magisk has been detected". Magisk's own su + # live at /system/bin/su -> magisk and /sbin/su -> magisk, so /system/xbin/su is never ours. + $cmds += @('cd /android/system/xbin','rm su','rm daemonsu') # 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') + $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','stat /android/system/xbin/su') $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') @@ -685,15 +690,19 @@ function Do-Clean { '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' + # also scrub any leftover CLASSIC/engine su so Magisk is the SOLE root (no "Abnormal State") + 'cd /android/system/xbin','rm su','rm daemonsu', + 'stat /android/system/bin/bindmount','stat /android/system/etc/bsr_su','stat /android/system/xbin/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') + $suGone = ($out -match '(?im)bsr_su:?\s*File not found') + $xbinSuGone = ($out -match '(?im)xbin/su:?\s*File not found') -or ($out -match '(?im)/android/system/xbin/su[^\n]*not found') if(-not $bmOk){ Say '[!] stock bindmount not detected after restore' Red } - return ($bmOk -and $suGone) + if(-not $xbinSuGone){ Say '[!] /system/xbin/su still present after scrub' Red } + return ($bmOk -and $suGone -and $xbinSuGone) } $true if(-not $ok){ throw "Clean failed." } Say '[+] CLEAN complete (bsr_su removed, stock bindmount restored).' Green @@ -716,19 +725,60 @@ function Do-Finalize { Say '[+] FINALIZE complete.' Green } +# Classify a device su inventory (lines "|link|" or "|file|") and return the +# NON-Magisk su paths. Magisk's own su are symlinks whose target contains 'magisk'; a real su binary +# (or a symlink pointing elsewhere) is a COMPETING root -- that is exactly what trips Magisk's +# "Abnormal State -- a su binary not from Magisk has been detected". Pure/string-only => unit-testable. +function Find-StraySu([string]$scan){ + $stray=@() + foreach($line in ($scan -split "`n")){ + $line=$line.Trim(); if(-not $line){ continue } + $p=$line.Split('|') + if($p.Count -lt 2){ continue } + $path=$p[0].Trim(); $kind=$p[1].Trim(); $target= if($p.Count -ge 3){ $p[2].Trim() } else { '' } + if(-not $path){ continue } + if($kind -eq 'link'){ if($target -notmatch 'magisk'){ $stray+=$path } } + elseif($kind -eq 'file'){ $stray+=$path } + } + ,$stray +} + 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() + # Enumerate EVERY su in the standard PATH dirs and classify each: a symlink to magisk is ours, + # anything else is a competing root (the cause of Magisk's "Abnormal State"). Pushed as a script + # file (not inline su -c '...') because the loop's semicolons don't survive PS -> adb -> device quoting. + $scanSh = @' +for f in /system/bin/su /system/xbin/su /sbin/su /vendor/bin/su /odm/bin/su /system_ext/bin/su /product/bin/su /debug_ramdisk/su; do + if [ -L "$f" ]; then echo "$f|link|$(readlink "$f")" + elif [ -e "$f" ]; then echo "$f|file|" + fi +done +'@ -replace "`r`n","`n" + $scanFile = Join-Path $env:TEMP 'bsr_work\bsr_suscan.sh' + New-Item -ItemType Directory -Path (Split-Path $scanFile) -Force | Out-Null + [System.IO.File]::WriteAllText($scanFile,$scanSh,(New-Object System.Text.UTF8Encoding($false))) + AdbTry @('-s',$serial,'push',(Fwd $scanFile),'/data/local/tmp/bsr_suscan.sh') | Out-Null + $scan = (& $Adb @('-s',$serial,'shell','su -c "sh /data/local/tmp/bsr_suscan.sh"') 2>&1 | Out-String) + & $Adb @('-s',$serial,'shell','rm -f /data/local/tmp/bsr_suscan.sh') *>$null + $stray = Find-StraySu $scan $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 " su inventory :" + foreach($l in ($scan -split "`n")){ $l=$l.Trim(); if($l){ Say " $l" DarkGray } } + if($stray.Count){ Say (" competing su : {0} <-- NOT from Magisk" -f ($stray -join ', ')) Red } + else { Say " competing su : none (good)" Green } 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 } + if($id -match 'uid=0' -and $whi -match 'magisk' -and $stray.Count -eq 0 -and $sweep -notmatch 'TRACE:'){ + Say '[+] VERIFY PASS: Magisk is the sole root; no competing su, no bsr_su traces.' Green + } else { + Say '[!] VERIFY FAIL: review the above.' Red + if($stray.Count){ Say ' -> a competing su is present; re-run Clean (it now scrubs /system/xbin/su) then reboot.' Yellow } + } } function Do-Undo {