Reverse-Engineering a Fingerprint Browser, Part 3: The Crypto Layer and the Runtime Gate

How the profile is protected, and why the obvious shortcuts are dead ends

Posted by 叉叉敌 on November 10, 2026

Part 2 ended with the punchline: editing the plaintext expiry in a profile file does nothing, because the enforcement code literally cannot read a string field. This post goes into the crypto layer properly — the envelope, the parser, the integrity checks, and a runtime check I will call the “runtime gate”.

A note on detail. This post explains the design of the protection scheme — the layers, the invariants, and why the obvious attacks fail. It intentionally omits the concrete constants, algorithm parameters, and addresses that would turn it into a reproduction guide. The mechanisms are the interesting part; the exact numbers are the part I am deliberately not publishing.

1. The envelope, at a structural level

The profile outer layer is plaintext JSON with a fixed key set and a fixed key order. Three content classes:

Class What it is
Plaintext metadata user agent, GPU strings, window/screen geometry, Client Hints, kernel/version/release tags, an expiry string
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 block size -> a block cipher (CBC / padded)
The outer expiry an ISO 8601 string — invisible to the numeric-only enforcement lookup

Two structural facts matter from here on:

  • The key block is a fixed size and unique per file, with a first-byte distribution that rules out the most naive key-wrapping schemes.
  • The fingerprint ciphertext length is always block-aligned, which tells you it is a block cipher, not a stream cipher.

2. The decryption pipeline

At a high level, loading a profile is a chain:

Callers (several entry points: the main profile loader, a second parser,
         and the automation-script environment)
        |
        v
Parser
 |  one-time init:
 |    decrypt an embedded configuration blob using a custom key-derivation step
 |    (the two string inputs to that step are runtime-initialized globals,
 |     so they are not readable statically)
 |
 +- Unwrap the key block -> key material
 |    (embedded, obfuscated key material; see below)
 |
 +- Decrypt the fingerprint block -> inner profile data

An embedded-material math test. I checked whether the embedded key blobs are plain RSA components (testing p*q==n and (n,e,d) self-consistency across plausible exponents). None held. The key material is not stored as plain components — it is obfuscated by a reordering step plus a derivation step.

Offline-ness (carried over from Part 2): decryption involves no network. So the key material needed to decrypt locally must be embedded in the binary — otherwise it could not decrypt at all. This is the foundation for the “analysis is possible” judgment in Part 5.

3. The runtime gate

After parsing, the loader runs a check that gates success. I fully unwrapped what it is, and it is the most elegant piece of the design.

It is not a cryptographic signature over the file bytes. It is an in-process state channel. Two thread-local variables are written at three points along the decryption path, always as a matched pair with a fixed relationship between the two values. At the end of the pipeline, a gate checks that the pair still satisfies that relationship, and that the primary value is non-zero.

Three consequences:

  1. It proves the pipeline actually ran. The gate only opens if decryption genuinely advanced far enough to write the pair. You cannot satisfy it by setting a constant somewhere.
  2. The paired write is anti-single-point-tampering design. Change one value, or change the relationship constant, and you break the invariant — the gate fails.
  3. It is no extra obstacle to copy or to analysis. A legitimately copied profile decrypts normally and the gate passes. The obstacle is the encryption itself, not the gate.

The design lesson: this is a proof of work, not a signature. It makes sure the code path really executed, without needing a secret. That is a neat way to defend against “patch out the check” attacks without shipping a verifier key.

4. Failure means refusal: there is no fallback load

After a decrypt/verify failure, the dispatcher inspects a status flag and a selector, then jumps through a table. Decoding the table targets, they are all destructor / cleanup functions — every path ends in exit.

Conclusion: there is no fallback path that loads an unencrypted profile when verification fails. Decryption failure = profile load failure. A forged profile cannot cheat by bypassing checks; it must genuinely decrypt.

5. The expiry decision, summarized

  • The numeric lookup only recognizes numbers -> the outer ISO string is invisible.
  • A two-pass loop brings the inner (decrypted) numeric expiry into the decision.
  • The clock comes from local time.
  • Two independent guards sit on the headless and headed paths.

That is the complete reason “changing the outer expiry does nothing”: the outer string is for humans; the effective expiry is inside the ciphertext — and without the key you cannot change it.

6. Direct answers

Can a profile be copied directly? — Yes

  • It is self-contained (key, ciphertext, metadata all in one file); no machine ID / hardware binding / one-time write was found.
  • The backup file is byte-identical to the original -> the program never rewrites or “activates” a profile.
  • Copy it to another machine, use another profile directory, and it works.

Can a profile be generated? — Conditionally feasible in principle

In principle yes — if the decryption key material is embedded, the corresponding public material needed to encrypt a new file must be obtainable from the binary or derivable from the key material. But there are hard prerequisites, all of which are exactly the pieces I am not publishing:

  1. Recover the public key / key material despite the reordering and derivation.
  2. Confirm the key-wrapping scheme and its parameters.
  3. Confirm the block-cipher mode and IV convention.
  4. Match the inner data schema exactly.

Once those are in hand, generation is essentially: build inner data -> block-cipher encrypt -> wrap the key -> assemble the envelope. The runtime gate and the outer expiry are no extra obstacle to a correctly encrypted file.

Alternative if the goal is just “use an existing profile indefinitely”: the patch route is far cheaper than fully replicating the crypto. Full generation only pays off if you need to mass-produce new profiles.

7. Open questions at this stage

# Question Status
1 The internal key-derivation step (inputs/outputs known, internals not fully disassembled) open
2 Two runtime-initialized string constants feeding the derivation open (suspected salt / label)
3 The reordering scheme open
4 Plaintext location of the public key ruled out as plain storage in the embedded blobs
5 Block-cipher mode / IV, inner data schema needs the key, or dynamic analysis
6 Some failure-dispatcher selector entries unaligned addresses, decoding uncertain (does not affect “no fallback”)
7 The role of a separate RSA public key in the binary unrelated to profile verification; exact use undetermined

Most of these get resolved in Part 5, where the key material is recovered and the whole chain is analyzed offline.

8. Method appendix: cross-reference scanning

To find all references to a global in a stripped binary, scan for the architecture’s page-relative address + immediate instruction pairs (on arm64, ADRP + ADD-immediate; on x86-64, RIP-relative lea/mov). Building a small, correct scanner for this one pattern is what makes the whole series possible: it turns “somewhere in a few hundred megabytes” into “these specific sites”. The subtlety is getting the immediate encoding exactly right — one wrong mask bit and you silently miss every reference.


Series navigation: