# UNMASKED_RENDERER_WEBGL returns null: why, and the tell

> Chrome returns null for the unmasked WebGL renderer until the page calls getExtension, so a spoof that answers it earlier is a two-line tell.

Source: https://mosaicbrowser.com/blog/webgl-debug-renderer-info-fresh-context

## UNMASKED_RENDERER is null until a page asks for it

Chrome returns null for the unmasked WebGL renderer until the page calls getExtension, so a spoof that answers it earlier is a two-line tell.

![null, and error 1280, until the page asks, beside a screenshot: a profile fingerprint in Mosaic: GPU, screen, fonts, timezone and language matched to the exit](https://mosaicbrowser.com/covers/webgl-debug-renderer-info-fresh-context.webp)

`UNMASKED_RENDERER_WEBGL` returns null until the page has called `getExtension('WEBGL_debug_renderer_info')` on that context. On a fresh context, Chrome answers `getParameter(0x9246)` with `null` and leaves `gl.getError()` at 1280, which is `INVALID_ENUM`. If your code gets null, call `getExtension` first. If you are building a fingerprint spoof, the same fact is a trap: answering that constant early is a tell that no renderer string can fix.

## Why it returns null

`WEBGL_debug_renderer_info` is an extension, and WebGL treats an extension's enums as unknown until the page enables it. Enabling means calling `getExtension` with its name and getting an object back. The extension defines two constants, `UNMASKED_VENDOR_WEBGL` (`0x9245`) and `UNMASKED_RENDERER_WEBGL` (`0x9246`), and only after that call does `getParameter` accept them.

So the working code reads them from the extension object:

```
const gl = document.createElement('canvas').getContext('webgl')
const ext = gl.getExtension('WEBGL_debug_renderer_info')
const renderer = ext && gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)

```

Skip the second line and pass the raw number, and Chrome returns `null` and records the error. The plain constants are a different thing: `gl.getParameter(gl.RENDERER)` needs no extension and returns `WebKit WebGL` in Chrome, and `gl.VENDOR` returns `WebKit`, the same on every machine.

If `getExtension` itself returns `null`, the browser is not offering the extension on that context, and there is no unmasked string to read.

Mosaic is a desktop app

Email yourself the link and open it on your computer.

[How it works: Machines that exist](https://mosaicbrowser.com/features/real-fingerprints)

## The two-line tell

Now look at it from the side of a detector. It can make a fresh context, skip `getExtension`, and ask for `0x9246` directly. A real Chrome answers `null`, and `getError()` says 1280. That costs two lines and asks for no permission.

A spoof written the obvious way overrides `getParameter`, checks whether the argument is `0x9245` or `0x9246`, and returns the claimed string. It answers every time, including on a context that never asked. That browser now does something no real Chrome does, and the value of the string does not matter. It could be the machine's own GPU, word for word, and the probe would still separate it from every real browser, because the tell is the moment of the answer, not its content.

We had exactly this. Before our own engine, Mosaic changed values with a script injected into stock Chrome, and its WebGL override answered both constants unconditionally. Pinning the string to the host's own chip could not have helped, for the reason above. The fix in that script was to remember which contexts had asked: `getExtension` adds the context to a `WeakSet` when the name is `WEBGL_debug_renderer_info` and an object comes back, and `getParameter` only answers for contexts in the set. Everything else falls through to the real implementation, which produces the null and the error as it should.

## On our engine, the check is Chromium's own

Tesseract Chrome, our Chromium, changes fingerprint values in C++ inside the browser, not in page JavaScript. Patch `0120` answers the two unmasked constants from the profile, and where it puts the answer is the whole point. In `webgl_rendering_context_base.cc` the stock code already reads:

```
case WebGLDebugRendererInfo::kUnmaskedRendererWebgl:
  if (ExtensionEnabled(kWebGLDebugRendererInfoName)) {
    return WebGLAny(script_state, String(ContextGL()->GetString(GL_RENDERER)));
  }

```

The patch puts the profile's renderer inside that `if`, above the stock `return`, and leaves the `ExtensionEnabled` check untouched. A context that never asked falls out of the `if` into Chromium's own null-and-error path, which is the same code a stock Chrome runs. No list of contexts to keep, because Chromium already keeps it.

That hunk has a history worth knowing if you build on other people's patches. The upstream version anchored it on a feature flag that only one Chromium fork has, so on stock source the hunk could not apply. It failed in silence: a `.rej` file beside a tree that still compiled, and the rest of the GPU patch shipped with its helpers built and nothing calling them. The renderer claim was simply never answered, and nothing in the build said so. We re-wrote the hunk against stock context, and the first draft of that landed in the middle of a two-line statement and cost a compiler error about a function-style cast, which had nothing to do with the real mistake.

## The string is not the whole claim

Answering at the right moment fixes the two-line tell. It does not make any renderer string believable on its own. A detector that reads the unmasked renderer usually reads the rest of the context too: every `MAX_*` limit, the shader precision formats, the extension list. Pixelscan's collector has a field for that record next to the two strings. A renderer has to agree with all of it, which is why the engine moves the capability record together with the string.

The other WebGL surface a page can cross-check, the image, has the same lesson in a different shape: two ways to read one framebuffer that have to agree, covered in [readPixels and toDataURL](https://mosaicbrowser.com/blog/webgl-readpixels-vs-todataurl). How CreepJS flags a function that answers when it should not is in [what a CreepJS lie is](https://mosaicbrowser.com/blog/what-a-creepjs-lie-is), and the values Tesseract Chrome changes are listed on [real fingerprints](https://mosaicbrowser.com/features/real-fingerprints).

## Questions

### Why does getParameter(UNMASKED_RENDERER_WEBGL) return null?

Because the context has not enabled the WEBGL_debug_renderer_info extension. Call gl.getExtension('WEBGL_debug_renderer_info') first and read the constant from the object it returns. Until then Chrome returns null and records INVALID_ENUM.

### What is error 1280 in WebGL?

1280 is 0x0500, GL_INVALID_ENUM. After a getParameter call with an enum the context does not accept, gl.getError() returns it once and then clears.

### What if getExtension('WEBGL_debug_renderer_info') returns null?

Then the browser does not offer the extension on that context, and the unmasked constants cannot be read at all. getParameter(gl.RENDERER) still works, but in Chrome it returns the fixed string WebKit WebGL.

### How does Mosaic answer the unmasked renderer?

Inside the engine, in C++, and only within Chromium's own check that the extension is enabled. A fresh context gets the same null and INVALID_ENUM a stock Chrome gives, and a context that asked gets the profile's renderer.

## Related

[readPixels and toDataURL read a canvas from opposite ends](https://mosaicbrowser.com/blog/webgl-readpixels-vs-todataurl): readPixels starts at the bottom row and toDataURL at the top, so position-keyed WebGL noise disagreed with itself. Keyed on colour, 4096 of 4096 pixels agree.

[Machines that exist: fingerprints drawn from real hardware](https://mosaicbrowser.com/features/real-fingerprints): Mosaic draws each profile's GPU, screen, CPU, memory and fonts from one real device, so the parts of a fingerprint agree with each other and with the host.

[What a CreepJS lie is, and why stock Chrome has none](https://mosaicbrowser.com/blog/what-a-creepjs-lie-is): CreepJS lies detected means an API behaved unlike the native one: wrong toString, wrong descriptor, or a worker that disagrees. Stock Chrome has none.

[The four-line probe that catches position-keyed canvas noise](https://mosaicbrowser.com/blog/canvas-offset-probe): To detect canvas noise injection, draw one shape twice at two offsets and diff them. A real browser gives 0 bytes; our old noise gave 76.









## Mosaic, on your own computer

The browser this blog is about: its own Chromium, fingerprints from real machines and a proxy exit per profile. Free holds 3 profiles. Pro is $49 a month for any number.

Mosaic is a desktop app

Email yourself the link and open it on your computer.
