VM obfuscation is supposed to be the one that holds. DataDome switched to it in February; Kasada and TikTok got there years ago.

Your code becomes bytecode for a made-up machine, so getting it back means reverse-engineering the machine first. Days of expert work, the story goes.

I gave that job to 9 AI models from 5 companies: 8 obfuscators, 64 runs, every answer checked against my original on thousands of inputs.

The VM obfuscators fell first, to every decent model, in minutes. The only thing that held longer was 400 times bigger and unshippably slow.

So "can AI crack it?" is the wrong question. The real problem is what you get when it cannot: code that passes every test, wrongly.

10 samples per agent, 6 shippable obfuscators, 2 programs:

Fable 5.1 GPT-6 Opus 5 Grok 4.7 Composer 2.5
Exact, out of 10 9 9 8 8 2
The 6 VM samples 6/6 6/6 6/6 6/6 2/6
Median time 6 min 7 min 7 min 17 min 36 min
Wrong files shipped as finished 1 1 1 1 7

What Was Tested?

2 programs of my own, both pure functions so I can check equivalence exactly:

  • A token generator - random numbers, a hash, a shuffle, a cipher, a checksum, custom base64. 70 lines. What an anti-bot script computes.
  • A rule engine - a tiny language parser with a scorer. 150 lines of branches rather than arithmetic.

8 protections, each confirmed to still produce correct output before any agent saw it:

Configuration Type Size
Obscura stack VM, opcodes reshuffled on every build, XOR-encrypted bytecode 38 KB
js-virtualizer register VM, compressed and base64 bytecode 24 KB
Twisted stack VM, seeded opcode permutation 18 KB
Enigma register VM, a recreation of Kasada's; repo since taken down 16 KB
obfuscator.io, max flattened control flow, RC4 strings, self-defending, debug protection 250 KB
js-confuser, high flattening, dispatcher, opaque predicates, hidden strings 874 KB
js-confuser, max plus runtime-generated functions, integrity lock, nested eval 7 MB
js-virtualizer + js-confuser the VM interpreter itself run through js-confuser 409 KB

5 agents ran the 6 shippable ones on both programs: Fable 5.1 and Opus 5 (Claude Code), GPT-6 (Codex), Grok 4.7 and Composer 2.5 (Cursor).

Only Opus ran the 2 unshippable ones. Sonnet 5, Haiku 4.5 and 2 Geminis (Antigravity) ran 2 samples each.

Each agent got a folder with 1 file, the same prompt, a shell and 45 minutes. No web, nothing outside the folder.

An answer counted only as a standalone file, no interpreter or bytecode smuggled inside, matching my original on 6,000 seeded inputs.

The 4 deobfuscators everyone recommends, webcrack, restringer, synchrony and obfuscator-io-deobfuscator, went first: 0 of 92 files.

webcrack got closest, decoding obfuscator.io's strings into output that hangs forever. The self-defending check works, against exactly the tool it was built for.

Which Agents Cracked What?

Time and outcome for every sample, one panel per agent
One panel per agent, one bar per sample, all in the same 45-minute scale. Green is an exact recovery, amber a file that is wrong on some inputs, red nothing usable. The two-sample models are at the bottom.

4 of 5 agents went 6 for 6 on the VMs in 2 to 18 minutes, and doubling the program's size barely slowed them.

obfuscator.io still fell to 4 agents. js-confuser earned its name: only GPT-6 cracked its token generator, only Fable and Opus its rule engine, nobody both.

But the biggest gap in that chart is between agents, not obfuscators. Same files, same prompt, and Composer composed 7 wrong answers.

2 of those took over 30 minutes, on VMs the others read in 4. The slow agents were not careful, they were lost.

Why Did the VMs Fall First?

How a VM obfuscator ships its own key, and the four steps an agent takes to use it
A VM obfuscator has to ship the interpreter next to the bytecode, in JavaScript the engine can run. That interpreter is the key, and it does not get bigger when your code does.

A VM hides your program as data, but the interpreter that reads it ships alongside, in plain JavaScript. The lock comes with its key.

And that key stays small however big your program gets, so the attack costs whatever the interpreter costs to understand.

Every agent that beat a VM paid it the same way, unprompted: read the dispatch loop, work out the opcodes, write a disassembler.

The disassemblers ran 39 to 78 lines. Part of 1 listing, from Obscura:

   16: LOADVAR    'GaKRvXsdwt'
   21: PUSHC      1540483477
   26: BXOR
   27: PUSHC      0
   32: SHR
   33: DECL       'VQvtwymIvz'
   ...
   80: LOADVAR    'Math'
   85: LOADVAR    'VQvtwymIvz'
   90: PUSHC      31
   95: METHCALL   argc=2 meth='imul'
  104: LOADVAR    'lczT'
  109: LOADVAR    'oIHkl$rp'
  114: METHCALL   argc=1 meth='charCodeAt'
  123: ADD

That is seed = (ts ^ 0x5bd1e995) >>> 0, then seed = Math.imul(seed, 31) + nonce.charCodeAt(i). From here the rest is typing.

None of the countermeasures survived contact:

  • Opcodes reshuffled per build ship their own mapping. 1 agent rebuilt Twisted's from the embedded seed without running the VM.
  • Encrypted bytecode decrypts itself: cut the file before its entry point and add a console.log.
  • Hidden constants (Enigma) fell to an agent that skipped disassembly and logged every value the VM produced.
  • Hashed comparisons (Obscura hashes each character before comparing) were brute-forced over every 1- and 2-character string.

2 VMs even break your code: js-virtualizer mangles 32-bit constants and Twisted's try/catch never catches. An agent reported the first by copying it faithfully.

Why Do Wrong Answers Pass Every Test?

My token generator has one line random testing cannot reach, if (seed === 0) seed = 0x1234567, hit once in 4 billion inputs.

Opus's js-confuser answer passed 6,000 of my inputs and 5,023 of its own, reported "fully recovered", and does not contain that line.

Fable's answer on the same file misses it too, and its notes say why: it regenerated the source "with never-executed statements removed", then tested that.

So I added a rare-branch check and looked at how each agent had got its algorithm:

How the agent got it Runs Exact Passes tests, still wrong
Read the code: disassembled it, traced opcodes, or deobfuscated it 12 12 0
Watched it run: hooked charCodeAt, poked it as a black box 7 3 4

In a listing a dead branch is a line. To an agent watching the program run it is nothing, and its tests inherit that.

The one exact black-box answer, Fable on the js-confuser rule engine, works because you can try every rule. You cannot try every cipher input.

Noise pushes agents from reading to watching: js-confuser re-decodes on every call, obfuscator.io routes every operator through a wrapper.

GPT-6 read anyway. It instrumented which switch branches ran, rewrote by hand, and its tests include "5 deliberately forced zero-seed cases".

9 minutes and right, against 27 minutes and wrong.

Did Anti-Debug Stop Anything?

No. Agents from every company spotted Obscura's timing check, obfuscator.io's debugger trap and self-check, and js-confuser's source hash.

None fired, because the agents pulled data out statically instead of stepping through protected code. Nothing to trip.

The only cost came from an accident: obfuscator.io's setInterval keeps Node alive after the script ends. Most agents added process.exit(0).

Haiku decided the file had a deliberate 2-minute load time and waited it out, every single run.

How Did the Smaller Models Do?

Outcome of every run, per model
One square per run for every model that took part. Amber is the one to watch: a file the agent shipped as finished that is wrong on some inputs.

Sonnet 5 cracked the Obscura VM in 10 minutes, then spent 45 minutes and 310k tokens on obfuscator.io missing the branch Opus found.

Haiku 4.5 failed both, guessing at HMAC and SHA variants instead of writing a disassembler, and spent more tokens failing than Opus spent succeeding.

Gemini 3.8 Flash cracked the VM in 3 minutes, then burned 1.2M tokens on js-confuser before Antigravity cut its turn short mid-trace.

That run pushed my account over a week-long quota, so Gemini's row stops at 2. Gemini 3.1 Pro refused the task in 12 seconds.

2 caveats. Codex does not tell you which model ran, so "GPT-6" is what my config asked for.

And the Cursor CLI's launcher cuts a multi-line prompt at the first line break, so every Cursor number here is from a full-prompt rerun.

Do Misleading Names Work?

An April 2026 paper found lying variable names survived Claude's deobfuscation every time. So I named my helpers crypto.sha256Seed, crypto.crc32, crypto.rc4, crypto.adler32.

None is that algorithm. Fable kept none of the names and called them decoys unprompted; Opus kept 1.

With a disassembler a name is a label on a call and the body comes from bytecode. Cost to the attacker: 90 seconds.

What Did It Cost?

Agent Time, 10 samples Tokens per sample, median
Fable 5.1 81 min 104k
GPT-6 66 min 73k
Opus 5 148 min 104k
Grok 4.7 223 min 325k
Composer 2.5 320 min 200k, plus 10M cache reads

Every run was on a flat subscription. The one list price I have is $1.73, Opus 5 on Obscura. Less than lunch.

Your side of the bill, per call on a 2 KB function that takes 0.0015 ms unprotected:

Configuration Size Per call Fastest exact recovery
The 4 VMs 16-38 KB 0.2-0.7 ms 2-4 min
obfuscator.io max 250 KB 0.3 ms 7 min
js-confuser high 874 KB 6 ms 9 min, one agent only
js-confuser max 7 MB 15 ms 42 min
js-virtualizer + js-confuser 409 KB 6.1 seconds 66 min
Shipped size against attacker time for each obfuscator configuration
Shipped size against Claude Opus 5's recovery time. Bottom-right is where a defender wants to be: small to ship, slow to break. Nothing lands there, and the one point that gets close takes six seconds per call. Size is on a log scale.

The VMs are cheap to ship because the interpreter is small and fixed, which is exactly why they are cheap to attack.

js-confuser bought the most attacker time by making your code 400 times bigger; the setup that held an agent an hour takes 6 seconds.

Elastic found the same on native binaries in April: obfuscation works when it exhausts the attacker's budget, and it exhausts yours first.

What Should You Do About It?

If you defend: a secret in the browser now lasts minutes, for anyone with a subscription to any of 4 companies.

Commercial VMs rotate per session and add WebAssembly, so DataDome is not broken. But the category rests on tedium, and agents do tedium best.

Score on the server, where nobody can read your code.

If you attack: never trust an answer because it passes tests. Ask the agent how it got the algorithm.

"I hooked the built-ins and watched" means you have the paths that happened to run. Make it show a listing and account for every branch.

Where Does This Not Apply?

  • These are my own small functions, not a vendor's rotating production script.
  • 1 run per cell, so treat the times as rough.
  • Each model ran in its own vendor's tool, so tool and model are tangled. Gemini has 2 samples because of the quota.
  • The commercial "AI-resistant" obfuscators are paid or demo-only. Their trick, per-build uniqueness, is what Obscura and Twisted already do.

Key Takeaways

  • Agents from 4 companies recovered clean source from VM-obfuscated JavaScript in 2-18 minutes, every time, the same way. Classic tools: 0 of 92.
  • The interpreter is the weak point, and it ships with your code.
  • Only heavy conventional obfuscation cost attackers real time, and you pay for it with 400x size or seconds per call.
  • Anti-debug, timing checks and integrity hashes changed nothing.
  • The dangerous failure is silent: wrong code that passes thousands of tests, reported as done. Every one came from watching instead of reading.
  • Model choice dominated: 9, 9, 8, 8 and 2 out of 10 on identical files.
  • Put the decisions that matter on the server.

Every line of code obfuscated and recovered here is mine, written for this benchmark. Nothing targets a production system, and I am not publishing tooling against anyone's script.