AIREITER

Can't Find the Signing Logic in the Code? It Might Not Be in the Code at All

Last Updated: 2026-07-31 06:40:36

You dump a whole webpack bundle, cut the AST, list the leaf primitives, and you can read fingerprints now (the routine you already know). Now you're staring at one signature value in a request header. It's different every time, and you want the function that generates it. You search every suspect bit operation and every hash skeleton across all the static assets, and you find nothing.

It isn't that you didn't search carefully enough. That logic isn't in the code you dumped.

There's a class of signature that static analysis will always come up empty on, because the algorithm itself only appears at runtime. For a target like this, the right move isn't to keep hunting a function that doesn't exist. It's to switch strategy: don't reverse the algorithm, execute it or read it, as minimally as you can.

Two signs that static analysis is coming up empty

First confirm you've actually hit this kind of target, rather than just missing something. Two signs are easy to spot.

Sign one: the value changes per session or per request, but nothing in the static assets generates it. You see the value in a network request, different on every refresh, but pull down all the .js and full-text search it and there's nowhere that assembles it. The code that assembles it is shipped at runtime.

Sign two: the value is searchable as a literal in the bundle, but what you wrote down yesterday stops working today. It's a plain string constant in the build output, still usable if you copy it that day. A couple of days later the endpoint errors, and you look back to find that "constant" swapped for another new literal you can also find. It's hardcoded at build time, but it rolls with each frontend release.

Both signs share one thing: the static view is a snapshot, but the truth is a stream that changes over time or per session. Search the code and find nothing, or find it and it's alive. Either way, "dump once statically and copy" is the action that stopped working. This is a different problem from "can't recognize the algorithm family." It isn't that your fingerprint matching is weak. It's that the thing to match isn't in the copy of the code you're holding.

Type one, the challenge: the server ships you the algorithm to run

The first kind works like this: to reach some endpoint, the server first ships you a one-time piece of JS. That script runs in the browser and produces a cookie or token, and you need to carry it to get through. The script's contents usually differ per session, sometimes per request.

Why static analysis is guaranteed to come up empty: the logic is shipped at runtime and simply isn't in the static bundle. A shipped script you happened to capture is only that one instance. What you dumped today and what gets shipped tomorrow can be two different pieces of code, and reversing it is reversing a moving target.

The right strategy is to run it as a black box. You don't need to understand what it computes, only to give it a real enough environment to run to a result and take the result away. In practice:

  • Build a minimal browser-environment shim: empty stubs for document, location, navigator, cookie, and a few Observers and timers, filled in just until the script stops throwing because some global object is missing.

  • Run the shipped source in a local Node/V8 sandbox (node:vm, or a process started by execjs), with an execution timeout.

  • When the script runs it writes a cookie (or stuffs a value into some global). Intercept that write and pull the token you need out of it.

Zhihu's visitor check is this shape: a shipped script drops a visitor identifier into the browser cookie, and you shim the DOM environment so it believes it's running in a real page, then take the value once it finishes. Through the whole thing you reversed not a single line of algorithm, you just provided a convincing enough stage for it to act out its own play. The cost, then, isn't in "understanding the algorithm," it's in "keeping the environment real enough." The script probes navigator.webdriver, checks whether some node exists, expects some API to return, and your shim has to fool it exactly without growing too big to maintain. That tradeoff is what the "minimal execution surface" section below is about.

Type two, the dynamic identifier: the value is in the code, but changes every release

The second kind is the reverse: the value really is in the static bundle, as a literal, but it's generated at build time and rolls with each frontend release.

The classic case is a modern frontend using pre-registered queries instead of plaintext GraphQL. Each operation (fetch the timeline, fetch search results) maps to a build-time operation or query id, carried in the endpoint path. That id is searchable in the build output, but the moment the frontend ships a release, the same operation's id is a new value.

Static analysis gives you a false "I've got it" here. You found it, copied it, ran it that day, and hardcoded it. Two weeks later the endpoint returns 400, and only then do you find that the "constant" was alive. This is sneakier than type one, because you thought you'd already found it.

The right strategy is not to reverse it (there's no algorithm to reverse, it's a build constant) but to read the current value out of the current page at request time and cache it for the session. Reading has a ladder of candidates from cheap to expensive, and the earlier ones should be tried first:

  1. Look at resources the page already loaded. The page just sent a request carrying this identifier, so the current value is right there in that URL, and you pull it out of the resource record. Cheapest, reading an existing fact, not touching a line of algorithm.

  2. If you can't pull it there, extract it by pattern from the script source. Locate the declaration in the current bundle that says "this operation name maps to this id," and take it.

  3. Only if that fails, dig through the bundler's module table or fetch and parse related bundles by clues. Highest cost, kept for last.

