Media Codecs and Capability Probing
What Your Machine Can Decode Is Part of Your Identity
Beyond canvas pixels and GPU strings, a browser answers a much cheaper question: what can it actually play? Codec support, camera counts, core counts and installed voices are all readable in under a millisecond, none of them require permission, and all of them are stable for the lifetime of the machine. A WAF can run the whole battery on a single page load and come away with a coherent hardware description.
The trap is that these probes look optional. Nothing on a normal page needs getVoices() or a WebRTC codec table, so it is tempting to hide them. Hiding them is worse than answering them badly, because a browser with no capabilities at all is a build nobody ships.
canPlayType Returns Three Words, and Every One of Them Means Something
HTMLMediaElement.canPlayType(type) takes a MIME type with an optional codec parameter and returns exactly one of three strings. The spec is precise about the vocabulary and vague about the semantics, which is why the answer is still useful.
| Return value | Meaning | Collector reads it as |
|---|---|---|
"probably" |
the browser believes it can play this without failure | full support |
"maybe" |
it can decode, but the container or profile is unverified | partial support |
"" (empty string) |
the MIME type or codec is not recognised | no support at all |
The container and the codec are separate axes. video/mp4 with no codec parameter tests only the demuxer; video/mp4; codecs="avc1.42E01E" names H.264 Baseline Level 3.1. A build that ships an MP4 demuxer without a proprietary decoder answers "" to the second and "maybe" to the first, and asking both has separated "no MP4" from "MP4 but no H.264", which is a different machine entirely.
The Set Names a Build, Not a Device
A single answer separates perhaps 4 percent of the population. The set of answers, together with the support level of each, narrows to a build. Chromium and Chromium-based browsers ship genuinely different codec matrices depending on whether the build was compiled with proprietary codecs, and that is a licensing decision made by whoever packaged the browser, not by the user.
| Environment | H.264 | HEVC | AV1 | MP3 | FLAC | Opus |
|---|---|---|---|---|---|---|
| Chrome on Windows, all channels | probably | probably | probably | probably | probably | probably |
| Chrome on Linux, Google's own builds | probably | probably | probably | probably | probably | probably |
Chromium on Linux, proprietary_codecs = false |
"" |
"" |
probably | probably | probably | probably |
Debian chromium package, 2023 and later |
"" |
"" |
probably | probably | probably | probably |
| Firefox on Windows | probably | maybe | probably | probably | probably | probably |
| Safari 18 on macOS and on iOS | probably | probably | probably | probably | probably | probably |
A distro chromium-headless-shell, or a bare Playwright download |
"" |
"" |
probably | probably | probably | probably |
Read down the first column and you have a fingerprint needing no permissions, no canvas and no interaction. A profile that claims Chrome/140 on Windows while answering "" for avc1.42E01E is describing a Chromium build wearing a Chrome user agent, and that pairing is rare enough to score highly on its own.
MediaSource, MediaRecorder and the WebRTC Codec Tables
The same question is asked through three other doors, and each fails differently on a stripped build. MediaSource.isTypeSupported(mimeType) and MediaRecorder.isTypeSupported(mimeType) are static, permission-free, and cover the streaming pipeline rather than the element pipeline; a headless build with no proprietary codecs typically answers false for video/mp4; codecs="avc1.42E01E" and true for video/webm; codecs="vp8,opus", and MediaRecorder.isTypeSupported('video/x-matroska;codecs=avc1') is a separate axis again because the container list differs from the demuxer list.
WebRTC publishes its own tables. RTCRtpSender.getCapabilities('video') returns every codec the build can send, each with mimeType, clockRate, channels, sdpFmtpLine and, in Chromium, a numeric payloadType; RTCRtpReceiver.getCapabilities(kind) does the same for decoding. On a live sender, getParameters().headerExtensions is where the SDP extension list shows up: abs-send-time, transport-wide-cc, sdes:mid, sdes:rtp-stream-id, sdes:repaired-rtp-stream-id, video-orientation, toffset, playout-delay. An empty headerExtensions array means there is no real WebRTC stack behind it.
const v = document.createElement('video');
for (const t of ['video/mp4; codecs="avc1.42E01E"', 'video/webm; codecs="vp9"', 'audio/mpeg'])
console.log(t, '->', JSON.stringify(v.canPlayType(t)));
console.log('send codecs:', RTCRtpSender.getCapabilities('video').codecs.map(c => c.mimeType).join(' '));
const pc = new RTCPeerConnection();
pc.addTransceiver('video', {direction: 'sendonly'});
console.log('headerExtensions:', pc.getParameters().headerExtensions.map(h => h.uri).join(' '));
pc.close();
The extension list is not uniform across builds or platforms. A Linux Chromium build and a Windows Chrome build of the same version do not advertise identical sets, so this leaks platform as well as automation.
Camera and Microphone Counts Are Not Permission-Gated
navigator.mediaDevices.enumerateDevices() is the awkward one, because privacy rules have progressively reduced what it returns. Since roughly Chrome 68, and by the equivalent changes in Safari and Firefox, the label field of every device is an empty string until the page is granted camera or microphone permission. A page with no permission still receives the full list of devices with blank labels, plus one synthetic default entry per kind.
What is not suppressed is the shape of the list. Entries still carry deviceId, groupId and kind, and a groupId is normally shared between a camera and its paired microphone, so even ungranted a collector learns how many audioinput and videoinput entries exist and how they group. Chrome also injects a synthetic audiooutput and one default per kind regardless of hardware, and those must be discounted before counting.
| Claimed device | Realistic audioinput |
Realistic videoinput |
|---|---|---|
| A desktop laptop with a webcam | 1 to 2 (built-in mic, headset) | 1 |
| A desktop tower, no peripherals | 0 to 1 | 0 |
| A headless Linux container | 0 | 0 |
| An iPhone over a remote debug session | 0 (permission never granted) | 0 |
A profile claiming a consumer laptop while reporting zero audio inputs and zero cameras is describing a machine nobody types on. The remedy is not a bigger list but a plausible one: either fake a headset and a built-in camera with a stable deviceId and a shared groupId, or pick a class where zero is correct, such as a tower or an iPhone, and let the rest of the profile agree with that choice.
The Cheap Scalar Probes
A dozen numbers and booleans, none of them permission-gated, describe a machine better than most hardware strings.
navigator.hardwareConcurrencyis the logical core count the browser can see. On a VM it is the vCPU allocation, which is why a 64-core cloud host reporting64is a datacenter tell and not a power user.navigator.deviceMemoryis a Chromium-only approximation in GiB, rounded down to exactly six buckets:0.25,0.5,1,2,4,8. The coarseness is the point: a real value is always one of the six, and it correlates with the screen size the owner chose.navigator.maxTouchPointsis0on any mouse-driven desktop and5on every current iPhone, a one-bit phone/desktop split that generic spoofs get wrong constantly.navigator.getBattery()returns a promise, and the presence of the function is the signal. It shipped in Chrome 53, was removed from Firefox, has never existed in Safari, and was dropped from the main thread in Chrome workers. A claimed iPhone exposing it is describing an Android or a spoofed global.window.matchMediaanswers per class:(hover: hover)and(pointer: fine)are true on a desktop and false on a touch phone,(prefers-reduced-motion: reduce)and(prefers-color-scheme: dark)follow OS settings, and(display-mode: browser)separates a tab from an installed PWA.SpeechSynthesis.getVoices()returns the OS voice list:[]on a bare container, a multi-entry SAPI or OneCore list on Windows, a language-specific list on macOS. It is empty untilvoiceschangedfires, often 100 to 500 ms after load, so a synchronous read returning nothing is a timing artefact while a post-event read returning nothing means a build with no speech engine.navigator.gpu.requestAdapter()returns an adapter whoserequestAdapterInfo()exposesvendor,architecture,device,descriptionand asubgroupMinSize. A null adapter on a claimed GPU machine is a strong tell, and the strings must agree with the WebGLUNMASKED_RENDERER_WEBGLvalue covered in Spoofing Canvas, WebGL and Audio.navigator.connectionexposeseffectiveType,rtt,downlinkandsaveDatawhere it exists, tying the profile to a network tier. It is Chromium-only; the local-IP side of the same API family is in WebRTC and Network Information Leaks.
Suppressing Capability Is Its Own Tell
Here is the design trap. If the hardening layer makes canPlayType return "" for everything, enumerateDevices return an empty list, getVoices return [], matchMedia('(hover: hover)') return false and requestAdapter return null, you have not built a discreet profile. You have built a machine no consumer ships, and the collector's job is now trivial: every capability is zero.
The real reference points are mid-market and unglamorous. A 2023-era Windows 11 laptop with an Intel Iris Xe, Chrome 140, a 1920x1080 panel, one built-in camera, one audio input, 16 GiB of RAM reported as 8, 16 hardware threads, no getBattery, four to fifteen speech voices, acceleration on, and a 16384-pixel WebGL texture limit. Every one of those numbers is boring, which is exactly why it passes. The iPhone 15 reference is equally ordinary: six cores, no getBattery, deviceMemory of 8, five touch points, a 393x852 screen at a device pixel ratio of 3, a single Apple GPU renderer, no camera or microphone entries until permission is granted, and one or two system voices. Reporting real container values honestly gets you a datacenter profile; faking a minimal set gets you a stripped build; copying one real profile end to end is the only one of the three that works.
What You Can Override and What You Cannot
The distinction that matters is between values the browser looks up in a table you can reach and values it reads from a native stack you cannot. hardwareConcurrency, deviceMemory and maxTouchPoints are plain attributes on a plain object, so any of them can be replaced from a Page.addScriptToEvaluateOnNewDocument hook. Codec support is a lookup into a compiled-in table: JavaScript can shadow the answer but cannot make the demuxer exist, so a video element claiming H.264 still cannot seek in an H.264 file. enumerateDevices enumerates real hardware through the OS subsystems, so a fake list yields devices that never appear in a permission prompt. SpeechSynthesis voices come from the OS, so a fake list gives voices that speak nothing. requestAdapter talks to the GPU process, so a fake adapter returns a null requestAdapterInfo() promise the moment anything calls it.
| Probe | Override point | Survives a native call? |
|---|---|---|
navigator.hardwareConcurrency, deviceMemory, maxTouchPoints |
attribute or getter override | yes, nothing native reads them |
navigator.getBattery presence |
add or delete the function | no, the real battery path is absent anyway |
canPlayType |
prototype method override | no, the element still cannot decode |
MediaSource.isTypeSupported |
static method override | no, MSE still refuses the stream |
enumerateDevices |
promise-returning override | no, the permission prompt stays empty |
SpeechSynthesis.getVoices |
prototype method override | no, speechSynthesis.speak is a no-op |
navigator.gpu.requestAdapter |
object override | no, GPU queries return null |
matchMedia answers |
override matchMedia |
no, CSS media queries still evaluate truthfully |
The last row is the general case: overriding matchMedia to lie about (hover: hover) does not change which CSS rules the engine applies, so a stylesheet with @media (hover: hover) renders one way while your probe says another. Durable fixes for the right-hand column are real media devices, a real codec-enabled binary, a real GPU and a patched build, which is the subject of Patching and Building Chromium for Stealth.
Auditing a Claimed Device Class Against Real Answers
The audit is mechanical: state which device class the profile claims, collect the eight probe answers the real browser produced, and check each against the set of values a real machine in that class could return.
DEVICE_RULES = {
"windows-11-laptop": {"cores": {4, 6, 8, 12, 16, 20, 24}, "ram": {4, 8, 16},
"touch": {0}, "audio_in": {1, 2}, "cams": {1, 2},
"h264": True, "battery": False, "voices": 3},
"iphone-15-safari": {"cores": {6}, "ram": {8}, "touch": set(range(1, 11)),
"audio_in": {0}, "cams": {0}, "h264": True,
"battery": False, "voices": 1},
"linux-ci-container": {"cores": {1, 2, 4, 8, 16}, "ram": {0.25, 0.5, 1, 2, 4, 8},
"touch": {0}, "audio_in": {0}, "cams": {0},
"h264": False, "battery": False, "voices": 0},
}
WHY = {
"cores": "%s cores is not a shipping count for this class",
"ram": "navigator.deviceMemory %s is outside the buckets for this class",
"touch": "maxTouchPoints %s never occurs on this class",
"audio_in": "%s audio inputs: no real user of this class has none",
"cams": "%s cameras: no real user of this class has none",
"h264": "canPlayType returned %s: no H.264 decoder in this build",
"battery": "navigator.getBattery is %s, absent on this class",
"voices": "%s voices after voiceschanged: a real OS always has some",
}
def show(label, device_class, probes):
rules = DEVICE_RULES[device_class]
print("=" * 74)
print(label)
print("=" * 74)
print("%-9s %-10s %-34s %s" % ("probe", "observed", "expected", "verdict"))
bad = 0
for key, expected in rules.items():
seen = probes.get(key, "?")
if key == "h264":
ok, shown = (seen != "") == expected, "yes" if expected else "no"
elif key == "battery":
ok, shown = seen is expected, expected
elif key == "voices":
ok, shown = isinstance(seen, int) and seen >= expected, ">= %d" % expected
else:
ok = seen in expected
shown = "in {%s}" % ", ".join(str(v) for v in sorted(expected))
print("%-9s %-10s %-34s %s" % (key, repr(seen), shown, "ok" if ok else "IMPOSSIBLE"))
if not ok:
bad += 1
print(" ! " + WHY[key] % repr(seen))
print("--> %d of %d probes contradict %r\n" % (bad, len(rules), device_class))
show("Claimed iPhone 15, answered by a 64-core datacenter host", "iphone-15-safari", {
"cores": 64, "ram": 8, "touch": 0, "audio_in": 0, "cams": 0,
"h264": "probably", "battery": True, "voices": 0})
show("Claimed Windows 11 laptop, answered by a bare CI container", "windows-11-laptop", {
"cores": 4, "ram": 8, "touch": 0, "audio_in": 0, "cams": 0,
"h264": "", "battery": False, "voices": 0})
show("Claimed Linux CI container, answered honestly", "linux-ci-container", {
"cores": 8, "ram": 4, "touch": 0, "audio_in": 0, "cams": 0,
"h264": "", "battery": False, "voices": 0})
==========================================================================
Claimed iPhone 15, answered by a 64-core datacenter host
==========================================================================
probe observed expected verdict
cores 64 in {6} IMPOSSIBLE
! 64 cores is not a shipping count for this class
ram 8 in {8} ok
touch 0 in {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} IMPOSSIBLE
! maxTouchPoints 0 never occurs on this class
audio_in 0 in {0} ok
cams 0 in {0} ok
h264 'probably' yes ok
battery True False IMPOSSIBLE
! navigator.getBattery is True, absent on this class
voices 0 >= 1 IMPOSSIBLE
! 0 voices after voiceschanged: a real OS always has some
--> 4 of 8 probes contradict 'iphone-15-safari'
==========================================================================
Claimed Windows 11 laptop, answered by a bare CI container
==========================================================================
probe observed expected verdict
cores 4 in {4, 6, 8, 12, 16, 20, 24} ok
ram 8 in {4, 8, 16} ok
touch 0 in {0} ok
audio_in 0 in {1, 2} IMPOSSIBLE
! 0 audio inputs: no real user of this class has none
cams 0 in {1, 2} IMPOSSIBLE
! 0 cameras: no real user of this class has none
h264 '' yes IMPOSSIBLE
! canPlayType returned '': no H.264 decoder in this build
battery False False ok
voices 0 >= 3 IMPOSSIBLE
! 0 voices after voiceschanged: a real OS always has some
--> 4 of 8 probes contradict 'windows-11-laptop'
==========================================================================
Claimed Linux CI container, answered honestly
==========================================================================
probe observed expected verdict
cores 8 in {1, 2, 4, 8, 16} ok
ram 4 in {0.25, 0.5, 1, 2, 4, 8} ok
touch 0 in {0} ok
audio_in 0 in {0} ok
cams 0 in {0} ok
h264 '' no ok
battery False False ok
voices 0 >= 0 ok
--> 0 of 8 probes contradict 'linux-ci-container'
The third case is the lesson. A container that admits it is a container scores zero, because nothing about it is contradictory. The problem is never that the answers are wrong, it is that they are wrong for the claim, which is the same reasoning as Cross-Validating Fingerprint Signals applied to hardware instead of headers.
Failure Modes
- Reading the voice list too early.
getVoices()returns[]synchronously on a healthy Windows machine. Patching it to a fixed list breaksvoiceschanged, so a page that waits for the event never gets it. - A fresh
deviceIdperenumerateDevicescall. An unstabledeviceIdmakes one machine look like an unlimited fleet and contradicts thegroupIda real camera and microphone share. - Overriding
matchMediawithout touching CSS. The stylesheet and the probe now disagree, visibly, in computed styles. hardwareConcurrencyspoofed to a prime. Thread counts are the base count times two with SMT, or the vCPU allocation.7and13exist on almost nobody's machine.deviceMemoryset to16. The buckets stop at8, and16is the commonest error in spoofed profiles.- Claiming a GPU you did not build. An RTX 4070 renderer string next to a null
requestAdapter(), or a 16384 texture limit next tohardwareConcurrency: 2, is the magnitude mismatch covered next.
Capability Checklist
- Every probe agrees with the claimed class, and the class is a shipping configuration rather than a minimum.
canPlayTypeis called for the bare container and for the container with a codec parameter, separating demuxer from decoder.MediaSource.isTypeSupportedandMediaRecorder.isTypeSupportedmatch the element probe for the same MIME types.RTCRtpSender.getCapabilities('video')is non-empty and the liveheaderExtensionsarray is non-empty.enumerateDevicesreturns a count the class would really have, with a stabledeviceIdand agroupIdshared by the camera and its paired microphone.navigator.getBatteryexists only on the platforms that ship it, anddeviceMemoryis one of the six Chromium buckets.- The
matchMediaanswers match the class, and the rendered CSS agrees with them. - The voice list is non-empty after
voiceschanged, and the firstlangmatchesnavigator.language. requestAdapterInfo().vendorand.architectureagree with the WebGLUNMASKED_RENDERER_WEBGLstring.- Nothing is zero where a real consumer device would report a non-zero value.
The Legitimate Route
Everything above is about making an environment you are authorised to drive look internally consistent: your own test account, your own staging deployment, or a site whose terms permit automated collection. The legitimate routes for data are the vendor's official API and partner feed, a documented rate limit in the response headers, or a written agreement; the same probes aimed at a third-party user who never consented are surveillance, and a browser reporting capabilities you could only have obtained without consent has not become a real browser. On the defensive side the answer is cheap: probe the capabilities you need, compare the vector against your own traffic, and alert on joint rarity rather than on any single value, which is Evading ML Anomaly Detection read backwards.