Harden engine patch: survive updates, refuse stale/shifted restores#35
Conversation
- integrity_patch: when BlueStacks replaces a patched binary with a newer build and it's re-patched, the old .prepatch.bak was kept while its .sha256 was updated to the new build -- a later "Undo" would then restore the WRONG build. Detect the update (current unpatched binary != backup) and refresh the backup, archiving the stale one to .prepatch.bak.old. - su_patch_offline: detect a dirty VHDX log (non-zero LogGuid in the active header) and warn before patching/un-rooting, so a hard-killed instance's pending log can't silently drop the patch on next boot. - su_patch_offline: on un-root, only restore a location that still holds our patch (b0 01 c3); if the ext4 layout shifted and the bytes are neither the patch nor the original, skip it instead of clobbering unrelated data. - README: FAQ note that a patched HD-Player.exe loses its signature and may be blocked by WDAC/AppLocker on locked-down machines.
There was a problem hiding this comment.
Code Review
This pull request introduces several safety enhancements and documentation updates. It adds a warning in the README about WDAC/AppLocker blocking patched binaries on corporate PCs. In integrity_patch.py, it adds logic to detect when BlueStacks has updated a binary, archiving the stale backup and refreshing it. In su_patch_offline.py, it implements VHDX dirty log detection to warn users about unclean shutdowns, and adds a safety check during un-rooting to prevent clobbering shifted ext4 blocks.
Regarding the feedback, a critical issue was identified in integrity_patch.py where re-running the patcher on an already-patched binary would incorrectly overwrite the original backup with the patched version. A code suggestion is provided to check the recorded .sha256 hash before refreshing the backup.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| elif _sha256(path) != _sha256(backup): | ||
| # A backup exists but the current unpatched binary differs from it: | ||
| # BlueStacks replaced the binary with a newer build since we last | ||
| # patched. The old backup is stale (a different version), and keeping | ||
| # it would let a later "Undo" restore the WRONG build over this one. | ||
| # Archive the stale backup and take a fresh one of the current build. | ||
| stale = backup + ".old" | ||
| try: | ||
| shutil.copy2(backup, stale) | ||
| logger.info("BlueStacks updated %s since last patch; archived " | ||
| "stale backup to %s", os.path.basename(path), | ||
| os.path.basename(stale)) | ||
| except OSError: | ||
| logger.debug("Could not archive stale backup %s", backup, exc_info=True) | ||
| shutil.copy2(path, backup) | ||
| logger.info("Refreshed backup for updated %s", os.path.basename(path)) |
There was a problem hiding this comment.
If patch_file is re-run on an already-patched binary (for example, when applying a new patch spec or if the tool is updated), path on disk will contain the patched bytes. Consequently, _sha256(path) (the patched hash) will differ from _sha256(backup) (the original unpatched hash). This triggers the elif block, causing the original unpatched backup to be archived to .old and overwritten with the currently patched binary. A subsequent 'Undo' operation would then restore the patched binary instead of the original unpatched one.
To prevent this, we should check if the file on disk is the previously patched binary by comparing its hash against the recorded patched hash in backup + ".sha256". If they match, we should not refresh the backup.
else:
current_hash = _sha256(path)
if current_hash != _sha256(backup):
# Check if the file on disk is the previously patched binary.
# If its hash matches the recorded patched hash, BlueStacks has not
# updated it (we are just re-patching or applying a new spec).
# In that case, the existing backup is still the correct original.
is_already_patched = False
sha_path = backup + '.sha256'
if os.path.exists(sha_path):
try:
with open(sha_path, 'r', encoding='utf-8') as sf:
if sf.read().strip() == current_hash:
is_already_patched = True
except OSError:
pass
if not is_already_patched:
stale = backup + '.old'
try:
shutil.copy2(backup, stale)
logger.info('BlueStacks updated %s since last patch; archived '
'stale backup to %s', os.path.basename(path),
os.path.basename(stale))
except OSError:
logger.debug('Could not archive stale backup %s', backup, exc_info=True)
shutil.copy2(path, backup)
logger.info('Refreshed backup for updated %s', os.path.basename(path))
Three safety hardenings in the engine-patch / offline-su path. No behavior change on the happy path; each only guards a failure mode.
1. Update-lockout: refresh the backup when BlueStacks updates the binary
Before: if BlueStacks replaced a patched
HD-Player.exewith a newer build and the user re-patched,patch_filekept the old.prepatch.bakbut rewrote its.sha256to the new build. A later Undo then passed the hash guard and restored the wrong (older) binary over the current build.After: on re-patch we compare the current unpatched binary to the existing backup; if they differ, BlueStacks updated it — so we archive the stale backup to
.prepatch.bak.oldand take a fresh backup of the current build. Undo now restores the correct build.2. Warn on a dirty VHDX log
Data.vhdxcarries a metadata log; a non-zeroLogGuidin the active header means the instance wasn't shut down cleanly. We write the payload directly and don't replay that log, so a pending entry could drop the patch on next boot. We now detect this and warn (not block) so the user can do a clean shutdown first.3. Don't clobber shifted blocks on un-root
On un-root we now only restore a location that still holds our patch (
b0 01 c3). If the bytes are neither the patch nor the original (ext4 block reallocated under us), we skip and flag it instead of writing the original bytes into whatever now occupies that offset.Testing
Offline unit tests for both the update-lockout scenario (patch v1 → simulated update to v2 → re-patch → Undo restores v2, stale v1 archived) and the dirty-log header detection (active header dirty → warn; stale dirty header ignored). All pass.
py_compile+ruffclean.