← Back to all posts

Owning the runtime beats reversing the obfuscation

Anti-bot fingerprinting scripts ship as obfuscated JavaScript VMs with anti-debug traps, and the usual response is to deobfuscate them. That is a losing game the defender controls. The alternative is to stop reading the script and watch what it does to the browser instead, at the one boundary every fingerprinting probe has to cross: the call from JavaScript into V8's native C++ APIs. VisibleV8 instruments that boundary. Obfuscation, packers, eval, a JS interpreter-in-an-interpreter, and debugger-based anti-tampering all sit above it and change nothing about what gets recorded.

Summarize this article with

An anti-bot fingerprinting script does not arrive as readable code. It arrives as a few hundred kilobytes of generated JavaScript: identifiers renamed to _0x1a2b, control flow flattened into a dispatch loop, strings pulled from a shuffled array through a decoder function, and often a small bytecode interpreter written in JavaScript that runs the real logic as data. The script that reads your navigator, draws to a canvas, and times a few math functions is in there, buried under layers whose only job is to make it unreadable.

The instinct is to read it anyway. Pretty-print it, rename the variables back, trace the string decoder, unroll the dispatch loop, and recover the original program. This works, once, for one version of one vendor’s script. Then the vendor ships a new obfuscation seed and the recovered names are gone.

The defender controls the obfuscation, so the defender controls the cost of reading it. Every hour spent deobfuscating is an hour they can invalidate by rebuilding, and rebuilding is cheap for them and expensive for you. Playing that game means agreeing to a race on the terms of the side that sets the terms.

There is a different place to stand. Stop reading what the script says and watch what it does to the browser. This post is about that move, and about the tool that makes it practical: VisibleV8, an instrumented build of V8 from a North Carolina State University research group1 that logs every call a script makes across the boundary from JavaScript into the browser’s native code. Whatever the obfuscation does above that boundary, the calls that actually collect the fingerprint have to cross it, and on the far side they are recorded in the clear.

Outline

  1. What obfuscation actually protects
  2. The boundary every probe has to cross
  3. Where VisibleV8 puts the hook
  4. Why a VM in JavaScript changes nothing
  5. Detection, anti-debug, and what the engine hook sees
  6. Reading a trace
  7. What it costs and what it misses

What obfuscation actually protects

Obfuscation transforms a program into another program that computes the same result and resists reading. Renaming, string encoding, control-flow flattening, dead-code injection, and JavaScript-in-JavaScript interpreters all target one thing: a human, or a static analyzer, trying to understand the source.

None of it changes the result. The obfuscated script still has to end up calling navigator.userAgent, HTMLCanvasElement.prototype.toDataURL, AudioContext, WebGLRenderingContext.prototype.getParameter, and the rest of the fingerprinting surface, because those are the only ways to read the values it is after. Obfuscation hides the path to the call. It cannot remove the call, because the call is the point.

That is the seam. Everything the obfuscation protects lives in JavaScript. Everything the script wants to learn about the browser lives on the other side of a boundary, in the browser’s native code, and the script has to reach across to get it. Watch the reaching and the obfuscation above it becomes irrelevant.

The boundary every probe has to cross

V8 runs JavaScript. It does not, by itself, know what a navigator is, how to rasterize a canvas, or what GPU is installed. Those are provided by the browser (Blink and the rest of Chromium) and exposed to JavaScript as native functions and properties. When a script reads navigator.hardwareConcurrency, it is not running JavaScript that computes the answer. It is crossing from the JavaScript world into C++ that returns a value the JavaScript engine had no way to produce on its own.

The VisibleV8 authors make the analogy exact. V8’s runtime library, the set of native API bindings the engine calls into, is a software-enforced boundary that is to a web application roughly what the system-call interface is to a normal program: the gateway through which less-privileged code reaches the more-privileged code that can touch sensitive resources. Reading the GPU string is a syscall in this picture. Drawing to a canvas is a syscall. Every fingerprinting signal worth collecting is one or more crossings of this line.

