← All reports

[1] WebGL paint readback returns uninitialized PixelBuffer on rejected read

HighWebCore ANGLE WebGL backendUninitializedMemory

A WebGL2 page could get the GPU process to paint stale heap where its frame belonged.

6b90afd

High. Any page could issue a two-call WebGL2 sequence that made the GPU process hand an untouched heap allocation to the canvas paint path as if it were the rendered frame. It stays a read-only leak. Turning it into an ASLR break or a cross-context leak depends on steering which freed memory the buffer lands on.

WebKit's WebGL implementation runs in the GPU process on top of ANGLE, and every canvas paint reads the rendered frame back into a CPU-side pixel buffer. That readback is done by readPixelsForPaintResults(), an internal helper that copies the WebGL drawing buffer into a PixelBuffer (a raw byte container that can be allocated without zero-filling). It uses the same GL context as the page's own WebGL calls. The expected rule is that a buffer allocated uninitialized is handed onward only after the GL read that should overwrite every byte has actually succeeded.

The angle: a WebGL2 page could make the GPU process return an uninitialized heap allocation in place of its rendered frame. If that image reaches a script-readable sink, the page gets a read-only leak of stale GPU-process memory.

WebGL: Uninitialized heap memory leak via GL_READ_BUFFER desync in readPixelsForPaintResults. Reset the read buffer for the default framebuffer if the client has changed it. Originally landed as 316606.99@safari-7625-branch.

Source/WebCore/platform/graphics/angle/GraphicsContextGLANGLE.cpp

RefPtr<PixelBuffer> GraphicsContextGLANGLE::readPixelsForPaintResults()
...
ScopedBufferBinding scopedPixelPackBufferReset(GL_PIXEL_PACK_BUFFER, 0, m_isForWebGL2);
setPackParameters(1, 0, false);
+ updateErrors();
GL_ReadPixelsRobustANGLE(0, 0, pixelBuffer->size().width(), pixelBuffer->size().height(), GL_RGBA, GL_UNSIGNED_BYTE, pixelBuffer->bytes().size(), nullptr, nullptr, nullptr, pixelBuffer->bytes().data());
+ // The pixel buffer is uninitialized; never return it if the read was rejected for any reason.
+ if (updateErrors())
+ return nullptr;
return pixelBuffer;
}

Tools/TestWebKitAPI/Tests/WebCore/cocoa/TestGraphicsContextGLCocoa.mm

