Skip to content

Harden engine patch: survive updates, refuse stale/shifted restores#35

Merged
RobThePCGuy merged 1 commit into
masterfrom
fix/engine-patch-safety
Jul 9, 2026
Merged

Harden engine patch: survive updates, refuse stale/shifted restores#35
RobThePCGuy merged 1 commit into
masterfrom
fix/engine-patch-safety

Conversation

@RobThePCGuy

Copy link
Copy Markdown
Owner

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.exe with a newer build and the user re-patched, patch_file kept the old .prepatch.bak but rewrote its .sha256 to 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.old and take a fresh backup of the current build. Undo now restores the correct build.

2. Warn on a dirty VHDX log

Data.vhdx carries a metadata log; a non-zero LogGuid in 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 + ruff clean.

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request 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.

Comment thread integrity_patch.py
Comment on lines +281 to +296
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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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

@RobThePCGuy RobThePCGuy merged commit d3cbbbd into master Jul 9, 2026
4 checks passed
@RobThePCGuy RobThePCGuy deleted the fix/engine-patch-safety branch July 9, 2026 04:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant