A canvas hash can change without a readback hook

An opaque control separates ordinary alpha conversion from a deliberate one-byte canvas readback alteration.

5 min read
RSS

A canvas test that treats any changed byte as evidence of tampering needs a better control. In a local Chromium experiment, writing translucent pixels and reading them back changed 3,032 bytes. The same RGB pattern with fully opaque pixels changed none. A deliberate readback hook changed one byte.

That difference matters to Heretic's canvas finding. The premise is preservation of a specific opaque byte pattern, rather than agreement with a fingerprint collected from another browser or an earlier visit.

What we ran

We used Playwright's Chromium 151.0.7922.34 in headless mode. Each condition had a fresh isolated browser context, with three consecutive measurements. All pages were served locally. This was an automated browser fixture, not a sample of visitors.

The input was a deterministic 64 by 32 RGBA pattern derived from one fixed nonce. Heretic's collector sets every alpha byte to 255. For the translucent control, our harness changed those bytes to 128 before writing. Both paths requested an sRGB canvas and rgba-unorm8 ImageData.

The reproduction includes the exact opaque input as input.json. Its core write/read step is:

js
const bytes = new Uint8ClampedArray(input.rgba);
// Translucent control only:
// for (let i = 3; i < bytes.length; i += 4) bytes[i] = 128;
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 32;
const context = canvas.getContext('2d', {
  alpha: true, colorSpace: 'srgb', willReadFrequently: true
});
context.putImageData(new ImageData(bytes, 64, 32, {
  colorSpace: 'srgb', pixelFormat: 'rgba-unorm8'
}), 0, 0);
const read = context.getImageData(0, 0, 64, 32, {
  colorSpace: 'srgb', pixelFormat: 'rgba-unorm8'
}).data;
const changed = bytes.reduce((n, byte, i) => n + (byte !== read[i]), 0);

The deliberately altered condition wrapped getImageData and flipped the first returned byte after the native method completed:

js
const original = CanvasRenderingContext2D.prototype.getImageData;
CanvasRenderingContext2D.prototype.getImageData = function (...args) {
  const image = Reflect.apply(original, this, args);
  image.data[0] ^= 1;
  return image;
};

We compared bytes directly before comparing SHA-256 hashes. Each repetition produced the same count:

Condition Changed bytes out of 8,192
Opaque input, native readback 0
Alpha 128 input, native readback 3,032
Opaque input, deliberate readback hook 1

The number 3,032 describes this pattern on this build. It is not a threshold for detecting a browser or privacy feature.

Why alpha changes the question

Canvas2D stores transparent colors with premultiplied alpha. Conceptually, a color channel is multiplied by alpha when stored, then divided when converted back. Finite precision can discard information in that round trip. The HTML canvas specification describes this loss for colors that are not fully opaque. It also describes color-space conversion as another possible source of loss.

At full opacity, the alpha multiplier is one. Our matching sRGB byte formats and opaque pattern remove those particular conversion differences from the experiment. We observed exact preservation in the native control. We did not trace Chromium's internal arithmetic to attribute every changed translucent byte.

What the Heretic check accepts as evidence

We also ran the production collector beside each condition. It always generated its own opaque pattern, including beside the translucent experiment. Its reported input and output hashes matched in both unhooked contexts. They differed in the hooked context.

The Rust canvas assessor then evaluated those saved collector observations. All three hooked observations produced integrity.canvas-pixel-altered. The six unhooked observations produced no finding from that assessor.

There is a guard before that comparison. The assessor independently derives the expected opaque input from the nonce and validates the observation's dimensions and format. We substituted the translucent experiment's input and output hashes into an otherwise valid observation. The input guard rejected it in all three repetitions, and the canvas assessor emitted no alteration finding. Unequal hashes alone did not pass the premise.

That rejection is not an allow decision. This test called the canvas assessor directly; it did not evaluate the complete session or its required-measurement refusal path.

What an equal result leaves unanswered

Our native control was headless automation and still preserved the canvas bytes. An equal result answers this byte-preservation question. It cannot establish that a person is present.

An unequal result under the validated conditions establishes reported readback alteration. This experiment knows its cause because we installed the hook ourselves. The same output alone would not identify a particular extension, privacy feature or browser vendor. It also cannot stop a hostile client from fabricating a report instead of running the collector.

A useful fingerprinting test states what it held constant and what relation it checked. For this finding, the useful relation is the nonce-derived opaque input against its readback, with guards that exclude a different input. Comparing two arbitrary canvas hashes would answer a different question.

Reproduce the browser matrix

Download the input vector, runner and recorded results. With Node 22.22.2, run npm ci, npx playwright install chromium, then npm test. The browser command downloads Chromium if it is not cached. The runner writes its observations before checking the original byte counts and hashes. A mismatch on another build or platform is a reproduction difference, not automatically tampering.

The archive reproduces the browser matrix. It includes the separately recorded collector observations and Rust assessor output; it does not execute the server engine.

Next article A scraping gate must cover the data routes Ten requests through the reference Worker show where clearance is checked, which requests reach the origin, and why an image header does not admit an export.

The probe runs on your own browser.

The public demo returns the verdict and its named findings.