Twitter's timeline and search operation ids are read exactly this way: pull the id out of a GraphQL request URL the page already sent, and once you have it, cache it for the session and use it throughout.

The systematic version of this kind, how many ways there are to get a pre-registered query, how much they cost, and which to pick when, is the subject of the piece on persisted operations. Here I'll only mark that it belongs to the larger class of runtime-derived values.

The line between the two kinds

Put the two side by side and what to do for each becomes clear.

Type one, the challenge

Type two, the dynamic identifier

Where the truth is

Shipped at runtime, never in the static code

In the static code, but rolls each release

What you do

Execute it: run the real algorithm, take the side effect

Read it: locate and read a constant, don't run the algorithm

What failure looks like

Environment shim too thin, code won't finish

Reader misses after a bundle reshuffle, gets a stale or empty value

Who triggers the change

The server, any time

A frontend release, on the deploy cadence

One line to close it: both kinds break "dump once statically and copy," and the only difference is whether you have to actually run the code. Knowing which kind you're in decides directly whether the next step is a sandbox or an extractor.

Why you shouldn't force purification on this kind of target

Someone will push back here: can't you fully reverse the shipped script's algorithm, or fully reconstruct the dynamic identifier's generation rule, into a native implementation detached from the original runtime? That's the purification from stage four of the four-stage workflow, the cleanest form, one investment for long-term zero dependency, and it goes into CI.

For these two kinds of target, the answer is usually that it doesn't pay. Here's a rough but workable payback model:

Purification costs a one-time C (reverse the algorithm, validate it with differential testing). After purifying, it saves s per unit time over "execute or read each time." Payback period is about C / s.

The deciding variable isn't C or s. It's the target's change cycle T, how often it changes.

  • T < C/s: it changes before you've paid it back, the purified implementation stops matching within days of shipping, and you redo it. Negative return.

  • T much greater than C/s: purification is a sure win, one investment lasts a long time, and you should go up the purification ladder toward the native-rewrite tier.

The two kinds of target naturally land in different places:

  • Type one's T is controlled by the server and can be arbitrarily short. They can change the shipped script's logic any time, and you can't predict it. So it lands on the "execute" side almost always. Forcing purification on an algorithm someone will change tomorrow hands your fate to their release cadence.

  • Type two's T is the frontend release cycle, days to months. The more robust your reader (a few more fallbacks, steadier anchors), the lower C/s, and either side can be reasonable, so you actually do the math.

That's the full meaning of the title. If you can't find it in the code, it's not necessarily that you missed it. It might be that there's no stable algorithm there worth purifying.

How to draw the minimal execution surface

Once you've decided on "execute" rather than "purify," the engineering goal changes. You aren't after clean, you're after the execution surface pulled down to minimal, controlled, and attributable. Three things.

Run only the necessary fragment. Don't move the whole page runtime in. Feed only the code the algorithm actually depends on, plus a minimal shim. The shim size has a sweet spot: small enough to just let the code run without throwing. Each extra stub is another maintenance burden (they tweak the probe, you have to follow), and each missing stub crashes it on the spot. Don't move a whole browser environment in to save effort, or what you maintain isn't a signer, it's half a browser.

Lock the context. A compiled V8/Node context isn't thread-safe. A one-time challenge naturally opens a fresh sandbox each time and they don't interfere. But the moment you reuse a compiled context to save startup cost, or cache a dynamic-identifier parser to share across requests, concurrent calls have to be serialized:

class RuntimeSigner:
    def __init__(self, source: Path) -> None:
        self._context = execjs.get("Node").compile(source.read_text())
        self._lock = threading.Lock()  # V8 context is not thread-safe

    def call(self, fn: str, *args):
        with self._lock:
            return self._context.call(fn, *args)

Failure has to be attributable. This is the most overlooked and most time-saving part of execution-style value fetching. A failure has to tell you which layer:

  • Environment too thin: the code throws a ReferenceError, or hangs to timeout. Your shim is missing something it wants.

  • Protocol changed: the code finishes and you get output, but the shape is wrong, the JSON won't parse. They changed the output format.

  • Semantics not met: you got a value, it parses, but it's incomplete, or it fails validation when you use it. The algorithm itself was changed.

Those three call for three completely different fixes (patch the shim, follow the protocol, check the algorithm). An executor that only reports a blanket "it failed" makes you investigate from scratch every time. A mature challenge executor distinguishes them explicitly: execution failure, unparseable output, incomplete result, and timeout are each their own error.

Which model for which step

