Skip to content
Detection teardowns

Probing for a package that should not exist: how one Worker call exposed a patched Firefox

A challenge script asked the browser to start a worker from an internal address. Stock Firefox refused at once; a build that shipped its automation protocol as a chrome package did not.

Pim
Pim· Clearcote Research
8 min read

Key takeaways

  • In Firefox, new Worker("chrome://<package>/…") throws a synchronous SecurityError when the package is not registered and returns a Worker when it is, so a page can test whether any named chrome package exists.
  • Fetch, import(), <script>, <link> and <img> fail the same way for both cases; only Worker, SharedWorker and async XHR showed the difference, which is why ordinary test pages missed it.
  • Renaming the package only moves the tell. The fix that held was to stop registering the automation code as a chrome package at all and to make every content-reachable file and package match stock Firefox.

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 terminate within a few milliseconds.
  • Package not registered (stock Firefox): the constructor throws SecurityError: Worker constructor: Failed to load worker script straight 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.

#bot-detection#firefox#reference

Clearcote puts this into practice

An open-source Chromium with fingerprint control compiled into the engine. A drop-in for Playwright & Puppeteer.

  • 222engine patches
  • 34,478lines added
  • 330Chromium files changed
  • 354checks per build

Engine numbers are for the Pro build; the free open build is a smaller, public patch set.

Free for one browser with GitHub. No card.