CVE-2026-78958
Overview
Background
- SharedImage cleared state
- A
SharedImagetracks whether its backing memory has been initialized viaIsCleared(),SetCleared(), andSetClearedRect(), and Chrome lazily zero-clears any region reported as not-yet-cleared before it can be read. - Graphite Recorder / Recording
- Under the Graphite backend, GPU work (including a clear) is not executed immediately but recorded into a
skgpu::graphite::Recorder, snapped into aRecording, and only actually runs once that recording is handed tographite_shared_context()->insertRecording(...). - `FlushWriteAccess` / `FlushGraphiteRecorder`
SharedContextState::FlushWriteAccessfinalizes a scoped write to aSharedImage, and for Graphite it callsSharedContextState::FlushGraphiteRecorder, which snaps the recorder and inserts the recording into the context.- Insertion failure
insertRecordingcan fail (returning failure) so the recorded clear command is silently dropped and never executes on the GPU.
Root Cause Analysis
When a SharedImage was uninitialized, the affected code paths recorded a clear command into the Graphite Recorder (via drawColor(..., SkBlendMode::kSrc) or BlitRGBAToYUVA) and then unconditionally called SetCleared() / SetClearedRect() to mark the image initialized. The clear, however, only actually runs if the snapped Recording is successfully passed to insertRecording; if FlushGraphiteRecorder / GraphiteFlush fail to insert the recording for any reason, the clear command is dropped, yet the SharedImage has already been flagged as cleared. This violates the invariant that “IsCleared() is true” implies “the backing memory was actually initialized,” so subsequent accesses skip the lazy re-clear and read whatever stale bytes occupy that GPU allocation.
The fix makes FlushGraphiteRecorder, FlushWriteAccess, GraphiteFlush, and GraphiteFlushAndSubmit return the insertion success bool, and defers every SetCleared() / SetClearedRect() call until that bool confirms the recording was actually inserted. Where the cleanup ordering differs (as in copy_shared_image_helper.cc), the intent is captured in update_cleared_rect / new_cleared_rect and applied only inside the success-guarded branch. In raster_decoder.cc the same deferral is expressed with the new should_clear_shared_image_ member, which delays SetCleared() until after a successful flush.
SharedImage cleared before confirming insertRecording succeeded. The fix threads the insertion-success bool up to every callsite and gates SetCleared() / SetClearedRect() on it, so the cleared flag is only set when the clear is guaranteed to run.Attack Path
- Trigger a Graphite-backed write to an uninitialized SharedImage
From a renderer, drive a raster, copy, or WebGPU operation (e.g. the
RasterDecoderImplclear path or acopy_shared_image_helper.ccblit) that targets aSharedImagewhose region is not yet cleared, causing a clear command to be recorded into the GraphiteRecorder. - Cause the recording insertion to fail
Arrange conditions under which
graphite_shared_context()->insertRecording(...)fails, so the recorded clear command is dropped and never executes on the GPU (the specific way to force this failure is not shown by the diff)(inferred). - SharedImage is wrongly marked cleared
In the unpatched code the callsite calls
SetCleared()/SetClearedRect()unconditionally, soIsCleared()now returns true even though the backing memory was never initialized. - Read back uninitialized GPU memory
A later access to the
SharedImageseesIsCleared() == true, skips the lazy re-clear, and returns the stale, uninitialized bytes to the attacker (e.g. via readback or compositing), leaking whatever data previously occupied that allocation(inferred).
Impact Assessment
SharedImage, potentially disclosing stale GPU allocation contents; the advisory classifies this as an UninitializedMemory / “Uninitialized resource in Skia” issue in the Skia/GPU Component. The precondition is a Graphite-backed write path to an uninitialized SharedImage combined with a failed recording insertion, after which the never-actually-cleared image is read back. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 3.1. Any escalation of the leaked bytes into a stronger primitive is (inferred) and not demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifgpu/command_buffer/service/copy_shared_image_helper.cc |
modified | |
ifgpu/command_buffer/service/graphite_utils.cc |
modified | |
GraphiteSharedContextgpu/command_buffer/service/graphite_utils.h |
modified | |
ifgpu/command_buffer/service/raster_decoder.cc |
modified | |
ifgpu/command_buffer/service/shared_context_state.cc |
modified | |
ifgpu/command_buffer/service/webgpu_decoder_impl.cc |
modified |
Files Changed
gpu/command_buffer/service/shared_context_state.ccgpu/command_buffer/service/graphite_utils.ccgpu/command_buffer/service/webgpu_decoder_impl.ccgpu/command_buffer/service/shared_context_state.hgpu/command_buffer/service/copy_shared_image_helper.ccgpu/command_buffer/service/raster_decoder.ccgpu/command_buffer/service/graphite_utils.h
Audit Directions
- Set-cleared-before-flush-confirmedSearch for any
SetCleared()/SetClearedRect()(or analogous initialization flags) called before or without checking that the flush/insertRecordingthat performs the actual clear succeeded. - Discarded flush/submit return valuesAudit callers of
FlushWriteAccess,FlushGraphiteRecorder,GraphiteFlush,GraphiteFlushAndSubmit,insertRecording, andsubmit()for places that ignore the now-meaningful success bool. - Recorded-vs-executed assumptionsLook across Graphite/Dawn command-recording paths for any state (layout transitions, semaphores, cleared/valid flags) updated on the assumption that recorded work always executes, and verify each is deferred until insertion is confirmed.
Patch
From 872f3f32bf81dfa4efa23352c1741267401ea14f Mon Sep 17 00:00:00 2001 From: Greg Daniel <[email protected]> Date: Thu, 16 Jul 2026 06:50:30 -0700 Subject: [PATCH] Verify Graphite recording insertion before updating SharedImage cleared state Currently, when a SharedImage is uninitialized, we record a clear command into the Graphite Recorder and immediately mark the SharedImage as cleared. However, if the Recording containing the clear fails to be inserted into the Graphite Context for any reason, the clear command never executes. Since the cleared flag was already updated on the SharedImage, Chrome never attempts to clear it again on subsequent accesses, causing uninitialized memory reads. This CL propagates the recording insertion success (by returning a bool from FlushGraphiteRecorder and FlushWriteAccess) up to the callsites, and defers calling SetCleared() / SetClearedRect() until we know the Graphite recording insertion successfully completes. Bug: b/497205529 Change-Id: I78f0028b4a2206985417e588534ef404cdbf8a8b Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8102302 Reviewed-by: Vasiliy Telezhnikov <[email protected]> Commit-Queue: Greg Daniel <[email protected]> Cr-Commit-Position: refs/heads/main@{#1663118} --- diff --git a/gpu/command_buffer/service/copy_shared_image_helper.cc b/gpu/command_buffer/service/copy_shared_image_helper.cc index 31efea78..3bc48e0 100644 --- a/gpu/command_buffer/service/copy_shared_image_helper.cc +++ b/gpu/command_buffer/service/copy_shared_image_helper.cc @@ -188,12 +188,12 @@ dest_scoped_access->surface()->writePixels(subset, xoffset, yoffset); - shared_context_state->FlushWriteAccess(dest_scoped_access); + bool success = shared_context_state->FlushWriteAccess(dest_scoped_access); shared_context_state->SubmitIfNecessary( std::move(end_semaphores), dest_scoped_access->NeedGraphiteContextSubmit()); - if (!dest_shared_image->IsCleared()) { + if (success && !dest_shared_image->IsCleared()) { dest_shared_image->SetClearedRect(dest_cleared_rect); } @@ -291,14 +291,20 @@ } bool need_graphite_submit = dest_scoped_access->NeedGraphiteContextSubmit(); + bool update_cleared_rect = false; + gfx::Rect new_cleared_rect; + // Flush dest surface and submit if necessary before exiting. absl::Cleanup cleanup = [&]() { - shared_context_state_->FlushWriteAccess(dest_scoped_access.get()); + bool success = + shared_context_state_->FlushWriteAccess(dest_scoped_access.get()); shared_context_state_->SubmitIfNecessary(std::move(end_semaphores), need_graphite_submit); + if (success && update_cleared_rect) { + dest_shared_image->SetClearedRect(new_cleared_rect); + } }; - gfx::Rect new_cleared_rect; gfx::Rect old_cleared_rect = dest_shared_image->ClearedRect(); if (!gles2::TextureManager::CombineAdjacentRects(old_cleared_rect, dest_rect, &new_cleared_rect)) { @@ -360,7 +366,7 @@ } if (!dest_shared_image->IsCleared()) { - dest_shared_image->SetClearedRect(new_cleared_rect); + update_cleared_rect = true; } // Note, that we still generate error for the client to indicate there was @@ -454,20 +460,26 @@ skia::BlitRGBAToYUVA(source_image.get(), yuva_sk_surfaces, yuva_info, gfx::RectToSkRect(dest_rect), false, gfx::RectToSkRect(source_rect)); - dest_shared_image->SetCleared(); + new_cleared_rect = gfx::Rect(dest_shared_image->size()); + update_cleared_rect = true; } if (!dest_shared_image->IsCleared()) { - dest_shared_image->SetClearedRect(new_cleared_rect); + update_cleared_rect = true; } } // Cancel cleanup as the cleanup order is different here. std::move(cleanup).Cancel(); - shared_context_state_->FlushWriteAccess(dest_scoped_access.get()); + bool success = + shared_context_state_->FlushWriteAccess(dest_scoped_access.get()); source_scoped_access->ApplyBackendSurfaceEndState(); shared_context_state_->SubmitIfNecessary(std::move(end_semaphores), need_graphite_submit); + + if (success && update_cleared_rect) { + dest_shared_image->SetClearedRect(new_cleared_rect); + } return result; } @@ -818,11 +830,12 @@ } } - shared_context_state_->FlushWriteAccess(dest_scoped_access.get()); + bool success = + shared_context_state_->FlushWriteAccess(dest_scoped_access.get()); shared_context_state_->SubmitIfNecessary(std::move(end_semaphores), need_graphite_submit); - if (!dest_shared_image->IsCleared()) { + if (success && !dest_shared_image->IsCleared()) { dest_shared_image->SetClearedRect(gfx::Rect(src_width, src_height)); } return base::ok(); diff --git a/gpu/command_buffer/service/graphite_utils.cc b/gpu/command_buffer/service/graphite_utils.cc index f7518d6..32f781c 100644 --- a/gpu/command_buffer/service/graphite_utils.cc +++ b/gpu/command_buffer/service/graphite_utils.cc @@ -63,18 +63,20 @@ } // namespace -void GraphiteFlush(GraphiteSharedContext* context, +bool GraphiteFlush(GraphiteSharedContext* context, skgpu::graphite::Recorder* recorder) { auto recording = recorder->snap(); if (recording) { - context->insertRecording({recording.get()}); + return context->insertRecording({recording.get()}); } + return true; } -void GraphiteFlushAndSubmit(GraphiteSharedContext* context, +bool GraphiteFlushAndSubmit(GraphiteSharedContext* context, skgpu::graphite::Recorder* recorder) { - GraphiteFlush(context, recorder); + bool success = GraphiteFlush(context, recorder); context->submit(); + return success; } bool GraphiteReadPixelsSync(GraphiteSharedContext* context, diff --git a/gpu/command_buffer/service/graphite_utils.h b/gpu/command_buffer/service/graphite_utils.h index 4e35ea2..94f6ff2 100644 --- a/gpu/command_buffer/service/graphite_utils.h +++ b/gpu/command_buffer/service/graphite_utils.h @@ -21,11 +21,11 @@ class GraphiteSharedContext; GPU_GLES2_EXPORT -void GraphiteFlush(GraphiteSharedContext* context, +bool GraphiteFlush(GraphiteSharedContext* context, skgpu::graphite::Recorder* recorder); GPU_GLES2_EXPORT -void GraphiteFlushAndSubmit(GraphiteSharedContext* context, +bool GraphiteFlushAndSubmit(GraphiteSharedContext* context, skgpu::graphite::Recorder* recorder); // Synchronously read pixels from a graphite image. diff --git a/gpu/command_buffer/service/raster_decoder.cc b/gpu/command_buffer/service/raster_decoder.cc index 92f5fd2..058dff1 100644 --- a/gpu/command_buffer/service/raster_decoder.cc +++ b/gpu/command_buffer/service/raster_decoder.cc @@ -899,6 +899,7 @@ std::unique_ptr<SkiaImageRepresentation> shared_image_; std::unique_ptr<SkiaImageRepresentation::ScopedWriteAccess> scoped_shared_image_write_; + bool should_clear_shared_image_ = false; std::unique_ptr<RasterImageRepresentation> shared_image_raster_; std::unique_ptr<RasterImageRepresentation::ScopedWriteAccess> @@ -2059,12 +2060,13 @@ return; } - shared_context_state_->FlushWriteAccess(dest_scoped_access.get()); + bool success = + shared_context_state_->FlushWriteAccess(dest_scoped_access.get()); shared_context_state_->SubmitIfNecessary( std::move(end_semaphores), dest_scoped_access->NeedGraphiteContextSubmit()); - if (!dest_shared_image->IsCleared()) { + if (success && !dest_shared_image->IsCleared()) { dest_shared_image->SetClearedRect( gfx::Rect(x_offset, y_offset, src_width, src_height)); } @@ -2311,12 +2313,13 @@ /*numLevels=*/1, release_proc, graphite_texture_ptr); } - shared_context_state_->FlushWriteAccess(dest_scoped_access.get()); + bool success = + shared_context_state_->FlushWriteAccess(dest_scoped_access.get()); shared_context_state_->SubmitIfNecessary( std::move(end_semaphores), dest_scoped_access->NeedGraphiteContextSubmit()); - return written; + return written && success; } void RasterDecoderImpl::DoReadbackARGBImagePixelsINTERNAL( @@ -2951,9 +2954,13 @@ // incorrect. if (needs_clear) { raster_canvas_->drawColor(sk_color_4f, SkBlendMode::kSrc); - shared_image_->SetCleared(); + if (graphite_shared_context()) { + should_clear_shared_image_ = true; + } else { + shared_image_->SetCleared(); + } } - DCHECK(shared_image_->IsCleared()); + DCHECK(graphite_shared_context() || shared_image_->IsCleared()); } scoped_refptr<Buffer> RasterDecoderImpl::GetShmBuffer(uint32_t shm_id) { @@ -3133,7 +3140,8 @@ // scoped_shared_image_write_ can be nullptr if sk_surface_ was set by // SetUpForRasterCHROMIUMForTest. if (scoped_shared_image_write_) { - shared_context_state_->FlushWriteAccess(scoped_shared_image_write_.get()); + bool success = shared_context_state_->FlushWriteAccess( + scoped_shared_image_write_.get()); // Flushing surface will cause vulkan command buffer to be recorded with // image layout transitions as necessary. Transitioning layout back to // desired need to be happening after. @@ -3143,6 +3151,9 @@ scoped_shared_image_write_->NeedGraphiteContextSubmit(); shared_context_state_->SubmitIfNecessary(std::move(end_semaphores_), need_graphite_submit); + if (success && should_clear_shared_image_) { + shared_image_->SetCleared(); + } } else { DCHECK(end_semaphores_.empty()); } @@ -3153,6 +3164,7 @@ scoped_shared_image_write_.reset(); shared_image_.reset(); paint_op_shared_image_provider_.reset(); + should_clear_shared_image_ = false; // Test only path for SetUpForRasterCHROMIUMForTest. sk_surface_for_testing_.reset(); diff --git a/gpu/command_buffer/service/shared_context_state.cc b/gpu/command_buffer/service/shared_context_state.cc index 08ba87c8..eb67ed1 100644 --- a/gpu/command_buffer/service/shared_context_state.cc +++ b/gpu/command_buffer/service/shared_context_state.cc @@ -816,13 +816,14 @@ return true; } -void SharedContextState::FlushGraphiteRecorder() { +bool SharedContextState::FlushGraphiteRecorder() { auto recording = gpu_main_graphite_recorder()->snap(); if (recording) { skgpu::graphite::InsertRecordingInfo info = {}; info.fRecording = recording.get(); - graphite_shared_context()->insertRecording(info); + return graphite_shared_context()->insertRecording(info); } + return true; } void SharedContextState::FlushAndSubmit(bool sync_to_cpu) { @@ -837,16 +838,17 @@ } } -void SharedContextState::FlushWriteAccess( +bool SharedContextState::FlushWriteAccess( SkiaImageRepresentation::ScopedWriteAccess* access) { static int flush_count = 0; const base::TimeTicks start = base::TimeTicks::Now(); + bool success = true; if (graphite_shared_context()) { // The only way to flush GPU work with Graphite is to snap and insert a // recording here. It's also necessary to submit before dropping the scoped // access since we want the Dawn texture to be alive on submit, but that's // handled in SubmitIfNecessary. - FlushGraphiteRecorder(); + success = FlushGraphiteRecorder(); } else { if (access->HasBackendSurfaceEndState()) { access->ApplyBackendSurfaceEndState(); @@ -867,6 +869,7 @@ "GPU.RasterDecoder.TimeToFlush", base::TimeTicks::Now() - start, base::Microseconds(1), base::Seconds(1), 100); } + return success; } void SharedContextState::SubmitIfNecessary( diff --git a/gpu/command_buffer/service/shared_context_state.h b/gpu/command_buffer/service/shared_context_state.h index 9de74f5..d590efd 100644 --- a/gpu/command_buffer/service/shared_context_state.h +++ b/gpu/command_buffer/service/shared_context_state.h @@ -147,8 +147,8 @@ bool IsGLInitialized() const { return !!feature_info_; } void FlushAndSubmit(bool sync_to_cpu); - void FlushGraphiteRecorder(); - void FlushWriteAccess(SkiaImageRepresentation::ScopedWriteAccess* access); + bool FlushGraphiteRecorder(); + bool FlushWriteAccess(SkiaImageRepresentation::ScopedWriteAccess* access); void SubmitIfNecessary(std::vector<GrBackendSemaphore> signal_semaphores, bool need_graphite_submit); diff --git a/gpu/command_buffer/service/webgpu_decoder_impl.cc b/gpu/command_buffer/service/webgpu_decoder_impl.cc index bfaad93..a1bd314 100644 --- a/gpu/command_buffer/service/webgpu_decoder_impl.cc +++ b/gpu/command_buffer/service/webgpu_decoder_impl.cc @@ -894,12 +894,13 @@ // It's ok to pass in empty GrFlushInfo here since SignalSemaphores() // will populate it with semaphores and call GrDirectContext::flush. + success = true; if (shared_context_state_->gr_context()) { skgpu::ganesh::Flush(surface); } else { DCHECK(shared_context_state_->graphite_shared_context()); DCHECK(shared_context_state_->gpu_main_graphite_recorder()); - GraphiteFlushAndSubmit( + success = GraphiteFlushAndSubmit( shared_context_state_->graphite_shared_context(), shared_context_state_->gpu_main_graphite_recorder()); } @@ -909,7 +910,7 @@ SignalSemaphores(std::move(end_semaphores)); - return true; + return success; } void WaitForSemaphores(std::vector<GrBackendSemaphore> semaphores) { @@ -2494,17 +2495,21 @@ clear_color = {0, 0, 0, 0}; } canvas->drawColor(clear_color, SkBlendMode::kSrc); - representation->SetCleared(); // It's ok to pass in empty GrFlushInfo here since SignalSemaphores() // will populate it with semaphores and call GrDirectContext::flush. + bool success = true; if (shared_context_state_->gr_context()) { skgpu::ganesh::Flush(surface); } else { DCHECK(shared_context_state_->graphite_shared_context()); DCHECK(shared_context_state_->gpu_main_graphite_recorder()); - GraphiteFlushAndSubmit(shared_context_state_->graphite_shared_context(), - shared_context_state_->gpu_main_graphite_recorder()); + success = GraphiteFlushAndSubmit( + shared_context_state_->graphite_shared_context(), + shared_context_state_->gpu_main_graphite_recorder()); + } + if (success) { + representation->SetCleared(); } // Transition the image back to the desired end state. This is used for // transitioning the image to the external queue for Vulkan/GL interop.
Original Bug Report
Potential cross-origin info leak via state desynchronization in Skia Graphite QueueManager
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: Skia Graphite’s QueueManager updates its tracking of the last added recording ID before completing the insertion process. If insertion subsequently fails, the tracker remains incremented, allowing an attacker to drop a recording (e.g., a SharedImage clear) without failing the strict ordering check for future recordings, leading to a potential leak of uninitialized GPU memory.
Affected files:
third_party/skia/src/gpu/graphite/QueueManager.cppthird_party/skia/src/gpu/graphite/QueueManager.h
Estimated timestamp from git blame: 2026-01-20
Summary
A logic error in Skia Graphite’s QueueManager causes the internal tracking of recording IDs to be updated prematurely. If a recording fails during insertion after this update occurs, the system’s state becomes desynchronized. This allows subsequent recordings to pass ordering checks and execute, even though a preceding recording was dropped. This can be exploited to bypass GPU memory initialization, leading to a reliable cross-origin information leak.
Vulnerability Details
In third_party/skia/src/gpu/graphite/QueueManager.cpp, the addRecording function is responsible for adding commands from a Recording to the current command buffer. For contexts requiring ordered recordings (such as Chrome’s), it verifies that the incoming recording ID is exactly one greater than the last added ID. However, the tracking map fLastAddedRecordingIDs is updated prematurely at line 133:
128: RETURN_FAIL_IF(recordingID && info.fRecording->priv().uniqueID() != *recordingID + 1,
129: InsertStatus::kOutOfOrderRecording,
130: "Recordings are expected to be replayed in order");
131:
132: // Note the new Recording ID.
133: fLastAddedRecordingIDs.set(recorderID, info.fRecording->priv().uniqueID());
Following this update, there are multiple RETURN_FAIL_IF checks for operations like command buffer setup (line 145) or lazy proxy instantiation (lines 173, 178). If any of these fail, the function returns an error, but the fLastAddedRecordingIDs state is not rolled back.
Because the counter has been advanced, a subsequent recording ($N+1$) will be accepted even if recording $N$ failed to execute. The failed recording $N$ is dropped.
Potential Exploitation Steps
Note: These are suggested steps based on code analysis; we do not yet have a working proof-of-concept.
- From a compromised renderer, an attacker creates a new
SharedImage. Its internalIsCleared()state is initiallyfalse. - The attacker binds the image and issues a
BeginRasterCHROMIUMcommand. - In
RasterDecoderImpl::DoBeginRasterCHROMIUM, Chrome observes that the image needs clearing and issues a clear command to the Skia Canvas. This clear operation becomes part of recording $N$. - Crucially, Chrome immediately marks the
SharedImageas cleared viashared_image_->SetCleared()(inraster_decoder.cc), assuming the recording will successfully execute. - The attacker intentionally exhausts GPU memory (e.g., by allocating many large textures) and flushes the context.
- Chrome snaps recording $N$ and submits it to
QueueManager::addRecording. The ID tracker is updated to $N$. - Due to the induced OOM,
setupCommandBuffer()or a proxy instantiation fails.addRecordingreturns a failure status (e.g.,kAddCommandsFailed). - In Chrome’s
GraphiteSharedContext::insertRecording, this failure is treated as recoverable (unlikekOutOfOrderRecording). The context is kept alive, but the clear commands are dropped. - The attacker frees memory and issues a command to read from the
SharedImage(recording $N+1$). Since Chrome’s state indicates it is cleared, no further clear is issued. QueueManager::addRecordingaccepts recording $N+1$ because the tracker expects $N+1$.- The GPU executes recording $N+1$, reading uninitialized GPU memory that may contain cross-origin data.
Recommended Fix
The update to fLastAddedRecordingIDs should be deferred until the end of QueueManager::addRecording(), after all potential failure points have passed. Alternatively, a rollback mechanism should be implemented in the RETURN_FAIL_IF macro or failure paths.
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.