Skip to content

The lifecycle of a stealth patch: discovery, fix, detection, and re-discovery

· 19 min read
Copyright: MIT
Wordmark reading patch lifecycle with an orange underline above the cycle find, fix, detect the fix, find again

A stealth patch is never finished. It is born when someone notices that an automated browser answers a question differently from a real one, it lives for as long as that difference stays the cheapest thing to check, and it dies not when the original signal is fixed but when the fix becomes its own signal. That last turn is the part people new to this work miss. They imagine the contest as a wall that gets a little higher with each release. It is closer to a tide. The water that protected you yesterday is the water that drowns you today, because the moment a spoof is widespread enough to matter, its side effects are widespread enough to detect.

This post is about that loop and nothing else. Not how any single anti-bot vendor scores a request, not how to defeat one, but the shape of the cycle a patch travels through over months and years, and why the cycle is getting faster rather than slower. The clearest way to see it is to take one signal at a time and watch it go around. We start with the model itself: the four phases, and why phase three is the one nobody plans for. Then three worked examples that have each completed at least one full revolution, the navigator.webdriver flag, the cdc_ strings in undetected-chromedriver, and the Runtime.enable CDP leak. From there, the economics, because the reason the loop keeps turning is that the two sides do not pay the same price for a move. We close on what the cycle looks like when the underlying browser starts fixing the bugs the patches were written to hide.

The four phases, and the one that bites

The loop has four phases. Naming them plainly is half the work, because the third one rarely gets named at all.

Phase one is discovery. A researcher, or an anti-bot vendor’s detection team, or a frustrated scraper reading stack traces at two in the morning, notices that an automated browser exposes something a normal one does not. A property that should be absent is present. A method that should be native is a JavaScript function. A value that should vary is always the same. The discovery is usually cheap to make and cheap to write down, which is why so much of it happens in public on GitHub issues and vendor blogs.

Phase two is the fix. Someone writes a patch that makes the automated browser match the normal one for that specific check. Redefine the getter, delete the property, swap the launch flag, wrap the method. The patch closes the gap. For a while, requests that used to be flagged sail through, and the people running them conclude the problem is solved. It is not solved. It is one revolution into a cycle they have not finished watching.

Phase three is the part that bites. The patch itself becomes detectable. Not the original signal, the patch. A getter that returns undefined is a different object from a property that was never defined, and the difference is observable. A method swapped for a JavaScript function reports a different toString than a native one, unless you also patch toString, in which case the patched toString is itself observable, and so on down. Worse, the patch is now common. Thousands of bots run the same evasion code, so its fingerprint is not just detectable in theory, it is a high-confidence signal in practice. The defender no longer asks whether navigator.webdriver is true. They ask whether it was set to undefined in the precise way a popular library sets it. The spoof has become the tell.

Phase four is re-discovery, which is just phase one again pointed at a slightly deeper layer. Having lost the surface signal, the defender goes looking for the next difference, and finds one, because there is always one. The automation client touches the browser through some interface, and every interface leaves a trace somewhere. So the loop closes and reopens at the same instant, one layer lower than where it started.

1. discovery a difference is noticed 2. fix patch closes the gap 3. the fix leaks patch becomes the tell 4. re-discovery one layer deeper *Phase three is the trap. The patch that closed the original gap is now a shared artifact, and shared artifacts are exactly what a detector wants to key on.*

The reason this is worth drawing out is that most stealth tooling is sold and discussed as if only phases one and two exist. A tool passes a public detection test, the author ships it, users adopt it, and for a few months the verdict is positive. Then the verdict quietly flips, the same tool starts getting blocked, the GitHub issues fill up with “this used to work,” and nobody can point to a single release that broke it. Nothing broke. The tool simply finished phase two and entered phase three on the defender’s schedule, not the author’s. That is the normal life of a patch, not a malfunction.

The navigator.webdriver flag is the cleanest example because it has been around all four phases more than once, and the whole arc is documented in public.

Discovery was free. The W3C WebDriver specification requires that a user agent under automation expose a read-only webdriver property that returns true, precisely so that pages can tell. The spec is the discovery: the signal exists on purpose, written into a standard, and any detection script can read one boolean and be done. For years this was the single most checked automation tell on the web, because nothing is cheaper than a property lookup.

The fix went through two generations. The first generation overrode the value in JavaScript, walking the prototype chain and deleting or redefining the property so a lookup resolved to undefined. That worked against a naive read and immediately created a phase-three problem, because a property that has been redefined with Object.defineProperty carries a descriptor that does not match a property that was never there, and the override leaves a non-native getter sitting on the prototype where a real browser has nothing. The second generation moved the fix out of JavaScript and into the launch: Chromium gates the flag behind a Blink feature called AutomationControlled, and starting the browser with --disable-blink-features=AutomationControlled stops it from ever setting the property. That is a strictly better fix, because there is no redefined getter to inspect, the value is simply never written. It is the patch most stealth users now assume is bulletproof.

It is not bulletproof, for two reasons that are both instructive about phase three. First, disabling the feature historically tripped an “automation” infobar in the visible browser chrome, an artifact removable only through enterprise policy, so the act of hiding one signal surfaced another in a different channel. Second, and more important, the automation signal was never confined to that one property. The same browser that you cleaned up still advertised a HeadlessChrome brand entry through the user-agent client hints, still answered navigator.userAgentData.getHighEntropyValues in a way a desktop Chrome would not, still carried a viewport that defaulted to a tell-tale size. Closing the boolean read left a parallel set of channels wide open, which is phase four arriving the moment phase two completed. The catalog of where the automation signal surfaces beyond this one flag is its own subject, covered in headless Chrome detection and the patch-by-patch tour in how puppeteer-extra-plugin-stealth works.

What makes navigator.webdriver the model case is that the better fix did not end the cycle, it relocated it. The contest moved from “is the flag set” to “is the flag absent in the specific way a known tool makes it absent, and do the other twenty things that should change when a real person is driving also check out.” A single boolean became a consistency problem across the whole browser surface, and consistency problems do not have a final patch.

cdc_ strings: when the defender plants the trap

The cdc_ story is the sharpest illustration of phase three, because here the defender does not wait for the patch to leak. The defender reaches in and turns the patch into a weapon.

ChromeDriver, the binary Selenium drives, historically injected a set of global variables into every page with names beginning cdc_, followed by a random-looking string, attached to the document object. Discovery was, again, nearly free: read the document’s own property names, match against the cdc_ pattern, and you have caught Selenium. undetected-chromedriver, the most popular Selenium stealth wrapper, was built around fixing exactly this. Its early approach patched the ChromeDriver binary directly, renaming or stripping the cdc_ strings so the injected globals no longer matched the known pattern.

That fix worked and then walked straight into the trap. Because the patch operated by removing properties whose names matched the cdc_ regex, anti-bot scripts started adding their own decoy properties whose names also matched that regex. A real, unautomated browser never has a cdc_-shaped property, so on a real browser the decoy stays put. An undetected-chromedriver session, running a patch that hunts down and deletes anything matching the pattern, deletes the decoy too. The defender plants a property, watches to see if it vanishes, and the act of cleaning up is the confession. The tell is no longer the presence of the variable. The tell is the eagerness to remove it.

defender injects a decoy property matching the cdc_ pattern document.cdc_aQ1m... = "decoy" real browser no cleanup code running decoy still present verdict: human patched stealth browser strips anything matching cdc_ decoy deleted verdict: automated *The patch that removes a signal can be more revealing than the signal it removes. Deletion is an action, and actions are observable.*

undetected-chromedriver’s response was to stop removing the strings at all. Rather than chase and delete properties after injection, later versions prevent the cdc_ globals from being injected in the first place, so there is nothing to clean up and nothing to trap. That is the right correction, and it is also a small lesson in the structure of the whole game: the durable fix is the one that changes the state, not the one that reacts to it. A patch that produces an observable reaction is a patch that hands the defender a button to press.

There is a second layer here worth saying plainly. The exact decoy names a given vendor injects, and the precise regex undetected-chromedriver used at any moment, are not fully documented in one place and change between releases. What follows from the public issue history is the mechanism, not a current property list: anti-bot scripts add properties matching the pattern the patch hunts for, and the patch’s removal of them is the signal. Treat the specific strings as illustrative rather than authoritative, because the only people with the up-to-date list on each side are not publishing it.

Runtime.enable: the cycle at the protocol layer

The first two examples live in JavaScript, in properties a page can read directly. The most consequential recent revolution happened a layer below that, in the wire protocol the automation client uses to drive the browser, and it shows the loop running at a depth the page cannot see at all.

Every mainstream automation library, Puppeteer and Playwright and Selenium alike, drives Chrome through the Chrome DevTools Protocol. To run JavaScript in a page the client needs the execution context IDs for every frame and worker, and the standard way to get them is to send the CDP command Runtime.enable, which switches on the Runtime domain and starts streaming context events. Discovery, when it came, was the usual cheap read pointed at an unusual place. Enabling the Runtime domain has a side effect: a Runtime.consoleAPICalled event fires through the protocol in a way that a page script can provoke and observe, because the browser’s handling of certain console operations changes once a CDP client is listening. A few lines of JavaScript on the page can tell whether something on the other end of a DevTools connection has enabled that domain. The full mechanics of how this surfaces are in detecting CDP in the wild and the broader CDP as a detection vector.

This one was put on the map in public by a DataDome writeup from Antoine Vastel dated 13 June 2024, and the disclosure mattered because it hit a signal that no JavaScript-level stealth patch could touch. You can spoof navigator.webdriver all day; you cannot spoof your way out of having enabled a CDP domain, because the leak is in the protocol traffic, not in a property. The detection needs about five lines of code and keys on something every off-the-shelf automation stack did by default. Overnight, a large fraction of the world’s bots were carrying a signal they could not patch in the page.

The fix, naturally, moved down to the protocol. One line of attack keeps the original command but narrows its window: send Runtime.enable to learn the context IDs, then immediately send Runtime.disable, so the domain is only active for the sliver of time it takes to grab what you need. Another runs page scripts in an isolated world created with Page.createIsolatedWorld, so the client never needs the main-world context the naive flow depends on, and page scripts cannot watch the function calls in the isolated context. The deepest response abandons the high-risk commands altogether: a generation of “CDP-minimal” frameworks reimplemented automation without leaning on Runtime.enable at all, communicating with the browser in ways that avoid the leaky domains. nodriver and selenium-driverless and the patched Playwright forks belong to that lineage, traced in nodriver and undetected-chromedriver and the architecture question in patching Chromium from source vs runtime injection.

And then phase three, on cue. The enable-then-disable trick has its own window of exposure, brief but real, and a detector that fires during that window catches it. Running everything in an isolated world removes a capability that a normal automation session has, and the absence of that capability is itself a thing you can probe for, which is why the rebrowser bot-detector suite includes checks for exactly that kind of main-world starvation alongside the Runtime.enable leak it was built around. The community patch repositories say as much in their own notes: the fixes are fragile, they break as the libraries move, and they are correct for now rather than correct. None of this is failure. It is the same four phases the boolean flag went through, playing out one protocol layer down, where the page cannot reach but the wire still talks.

The economics that keep the loop turning

If the cycle were symmetric it would reach an equilibrium and stop. It does not stop, because the two sides do not pay the same prices, and the asymmetries all push toward more turns rather than fewer.

Start with the cost of a discovery versus the cost of a fix. Finding a new signal is often a few lines of JavaScript and an afternoon of curiosity, and once found it can be checked on every request forever at almost no marginal cost. Writing a fix that holds is harder, because the fix has to be invisible as well as correct, and invisibility is an open-ended requirement. You patch the value, then you patch the value’s descriptor, then you patch the toString of the patch, then you patch the stack traces the patch leaves in thrown errors. Each layer of hiding is another surface that has to itself be hidden. The defender writes one probe; the evader writes a patch plus the patch’s disguise plus the disguise’s disguise. That is not a fair trade, and it compounds.

The detection side also enjoys an enormous information advantage, and it is structural. A serious anti-bot vendor sees traffic from a large slice of the web at once, so it watches a signal appear across thousands of sites and millions of sessions before any individual evader knows the signal is being watched. When a popular stealth tool ships a new spoof, the vendor sees that spoof’s fingerprint spread across its whole customer base within days, which turns the spoof into a high-confidence label precisely because it is popular. Castle’s own measurement makes the point concretely: in a thirty-day window in October 2025 they counted on the order of two hundred thousand requests carrying the Puppeteer-stealth fingerprint, and they could pick it out specifically because the patch’s side effects are distinctive. Popularity is the evader’s enemy here. The more a fix is used, the louder it is.

detector: one probe read one value cheap, reusable, runs on every request sees the whole web at once evader: stacked disguise spoof the value hide the descriptor patch toString scrub stack traces each layer is a new surface to hide in turn *One probe versus a stack of disguises, and the probe runs against the evader's entire population at once. The asymmetry is the engine.*

There is a maintenance asymmetry on top of the discovery asymmetry. A detection signal, once written, mostly keeps working until the browser changes underneath it. A stealth patch needs continuous upkeep, because it is pinned to a moving target: every Chrome release shifts the ground, every new vendor probe opens a front, and an unmaintained patch rots fast. The most widely copied stealth code on the web has gone long stretches without active maintenance, which is the quiet reason so much of it now reads as a label rather than a disguise. The defender’s artifact ages gracefully. The evader’s artifact ages like fruit.

That said, the asymmetry is not infinite, and pretending it is would be its own mistake. The evader needs only a handful of sites to remain reachable, while the defender has to hold a hard line on false positives, because every real customer wrongly blocked is lost revenue and a support ticket. A detector that is too aggressive about a stealth fingerprint will eventually catch a real user whose browser, through some extension or corporate policy, happens to look unusual in the same way. That false-positive ceiling is the one structural advantage the evading side has, and it is why detection leans on scoring and consistency rather than single hard signals. The deeper economics of who can afford which mistakes is the subject of the economics of anti-bot vendors.

What happens when the browser fixes the bug

The most interesting recent twist is that a lot of these signals are being closed not by either combatant but by Chrome itself, and that changes the loop in a way worth ending on.

For years, headless Chrome was a separate browser. The old headless mode was a distinct implementation that did not share the full Chrome browser code, and that separateness was a gift to detection, because the two builds answered hundreds of small questions differently, and every difference was a free signal. Starting in Chrome 112, Google unified the headless and headful builds so that headless mode runs the same browser code, eliminating a whole class of those discrepancies at the source. The change was documented by Mathias Bynens and Peter Kvitek, and from Chrome 132.0.6793.0 the old implementation lives on only as a separate chrome-headless-shell binary for the narrow cases that still need it.

This does something subtle to the cycle. When the browser closes a gap, it closes it for everyone at once, and it closes it cleanly, with no patch artifact to detect later. A stealth tool that used to spend code mimicking headful behavior can delete that code, and the deletion is pure gain, because there is no longer a spoof to leak. So the unified-headless change did not just remove signals, it removed a layer of phase-three risk along with them. The signals that survive are the ones that are intrinsic to driving a browser from the outside, the protocol traces and the timing and the absence of a real human at the wheel, rather than the accidental ones that came from headless being a different build.

What is left, then, is the hard core of the problem, and it is harder than the surface ever was. The cheap signals were always going to be patched or fixed. What remains is the consistency requirement, the demand that the GPU string match the claimed hardware, the timezone match the IP, the input timing match a hand on a mouse, and that all of it stay coherent across a whole session rather than just passing a single point check. There is no patch for coherence. You either generate a self-consistent session or you do not, and the closer the surface signals get to fully fixed, the more the whole contest collapses onto that one demand. The treadmill does not stop. It just runs out of cheap rungs, and the rungs that remain are the ones that take real engineering to climb, which is the position both sides have been quietly moving toward all along.


Sources & further reading

Further reading