Reverse-Engineering a Fingerprint Browser, Part 2: Static Recon Without Running It

Strings, cross-references, and the profile envelope — all read-only

Posted by 叉叉敌 on November 10, 2026

In Part 1 I described what an anti-detection browser is at the surface. This post is about the first real step of the investigation: what can you learn from a large closed-source binary and an encrypted file format without ever running them?

The target was the macOS arm64 build:

  • The main application bundle and its core framework binary, several hundred megabytes.
  • A control sample: the original, unpatched build of the same version.
  • Method: static disassembly (read-only, no process attached) plus pure-Python string and cross-reference scanning.
  • Constraint: strictly read-only. No binary or project file was modified.

A warning up front: this whole post is static analysis. Where a conclusion needs dynamic confirmation (packet capture, runtime observation), I flag it. Static analysis is very good at answering “what code exists” and “what strings exist”; it is weaker at “what actually happens at runtime”.

A note on detail. Consistent with the series, I describe findings and method, not the exact addresses, offsets, or byte-level recipes that would make the work trivially reproducible. The value here is the approach and the conclusions.

1. Question one: does it phone home?

This is the first thing anyone should ask about a closed-source browser. The answer, after a full scan:

The expiry check is 100% local and offline. There is no vendor endpoint anywhere in the binary. The only product-specific feature that reaches the network is a public-IP lookup (for GeoIP fingerprint inheritance), and the GeoIP database is bundled locally — nothing is downloaded.

Evidence, point by point.

1.1 Hundreds of unique URL hosts, zero vendor endpoints

I extracted every URL host string and scanned all of them. No license server, no activation endpoint, no telemetry address. The renewal guidance shown after expiry is purely offline text — a support handle and an email, meant for a human to read, not a request target. After expiry it only shows a local dialog; it does not go online to verify authorization status.

1.2 The only product-specific network code is a public-IP lookup

A handful of public IP-echo services are used through a Mojo interface. The purpose is to take the public IP and look it up in the locally bundled GeoIP database for geo inheritance (timezone / language fingerprint). This is “look up an IP”, not “do an authorization check”, and it downloads no updates. (Part 6 covers this database in depth.)

1.3 Expiry is judged against the local system clock

The clock comes from a plain wall-clock read, with a test-only override hook that production does not use. There is no NTP or network time code — set the system clock back before the expiry date and the unpatched browser “revives”. That fact is itself proof it does not check time over the network.

1.4 No self-update mechanism

No third-party update framework; the binary links only system frameworks. There are switches that disable component updates and background networking, but no product-specific component ID or update source was found. Strings like the standard Google update host are native Chromium residue, with no evidence the product uses them to fetch its own content.

1.5 It writes files locally, but not as “updates”

At runtime it generates a built-in automation extension (manifest + background script written to disk). Crash-report directories are empty, and the launcher already disables crash reporting.

1.6 The profile file is never written back

The backup copies of the profile files are byte-identical to their originals — the browser does not modify the authorization file after loading it.

What it does check is all local: profile integrity (a signature check plus an internal consistency check), and version/kernel matching against built-in expectations (a local string comparison).

Reservation: the above is a static-analysis conclusion; I did not do dynamic packet capture to confirm.

2. Question two: how are the profile encryption and expiry computed?

2.1 Outer structure

Across all the profiles I examined, the structure is identical — a plaintext JSON envelope with a fixed set of keys. Three content classes:

Class Measured characteristics
Plaintext metadata user agent, GPU vendor/renderer strings, window/screen geometry, Client Hints, kernel/version/release tags, and an expiry field
An encrypted key block a fixed-size blob, unique per file
The fingerprint ciphertext a large blob whose length is always a multiple of the AES block size
The outer expiry an ISO 8601 string — and the enforcement layer never reads it

There is no plaintext profileExpires field in the outer layer.

2.2 Key finding: the outer expiry field is a “dead field”

The enforcement code looks the expiry up with a numeric-only lookup: it only recognizes integer/double nodes. Any other node type — including a string — returns “not found”.

The outer expiry is an ISO string, so the enforcement path cannot even see it. Editing it has no effect whatsoever — it is not even “rejected after a check”; it is simply never read.