The model's place in this flow is different from the fingerprinting work. There the model does algorithm-family identification. Here there's no algorithm to identify, and its main job is helping you make the architecture call of "purify or execute," plus attributing execution failures. The four sub-steps ask completely different things of a model:

Sub-step

Capability it needs

Pick

model id

Decide purify vs execute (argue both sides)

Strong reasoning, willing to argue against itself

Claude Opus 5

claude-opus-5

Where the dynamic identifier hides: locate the constant injection point and read candidates across the bundle

Long context, reads the whole bundle at once

Kimi K3

kimi-k3

Bulk-filter which candidate script might hold the target operation

Cheap, hundreds of calls at high concurrency

Claude Sonnet 5

claude-sonnet-5

On an execution failure, read the log or stack to tell which layer failed

Mid reasoning, explains against a specific error

GPT-5.6 Sol

gpt-5.6-sol

The first row is worth calling out, because it's the one step in this piece where switching models visibly changes the result. What it tests is exactly arguing both sides plus arguing against yourself, the same capability as the counter-evidence section in the fingerprinting work. A weaker model picks a path for you and piles on reasons to support it, without ever seriously arguing the other. A strong reasoning model argues both "purify" and "execute" to the end, writes out the strongest case and failure mode for each, and gives a conclusion by comparison.

Don't take my word for the difference, test it:

  1. Take a real target you've already made the call on as a control (you know in your gut whether it should execute or purify).

  2. Feed its observed facts (logic shipped at runtime or a release constant, how often it changes, how deep the environment dependency runs) to claude-opus-5 and gpt-5.6-sol, each writing a "purify vs execute" decision memo.

  3. Look at two things: did it recognize "change cycle" as the deciding variable (if not, if it only compared implementation difficulty, it loses); and are the conditions it gives for reversing itself observable ("if the identifier stops changing next release, flip back to purify," with a trigger signal, is usable).

One round shows you which model is making a decision and which is just calling it for you.

The switching cost is the real obstacle

Four models from three vendors, three SDKs, three auth schemes, three error formats. Rewriting your client three times just to switch models between sub-steps isn't worth it, so most people run one model the whole way, use a model that won't argue against itself at "purify or execute," dive headfirst into purifying a target the other side will change tomorrow, and go a long way around before realizing the direction was wrong from the start.

AIReiter removes that layer: one key, one OpenAI-compatible interface, all four tiers behind it, switching by changing the model field in the request body.

# Purify or execute: the reasoning tier arguing both sides
curl https://aireiter.com/api/v1/chat/completions \
  -H "Authorization: Bearer $AIREITER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-opus-5",
    "messages": [{"role": "user", "content": "<observed facts + argue the strongest case for both purify and execute>"}]
  }'

# Locate the dynamic identifier across the bundle: change the model field, leave the rest
#   "model": "kimi-k3"
# Bulk-filter candidate scripts:
#   "model": "claude-sonnet-5"
# Attribute an execution failure:
#   "model": "gpt-5.6-sol"

If you already use the OpenAI SDK, point base_url at https://aireiter.com/api/v1 and change nothing else. On the Anthropic SDK, hit POST /api/v1/messages with the same key.

On price, this flow's token weight is concentrated: Kimi K3 reads the whole frontend bundle to locate the dynamic identifier, a few hundred thousand tokens per input, and Claude Sonnet 5 bulk-filters candidate scripts, easily hundreds of calls. Those two decide the bulk of the bill. Claude at 30% off lands right on bulk filtering (Sonnet) and the decision argument (Opus), GPT at half price lands on failure attribution (GPT-5.6 Sol), and the long-context whole-bundle read on K3 is callable on the same key. The discount sits on the most token-heavy batch and the most expensive reasoning tier, not on a generic cheap.

In closing

Static analysis coming up empty isn't always about your skill, sometimes it's about the target: the signature isn't in the code, because it's shipped at runtime, or it changes every release.

For these two kinds, stop hunting the function that doesn't exist. For the challenge kind, execute it minimally, standing up a sandbox just real enough for it to run to a result. For the dynamic identifier, read it at runtime and cache it for the session. What both paths need first is admitting that the "static snapshot" view has itself stopped working.

And "purify or execute" is a pure architecture call, decided by one variable: whether the target's change cycle beats your one-time payback period. For the short-cycle ones, especially the kind the server can ship any time, forcing purification is a negative return. Hand that both-sides argument to a reasoning tier that will argue against itself, don't call it off the top of your head, and don't let the model purify for you. Purification is deterministic engineering, and its verification belongs to differential testing, which is stages three and four of the four-stage workflow. The model here only helps you think through one thing: whether this is even worth reversing.