Detecting DevTools via WebAssembly Overhead Asymmetry
A novel DevTools detection technique exploiting V8's synchronous inspector hooks during WASM compilation
Contents
Most DevTools detections rely on the same techniques:
console.logside-effectsdebuggertimingErrorbehaviour
These are fairly documented, easily mitigated and these patterns are widely recognised by reverse engineers.
This post describes a different approach. By exploiting an asymmetry between WebAssembly.Module() and WebAssembly.validate() inside V8, we can detect whether the Chrome DevTools inspector is attached using a signal that is structural to V8’s architecture.
My Initial Observation
While building a proof-of-work system that used WebAssembly for hash computation, I noticed a consistent performance degradation when DevTools were open.
I could have left this alone, but I have been collecting novel detections for a while, and I did think there was likely a new and interesting detection vector hiding here.
My initial theory was this was something related to DWARF. Maybe some debug information was being injected into the WASM pipeline when DevTools were attached?
After pulling up the chromium source I went hunting. Which is where I found this fairly interesting detection vector. V8 fires a synchronous chain of inspector callbacks on every WASM module compilation with no overhead when no inspector is connected.
How can this be abused?
WebAssembly.validate(bytes) and new WebAssembly.Module(bytes) both accept the same WASM bytecode, decoding and validating the module structure. But their implementation differences can be leveraged to flag devtools in a pretty sneaky way.
validate(): WASM Validation
WebAssembly.validate() enters V8 through WebAssemblyValidateImpl which calls WasmEngine::SyncValidate():
// v8/src/wasm/wasm-engine.cc
bool WasmEngine::SyncValidate(Isolate* isolate, WasmEnabledFeatures enabled,
CompileTimeImports compile_imports,
base::Vector<const uint8_t> bytes) {
TRACE_EVENT0("v8.wasm", "wasm.SyncValidate");
if (bytes.empty()) return false;
WasmDetectedFeatures unused_detected_features;
auto result =
DecodeWasmModule(isolate, enabled, bytes, true, kWasmOrigin,
DecodingMethod::kSync, &unused_detected_features);
if (result.failed()) return false;
WasmError error = ValidateAndSetBuiltinImports(
result.value().get(), bytes, compile_imports, &unused_detected_features);
return !error.has_error();
}
It calls DecodeWasmModule() to parse and validate the structure, checks import compatibility, then returns a boolean. The cost of validate() is constant regardless of DevTools being attached.
Module(): Compilation + Debug Hooks
new WebAssembly.Module() enters through WebAssemblyModuleImpl which calls WasmEngine::SyncCompile(). This function performs the same DecodeWasmModule() and ValidateAndSetBuiltinImports() calls, but then continues:
// v8/src/wasm/wasm-engine.cc - SyncCompile()
std::shared_ptr<NativeModule> native_module = CompileToNativeModule(
isolate, enabled_features, detected_features, std::move(compile_imports),
thrower, std::move(module), std::move(bytes), compilation_id, context_id,
pgo_info.get());
DirectHandle<Script> script =
GetOrCreateScript(isolate, native_module, kNoSourceUrl);
native_module->LogWasmCodes(isolate, *script);
isolate->debug()->OnAfterCompile(script);
CompileToNativeModule() compiles the module to native code or retrieves it from V8’s engine-wide NativeModule cache. When you compile the same bytes repeatedly, MaybeGetNativeModule() returns the cached result with no recompilation. GetOrCreateScript() caches Script handles per NativeModule. For repeated compilations of identical bytes, these are virtually free cache lookups.
LogWasmCodes() and OnAfterCompile() on the other hand, execute every time. These are the two calls that behave differently when DevTools is attached.
OnAfterCompile() delegation
OnAfterCompile() delegates to Debug::ProcessCompileEvent():
// v8/src/debug/debug.cc
void Debug::ProcessCompileEvent(bool has_compile_error,
DirectHandle<Script> script) {
RCS_SCOPE(isolate_, RuntimeCallCounterId::kDebugger);
if (script->id() == Script::kTemporaryScriptId) return;
if (running_live_edit_) return;
script->set_context_data(isolate_->native_context()->debug_context_id());
if (ignore_events()) return;
if (!script->IsSubjectToDebugging()) return;
if (!debug_delegate_) return;
SuppressDebug while_processing(this);
DebugScope debug_scope(this);
HandleScope scope(isolate_);
DisableBreak no_recursive_break(this);
AllowJavascriptExecution allow_script(isolate_);
{
RCS_SCOPE(isolate_, RuntimeCallCounterId::kDebuggerCallback);
debug_delegate_->ScriptCompiled(ToApiHandle<debug::Script>(script),
running_live_edit_, has_compile_error);
}
}
When no inspector session is connected, ignore_events() returns true (because is_active_ is false) and OnAfterCompile() returns at that check, never reaching the delegate call. The cost is some comparisons and a return (virtually zero).
When DevTools opens and enables the Debugger domain, V8’s inspector registers itself as the debug delegate and sets is_active_ to true. Now ignore_events() returns false, the call falls through to ScriptCompiled(), which iterates every active inspector session and calls didParseSource() on each one.
The expensive logic
V8DebuggerAgentImpl::didParseSource() runs synchronously on the main thread, the Module() constructor doesn’t return to JavaScript until this entire function completes. For WASM modules specifically, it performs:
- WASM debug symbol extraction - reads DWARF/sourcemap info from the module via
getDebugSymbols(), even when no debug info is present - Script registration - inserts the script into the agent’s
m_scriptsmap - Blackbox cache invalidation - resets cached blackboxed state
- Stack trace capture - walks the V8 stack via
V8StackTraceImpl::capture() - Breakpoint matching - iterates stored breakpoints (by URL, regex, and script hash) and tests each matching group against the new script. Matching breakpoints trigger
setBreakpointImpl(), which can cause WASM function recompilation with breakpoint instrumentation scriptParsedCDP event construction - serializes the full script metadata (ID, URL, hash, source map, debug symbols, resolved breakpoints) into a Chrome DevTools Protocol event for the frontend- Breakpoint resolution events - sends
breakpointResolvedfor each matched breakpoint - Instrumentation breakpoint setup - checks and sets instrumentation breakpoints if configured
Additionally, LogWasmCodes() (see SyncCompile above) is guarded by WasmCode::ShouldBeLogged(), which returns isolate->IsLoggingCodeCreation(). This is true when the inspector has registered a code event listener. With DevTools attached, it iterates every compiled function in the module and logs it. Without DevTools, it returns immediately.
Why the ratio inverts
For a minimal WASM module (34 bytes, single function returning a constant), the per-call costs break down roughly as:
Baseline - validate() cost:
| Function | Time | Note |
|---|---|---|
DecodeWasmModule() | ~15us | full decode + validation |
ValidateAndSetBuiltinImports() | ~3us | import compatibility |
| Total | ~18-19us |
Without DevTools - Module() cost:
| Function | Time | Note |
|---|---|---|
DecodeWasmModule() | ~9us | decode without validation |
ValidateAndSetBuiltinImports() | ~2us | import compatibility |
CompileToNativeModule() | ~2us | cache hit via MaybeGetNativeModule |
GetOrCreateScript() | ~1us | cache hit |
LogWasmCodes() | ~0us | ShouldBeLogged = false, returns |
OnAfterCompile() | ~0us | ignore_events() = true, returns |
| Total | ~14-16us |
With DevTools - Module() cost:
| Function | Time | Note |
|---|---|---|
DecodeWasmModule() | ~9us | same |
ValidateAndSetBuiltinImports() | ~2us | same |
CompileToNativeModule() | ~2us | same cache hit |
GetOrCreateScript() | ~1us | same cache hit |
LogWasmCodes() | ~3-5us | iterates compiled functions |
OnAfterCompile() | ~22-25us | the signal |
- didParseSource() | ~20us | debug symbols, script registration, stack capture, breakpoint matching, scriptParsed serialization |
| Total | ~40-44us |
Module() is slightly faster than validate() because cached compilations skip the full validation that SyncValidate always performs. SyncCompile passes validate_module = v8_flags.wasm_jitless (which is false in the standard non-jitless path), deferring validation to the compiler, while SyncValidate passes true. The cached NativeModule was already validated during its initial compilation.
When devtools are open and the compile / validate ratio changes from ~0.78 to ~2.33.
Proof of Concept (PoC)
The detection loops both operations, computes the compile/validate ratio, and averages over 5 rounds. A threshold of 1.5 provides comfortable margin above the clean baseline (~0.78) while staying well below the DevTools ratio (~2.33):
const THRESHOLD = 1.5;
const WASM_BYTES = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60,
0x00, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x07, 0x05, 0x01, 0x01, 0x66,
0x00, 0x00, 0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a, 0x0b,
]);
function sample(N) {
const t0 = performance.now();
for (let i = 0; i < N; i++) new WebAssembly.Module(WASM_BYTES);
const compileMs = performance.now() - t0;
const t1 = performance.now();
for (let i = 0; i < N; i++) WebAssembly.validate(WASM_BYTES);
const validateMs = performance.now() - t1;
const ratio = compileMs / Math.max(validateMs, 0.001);
return { compileMs, validateMs, ratio };
}
function runDetection() {
const N = 1000;
sample(50);
const results = [];
for (let i = 0; i < 5; i++) results.push(sample(N));
const avgRatio = results.reduce((s, r) => s + r.ratio, 0) / results.length;
return avgRatio > THRESHOLD;
}
Observed results
Tested on Windows 11, Microsoft Edge 148.0.3967.70:
| State | Module() per-call | validate() per-call | Ratio |
|---|---|---|---|
| No DevTools | ~15us | ~19us | 0.78 |
| DevTools open | ~42us | ~18us | 2.33 |
The validate() cost stays constant at ~18-19us regardless of DevTools state. The Module() cost jumps from ~15us to ~42us, close to a 3x increase driven entirely by the didParseSource chain.