+TEST_F(GraphicsContextGLCocoaTest, CopyNativeImageWithReadBufferNoneWebGL2)
+{
+ attributes.isWebGL2 = true;
+ ...
+ gl->bindFramebuffer(GL::FRAMEBUFFER, 0);
+ gl->readBuffer(GL::NONE);
+ ...
+ RefPtr readbackImage = gl->GraphicsContextGLANGLE::copyNativeImage(buffer);
+ ASSERT_NE(readbackImage, nullptr);
+ EXPECT_TRUE(nativeImagePixelsIs(Color::green, *readbackImage, FloatPoint(5, 5)));
+ EXPECT_TRUE(gl->getErrors().isEmpty());

The patch makes GraphicsContextGLANGLE::readPixelsForPaintResults() fail closed. A new updateErrors() call before GL_ReadPixelsRobustANGLE(...) drains any GL errors that are already pending, so the next check sees only this read. A second updateErrors() after the read returns nullptr if any error was raised. Previously the function returned pixelBuffer unconditionally. The new comment states the invariant directly.

The commit message also lists ScopedReadBuffer changes in ANGLEUtilities.cpp/.h, but those hunks are not part of this main-branch commit. Instead, ANGLEUtilities.h on main has ScopedScratchReadFramebufferBinding. Its comment says it binds a scratch framebuffer "so that a read is unaffected by application visible default framebuffer state such as READ_BUFFER". On main, this commit therefore brings over only the fail-closed error check from the Safari-branch fix. The new API test CopyNativeImageWithReadBufferNoneWebGL2 covers both the platform copyNativeImage() surface copy and the base GraphicsContextGLANGLE::copyNativeImage() GL readback, for both DrawingBuffer and DisplayBuffer.

Publishing an uninitialized output buffer after a fallible fill operation without checking whether the fill actually succeeded, where client-controlled shared state can force the fill to fail.

Where this lives. When the GPU process is enabled, WebGL calls made in the WebContent process are serialized over IPC to RemoteGraphicsContextGL in the GPU process. That object drives GraphicsContextGLANGLE on top of ANGLE, Google's GL-on-native-graphics translation layer. To paint or export a WebGL canvas, WebKit has to read the drawing or display buffer back into a PixelBuffer or image. On the base readback path, readPixelsForPaintResults() does that work.

The emulated default framebuffer. The page never gets a real window-system framebuffer. The "framebuffer 0" that content sees is emulated by WebKit over internal textures and surfaces (the drawing buffer and the display buffer). Any state the page sets on framebuffer 0 lives in the same GL context that WebKit's own internal operations use.

WebGL2 readBuffer. In WebGL2 / OpenGL ES 3.0, readBuffer(mode) chooses which color buffer of the bound read framebuffer readPixels and copyTexImage read from. For the default framebuffer the valid values are BACK and NONE.

GL_ReadPixelsRobustANGLE and the GL error queue. GL_ReadPixelsRobustANGLE is ANGLE's bounds-checked glReadPixels, which takes an explicit destination size. When a call is rejected, GL records an error code and leaves the destination untouched. updateErrors() is a GraphicsContextGLANGLE helper that drains the GL error queue into the context's tracked error set and reports whether any new error appeared.

PixelBuffer. A WebCore container of raw pixel bytes with a format and a size. It can be allocated without zero-filling when the caller intends to overwrite every byte.

The bug is an uninitialized memory disclosure caused by trusting a fallible producer without checking its result:

  Before:                                   After:
  readPixelsForPaintResults()               readPixelsForPaintResults()
    ├─► PixelBuffer (uninitialized)           ├─► PixelBuffer (uninitialized)
    ├─► GL_ReadPixelsRobustANGLE(...)         ├─► updateErrors()        (drain)
    │     content READ_BUFFER == NONE         ├─► GL_ReadPixelsRobustANGLE(...)
    │     → GL_INVALID_OPERATION              │     rejected → error recorded
    │       0 bytes written                   ├─► updateErrors() == true
    └─► return pixelBuffer ──► paint sink     └─► return nullptr     (fail closed)
          (stale heap bytes)

On the left of the diagram, the page has already run bindFramebuffer(FRAMEBUFFER, 0) followed by readBuffer(NONE) on the emulated default framebuffer. OpenGL ES 3.0 rejects ReadPixels from a read buffer of NONE with GL_INVALID_OPERATION and writes nothing. The internal paint readback ran with the READ_BUFFER value the page had set, so the read was rejected. The function still returned the untouched buffer as if it held the rendered frame. That buffer contains whatever was last in the allocation, and it flows into the paint/composite path, where it becomes the canvas image.

Two separate things had to go wrong. State the page controls leaked into an internal operation, and the internal operation trusted its producer to have filled the buffer. These map to two layers of defense: isolation (ScopedReadBuffer on the Safari branch, ScopedScratchReadFramebufferBinding on main) and failing closed. This commit adds the fail-closed layer. It also covers any other reason the read could be rejected, such as an incomplete framebuffer or a future state-desync variant.

The regression test runs the trigger end to end:

  1. Create a WebGL2 context and clear it to green.
  2. Bind framebuffer 0 and set readBuffer(GL::NONE), putting the page-visible read state into the configuration that rejects reads.
  3. For each of DrawingBuffer and DisplayBuffer, take an image through the platform copyNativeImage() and through the base GraphicsContextGLANGLE::copyNativeImage() GL readback.
  4. Assert that the image is non-null, the pixel at (5, 5) is green, and getErrors() is empty.

With the fail-closed check in place, a rejected read now produces nullptr and step 4's ASSERT_NE would fail. Green pixels therefore mean the read succeeded despite NONE, which is consistent with scratch-framebuffer isolation being active on the base path. That does not prove isolation on every platform caller. The empty error set encodes a second expectation: the internal read must not surface errors to content through getError(). Testing the base-class readback explicitly matters because platform copyNativeImage() overrides can hide a vulnerable base path. The bug title's "GL_READ_BUFFER desync" framing points to discovery by systematically listing the WebGL2 state that applies to framebuffer 0 and checking which internal readbacks honor it.

The trigger is ordinary WebGL2 API usage and needs no special privilege. The result is a disclosure bounded to one PixelBuffer allocation of drawing-buffer width × height × 4 bytes. It repeats if each paint readback reallocates. It gives no write or control-flow primitive. Two conditions separate this from a practical leak. First, the image must reach a script-readable sink, such as canvas export or compositing into a readable 2D canvas. Which web-facing paths, if any, forward readPixelsForPaintResults() output from the GPU process to a script-readable sink on each platform is the open audit question. Second, the stale contents must be steerable. Content controls the drawing buffer's dimensions through canvas size and reshape, so an attacker could plausibly choose the allocator size class and aim the uninitialized allocation at freed GPU-process memory that held interesting data. If both conditions hold, the leak could become an ASLR-defeating or cross-context data-leak building block in a larger chain. If the leaked image is forwarded to WebContent and reaches a script-readable sink, the disclosure crosses the WebContent/GPU process boundary for confidentiality only, with no code execution or sandbox escape.

This vulnerability weakens memory confidentiality between processes. The security model assumes that pixel data the GPU process returns for a WebGL canvas reflects only that context's own rendering. Before the fix, a WebGL2 page could cause stale GPU-process heap to be returned in place of its frame, which might include data processed for other contexts or other web processes, or pointer values useful for defeating GPU-process ASLR.

Insight: On main, ScopedScratchReadFramebufferBinding is designed to isolate internal reads from READ_BUFFER. Where every platform caller of readPixelsForPaintResults() goes through it, this commit acts as defense in depth. Either way, it is the more durable half of the fix: any future desync, where some other piece of content-visible state makes ReadPixels reject, is now stopped at the output instead of requiring every state input to be found and isolated.