flowchart TB subgraph JS["JavaScript world (the script's home)"] direction TB OBF["Obfuscated fingerprint script
renamed vars, packed strings,
flattened control flow"] VM["Optional: JS bytecode VM
runs the real logic as data"] OBF --> VM end BOUNDARY{{"JS / native boundary
V8 runtime library"}} subgraph NATIVE["Native browser code (C++)"] direction TB API["navigator, canvas, WebGL,
AudioContext, Date, ..."] end VM -->|must cross to read anything real| BOUNDARY OBF -->|must cross to read anything real| BOUNDARY BOUNDARY --> API BOUNDARY -.->|VV8 records every crossing here| LOG[("VV8 trace log
object, member, args,
script id, code offset")]

The boundary is not a place the script can avoid. A canvas fingerprint that never calls into the canvas implementation has no pixels to hash. A WebGL fingerprint that never calls getParameter learns nothing about the GPU. The obfuscation can make the crossing hard to find in the source. It cannot route around it, because on the far side is the only code that has the answer.

Where VisibleV8 puts the hook

Instrumenting the boundary from inside JavaScript is the obvious approach and the one that fails. Wrapping navigator or patching HTMLCanvasElement.prototype.toDataURL with a logging function puts your instrumentation in the same JavaScript object space the script lives in, where the script can see it. A patched native function stops reporting [native code] from its toString. A wrapper can be caught with a Trojan argument that triggers a stack trace on the call path. A prototype patch is bypassed by pulling a fresh, unpatched copy of the API out of a new <iframe>. The VisibleV8 paper walks through real scripts from the wild doing all three. Instrumentation that lives where the adversary can reach it is instrumentation the adversary can detect and defeat.

VisibleV8 moves the hook out of JavaScript and into V8 itself, in C++, below the object space the script can inspect. Two insertion points cover the whole boundary.

  • Native function calls all funnel through a single V8 runtime function that handles the transition from the Ignition bytecode interpreter into native C++. Adding one call statement there logs every foreign call a script makes. One optimization in the TurboFan JIT would have inlined past that hook, so VisibleV8 disables that single reduction and leaves the rest of the JIT untouched.
  • Property reads and writes are caught by patching V8’s bytecode generator. As the generator walks the abstract syntax tree and emits bytecode for a property get or set, VisibleV8 emits extra bytecode that calls a custom runtime function carrying the details. Reflect.get and Reflect.set are hooked the same way in the runtime library, so property access through the reflection API is captured too.

The whole change is 67 lines modified inside V8 plus 472 lines of new code for filtering and logging. The hooks run in native code, write to per-thread log files outside the page, and leave nothing in JavaScript object space. From the script’s side there is no wrapper to toString, no unexpected stack frame, no patched prototype, no fresh-iframe escape, because there is nothing in JavaScript to find. The engine reports on itself.

Why a VM in JavaScript changes nothing

The strongest obfuscation in wide use is a virtual machine: the fingerprinting logic is compiled to a custom bytecode, embedded as data, and executed by an interpreter written in JavaScript. Reversing it means recovering the bytecode format and the interpreter semantics, a real project, and the vendor can regenerate both.

Against runtime observation, the VM buys nothing. The interpreter is ordinary JavaScript running on V8. When its bytecode program decides to read navigator.userAgent, the interpreter still executes a real property get on the real navigator object, and that get crosses the same boundary as a direct access would. Nesting an interpreter inside JavaScript adds JavaScript-level indirection, and VisibleV8 observes below the JavaScript level. The number of interpreter layers stacked on top does not change what reaches the native API, and what reaches the native API is the only thing recorded.

The same reasoning covers the rest of the toolbox. eval and Function() build new code at runtime, and that code still calls native APIs to do anything observable. String-array decoders rebuild "userAgent" at runtime, and the property get that uses it is logged with the resolved name, because the hook fires at access time on the actual object, after every decoder has run. Control-flow flattening reorders how execution reaches the call and does not remove the call. Each of these operates on the JavaScript above the boundary. The recording happens at the boundary.

This is the sense in which owning the runtime beats reversing the obfuscation. Reversing works on the representation the defender chose and can change. Runtime observation works on the API surface the browser defines, which the script cannot redefine and cannot avoid touching.

Detection, anti-debug, and what the engine hook sees

There is a weaker version of runtime observation that people reach for first: hook the APIs from a browser extension or an injected content script, in JavaScript. It is easier to deploy and it runs into every wall from the section above. It lives in the page’s object space, so it is detectable, and the same 2019 measurement found that on 29% of the Alexa top 50k, at least one script was already probing for exactly these in-band instrumentation artifacts. A meaningful slice of the web actively looks for the JavaScript-level hook and behaves differently when it finds one. Instrumentation that changes the measured behavior corrupts the measurement it was meant to take.

Anti-bot scripts also carry active anti-debugging defenses, and script injection walks straight into them. A debugger statement in a hot loop pauses execution to a crawl whenever DevTools is open, and scripts detect the pause by timing it: measure Date.now() around the statement, and a gap of tens of milliseconds where there should be microseconds means someone is watching. Console access is trapped the same way, by defining a getter on a logged object that fires when the console formats it. When any of these trips, the script does what it likes: bail out before the fingerprint runs, feed back plausible but poisoned values, or behave like a clean browser and hide the logic you were trying to see. All of it targets the analyst who is stepping through the code or has the devtools open, and all of it lives in the same JavaScript the script controls.

VisibleV8 is invisible to that entire category. There is no debugger attached and no debugger statement can pause it, because the observation is a native logging call inside the engine, not a breakpoint. There is no console hook, no injected script, no timing artifact, because nothing in the page’s JavaScript is different from an ordinary Chrome run. The script executes at full speed, its anti-debug traps find nothing to trip on, and it runs its real fingerprinting logic while the engine records every native call underneath it. The defenses assume the observer is up in JavaScript with them, and the observer is one layer down.

The engine-level hook does not have this problem, and it comes with coverage the JavaScript approach cannot match. Some native properties in V8 are marked unforgeable and cannot be wrapped or replaced from JavaScript at all; for Chrome 64 the WebIDL definitions marked 21 API members this way, including accessors on window.location and window.document. A JavaScript instrumentation simply cannot see accesses to those. The bytecode-generator hook sees them, because it fires on the property-access bytecode regardless of whether the property could have been patched from script.

The practical payoff is a complete, ground-truth list of what a given anti-bot script actually touched, per page load, with no deobfuscation step in the pipeline. Load the page under the instrumented engine, let the script run, read the trace. The output is the set of native operations the fingerprinter performed, in order, with arguments. From that you learn which signals it collects and, over several loads, which it compares run to run and which it treats as stable. That is the information you were trying to extract by reading the source, obtained without reading the source.

Reading a trace

We run VisibleV8 in house, from the upstream patchset, and its log is newline-delimited JSON, one record per boundary crossing. A native call is a callargs record, and it carries the API member touched, the arguments, the script the call came from as a SHA-256 hash plus a byte offset into that script, and the security origin. Here are real records from a crawl, reformatted for reading:

["callargs", {
  "api_name": "178437,HTMLCanvasElement.getContext",
  "passed_args": [[["\"webgl\""], ["\"experimental-webgl\""]]],
  "script_hash": "06026e79b3a839ad46752ef9fd2ea8b6b91aa587d1510389...",
  "script_offset": 115074, "security_origin": "?"
}]
["callargs", {
  "api_name": "328744,CanvasRenderingContext2D.createLinearGradient",
  "passed_args": [[["10","0","180","1"], ["0","0","100","100"]]],
  "script_hash": "06026e79b3a839ad46752ef9fd2ea8b6b91aa587d1510389...",
  "script_offset": 49159, "security_origin": "?"
}]

The api_name is the native member, prefixed with an internal object id (178437) so distinct object instances can be told apart. passed_args groups the argument sets seen at that call site across the page’s execution, which is why a single logged site shows both "webgl" and "experimental-webgl": the script tried both. The script is identified by content hash and offset, not a filename, because a fingerprinting script is usually inlined, generated, or served from a rotating URL, and the hash is stable when the URL is not.

The variable that held the canvas was named _0x3f9c in the source, the string "webgl" came out of a decoder array, and the sequence may have run inside a bytecode interpreter. None of that appears in the record, because none of it is what the engine executed against the DOM. What the engine executed was a getContext("webgl") and a createLinearGradient, and that is what got logged, at the moment of the call, on the real object, after every decoder had run.

Other records from the same crawl show the rest of the fingerprinting surface in the clear. A Permissions.query call enumerating twenty permission names in sequence (geolocation, notifications, push, midi, camera, bluetooth, accelerometer, gyroscope, magnetometer, and on down the list) is a permission-fingerprint probe, whatever the surrounding code looked like. HTMLScriptElement.getAttribute("src") fired forty-six times from one script is that script inventorying which other scripts are on the page. The behavior names itself.

To make a 9,000-record log navigable, the project ships Go post-processors that fold the raw callargs stream into higher-level views (per-script feature sets, call sequences, third-party attribution) and we keep a small browser viewer that loads the NDJSON and lets us filter by API interface, origin, or script hash and regex-search the argument values. Load a page once under the instrumented engine, open the trace, filter to the fingerprinting script’s hash, and its entire native-API footprint is in front of you, in order, with arguments, and with no deobfuscation step anywhere in the pipeline.

What it costs and what it misses

Runtime observation is not free and not total, and the limits are worth stating plainly.

The build is heavy. VisibleV8 is a full patched Chromium compiled from source, and the upstream project ships Docker images and Debian packages to make that manageable. The current release tracks Chromium 147.0.7727.137 from April 2026, so the analysis engine stays close to the Chrome our fork ships. Runtime overhead for the full instrumentation runs around 60 to 70 percent against browser-level benchmarks, driven by logging every property access. The single disabled JIT reduction costs about 1.3 percent on its own; the rest is logging volume. That overhead is fine for analysis and wrong for serving live traffic, so the instrumented engine stays offline: it is where we understand a script, and what we learn there goes into the production browser.

It observes, it does not defeat. A trace tells you which signals a fingerprinter collects and how it weighs them. Making your browser produce the right values for those signals is a separate body of work, and the trace is what tells you which values to get right.

It sees the JavaScript-to-native boundary, and only that. Fingerprinting that happens entirely in native code with no per-probe JavaScript crossing, or signals gathered outside V8 altogether such as TLS and HTTP/2 handshake shape, is off this instrument’s field of view and needs its own tooling. Within its scope, which is the entire JavaScript fingerprinting surface, coverage is close to complete, because that surface is defined by the boundary VisibleV8 sits on.

The reason to reach for it is the framing at the top. Deobfuscation fights the adversary on ground they own and rebuild at will. Runtime observation moves the fight to the browser’s own API boundary, which the adversary defined and cannot move, and reads the fingerprinting operation off the one surface every probe is forced to touch. The obfuscation stops being an obstacle and becomes noise above the line where the recording happens.

Understanding exactly what a fingerprinting script measures, at the level the browser actually executes it, is how Scrapium, our stealth-patched browser stack, decides what to make indistinguishable from a real browser.


  1. Jordan Jueckstock and Alexandros Kapravelos, “VisibleV8: In-browser Monitoring of JavaScript in the Wild,” Internet Measurement Conference (IMC) 2019. The paper introduces the kernel/user-space analogy for the JS/native boundary, documents the 67-line V8 patch plus 472 lines of logging, and reports the measurement that 29% of the Alexa top 50k probe for in-band instrumentation artifacts. Source and patchsets: github.com/wspr-ncsu/visiblev8↩︎