Hooking and tracing JavaScript with the DevTools Protocol
A piece of obfuscated JavaScript on a page does not want to be read. The identifiers are single letters, the strings are decoded at runtime from a packed array, the control flow has been flattened into a dispatcher loop, and somewhere in the middle of all that a function builds a token and posts it back to a server. You can stare at the deminified source for a week. Or you can let the code run and watch what it actually does: which functions get called, with which arguments, in which order, and what they return. That second approach is dynamic analysis, and the cleanest way to do it on Chromium is the same protocol DevTools uses to drive the browser.
The Chrome DevTools Protocol gives an external client the ability to set breakpoints, pause execution mid-function, read the entire scope chain at the pause point, evaluate arbitrary expressions inside that frozen context, and resume. Layered on top of that, plain JavaScript gives you a second instrument: the Proxy, which can wrap an object or a function and report every property read, write, and call that passes through it. Used together, the two let you observe a running program without editing its source, which matters when the source is a minified blob you do not control and cannot meaningfully patch. This post is a methodology walk-through of both, grounded in the protocol reference, and honest about where the technique leaks its own presence to code that is watching for it.
The sections go from the protocol primitives outward. First the Debugger domain: how breakpoints get placed by URL and regex, what arrives in the paused event, and how to read a call frame. Then the Runtime domain: evaluating in a paused context, calling functions on remote objects, and the binding mechanism that lets page code phone home to your client. Then the Proxy technique for tracing property access on a live object, with its sharp edges. Then a section on the reverse side of the coin, because obfuscated payloads watch for exactly this kind of inspection, and the same protocol features that let you observe are themselves observable. A closing read on what that mutual visibility means for anyone doing this work in 2026.
The Debugger domain: breakpoints you place from outside
The Debugger domain is the part of CDP that exposes V8’s debugging machinery over the wire. You enable it, and from then on the browser streams you a Debugger.scriptParsed event for every script the page compiles, each carrying a scriptId, the script’s URL if it has one, and enough metadata to fetch its source with Debugger.getScriptSource. That stream is the first useful thing. Before you have set a single breakpoint, you have a live inventory of every script the page is running, including the inline ones and the ones injected after load, which a static crawl of the HTML would miss entirely.
Breakpoints come in a few shapes. Debugger.setBreakpoint takes an exact location, a scriptId plus line and column, and stops there. That is precise but brittle, because the scriptId is assigned at parse time and changes on every reload. The method you actually want for analysis is Debugger.setBreakpointByUrl, which the protocol describes as setting a breakpoint “at given location specified either by URL or URL regex.” It survives reloads, and it can match scripts that do not exist yet at the moment you set it. Give it a urlRegex and every script whose URL matches gets the breakpoint when it loads. For a payload served from a path like /captcha/v3/<hash>.js, a regex on the stable part of the path catches the script across sessions even as the hash rotates.
There is a subtler breakpoint that earns its place in this work. Debugger.setInstrumentationBreakpoint stops execution at a lifecycle moment rather than a code location, and its one genuinely useful value is beforeScriptExecution: pause the VM the instant a script is about to run, before its first statement. Obfuscated bootstrappers like to install their own anti-debugging traps in the first few lines they execute, so being parked at the top of the script before any of that runs gives you a window to inspect the source, set further breakpoints, and decide where to go. The other allowed value is beforeScriptWithSourceMapExecution, narrower and rarely what you want here.
When any of these fire, the browser pushes a Debugger.paused event, and that event is where the analysis actually lives. It carries a reason (one of breakpoint, exception, debuggerStatement, instrumentation, and a handful more), the hitBreakpoints that matched, and the thing you came for: an array of callFrames. The frames are ordered top of stack first, so callFrames[0] is the function currently executing. Each CallFrame carries a callFrameId, the functionName, a location pointing into a parsed script, the value of this, and a scopeChain. The protocol is explicit that the callFrameId is “only valid while the virtual machine is paused” and that one of these on a stale pause is useless, which is the single most common mistake when scripting against this domain.
The scope chain is the prize. Each entry in scopeChain has a type (global, local, closure, block, catch, with, and others) and an object, a remote handle to the actual variable bag for that scope. A closure scope two frames up is right there as an object you can enumerate. In obfuscated code that decrypts strings lazily, the decryption key, the decoded-string cache, and the dispatcher’s current state are all variables sitting in one of these scopes at the moment you pause, and you can read every one of them without understanding a line of the surrounding code. That is the difference dynamic analysis buys you. Static deobfuscation has to reconstruct those values by simulating the program; a breakpoint hands them to you because the program already computed them.
Reading a frozen frame: evaluateOnCallFrame and the Runtime mirror
Pausing is half the technique. The other half is interrogating the frozen state, and there are two methods for it that overlap in confusing ways. Debugger.evaluateOnCallFrame runs an expression in the lexical context of a specific call frame. Hand it a callFrameId and an expression, and the expression sees that frame’s local variables, its closures, and its this exactly as the paused code would. If callFrames[2] has a local named k holding a decryption key, evaluateOnCallFrame against that frame with the expression k returns the key. This is how you reach into a closure that the language otherwise seals off completely.
The result comes back as a RemoteObject, which is the Runtime domain’s mirror representation of a JavaScript value. Primitives come back by value. Objects come back as a handle: a type, a className, a description string, and an objectId you can use later. To expand a returned object you call Runtime.getProperties with its objectId, which gives you the property names and their values, each itself a RemoteObject. For functions, the description is the function’s source text, which means you can pull the body of any function you have a handle to, even one that was generated at runtime and never appeared in a source file. That is how you recover the implementation of a method that an obfuscator built with Function(...) from a decoded string.
Runtime.evaluate is the sibling method, and the distinction matters. Runtime.evaluate runs in a global execution context, not in a paused frame’s scope, so it cannot see frame locals. What it can do is reach the page’s global state at any time, paused or not. Its parameters are worth knowing because they shape what you get back. returnByValue asks for a deep JSON copy instead of a handle, useful for small results and a trap for large object graphs. awaitPromise makes the call resolve a returned promise before answering, which matters because so much anti-bot work is async. contextId targets a specific execution context, which is how you choose between the page’s main world and an isolated world. And includeCommandLineAPI switches on the DevTools console helpers like $$ and monitor inside the evaluated string.
There is a third way to act on a remote object that avoids re-serializing expressions as strings. Runtime.callFunctionOn takes an objectId and a function declaration, and calls that function with the remote object as this. It is how you invoke a method on a deeply nested object you only hold a handle to, and how tooling implements operations like “call this object’s toString” or “run this decode function on that argument” without round-tripping the whole object through returnByValue. For tracing an obfuscated decoder, callFunctionOn against the decoder’s handle, passing a candidate index, tells you what string that index maps to, one probe at a time.
Hooking without a breakpoint: bindings and conditional pauses
Breakpoints stop the world, and stopping the world thousands of times is slow and, worse, observable through timing. Two CDP features let you observe specific events without the full pause-and-resume cycle.
The first is the binding mechanism. Runtime.addBinding installs a function with a name you choose onto the global object of inspected contexts, and the protocol notes that when no executionContextId is given the binding applies to “all inspected contexts, including those created later” and that “bindings survive reloads.” The function takes exactly one string argument. Every time page code calls it, the browser emits a Runtime.bindingCalled event carrying the binding name, the string payload, and the executionContextId that made the call. That gives you a one-way channel from inside the page out to your client with no polling. If you can arrange for the code under analysis to call your binding with a value, say through a conditional breakpoint that invokes it, you get a stream of observations out without ever pausing the VM.
The second is the conditional breakpoint used for its side effect rather than its condition. Debugger.setBreakpointByUrl accepts a condition, an expression the VM evaluates each time control reaches the location; the breakpoint only stops if the expression is truthy. The standard use is filtering. The analysis use is sneakier: write a condition that does the logging you want and then evaluates to false, so the VM never actually stops. A condition like (myBinding(JSON.stringify(arguments)), false) records every call’s arguments through the binding channel and lets execution flow straight through. You have hooked the function, captured its inputs on every invocation, and never paused. This is the closest CDP comes to a tracepoint, and it is the right tool when you want a high-frequency call traced rather than inspected one hit at a time.
The trade-off is the one the instrumentation literature keeps returning to. The synchronous breakpoint model has no clean way to intercept asynchronous flows, because a pause sits on the current synchronous stack and microtask continuations run later on a different stack. Tracing a value as it moves through a promise chain means re-breaking at each then rather than following it through, and the protocol’s async stack traces help reconstruct the history after the fact but do not let you ride the value live. And in minified code where a dozen functions share the variable name a, return-value tracing carries real ambiguity about which a you are watching. Dynamic analysis tells you what happened on the runs you observed; it does not prove what happens on every run. For payloads that branch on environment, that gap matters, and it pairs naturally with static work on the same code. The companion piece on control-flow flattening and string encryption covers the static side of the same obfuscation these breakpoints are cutting through.
Tracing property access with a Proxy
The Debugger domain hooks code at locations you choose. Sometimes you do not know the location, but you do know the object: a global the payload reads, a function it calls, a config bag it consults. For that, the instrument is the JavaScript Proxy, which wraps a target and lets a handler intercept the fundamental operations on it. The MDN reference lists the traps; the ones that matter for tracing are get for property reads, set for writes, apply for calls, construct for new, and has for in checks. A handler that logs in each trap and then forwards to the real operation gives you a complete record of how the code under analysis touches that object, with no breakpoints and no protocol round-trips per access.
The pattern is a transparent wrapper. The get trap receives the target, the property key, and the receiver; it records the access, reads the real value, and if that value is itself a function or object worth following, returns a fresh Proxy wrapping it so the trace continues one level deeper. This is the taint-style propagation that runtime analysis tooling uses to follow a value through an object graph. Wrap navigator, and you see every property the fingerprinting code reads off it, in order, which for an anti-bot payload is a near-complete list of the signals it collects. The post on how anti-bot systems fingerprint the JavaScript runtime walks through what those reads are actually probing for.
The Proxy approach has two sharp edges, and pretending otherwise would be dishonest. The first is that the engine enforces invariants on traps. MDN is explicit: if a property on the target is non-configurable and non-writable, the get trap must return the exact value the target holds, or the runtime throws a TypeError. Some built-in objects carry such properties, and a naive wrapper that tries to substitute a value for one of them will crash rather than lie quietly. The second edge is identity. A Proxy is not its target. proxy === target is false, Object.prototype.toString.call(proxy) can reveal the wrapper’s nature for certain exotic targets, and code that stashes a reference to the real object before you wrap it, or that compares an object against a known global, sees through the substitution. Proxies are excellent for passive observation and unreliable as a disguise, which is exactly the right way to use them in analysis: trace, do not deceive.
The reverse view: the code knows it is being watched
Everything above assumes the payload sits still while you inspect it. Hostile JavaScript does not. Obfuscated anti-bot code is built on the premise that someone will try precisely this, and it spends real effort detecting and disrupting it. Understanding the defenses is part of doing the analysis, because the first thing your breakpoint hits may be a trap rather than the logic you came for. The deeper treatment is in the companion post on how anti-bot JS detects it is being debugged; what follows is the part that touches the techniques above directly.
The oldest trick is the bare debugger statement on a timer. A setInterval that calls debugger every few hundred milliseconds does nothing when no debugger is attached and pauses hard when one is, which makes stepping through the surrounding code miserable. The companion technique is timing: wrap a debugger statement, or any cheap operation, in performance.now() reads before and after, and if the gap is large, a human is paused at a breakpoint and the code branches into decoy behavior or simply refuses to produce a token. A 2021 USENIX Security study, “U Can’t Debug This,” surveyed this in the wild and found severe anti-debugging measures on roughly one site in 550 across a million-site crawl, with a focused sample showing over 200 of 2000 anti-debugging sites running materially different code when they believed they were under analysis. The countermeasure on the CDP side is Debugger.setBlackboxPatterns, which tells the debugger to skip stepping into scripts matching a regex, so the timer’s own script can be blackboxed and stepped over rather than into. It does not stop the debugger statement from firing, which is why instrumentation breakpoints and conditional, non-pausing tracepoints often beat manual stepping against self-defending code.
Then there is CDP detection proper, and this is where the protocol’s own design works against the analyst. Attaching the Runtime domain is not invisible to the page. For years the reliable signal was an interaction between Runtime.enable and console serialization. When the Runtime domain is enabled, passing an object to a console method like console.debug makes the inspector build a preview of that object, and building the preview reads the object’s properties, including any the page defined as getters. The classic detector put an Error object through console.debug with a getter installed on its stack property: if the getter ran, something was serializing the object across the protocol, which meant CDP was attached. A few lines of JavaScript, no false positives, and it caught most automation stacks because their libraries called Runtime.enable on startup.
That signal changed in 2025. V8 landed guards that May, described in the commits as avoiding error side effects in DevTools and applying a getter guard throughout the error preview path, so that previewing an error object no longer invokes user-defined getters on it. After that change, the Error.stack getter detector goes quiet: DevTools reads the stack without tripping the trap. The more general version of the attack survives, though, because the underlying issue is that console previews enumerate object properties and the spec forces proxy traps to run during key collection. A detector built on a Proxy sitting in an object’s prototype chain, rather than a getter on a plain property, can still observe the enumeration that a console serialization triggers. The signal narrowed; it did not vanish. The full treatment of this, including the Runtime.enable startup pattern and the isolated-world mitigations that automation libraries adopted, is in the post on the CDP as a detection vector and its companion on the Runtime.enable leak specifically.
The mitigations the automation side reached for are instructive precisely because they reshape how you do the analysis. Rather than call Runtime.enable and accept the console-serialization exposure, patched libraries acquire an execution-context id another way. One approach installs a binding with Runtime.addBinding and calls it once to learn the main context’s id; another runs all injected code in an isolated world created off Page.createIsolatedWorld, where page scripts cannot see the injection at all; a third flips Runtime.enable and Runtime.disable back to back to capture the executionContextCreated event with only a brief exposure window. Each of these has a different visibility footprint, and which one you use changes whether your own tracing scripts run somewhere the payload can find them. The isolated world is the cleanest place to run analysis code that the page must not see, at the cost of losing direct access to main-world variables, which is the same trade the Proxy section ran into from the other direction.
What mutual visibility means
The honest summary of this technique is that it works, that it is the most direct way to understand obfuscated JavaScript at runtime, and that it is not invisible. CDP hands an analyst capabilities that no amount of source obfuscation can take away: you can pause at any location, read any scope including sealed closures, pull the source of functions that were built at runtime and never written to a file, and trace every access to any object you can name. Against code that decrypts strings lazily and flattens its control flow, that is decisive, because the running program computes the very values the obfuscation was hiding and a breakpoint simply asks for them. Static analysis has to reconstruct what the program would compute; dynamic analysis lets the program compute it and reads the answer off the stack.
The cost is that the same protocol features leave marks. Enabling the Runtime domain changes how console serialization behaves in ways a page can probe, the Debugger domain’s pauses are measurable as time, and the debugger statement fires whether you wanted it to or not. The 2025 V8 changes that closed the Error.stack getter signal are a good illustration of where this is heading: a specific, widely-known leak gets patched in the engine, the general mechanism behind it persists in a narrower form, and the defenders move to a slightly different probe. Neither side gets a permanent win. The analyst’s advantage is structural, because the debugging machinery is part of the runtime and cannot be removed without removing the runtime. The defender’s advantage is also structural, because attaching that machinery is a state change in the browser and state changes are observable from inside it. You are reading the code while the code is checking whether it is being read, and the most reliable analysis runs assume that the first interesting thing a breakpoint catches is the payload looking back.
Sources & further reading
- Chrome DevTools team (2026), Chrome DevTools Protocol — Debugger domain — reference for setBreakpointByUrl, setInstrumentationBreakpoint, the paused event, CallFrame and Scope objects, evaluateOnCallFrame, and setBlackboxPatterns.
- Chrome DevTools team (2026), Chrome DevTools Protocol — Runtime domain — reference for evaluate, callFunctionOn, getProperties, addBinding, bindingCalled, and the RemoteObject mirror representation.
- Mozilla (2026), Proxy — JavaScript reference (MDN) — the handler traps (get, set, apply, construct, has) and the engine-enforced invariants on them.
- kserude (2024), Non-Intrusive JavaScript Runtime Instrumentation via the Chrome DevTools Protocol — methodology for breakpoint-driven argument inspection and its limits on async and minified code.
- Castle (2025), Why a classic CDP bot-detection signal suddenly stopped working — the Error.stack getter detector and the May 2025 V8 commits that neutered it.
- svebaa (2025), How V8 Leaks Your Headless Browser’s Identity — the console-preview serialization path, the Error.stack getter, and the prototype-chain Proxy variant that survives the patch.
- Rebrowser (2024), How to fix Runtime.Enable CDP detection of Puppeteer and Playwright — the Runtime.enable startup leak and the binding, isolated-world, and enable/disable mitigations.
- rebrowser (2024), rebrowser-patches (GitHub) — the addBinding, alwaysIsolated, and enableDisable fix modes and the Page.createIsolatedWorld approach in code.
- Musch and Johns (2021), U Can’t Debug This: Detecting JavaScript Anti-Debugging Techniques in the Wild — 30th USENIX Security Symposium; prevalence of debugger-statement, timing, and console anti-debugging across a million-site crawl.
- DataDome (2024), How new headless Chrome and the CDP signal are impacting bot detection — a defender’s account of the CDP runtime signal and how the new headless mode changed it.
Further reading
Inside the DataDome JS tag: what ddjskey and the client payload carry
A reference on DataDome's client-side JavaScript tag: the ddjskey site identifier, the signals the browser collector gathers and posts to api-js.datadome.co, and how the challenge and interstitial flow is wired.
·21 min readInside the Cloudflare challenge platform: anatomy of the cf-chl orchestration
A primary-source walk through the Cloudflare interstitial: the window._cf_chl_opt object, the /cdn-cgi/challenge-platform/h/ orchestration endpoints, the obfuscated client script, and how a cf_clearance pass is returned.
·18 min readKasada's KPSDK: the 128-bit token, the VM, and the obfuscated bytecode
Traces how Kasada's client SDK works: the x-kpsdk-ct and x-kpsdk-cd tokens, the obfuscated JavaScript VM that runs Kasada-specific bytecode, the proof-of-work it computes, and how the payload rotates per tenant.
·19 min read