[1] WebGL paint readback returns uninitialized PixelBuffer on rejected read
A WebGL2 page could get the GPU process to paint stale heap where its frame belonged.
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
Tools/TestWebKitAPI/Tests/WebCore/cocoa/TestGraphicsContextGLCocoa.mm
Patch Details
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.
Background
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.
Analysis
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:
- Create a WebGL2 context and clear it to green.
- Bind framebuffer 0 and set
readBuffer(GL::NONE), putting the page-visible read state into the configuration that rejects reads. - For each of
DrawingBufferandDisplayBuffer, take an image through the platformcopyNativeImage()and through the baseGraphicsContextGLANGLE::copyNativeImage()GL readback. - 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.
Audit directions
- Allocate-uninitialized-then-fill via a fallible API. The invariant is that an uninitialized buffer must not escape unless its producer reported success. Narrow: audit every
GraphicsContextGLANGLE/GraphicsContextGLCocoa/GraphicsContextGLGBM/GraphicsContextGLEGLhelper that creates aPixelBufferor other uninitialized span and then callsGL_ReadPixels*,GL_GetBufferSubData,GL_MapBufferRangeorEGL_BindTexImage. Check whether an error or null return gates the return value. Start with anyreadCompositedResults()implementations and thereadPixelsIPC handlers inRemoteGraphicsContextGL. Wider: the same shape appears wherever WebKit pairs an uninitialized allocation (tryCreateUninitialized,MallocSpanwithout zero-fill,Vector::growon POD) with a producer that can do nothing. Examples include IOSurface lock/copy failures, CoreGraphics draw-into-bitmap, image decoder partial frames and WebGPU buffer readbacks. Widest: this is the general uninitialized-output-on-error class, which also covers Chromium GPU-process readbacks, SkiareadPixelsreturning false and kernelcopy_to_userof partially filled structs. Match tell: a function returns or forwards a buffer on a path that never consults the producer's success flag or GL error state, and the buffer was not zero-initialized at allocation. - Client-visible state silently parameterizing an internal operation. The invariant is that every internal operation must set, or explicitly isolate, every piece of state it depends on. Narrow: list the internal operations in the ANGLE WebGL backend that read from, draw to or blit the emulated default framebuffer, such as the paint readback,
prepareTexture, multisample resolve and display-buffer copies. For each, check whether it scopesREAD_BUFFER,DRAW_BUFFERS, thePACK_*/UNPACK_*parameters, scissor, color mask andRASTERIZER_DISCARD. Use existing uses ofScopedPixelStorageMode,ScopedGLCapabilityandScopedScratchReadFramebufferBindingas the map of what is already covered. Wider: the same desync shows up with buffer bindings (thePIXEL_PACK_BUFFERreset is one existing guard), sampler and texture bindings during internal blits, and transform-feedback state during internal draws. Widest: any virtualized or multiplexed state machine where the implementation and the client share one context, such as Chromium's GLES2 command decoder state restoration, ANGLE's internal blit and clear helpers, and Vulkan or Metal command-buffer wrappers that inherit dynamic state. Match tell: an internal function relies on state it did not set, and that state is settable through a public WebGL entry point. - Shared error channel between implementation and client. Check how internal operations interact with the client-visible GL error queue in
GraphicsContextGLANGLE. Internal operations can swallow the client's pending errors or leak their own errors to the client. Either outcome desynchronizes what content sees from what happened, and either can mask a failed internal read. Narrow: trace eachupdateErrors()call site that wraps an internal operation. Verify that errors from the internal read are attributed correctly and do not show up throughgetError()as spurious content-visible errors. The new test'sgetErrors().isEmpty()assertions encode this expectation. Wider: apply the same review toRemoteGraphicsContextGLpaths that batch or synthesize errors across IPC. Widest: any API that multiplexes implementation and client work over a single sticky error flag (OpenGLglGetError, POSIXerrno, Win32GetLastError) needs explicit save/drain/restore around internal calls. Match tell: an internal call sits between client calls with no drain before it and no explicit, attributed check after it.