· 13 min read

How V8 Leaks Your Headless Browser's Identity


Introduction

There is a fundamental tension at the heart of browser DevTools: to display your objects usefully, the inspector has to look inside them. And looking inside an object is, in JavaScript, an observable action.

This post is about exploiting that tension. We will cover how bot detection engineers discovered that Chrome’s DevTools Protocol leaks its own presence through console API calls, walk through the V8 patches that closed the most well-known vector, and then examine a second signal that bypasses those patches entirely — one rooted deeper in how V8’s debugging iterator was designed.

To be precise about what we are detecting: opening Chrome DevTools activates the CDP Runtime domain internally. Puppeteer, Playwright, and any other automation tool that calls Runtime.enable triggers the same code path. From V8’s perspective these are identical. Both cause the inspector to subscribe to console events and begin serializing arguments for display. The detection signals described here fire in both cases.

Throughout this post, “the inspector” refers not to the DevTools UI itself, but to the C++ subsystem inside V8 — v8/src/inspector/ — that implements the CDP protocol, intercepts console calls, and serializes objects for display.

The Classic Signal: Trapping the Error Stack Getter

The technique that circulated widely among bot detection vendors looked like this:

let detected = false;
const e = new Error();
Object.defineProperty(e, "stack", {
    configurable: false,
    enumerable: false,
    get() {
        detected = true;
        return "";
    },
});
console.debug(e);
return detected; // true if Runtime domain is enabled

The payload is an Error object with a custom getter on the stack property. In a normal browser context, calling console.debug(e) just logs the object. No one reads .stack. The getter never fires. detected stays false.

With Runtime.enable active, the behavior changes. The inspector intercepts the console.debug call and routes the argument through the error formatting path. The smoking gun is in v8/src/inspector/value-mirror.cc, function descriptionForError():

String16 descriptionForError(v8::Local<v8::Context> context,
                              v8::Local<v8::Object> object) {
  v8::Local<v8::Value> nameValue;
  if (object->Get(context, toV8String(isolate, "name")).ToLocal(&nameValue)) {
    // uses nameValue...
  }
  v8::Local<v8::Value> stackValue;
  if (object->Get(context, toV8String(isolate, "stack")).ToLocal(&stackValue)) {
    // uses stackValue...
  }
  v8::Local<v8::Value> messageValue;
  if (object->Get(context, toV8String(isolate, "message")).ToLocal(&messageValue)) {
    // uses messageValue...
  }
}

object->Get() in the V8 C++ API is not a simple memory read. When V8 resolves a property access, it walks the property descriptor chain. If the descriptor defines a getter function, V8 calls it. The name, stack, and message reads each independently invoke object->Get(), which independently triggers any getter defined on those properties. Your getter runs. detected flips to true.

The Patch and Why It Is Incomplete

In May 2025, two commits landed in V8 that addressed this signal — May 7th and May 9th. The fix introduced getErrorProperty(), a wrapper around object->Get() that extracts the getter and checks its ScriptId before invoking it:

if (deepBoundFunction(getter)->ScriptId() != v8::UnboundScript::kNoScriptId) {
  return v8::MaybeLocal<v8::Value>(); // skip user-defined getters
}

kNoScriptId identifies native C++ accessors. User-defined getters have real script IDs — getErrorProperty() returns empty and the read is skipped.

The guard has a prerequisite: GetOwnPropertyDescriptor on the instance must return a descriptor. If it doesn’t, the function takes Path B — object->Get() directly, before the ScriptId check is ever consulted:

v8::Local<v8::Value> descriptor;
if (!object->GetOwnPropertyDescriptor(context, name).ToLocal(&descriptor)) {
  return object->Get(context, name);  // Path B: guard never reached
}
// ScriptId check only happens below here

Property resolution in JavaScript is not limited to own properties. Any construction where the relevant getter does not appear as an own property of the Error instance bypasses the guard entirely. I have verified that Path B is exploitable in post-patch Chrome — the construction is outside the scope of this post, but the structural gap is not theoretical.

getErrorProperty() decision tree — Path B bypasses the ScriptId check entirely

The CDP Serialization Path

When Runtime.enable is active and JavaScript calls any console.* method, the CDP backend in v8/src/inspector/v8-console-message.cc intercepts the call before it reaches the user-visible console. The function V8ConsoleMessage::wrapArguments is invoked on every argument.

For simple primitives this is cheap. For objects, the inspector does something more expensive: it builds a preview — a deep, interactive representation of the object’s properties intended for display in the DevTools panel. This is preview serialization, and it requires enumerating the object’s keys.

Key insight: enumerating keys on a normal JavaScript object is a silent memory read. Enumerating keys on a Proxy is not. A Proxy turns key enumeration into a function call. If that function is user-supplied, the inspector has just called into your code.

Everything that follows is a consequence of that.

The Prototype-Chain Proxy Technique

All analysis and logs in this section are from a local content_shell build (inspector + DevTools + Blink) from March 2026. The technique was unpatched at the time of writing.

The payload:

let detected = false;
const trap = new Proxy(
    {},
    {
        ownKeys() {
            detected = true;
            return [];
        },
    },
);
const obj = Object.create(trap);
console.groupEnd(obj);
return detected; // true if Runtime domain is enabled

The construction is deliberate. obj is not a Proxy. It is a plain object whose prototype is a Proxy. typeof obj is "object".

Tracing the Execution

When Runtime.enable is active and console.groupEnd(obj) is called, detected becomes true. Four C++ layers separate the console call from the JavaScript trap firing: the inspector decides to serialize the argument (A), its Proxy guard checks only the surface (B), the property iterator eagerly walks the prototype chain (C), and the spec forces V8 to call the trap (D).

Layer A: The Inspector Decides to Preview

V8ConsoleMessage::wrapArguments in v8/src/inspector/v8-console-message.cc is called with the session and a generatePreview flag. obj is not passed in — it was captured into m_arguments when the console call was made. The function retrieves it and wraps each argument with generatePreview set to true:

for (size_t i = 0; i < m_arguments.size(); ++i) {
  std::unique_ptr<protocol::Runtime::RemoteObject> wrapped =
      session->wrapObject(context, m_arguments[i]->Get(isolate), "console",
                          generatePreview); // generatePreview is TRUE
  args->emplace_back(std::move(wrapped));
}

This applies to every console.* method — log, warn, error, groupEnd all route through wrapArguments. Even though groupEnd requires no parameters in the JS spec, JavaScript allows passing arguments to any function — and V8’s CDP backend dutifully processes them anyway. The inspector does not distinguish — it requests deep, interactive preview serialization on whatever was passed.

Layer B: The Proxy Check Fails

Chromium developers were aware that Proxies could be weaponized through this path. A guard exists in buildObjectPreviewInternal in v8/src/inspector/value-mirror.cc:

v8::Local<v8::Value> value = v8Value(v8::Isolate::GetCurrent());

while (value->IsProxy()) value = value.As<v8::Proxy>()->GetTarget();

if (value->IsObject() && !value->IsProxy()) {
  v8::Local<v8::Object> objectForPreview = value.As<v8::Object>();
  getPropertiesForPreview(context, objectForPreview, ...);
}

The intent: if someone passes a Proxy directly, the while loop peels it away until the underlying target is reached.

obj is not a Proxy. value->IsProxy() returns false on the first check. The loop body never executes. The guard exits having verified only the surface of the argument.

[DEBUG] buildObjectPreviewInternal: Initial Object is Proxy? 0
[DEBUG] After unwrapping: Object is Proxy? 0. Proceeding to getProperties...
Logs from a local debug build of V8

The inspector concludes the object is “passive data” and hands it to getPropertiesForPreview.

Layer C: Premature Key Collection

To enumerate properties, V8 creates a DebugPropertyIterator in v8/src/debug/debug-property-iterator.cc. The iterator is constructed through a static factory method, Create(), which contains its own Proxy guard — the same pattern as Layer B:

std::unique_ptr<DebugPropertyIterator> DebugPropertyIterator::Create(
    Isolate* isolate, DirectHandle<JSReceiver> receiver, bool skip_indices) {
  auto iterator = std::unique_ptr<DebugPropertyIterator>(
      new DebugPropertyIterator(isolate, receiver, skip_indices));

  if (IsJSProxy(*receiver)) {        // surface-level check only
    iterator->AdvanceToPrototype();
  }

  if (!iterator->FillKeysForCurrentPrototypeAndStage()) return nullptr;
  if (iterator->should_move_to_next_stage() && !iterator->AdvanceInternal()) {
    return nullptr;
  }
  return iterator;
}

If the receiver itself is a Proxy, Create() skips past it before collecting any keys. But obj is not a Proxy — it is a plain object whose prototype is a Proxy. IsJSProxy(*receiver) returns false and the guard is bypassed. This is the same incomplete check as buildObjectPreviewInternal in Layer B: only the immediate value is tested, not the prototype chain.

Rather than waiting for the caller to request keys, Create() calls FillKeysForCurrentPrototypeAndStage and then AdvanceInternal, walking the entire prototype chain before returning the iterator. The caller — getProperties in value-mirror.cc — never gets a chance to decide it only wants own properties. The trap fires before the iterator is even returned.

AdvanceInternal is a state machine that cycles through three stages per prototype level. Each call to FillKeysForCurrentPrototypeAndStage collects keys for the current prototype at the current stage. When a stage yields zero keys, the while loop advances to the next stage. When all three stages on a prototype are exhausted, AdvanceToPrototype() moves the iterator to the next prototype in the chain and the cycle repeats:

bool DebugPropertyIterator::AdvanceInternal() {
  ++current_key_index_;
  calculated_native_accessor_flags_ = false;
  while (should_move_to_next_stage()) {
    switch (stage_) {
      case kExoticIndices:
        stage_ = kEnumerableStrings;
        break;
      case kEnumerableStrings:
        stage_ = kAllProperties;
        break;
      case kAllProperties:
        AdvanceToPrototype(); // steps up the prototype chain
        break;
    }
    if (!FillKeysForCurrentPrototypeAndStage()) return false;
  }
  return true;
}

For our payload, obj has no own properties. The loop cycles through all three stages — kExoticIndices, kEnumerableStrings, kAllProperties — each yielding zero keys. On kAllProperties, should_move_to_next_stage() returns true one more time, the switch hits AdvanceToPrototype(), and the iterator moves from obj to its prototype: the Proxy. The stage resets to kExoticIndices and the loop continues — now operating on the Proxy.

Whether each stage is safe or dangerous depends on FillKeysForCurrentPrototypeAndStage, which decides how to collect keys:

bool DebugPropertyIterator::FillKeysForCurrentPrototypeAndStage() {
  // ...
  DirectHandle<JSReceiver> receiver =
      PrototypeIterator::GetCurrent<JSReceiver>(prototype_iterator_);
  if (stage_ == kExoticIndices) {
    if (skip_indices_ || !IsJSTypedArray(*receiver)) return true; // no GetKeys call
    // ...
  }
  PropertyFilter filter =
      stage_ == kEnumerableStrings ? ENUMERABLE_STRINGS : ALL_PROPERTIES;
  KeyAccumulator::GetKeys(isolate_, receiver, KeyCollectionMode::kOwnOnly,
                          filter, ...);
}

receiver is whatever prototype the iterator is currently pointing at — after AdvanceToPrototype(), that is the Proxy. At kExoticIndices, the function checks IsJSTypedArray and returns early without calling GetKeys — this is why the first stage on the Proxy is harmless. At kEnumerableStrings, there is no such short-circuit — the function calls KeyAccumulator::GetKeys directly on receiver, which is the Proxy. The log shows this progression:

[DEBUG API] Child object had 0 keys. Moving to next stage? 1
[DEBUG API] Stepping up to Prototype!
[DEBUG API] Requesting keys from V8 Core. Is receiver a Proxy? 1  ← kExoticIndices on Proxy, passes harmlessly
[DEBUG API] Requesting keys from V8 Core. Is receiver a Proxy? 1  ← kEnumerableStrings on Proxy, triggers trap
Logs from a local debug build of V8

kExoticIndices returns immediately — the Proxy is not a TypedArray. kEnumerableStrings calls KeyAccumulator::GetKeys on the Proxy, and the trap fires.

Layer D: The Spec Forces the Crossing

KeyAccumulator::GetKeys creates a FastKeyAccumulator, which calls KeyAccumulator::CollectKeys. CollectKeys detects the receiver is a Proxy in kOwnOnly mode and routes immediately to CollectOwnJSProxyKeys — no prototype walk, no further indirection. CollectOwnJSProxyKeys in v8/src/objects/keys.cc then receives the request. The ECMAScript specification is unambiguous: to retrieve the own property keys of a Proxy, the engine must invoke the [[OwnPropertyKeys]] internal method, which means calling the ownKeys trap if one is defined. There is no mechanism to observe keys without executing the trap.

// 5. Let trap be ? GetMethod(handler, "ownKeys").
DirectHandle<Object> trap;
ASSIGN_RETURN_ON_EXCEPTION(isolate_, trap, ...);

// 6. If trap is undefined, then
if (IsUndefined(*trap, isolate_)) {
  // 6a. Return target.[[OwnPropertyKeys]]().
  return CollectOwnJSProxyTargetKeys(proxy, target);
}

// 7. Let trapResultArray be Call(trap, handler, «target»).
DirectHandle<Object> trap_result_array;
ASSIGN_RETURN_ON_EXCEPTION(
    isolate_, trap_result_array,
    Execution::Call(isolate_, trap, handler, ...)); // the portal to JS

Step 6 is the spec’s escape hatch: if the handler does not define an ownKeys trap, V8 falls through to CollectOwnJSProxyTargetKeys, which queries the target object directly — no user code is executed. Our payload defines the trap, so step 6 is skipped and execution reaches step 7.

Execution::Call is the C++-to-JavaScript boundary. V8 pauses native execution and transfers control to the ownKeys function — the one we wrote. detected = true. Control returns to C++.

Execution::Call() is the only path — the spec leaves V8 no choice but to invoke the trap
[V8 CORE] PROXY DETECTED: 0x18b00104e3b9
[V8 CORE] Prepping to jump from C++ to JS to run 'ownKeys'...

>>>>>>>>>>>> CROSSING THE C++ / JAVASCRIPT BOUNDARY >>>>>>>>>>>>
[V8-DEBUG] CROSSING BRIDGE TO JS CODE:
() => {
    detected = true;
    return [];
}
<<<<<<<<<<<< RETURNED TO C++ NATIVE CODE <<<<<<<<<<<<

[V8 CORE] JS Trap returned 0 keys.
Logs from a local debug build of V8

Root Cause: Three Design Decisions in Combination

No single line of code is wrong here. The vulnerability is a combination of design decisions that are individually reasonable:

1. Unconditional preview serialization. wrapArguments performs full preview serialization on every argument passed to any console.* method. The payload uses console.groupEnd, which takes no arguments per spec — but any console method works. The point is not that groupEnd is special, but that the inspector does not distinguish between methods that need to display their arguments and methods that do not. If preview generation were skipped for methods with no use for it, the serialization path would never be entered.

2. Incomplete Proxy unwrapping. The IsProxy() check in buildObjectPreviewInternal only examines the top-level value, and the IsJSProxy(*receiver) check in DebugPropertyIterator::Create() repeats the same pattern. Neither inspects the prototype chain. An object that is not itself a Proxy, but whose prototype is, passes both checks silently. A complete defense would need to traverse the prototype chain and check every ancestor — an expensive operation for what is currently a single-call guard.

3. Premature key collection. The DebugPropertyIterator accumulates all keys at construction time, including from prototypes, before any iteration is requested. Lazy evaluation — only collecting keys when actually needed by a rendering loop — would reduce the window in which a Proxy trap can be triggered by preview code that may decide not to iterate at all.

Implications

The castle.io post Why a classic CDP bot detection signal suddenly stopped working — and nobody noticed was the inspiration for this one — it documented the Error-getter technique and the May 2025 patch, and is worth reading alongside this post.

The prototype-chain Proxy technique described here requires no special permissions, no browser extensions, and no timing measurements. It is a synchronous, deterministic test. Any environment where Runtime.enable has been called — automation frameworks, open DevTools, headless browser tooling — will trigger the trap. Environments where it has not been called will not.

The underlying pattern — reaching a user-controlled trap through an inspector code path that only guards the immediate argument — is unlikely to be unique to these two surfaces.


If you think I got something wrong, have questions, or just want to talk — feel free to reach out on Discord: @sveba