Reverse-Engineering a Fingerprint Browser, Part 4: Binary Patching as a Research Exercise

Crash traps, license guards, and the discipline of verifying both code paths

Posted by 叉叉敌 on November 10, 2026

The first three parts were about understanding. This one is about the mechanics of a binary patch as a research exercise — turning an analysis finding into a byte-level change, verifying it, and not fooling yourself in the process.

A note on detail. This post deliberately describes the method and the pitfalls, not the specific addresses, offsets, or byte recipes. Those are build-specific and, more importantly, they are the part that turns understanding into a copyable recipe. If this is the kind of work you need done on a specific target, that is a conversation, not a blog post.

Read this first. Patching a binary to defeat a license check is a research exercise on software you already have. It is not a distribution recipe, and it does not change the legal or ethical boundaries in the vendor’s own responsible-use documents. The value here is methodological: how do you go from a crash report to a root cause, and from a root cause to a minimal, verifiable change?

1. Environment and conventions

A few conventions that make this kind of work survivable:

  • Keep a pristine control copy of the original binary, of the exact same version, outside the application bundle.
  • Know which copy you are actually patching — a product directory often accumulates several builds, with different patch states.
  • On macOS, the real binary lives under a versioned framework path; the friendly path is a symlink. Patching through the symlink writes the same file, but code signing needs the real path.
  • Keep backups outside the app bundle, so the signer does not try to scan them as subcomponents.

2. From a crash report to a root cause

Step 1 — Capture and parse the crash report

On macOS, a renderer crash produces an .ips file in ~/Library/Logs/DiagnosticReports. The useful fields are the termination reason and the first frames of the faulting thread, including the offset into the offending framework image.

In the case I studied, the termination reason was a SIGTRAP (EXC_BREAKPOINT) and the top frame pointed at a specific offset in the core framework.

Step 2 — Disassemble the crash site

Disassembling around that offset revealed something important: the crash was not a segfault or null dereference. It was a deliberate trap: read a flag byte; if non-zero, execute a breakpoint instruction to abort.

Step 3 — Trace who wrote the trap flag

To find who sets that flag, scan for the instruction pattern that stores to that page and offset (on arm64, an ADRP + STRB pair). This locates the function that arms the trap.

Takeaway: a deliberate breakpoint in a closed-source binary is almost never a real bug. It is a canary. Find the writer, and you find the condition that trips it. This one insight turns a mysterious crash into a named, addressable condition.

3. The license guard and its patch

The expiry check funnels through two guards — one on the headless path, one on the headed path. Part 2 established the structure; here is the patching discipline around it.

3.1 Options considered

Option Change Verdict
A Force the guard with a constant — no time, no fingerprint touched preferred: smallest unit
B Flip the condition code often ineffective — the alternative code may yield the same result in the already-expired case
C Patch only one guard incomplete — headless works, but headed mode still pops the dialog
D Force a jump over the whole block skips too much; easily breaks later state

Counter-measure note: the region around the check contains hook detection — it compares global function pointers and takes a different path if they differ. So hooking the time source via a library-injection trick gets detected. Editing machine code directly never touches the hook path, so it bypasses that detection naturally.

3.2 The two guards are on different paths

The headless and headed paths have separate guards. Verifying only one of them gives a false result — this is the single most common mistake, and it is why a “working” patch can still fail for the actual use case.

3.3 The driver side (the project’s own code)

There is usually a second layer of enforcement in the automation driver, independent of the browser binary: a pre-filter that reads the outer plaintext expiry and rejects profiles that are expired or near-expiry, and a second check at dispatch time. Removing the binary guard alone is not enough if this layer still rejects the profile before the browser ever sees it. For a “no check at all” goal, both driver-side checks have to be removed or neutralized — and the related config/type surface cleaned up.

Keep what is still useful: the metadata reader can still parse the expiry for display purposes; it just must not participate in any rejection decision.

3.4 Re-signing and verification

On macOS, after editing the binary you must re-sign the bundle, and the signer needs to recurse into subcomponents. Two practical notes:

  • Clear extended attributes first (quarantine).
  • If the signer complains that a data file is an “unrecognized bundle format”, it is because that file has the executable bit set — clear it.

3.5 Verify both headed and headless

The single most important verification rule: test both paths.

  • A headless run (with a fresh user-data directory) should load a test page and show no expiry prompt.
  • A headed run (again with a fresh user-data directory) must show the same.
  • Then run the driver’s own smoke test in headed mode, because that is the path the real operation uses.

Do not stop after the headless test. The headed path is a different guard; a patch that only fixes headless will pass the first test and fail the real one.

Use a fresh user-data directory every time. Residual state causes false results.

4. Troubleshooting checklist

Symptom Cause Fix
Bytes are correct but the popup still appears Old process still running. Code is already in memory; changing the on-disk file does nothing to it kill the process, confirm the count is zero, retry
Headless works, headed pops only one guard patched patch both sites
Changing the condition code changes nothing condition code read backwards re-check the direction of each site
First verification fails, retry succeeds residual user-data state wipe the directory before each verification
Driver rejects the profile the driver-side pre-filter still checks see 3.3
Signer reports an unrecognized bundle format a data file has the executable bit clear it or move it out

5. Multiple copies — know which one you are patching

Product directories accumulate several builds with different patch states. Always:

  1. Print a fingerprint of the two guard sites for every candidate copy.
  2. Trust the configured executable path, and confirm the copy it points at has both sites patched.

Copies may differ at the byte level. In practice, several builds in the same directory can have different bytes at the same offset — because they are different versions. Always assert the expected original bytes before writing; if the assert fails, re-locate; do not force the write.

6. The same workflow on another architecture

The Windows x86-64 port is structurally the same but none of the offsets carry over. arm64 and x86-64 share nothing at the instruction-encoding level. The workflow transfers; the numbers do not.

Key structural differences worth knowing:

Aspect macOS arm64 Windows x86-64
Architecture ARM64 x86-64
Constant reference page-relative ADRP + ADD RIP-relative lea/mov
Conditional set cset setl / setg
Deliberate crash breakpoint instruction -> SIGTRAP illegal-instruction or int3
NOP padding fixed 4-byte 1-byte, pad to the original length
Disassembler lldb objdump or a hand-written scanner
Crash log .ips files minidumps, or a debugger
Signing required after editing no mandatory signing
File attributes clear quarantine no equivalent

On Windows there is no mandatory re-signing step. PE files have no equivalent of the macOS code-signing gate; Chromium itself does not verify DLL signatures. (Enterprise AppLocker / SmartScreen / EDR policies are the exception.)

6.1 A to-measure checklist for a fresh architecture

When porting the workflow, the honest starting point is a checklist of unknowns: PE structure and RVA<->offset conversion; the crash exception code and trap instruction form; the trap flag address and its writer; the two expiry string references and whether two symmetric guards exist; the condition directions; whether hook detection exists; and the Authenticode status plus SHA-256 baselines.

Do not reuse offsets across architectures or even across builds. Assert before you write.

7. The methodology, distilled

  1. A crash is a clue, not a bug. A deliberate breakpoint in a closed binary is a canary. Find the writer.
  2. Patch the smallest possible unit. A constant move beats a forced jump; a forced jump beats restructuring a block.
  3. Two symmetric guards mean two verifications. Headless and headed are different code paths; test both.
  4. Old processes defeat new patches. Always kill, confirm, and use a fresh profile directory.
  5. Offsets are build-specific. Assert before you write; never reuse offsets across architectures.

Part 5 is the payoff: recovering the key material and analyzing the encrypted profile offline, using only what the client holds.


Series navigation: