GPU drivers are large, privileged, memory-unsafe code, and WebGPU lets any page feed them shaders and commands. Chrome 1 's WebGPU Technical Report names the new attack surfaces: the API implementation in the renderer and GPU processes, and "the WGSL shader compiler added to the GPU process". The defenses are layered (Figure in Dawn):
Process isolation. Pages run in renderer processes at Windows' lowest (Untrusted) integrity level; only the GPU process, at Low integrity, talks to the driver. Everything crosses between them as serialized Dawn 1,149 Wire commands, which the wire server copies out of shared memory before parsing.
Validation before the driver. Dawn Native re-checks every call in the GPU process.
Safe shaders. Tint or naga validates WGSL and rewrites it so no access escapes its resource: per the WGSL specification, an out-of-bounds load returns zero or a value from the same buffer, never other memory.
No leftovers or precise timers. Toggles such as lazy_clear_resource_on_first_use zero new resources, and timestamp_quantization coarsens GPU timers to blunt timing side channels.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
.stage { position: relative; width: 100%; max-width: 600px; }
.stage canvas { display: block; width: 100%; }
.stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
<canvas id="view" width="600" height="360"></canvas>
<canvas id="labels" width="600" height="360"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');
function showMessage(text) { // 2D fallback when WebGPU is missing
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}
function label(text, x, y, size = 12, color = '#2b2b2b', align = 'left', weight = '') {
ink.font = `${weight} ${size}px system-ui, sans-serif`; ink.fillStyle = color; ink.textAlign = align;
ink.fillText(text, x, y);
}
// A shader that reads far past the end of a six-price buffer. Robustness keeps every read inside.
const probe = /* wgsl */ `
@group(0) @binding(0) var<storage> prices: array<f32>; // 6 prices
@group(0) @binding(1) var<storage, read_write> out: array<f32>;
@compute @workgroup_size(1) fn main() {
let far = array(0u, 5u, 6u, 1000u, 1000000u);
for (var i = 0u; i < 5; i++) { out[i] = prices[far[i]]; }
}`;
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) return showMessage('WebGPU is not available in this browser');
const device = await adapter.requestDevice();
const context = canvas.getContext('webgpu');
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
// Run the probe and read its five results back.
const B = GPUBufferUsage;
const prices = device.createBuffer({ size: 24, usage: B.STORAGE | B.COPY_DST });
device.queue.writeBuffer(prices, 0, new Float32Array([14.99, 39.50, 24.00, 18.75, 16.20, 21.30]));
const out = device.createBuffer({ size: 20, usage: B.STORAGE | B.COPY_SRC });
const read = device.createBuffer({ size: 20, usage: B.COPY_DST | B.MAP_READ });
const compute = device.createComputePipeline({ layout: 'auto', compute: { module: device.createShaderModule({ code: probe }) } });
const enc = device.createCommandEncoder();
const cp = enc.beginComputePass();
cp.setPipeline(compute);
cp.setBindGroup(0, device.createBindGroup({ layout: compute.getBindGroupLayout(0), entries: [
{ binding: 0, resource: { buffer: prices } }, { binding: 1, resource: { buffer: out } }] }));
cp.dispatchWorkgroups(1);
cp.end();
enc.copyBufferToBuffer(out, 0, read, 0, 20);
device.queue.submit([enc.finish()]);
await read.mapAsync(GPUMapMode.READ);
const results = [...new Float32Array(read.getMappedRange())];
read.unmap();
// The layered defences, as nested boxes
const boxes = [[10, 10, 330, 340, 0.93, 0.88, 0.95, 16], // GPU process
[24, 60, 302, 60, 0.86, 0.91, 0.97, 10], // renderer
[24, 140, 302, 44, 1, 1, 1, 10], [24, 196, 302, 44, 1, 1, 1, 10],
[24, 252, 302, 44, 1, 1, 1, 10], [24, 304, 302, 36, 0.95, 0.85, 0.80, 10]];
label('Layers between a page and the driver', 20, 34, 13, '#5b2b6e', 'left', 'bold');
label('Renderer process: Untrusted integrity', 36, 84, 12, '#1f4f8a', 'left', 'bold');
label('the page and the Dawn Wire client', 36, 104, 12, '#1f4f8a');
label('Wire server copies commands out of shared', 36, 158, 11.5); label('memory before parsing (GPU process, Low)', 36, 174, 11.5);
label('Dawn Native re-validates every call', 36, 222, 12);
label('Tint / naga validate WGSL and clamp accesses', 36, 272, 11.5); label('zeroed resources, coarse timers', 36, 287, 11.5);
label('GPU driver (large, privileged, memory-unsafe)', 36, 327, 11.5, '#6a2a1a');
label('Live: reading prices[i] of a 6-element buffer', 352, 34, 12, '#222', 'left', 'bold');
[0, 5, 6, 1000, 1000000].forEach((index, i) => {
const y = 56 + i * 58, inside = index < 6;
boxes.push([352, y, 236, 48, ...(inside ? [0.86, 0.93, 0.87] : [0.98, 0.93, 0.80]), 10]);
label(`prices[${index.toLocaleString('en-US')}]`, 364, y + 20, 13, '#222', 'left', 'bold');
label(`= ${results[i].toFixed(2)}`, 364, y + 38, 13, '#222');
label(inside ? 'in bounds' : 'clamped or 0, never other memory', 580, y + 38, 10.5, inside ? '#1e6b3a' : '#8a5a00', 'right');
});
const module = device.createShaderModule({ code: `
struct Box { rect: vec4f, style: vec4f } // style: r, g, b, corner radius
@group(0) @binding(0) var<storage> boxes: array<Box>;
struct Out { @builtin(position) pos: vec4f, @location(0) local: vec2f,
@location(1) @interpolate(flat) i: u32 }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
let corner = vec2f(f32(v & 1), f32(v >> 1)); // triangle-strip corners
let r = boxes[i].rect;
let px = r.xy + corner * r.zw;
return Out(vec4f(px.x / 300 - 1, 1 - px.y / 180, 0, 1), corner * r.zw, i);
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
let b = boxes[in.i];
let half = b.rect.zw / 2;
let q = abs(in.local - half) - half + b.style.w; // signed distance to a rounded box
let d = length(max(q, vec2f(0))) + min(max(q.x, q.y), 0) - b.style.w;
let a = clamp(0.5 - d, 0, 1);
return vec4f(b.style.rgb * a, a); // premultiplied alpha
}` });
const blend = { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' };
const pipeline = device.createRenderPipeline({ layout: 'auto',
vertex: { module }, primitive: { topology: 'triangle-strip' },
fragment: { module, targets: [{ format, blend: { color: blend, alpha: blend } }] } });
const data = new Float32Array(boxes.flat());
const buffer = device.createBuffer({ size: data.byteLength, usage: B.STORAGE | B.COPY_DST });
device.queue.writeBuffer(buffer, 0, data);
const group = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer } }] });
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
pass.setPipeline(pipeline);
pass.setBindGroup(0, group);
pass.draw(4, boxes.length);
pass.end();
device.queue.submit([encoder.finish()]);
}
main();
</script>