liarjs.dev
← Field notes
Navigator

The plugin face: modern Chrome's five PDF entries and the version it betrays

September 19, 2026 · 8 min read

navigator.plugins is the API everyone stopped looking at. It used to leak Flash, Silverlight and a dozen NPAPI relics; then Chrome froze it and the reflex became “plugins carry no entropy anymore, ignore them.” That reflex is wrong. The surface is frozen, but it was frozen to a specific shape at a specific version, and that shape is now a version pin. A browser that ships the wrong plugin face while claiming a modern Chrome is not low-entropy. It is self-contradictory, and contradiction is the cheapest thing a detector can catch.

The modern five-plugin PDF face versus a stale legacy face on a claimed modern version

What Chrome actually exposes since v94

Before the freeze, navigator.plugins enumerated whatever the build had registered, and detectors mined it for entropy. Chrome 94 (late 2021) killed that. The list became a hardcoded, build-independent set of five entries, all pointing at the same internal PDF viewer. The entropy went to zero by design. MDN’s NavigatorPlugins note records that the array is now a fixed compatibility shim rather than a live registry.

The modern face, enumerated:

// Chrome >= 94, any platform - the fixed five
[...navigator.plugins].map(p => p.name)
// [
//   "PDF Viewer",
//   "Chrome PDF Viewer",
//   "Chromium PDF Viewer",
//   "Microsoft Edge PDF Viewer",
//   "WebKit built-in PDF"
// ]

[...navigator.mimeTypes].map(m => m.type)
// [ "application/pdf", "text/pdf" ]

// each plugin advertises both mime types; the mimeType -> plugin
// back-references resolve into the same five PluginArray entries
navigator.plugins["PDF Viewer"].length            // 2
navigator.mimeTypes["application/pdf"].enabledPlugin.name  // "PDF Viewer"

Five names, two mime types, cross-linked references. This is invariant across Windows, macOS and Linux, and across every Chrome and Chromium-derived build from 94 forward. Because it does not vary, any variation is a signal. Chrome traded plugin entropy for a version fingerprint, and most anti-detect vendors never noticed the trade.

The legacy face and why it’s a tell

The pre-freeze world, and several non-Blink or heavily-forked engines still living in it, looks different. The historical Chromium build exposed two PDF entries and a Google-specific mime type:

// Legacy / pre-94 / forked-engine face
[...navigator.plugins].map(p => p.name)
// [ "Chromium PDF Plugin", "Chromium PDF Viewer" ]

[...navigator.mimeTypes].map(m => m.type)
// [ "application/x-google-chrome-pdf" ]

There is nothing wrong with this face on a browser that actually is that old. The problem is purely relational. If the User-Agent, the UA-CH fullVersionList and the feature set all announce Chrome 139, but the plugin array is the two-entry application/x-google-chrome-pdf model from a decade ago, the browser is asserting two incompatible versions of itself in the same breath.

Legacy face (pre-94 / forked) Modern face (Chrome ≥ 94)
Plugin count 2 5
Names Chromium PDF Plugin, Chromium PDF Viewer PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF
Primary mime application/x-google-chrome-pdf application/pdf
Secondary mime (none) text/pdf
Consistent with UA “Chrome 139” No Yes
Entropy on its own some none (frozen)

The right-hand column is the only face that agrees with a modern UA string. Ship anything else and the disagreement becomes the fingerprint. What gives you away is the gap between the plugin list and the version you claimed.

A real failure: VMLogin’s stale face

This is not hypothetical. In controlled anti-detect browser testing against real profiles, VMLogin presented a User-Agent claiming Chrome 139 while navigator.plugins returned only the old two-entry model with application/x-google-chrome-pdf. The persona said 2025. The plugin face said roughly 2020.

A browser announcing Chrome 139 while exposing the two-plugin PDF face from a decade ago is telling two versions of the truth at once. Source: controlled anti-detect browser testing

Be precise about what caught it and what didn’t. CreepJS did not flag this as a “lie.” Its lie-detector is tuned for noise inconsistencies (canvas, DOMRect, audio per-call randomization), not for cross-referencing the plugin array against the claimed version. A team relying only on the CreepJS lie count would score VMLogin at zero lies and move on. But a purpose-built script that knows the v94 rule catches it in one comparison: major >= 94 && legacyFacePresent. The break is real and trivially detectable. It simply lives outside the specific checks CreepJS ships. Do not confuse “CreepJS didn’t flag it” with “it’s safe.”

For contrast, the same test round found bablo, AdsPower and CloakBrowser all correctly shipping the modern five-entry face with application/pdf. This is a static list, not hard to get right. VMLogin got the WebGL parameter set impeccably self-consistent (enough to earn CreepJS high confidence on its GPU) and still left the plugin face stranded a decade back. Meticulous on the expensive signal, asleep on the free one.

The empty-array variant

There’s a second failure mode on the same axis. An empty navigator.plugins on a browser claiming modern Chrome is its own tell, the classic headless/stripped signature. Headless Chromium historically returned zero plugins, and while newer headless modes have improved, a bare array under a Chrome 139 UA still reads as automation or a hardened runtime that forgot to synthesize the compatibility shim. Sannysoft has checked plugin presence for years precisely because “modern Chrome, zero plugins” is such a clean discriminator.

So the axis has three positions: empty (headless/stripped tell), legacy (version contradiction), and the modern five (consistent). Only the last one survives.

How the scanner scores it

Our consistency scanner treats this as a gated version check rather than an entropy measurement. The gate is the claimed major version; below 94 the rule doesn’t apply:

// Plugins/mimeTypes vs claimed Chrome version
if (major && major >= 94) {
  const pc = client.navigator.plugins.length;
  const mimes = client.navigator.mimeTypes;
  const legacy =
    client.navigator.plugins.some((p) => /chromium pdf plugin/i.test(p)) ||
    mimes.includes('application/x-google-chrome-pdf');
  const hasPdfMime = mimes.length === 0 ? true : mimes.includes('application/pdf');

  if (pc === 0)
    warn('Chrome ' + major + ' but navigator.plugins is empty (headless/stripped).');
  else if (pc < 4 || legacy || !hasPdfMime)
    bad('Chrome ' + major + ' should expose the modern 5-plugin PDF face + '
        + 'mime "application/pdf"; stale plugin face for the claimed version.');
  else
    ok('Modern ' + pc + '-plugin PDF face.');
}

The logic mirrors the three positions. Count zero warns (headless). Count under four, or any legacy marker, or a missing application/pdf mime flags as bad, and the clean five-entry face passes. The legacy test catches both halves independently, the old plugin name and the old mime type, because a spoof might fix one and forget the other. The check is deliberately blunt. It is looking for a contradiction, and a contradiction doesn’t need a subtle threshold.

The general rule: everything tracks the claimed version

The plugin face is one instance of a rule that governs the entire persona. The UA string, the UA-CH fullVersionList, the plugin/mime face and the shipped feature set must all agree on exactly one Chrome version. Pick a number, and every surface has to hold that number.

The heaviest sibling check is UA-CH against the UA string. If navigator.userAgent says Chrome 139 but the high-entropy fullVersionList reports a different major, that’s a direct contradiction between two things that are supposed to be generated from the same source:

const list = await navigator.userAgentData.getHighEntropyValues(["fullVersionList"]);
const notBrand = list.fullVersionList.find(b => !/Not.?A.?Brand/i.test(b.brand));
const chMajor = Number(String(notBrand.version).split(".")[0]);
// chMajor must equal the Chrome/NNN major in navigator.userAgent

Then there’s the subtler gap between claimed version and the real engine underneath. AdsPower announced Chrome 134 on an engine that was actually around 114 to 115. CreepJS’s “Features” table, which infers the engine version from which JS/DOM features are present, topped out near 115 in the tested build and therefore couldn’t yet see the gap. That’s a “not caught today” result, not a “safe” one. The moment a detector’s feature table extends past your real engine, the claimed-134-on-real-115 spread becomes visible, and there is no way to patch a feature you didn’t compile in.

The best practice is the one CloakBrowser demonstrated: the claimed version equals the real Chromium version compiled, 146 on a 146 engine, zero gap. When there’s no distance between what you claim and what you are, there’s nothing for a feature-inference table to find, now or in a future detector build. Spoofing the version string is borrowing against an engine you can’t produce. Matching it is the only durable answer.

Small-scalar cousins in the same family

The “match the persona, don’t invent values” discipline extends to a set of low-weight scalars that live in the same consistency family and are cheap to get wrong:

  • screen.colorDepth should be 24. Modern Chrome reports 24; a value of 32 is a common spoof artifact and a soft tell.
  • navigator.languages should include a bare base tag (e.g. "en" alongside "en-US"). A list of only region-qualified tags, ["en-US"] with no "en", is unusual for real Chrome and gets flagged.
  • navigator.storage.estimate() should report a GB-scale quota. Sub-gigabyte quotas (one competitor reported ~0.5 GB) don’t match real disks and read as a stripped or containerized runtime.

None of these sinks a persona by itself. But they accumulate, and like the plugin face, each is a value you chose, so each is a place a lazy default can contradict the story the rest of the fingerprint is telling.

Takeaway

The plugin array carries no entropy anymore, which is exactly why it’s dangerous. It’s a fixed shape pinned to a version, so any deviation is pure contradiction. On any browser claiming Chrome ≥ 94, the plugin face must be the five PDF entries with application/pdf and text/pdf, never the legacy two-entry application/x-google-chrome-pdf model and never empty. Treat that as the specific case of the general law: UA string, UA-CH, plugin face and real engine all agree on one version, and the only contradiction-proof way to do that is to claim the version you actually compiled.

Sources & further reading
  1. bot.sannysoft.com
  2. CreepJS (Abraham Juliot)
  3. PDF Viewer plugins (Chrome behavior, MDN NavigatorPlugins)
More field notes
← Back to field notes