LLM-Assisted JS Reverse Engineering: Four Things AI Can Do, and Three You Can't Hand Off

Last Updated: 2026-07-30 10:12:58

You open a signature SDK that webpack spat out. Every variable is a or _0x3f2b, the control flow has been flattened, and the string literals live in an array you index into. Your goal is clear enough: turn this into a standalone implementation whose output matches the original byte for byte.

Almost everyone's first instinct now is to hand the whole thing to a large language model. And most people fall at the first step: they paste the entire bundle into the chat box and ask "what does this code do?" The model returns an explanation that reads perfectly reasonably, you implement it, and the result doesn't match the target by a single byte.

The problem isn't that the model is weak. It's that the division of labor is wrong. In deobfuscation work, a model is strong at pattern recognition and hypothesis generation and weak at factual verification. It can spot the skeleton of a cryptographic primitive buried in a pile of bit operations, but it cannot tell you whether it got it right — that has to be settled by your tests.

Here is a four-stage workflow organized around that division of labor, and three things I never hand to the model.

Stage 1: don't hand mechanical splitting to the model

The first reflex is usually "the context window is huge, just dump it all in." Don't. Two reasons.

The first is waste. Most of an obfuscated bundle is polyfills, runtime shims, and business modules that have nothing to do with your target. You pay to ship them into the context and get diluted attention back.

The second matters more: the bigger the context, the more places a hallucination can land. The model will stitch together features from two unrelated modules and hand you a conclusion that is internally consistent and simply does not exist. That kind of error is far harder to catch than obvious nonsense.

Splitting is deterministic work. Do it with a script:

  • Use an AST tool (@babel/parser, acorn) to break the bundle into modules and functions, and index them by scope;

  • Extract every numeric literal and string constant, grouped by frequency and bit width;

  • Build the call graph and mark the nodes with in-degree 0 (entry points) and out-degree 0 (leaf primitives);

  • Find every function with an unusually high density of bit operations — ^, >>>, <<, & clustered inside one function is usually the algorithmic core.

The leaf primitives are what you actually want to show the model. They're typically a few dozen lines, don't depend on upper-layer state, and have clear input/output boundaries. A 40-line leaf function plus the constants it references is a granularity the model handles reliably.

When this stage is done you should have a "candidate primitive list": each entry holds the function body, the constants it references, and where it's called from. Every model call from here on operates on one entry from this list — one at a time.

Stage 2: let the model identify the algorithm family

This is the step the model is irreplaceable at.

Cryptographic and encoding algorithms have strong fingerprints — specific constants, specific combinations of shift amounts, specific loop structures. A human recognizes them through accumulated experience; a model has seen every public implementation, so this comes naturally to it.

A few public examples, so you know what a "fingerprint" looks like:

  • 0x811c9dc5 and 0x01000193 together are the offset basis and prime of the 32-bit FNV-1a hash — the standard values published on the FNV reference page maintained by co-author Landon Curt Noll (given there in decimal: 2166136261 and 16777619);

  • 0x61707865, 0x3320646e, 0x79622d32, 0x6b206574 are the little-endian words of the ASCII string "expand 32-byte k" — ChaCha20's initial-state constants, listed in RFC 8439 §2.3;

  • a structure like a += b; d ^= a; d = rotl(d, 16); c += d; b ^= c; b = rotl(b, 12); with the four rotation amounts 16/12/8/7 is the signature of the ChaCha20 quarter round; the sibling Salsa20 uses 7/9/13/18, and those four numbers alone tell the two apart;

  • a 64-entry constant table starting with 0xd76aa478 is MD5's T-table (RFC 1321 §3.4);

  • a 256-byte table beginning 0x63, 0x7c, 0x77, 0x7b is the AES S-box (FIPS 197, Table 4);

  • 0xEDB88320 is the reflected CRC-32 polynomial (the value used in the gzip spec, RFC 1952).

The whole game is in how you ask. "What does this code do?" gets you prose. What you want is a verifiable, structured judgment, so the prompt has to force a three-part output: candidates, evidence, counter-evidence.

Below is a leaf function extracted from an obfuscated bundle, together with
every numeric constant it references.

<function>
{{function body}}
</function>

<constants>
{{constant list, with locations}}
</constants>

Answer in the following structure. Do not write prose.

1. Candidate algorithm families (at most 3, ranked by likelihood)
   For each: the name, and which class it belongs to
   (hash / stream cipher / block cipher / encoding / compression / checksum)

2. Supporting evidence
   Every piece of evidence must point to a specific constant value or a
   specific line above. "Structurally similar" and other unverifiable
   phrasing is not allowed.

3. Counter-evidence and deviation points
   If this were the standard implementation of that algorithm, what should
   appear here but doesn't? What appears that a standard implementation
   would never contain? Are these deviations a "variant," or "I got it wrong"?

4. The minimal test to decide this hypothesis
   Give 3 concrete inputs and, if the hypothesis holds, the shape of the
   output each should produce. Include boundary cases.

Part 3 is where this prompt earns its keep. The deviations from the standard implementation are exactly the places this code was modified — a custom alphabet, a swapped constant, an altered round count. And those deviations are the only parts you'll actually have to work at when you rewrite; everything else you copy from the public implementation. How to make the model surface every last deviation is the entire subject of the algorithm-fingerprinting piece.

Part 4 translates the model's judgment straight into the tests you run next, saving you a round trip.

Incidentally, this stage often runs into non-standard encodings. The test is mechanical: alphabet length 65 (64 characters plus one padding symbol), grouped by 6 bits, output length ceil(n/3)*4 — that's the Base64 family; a reordered alphabet means a custom table. Likewise, a dictionary that starts at 256 and grows, with output code width rising as the dictionary fills, is LZW. All of these can be verified from input/output length relationships alone, without reading a line of the code.

Stage 3: a hypothesis has to become a differential test

The model gave you a hypothesis. Until you've written the test, that's all it is — a sentence.

There's no shortcut here, and it's the only gate in the whole workflow that stops hallucinations (why it's the only gate is spelled out in the differential-testing piece). You treat the original implementation as a black box and compare your rewrite against it, case by case:

# Differential-test skeleton: original as black box, rewrite compared case by case
CASES = [
    b"",                      # empty input: exposes initial state and padding logic
    b"\x00",                  # single zero byte
    b"\xff",                  # single high byte: checks sign-bit handling
    b"a" * 63,                # one below the block boundary
    b"a" * 64,                # exactly one block
    b"a" * 65,                # one above: checks padding and carry
    bytes(range(256)),        # full byte coverage: checks alphabet mapping
]

for case in CASES:
    assert rewritten(case) == blackbox(case), case.hex()

A few rules of thumb:

Boundary crossings carry the most information. Block algorithms leak their padding logic most readily at 64n and 64n±1. If exactly one case in the whole run fails, the length of that input tells you directly which layer is wrong.

Run the same input twice. If the results differ, the implementation has mixed in a random number or a timestamp. You then have to find the injection point and allow it to be overridden externally, or you can never do a differential comparison at all. This is the most common sticking point when rewriting signature logic — the algorithm isn't wrong, the entropy source just hasn't been factored out.

Peel layer by layer; don't compare the whole thing. Match the innermost hash first, then the encoding layer once that passes, then the assembly layer. When the whole output disagrees you don't know where the fault is; peeled apart, the first layer that fails is the faulty one.

Commit your fixed vectors as a fixture. A table of known input → known output is the one thing that lets you quickly decide "did I write it wrong, or did they change it?" after an upstream update. The value of that fixture only grows over time.

The model's job in this stage is to generate test cases and explain differences — not to judge right from wrong. The thing that judges right from wrong is assert.

Stage 4: land it, degrading by transport layer

Once every hypothesis passes, you turn it into code that can run for the long haul. There's a priority order here, and the earlier the tier, the harder you should fight for it:

  1. A native rewrite in your target language. Fully detached from the original runtime, depending only on the standard library. This is the only form with no extra process, no extra dependency, and a clean path into CI.

  2. A local JS engine running a minimal fragment. Some logic is too expensive to purify in the short term, so you keep a small slice of the original JS and run it on a local Node/V8. Note that a JS engine context is not thread-safe; calling one compiled context from multiple threads has to be locked:

class Signer:
    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)
  1. A passive browser bridge. For state you can only get from a real page runtime, the browser is your only option for now. This is a temporary form; mark it clearly in the interface notes and never let it become the default implementation.

This degradation order is worth writing into your project conventions. Each tier down, your dependency surface, failure modes, and deployment cost all jump an order of magnitude — tier 1 is a pure function, tier 3 is an external process that needs a human to keep a session alive. Fight for tier 1 by default and you'll head off most of the long-term cost that "just get it working" quietly runs up. How to draw these three tiers, and how to keep the passive bridge from spiraling, is laid out in the purification-ladder piece.

One reference figure: an obfuscated signature SDK, run through all four stages, lands as a standalone implementation under 600 lines that depends only on the runtime's built-in crypto. That compression ratio isn't the model "understanding" the original file — it's that once the algorithm family is correctly identified, the vast majority of the code can be copied straight from the public implementation.

Which model for which stage

The four stages ask for completely different capabilities. Running one model for the whole thing either wastes money or wastes precision:

Stage

Capability it actually needs

My pick

model id

Post-split structural mapping

Long context, reads a whole module's call graph in one pass

Kimi K3

kimi-k3

Algorithm-family ID and counter-evidence

Strong reasoning; finds deviations, argues against itself

Claude Opus 5

claude-opus-5

Bulk symbol renaming, comment backfill

Cheap, runs hundreds of calls at high concurrency

Claude Sonnet 5

claude-sonnet-5

Difference attribution (reading a diff when a test fails)

Mid reasoning; explains against specific bytes

GPT-5.6 Sol

gpt-5.6-sol

Stage 2 is the one worth dwelling on. Algorithm-family identification is the only step where switching models visibly changes the result, because what it tests is precisely "how many public implementations have you seen" plus "are you willing to argue against yourself." On the same leaf function, a weaker model hands you a confident wrong answer; a stronger one crosses out its own candidate in the counter-evidence section.

Don't take my word for the difference — test it yourself. The protocol:

  1. Pick 3 leaf functions from your own obfuscated bundle, at least 1 you already know the answer to (as a control).

  2. Using the three-part prompt from Stage 2, feed the same input to claude-opus-5 and gpt-5.6-sol separately.

  3. Look at only two things: did it hit the candidate family; and is the counter-evidence section actually arguing against itself, or just restating the candidate in different words.

  4. The quality of the counter-evidence section is your selection criterion — it directly determines how many useless tests you'll write in Stage 3.

Stages 3 and 4 barely need model selection — anything that runs is fine. Stage 1 just wants a long-context model to save you writing your own chunked retrieval.

The friction isn't picking a model — it's the switching cost

Four models across three vendors: three SDKs, three auth schemes, three error formats. Rewriting your client to save a bit of money isn't worth it — which is exactly why most people end up running one model for everything.

AIReiter erases that layer: one key, one OpenAI-compatible interface, all four models behind it, and switching is just changing the model field in the request body.

# Algorithm-family ID: the reasoning tier
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": "<Stage 2 prompt + leaf function + constants>"}]
  }'

# Bulk symbol renaming: change the model field, leave everything else
#   "model": "claude-sonnet-5"

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

On price, Claude models run at 30% off list and GPT models at half price. That matters more than it sounds for this workflow: the bulk symbol renaming in Stage 3 easily runs into hundreds of calls, and the long-context pass in Stage 1 is a few hundred thousand tokens per single input — those two are the bulk of the cost, and the discount lands squarely on the most expensive part.

Three things not to hand to the model

One: don't let it produce the final implementation directly. Ask the model for "the complete rewrite" and what you get is code that looks complete and runs with subtle deviations — and you can't locate the deviation, because that code wasn't verified layer by layer by you. The right use is to have it give a hypothesis for each primitive, which you verify one at a time and assemble yourself. Slower, but you know why every line is the way it is.

Two: don't let it judge whether the result is correct. "Can you confirm this implementation is right?" is a dead-end question — the model tends to agree with you. The only authority on right and wrong is the differential test. Model says right but the test fails? The test wins. Model says wrong but every test passes? The test wins there too.

Three: don't let it make the compliance call for you. Whether you may touch the target, whether you may publish the conclusion, how you may use the data you obtain — all of this depends on your jurisdiction, the target's terms, and your specific purpose. The model has no factual basis for any of it; its answer is just mimicking the tone of the disclaimers it has seen. That call is yours to make, or your actual legal counsel's.

In closing

The model's place in this kind of work is specific: it's a pattern recognizer that has seen every public algorithm implementation and can hand you candidate hypotheses in seconds. It is not the source of answers, and it is not the verifier.

The skeleton of the workflow has nothing to do with whether AI is involved — mechanical splitting, hypothesis, verification, purification. Those four steps were the process long before models existed. What the model compresses is the "hypothesis" step, from days of digging through references down to minutes. The other three steps still cost exactly what they always did.

Once you've frozen the model calls in these four steps into scripts, the whole workflow runs fast. At that point the only friction left is model switching — an infrastructure problem, solved by picking your model on top of one unified interface.