2.3 The real value lives inside the ciphertext (two-pass parsing)

The fingerprint block is AES ciphertext. Only the decrypted inner data has the numeric expiry values. The parser works in two passes:

  1. First pass: look up a numeric expiry in the outer layer -> not found (it is a string / absent).
  2. -> Take the decrypt branch: decrypt the block, pass integrity checks, and merge the decrypted inner numeric fields in place into the document object.
  3. -> Loop back and re-check (with a bounded iteration count) -> the second pass hits the inner numeric field.
  4. -> Enter the expiry comparison.

That loop-back-and-merge is the whole trick: the authoritative value is hidden behind encryption, and the parser is written to find it on the second pass.

2.4 The two guard points

The expiry decision funnels through two independent guard computations — one on the headless path and one on the headed path. Both originally computed “is now greater than expiry”. This becomes the key structural fact for Part 4.

2.5 A correction, and the real risk of a naive patch

Correcting an earlier guess: a cluster of code that referenced an RSA key was not a license module — it is the code that generates the runtime automation extension, and the key is just the manifest’s key field (the standard way to pin an extension ID; the public key is public by design). There is no separate networked license-check module.

There is a naive patch that forces one of the guards to a constant. The subtlety: the original instruction means “expired when now > expiry”, and forcing it to 1 is an unconditional declaration of “expired”. For already-expired profiles there is no visible difference — but the guard also propagates to the automation-script flow, where either guard being set skips the script with a “script expired” message. So the wrong polarity can silently kill automation scripts. The correct direction is to force “never expired”, and to be deliberate about which guard you touch.

2.6 Why “just change the expiry” is not enough

Two layers:

Layer one — the outer expiry is inert. It is a string; the numeric lookup cannot see it. That is the first reason editing it does nothing: the line you edit is not on the enforcement path at all.

Layer two — the real value is inside the ciphertext, behind a triple lock. To change the profile “legitimately” you would need to:

  1. Get the AES key — the derivation is embedded and obfuscated.
  2. Decrypt -> modify the inner numeric expiry -> re-encrypt.
  3. Rebuild the key block — it is not the expiry time, it is signature / envelope material. The successful decrypt path requires a signature verification plus an internal consistency check.
  4. Producing a valid key block needs the vendor’s private key — and only the public key is embedded. Without the private key, a forged file hits an integrity-check failure on an unpatched browser.

Layer three — the practical conclusion: the file cannot be changed, and does not need to be.

  • It cannot be changed: the signature locks it, and the private key is not available.
  • It does not need to be changed: a binary patch bypasses the whole comparison at the enforcement endpoint, leaving the profile untouched — and the byte-identical backups prove the browser never rewrites or hash-checks the file after loading.

3. Current patch status

Guard Original meaning Naive patch Verdict
Headless path “expired when now > expiry” forced to expired Wrong direction; force “never expired” instead
Headed path “expired when now > expiry” forced to not-expired This is the real popup-killer; keep

Everything else checked (clock read, consistency check, signature verification, version match, crash reporting, component update) needs no change.

4. Honest uncertainties

  • The inner-merge mechanism is a runtime-proven causal chain (a patch that removed the popup), but the byte-level merge path was not single-stepped to completion.
  • The specific AES key-derivation algorithm was not pinned down (this does not affect “cannot be changed” — the signature is the hard lock).
  • The downstream consumers of two global status bits were not traced.
  • The network conclusion is based on static analysis, without dynamic packet capture.

5. Takeaways for anyone doing this

  1. String and URL scans first. Before disassembly, list every host string. Hundreds of hosts with zero vendor endpoints is a strong, cheap result.
  2. A numeric-only lookup tells you what the code can actually see. A plaintext field the code cannot read is a red herring — a very common trap in “I edited the config and nothing happened” situations.
  3. Two-pass parsing is a deliberate design. Merging decrypted inner fields back into the document and re-scanning is how you hide the authoritative value behind encryption.
  4. The signature is the lock, not the encryption. Even with the AES key, a forged file fails signature verification. The encryption protects the data; the signature protects the integrity of the whole envelope.

Part 3 zooms into that crypto layer — the runtime integrity gate, the signature lock, and the exact reason editing the expiry date is a dead end.


Series navigation: