What happened
In October 2026 the maintainers of Camoufox, an open-source anti-detect browser built on Firefox, received a precise report: a site that validated a captcha token on its server rejected every token produced in Camoufox, including tokens from challenges a human solved by hand. Stock Firefox on the same machine, same IP and a fresh profile was accepted every time. No automation client was attached in any of the tests.
The reporter narrowed it down step by step. A Camoufox build with the files of Juggler, the protocol Playwright uses to drive Firefox, removed from the browser's resource archive was accepted 6 of 6 times; every build that still contained Juggler, even with its code replaced by an empty component, was rejected 9 of 9 times. Fingerprint values such as navigator properties, window property names, canvas, WebGL and audio output were identical between the two. Something the usual checks do not read was deciding the outcome.
That something turned out to be one line in a manifest, and one constructor call in the challenge script.
The probe: a Worker from an internal address
Firefox's internal UI and add-ons load files from chrome:// URLs, grouped into named packages declared in chrome.manifest. Juggler was installed the same way, as a package named juggler. Every content process receives the list of registered packages, because content processes need it to resolve chrome URLs.
The challenge script, in Firefox-family browsers only (it first checks that window.mozInnerScreenX is a number), runs this inside a try/catch, about half a second after it starts:
Reflect.construct(Worker, ["chrome://juggler/content/content/WorkerMain.js"])- Package registered: the constructor returns a Worker object. The worker fails to load later, asynchronously, but by then the script has its answer; it calls
terminatewithin a few milliseconds. - Package not registered (stock Firefox): the constructor throws
SecurityError: Worker constructor: Failed to load worker scriptstraight away, because the chrome registry cannot map the URL to anything.
The URL never appears in the script as text. A WebAssembly module inside the script builds it at run time, so searching the published scripts for juggler found nothing. Archived copies of the script show the call appearing between two builds on 6 October.
Why the usual test pages missed it
The reporter's first test page tried to load chrome://juggler/… with fetch, import(), <script>, <link> and <img>, and compared both the result and the timing. All five fail in the same way whether the package exists or not. A web page is never allowed to read those files, and these APIs report the refusal the same way for a missing package and a forbidden one.
The Worker constructor is different because it validates the URL synchronously before it starts loading. A missing package fails that validation and throws; a registered one passes it and fails later, out of sight. SharedWorker, module workers, nested workers and asynchronous XHR (whose loadstart event fires only for a registered package) showed the same split. A probe needs only one API that answers differently, and it only has to find it once.
The general shape is an existence oracle: an API that leaks whether a name resolves, even when the content behind the name stays unreadable. Browser-extension detection has used the same idea for years by probing for known extension resources.
The fix: nothing to find
The reporter tested the obvious workaround first. A build with the package renamed to another seven-letter name, with all of Juggler still present and running, was accepted every time, and forcing the probe to throw on an unmodified build flipped the result the same way. That proves the diagnosis, but a rename only lasts until the next script version asks for the new name, or simply lists every registered package it can find.
The change merged into Camoufox on 12 October (pull request 852) removes the reason for the tell instead:
- Juggler is no longer a chrome package. Its files install as ordinary modules and load from
resource://gre/modules/juggler/. Inside that namespace stock Firefox also answers for files that do not exist, so a page cannot tell which ones are there. - The build is marked as an official Firefox build, which drops a development-only helper that stock Firefox does not ship, and the content-accessible branding files are Firefox's own.
- A CI guard compares every content-reachable file's hash and the full list of registered chrome packages and resource hosts with stock Firefox, and runs the Worker probe itself. It fails on the old build and passes on the new one.
What the case shows
First, the build artefacts are part of the fingerprint. Everything a page can address, including internal URL namespaces, is surface, whether or not any code behind it runs. A browser that differs from stock only in what is packaged can still be told apart.
Second, the right comparison is against the stock browser, file by file and name by name, not against a list of known signals. The guard in the fix checks parity directly, so it also catches the next package nobody thought to probe.
Third, careful isolation wins. The report held everything constant except one variable per build, ran interleaved trials, and checked the result both ways by forcing the probe's answer. That is the method described in how browser stealth is measured, applied to a single bit.
The through-line
The probe did not read a value and judge it unusual. It asked whether something exists that only an automation build contains, the same structure as the checks in spec invariants as lie detectors and timing native functions: a question the stock browser answers one way and a modified one cannot answer the same way. Chromium has the same class of risk wherever an automation build adds a reachable resource; how automation gets caught covers the driver layer where it usually lives.
