f3958d766c60b467f4b2dbd2dc415d5c55307aa6 [M154] Skip Graphite async read pixels callback when context is lost
Triage note: GPU process, reachable via WebGPU/canvas readback + context loss, but a 'potential' stack-UAF fixed by skipping the callback; stack-UAF is harder to exploit and the trigger is unproven from the diff. Bug:561997427. Backports M152/M153/M155.
The bug at a glance
A real lifetime bug in the GPU process: the thread-safe async-read-pixels wrapper could invoke the wrapped callback after the caller of asyncRescaleAndReadPixels* had already returned early due to GPU context loss. When the callback context (old_context / bound state) references the caller’s stack, firing it post-return is a stack use-after-free. Because it executes in the GPU process and is triggered by GPU context loss - a condition an attacker can induce (e.g. via crafted GPU work, TDR, or forced device removal) while readbacks are in flight - it is meaningfully more than hardening, warranting medium / CVSS ~6.0. It is not higher because the trigger is a race gated on context-loss timing rather than a directly controllable free/use, control over the freed stack contents is limited, and it is not shown to be reliably exploitable (exploitable=false in triage). The [M154] cherry-pick to a release branch is consistent with a stack-UAF worth backporting.
Skia Graphite readback. asyncRescaleAndReadPixels*/YUV*/YUVA* schedule an asynchronous pixel readback and are given a caller-supplied callback + context. GraphiteSharedContext wraps these in an AsyncReadContext and a thread-safe trampoline (ReadPixelsCallbackThreadSafe) so results are delivered on the originating thread. If the GPU context is lost, the original caller of the async API returns early - potentially unwinding a stack frame that the callback context still points into - yet the Graphite/Skia machinery may still fire the trampoline, dereferencing freed stack state.
Root cause
The trampoline previously had no notion of context liveness. On context loss, the code that invoked asyncRescaleAndReadPixels* returns early (the whole GraphiteSharedContext is tearing down / the delegate reports IsContextLost()), but an already-scheduled ReadPixelsCallbackThreadSafe can still run and invoke context->old_callback(context->old_context, result). If old_context (or state captured by old_callback) lives on the now-returned caller’s stack, this is a stack use-after-free. The fix adds a raw_ptr delegate to AsyncReadContext, threads delegate_ through CreateAsyncReadContextThreadSafe at all ten call sites (the six Async…Submit and the paired plain variants for RGBA and YUV420/YUVA420), and, at the top of ReadPixelsCallbackThreadSafe, returns early if context->delegate && context->delegate->IsContextLost() - dropping the callback instead of firing it into dead stack. A second, subtle correctness fix is in the cross-thread repost: previously it did PostTask(BindOnce(std::move(context->old_callback), context->old_context, result)), which would invoke the raw callback on the target thread and thereby bypass the new IsContextLost() check. The patch changes the repost to PostTask(BindOnce(&ReadPixelsCallbackThreadSafe, context.release(), result)), re-entering the trampoline on the correct thread so the context-lost gate is re-evaluated there before the real callback runs. context.release() transfers ownership across the post so the AsyncReadContext is freed exactly once when the reposted trampoline consumes it.
Key code
graphite_shared_context.cc: bail out of the trampoline when the delegate reports context loss
static void ReadPixelsCallbackThreadSafe(
/* std::unique_ptr<AsyncReadContext> context, async_result */) {
// ...
if (context->delegate && context->delegate->IsContextLost()) {
return; // caller already returned early; do not fire into dead stack
}
base::SingleThreadTaskRunner* task_runner = context->task_runner.get();
if (task_runner && !task_runner->BelongsToCurrentThread()) {
// Re-enter the trampoline on the origin thread (re-checks IsContextLost there),
// instead of binding old_callback directly (which bypassed the check).
task_runner->PostTask(FROM_HERE,
base::BindOnce(&ReadPixelsCallbackThreadSafe, context.release(),
std::move(async_result)));
return;
}
// ... otherwise invoke context->old_callback(context->old_context, ...)
}
Patch walkthrough
gpu/command_buffer/service/graphite_shared_context.cc— Three coordinated edits. (1) AsyncReadContext gains a raw_ptrGraphiteSharedContext::Delegate delegate member so the trampoline can later query context liveness. (2) CreateAsyncReadContextThreadSafe gains a Delegate* delegate parameter and stores it into the new AsyncReadContext; all ten async entry points (asyncRescaleAndReadPixels[AndSubmit], asyncRescaleAndReadPixelsYUV420[AndSubmit], asyncRescaleAndReadPixelsYUVA420[AndSubmit] and their variants) are updated to pass the object’s delegate_ at the call site. (3) ReadPixelsCallbackThreadSafe adds an earlyif (context->delegate && context->delegate->IsContextLost()) return;guard, and the cross-thread repost is changed from binding old_callback+old_context directly to reposting &ReadPixelsCallbackThreadSafe with context.release(), so the context-lost check is enforced on the destination thread rather than skipped. This is the security-relevant core: it prevents the wrapped callback from being invoked with a stale (potentially freed stack) context after context loss.gpu/command_buffer/service/graphite_shared_context_unittest.cc— Adds GraphiteSharedContextTest.ReadPixelsContextLost. It renders a red 64x64 surface, snaps a recording and inserts it, then drives asyncRescaleAndReadPixelsAndSubmit twice with a base::test::TestFuture callback. First it sets EXPECT_CALL(delegate_, IsContextLost()).WillOnce(Return(false)) and asserts the future becomes ready (callback fired). Then it clears the future, sets IsContextLost() to return true, re-issues the readback, and asserts EXPECT_FALSE(readback_future.IsReady()) - i.e. the callback is correctly suppressed when the context is lost. This is the regression oracle for the guard.
Background
Skia Graphite async readback — graphite::Context::asyncRescaleAndReadPixels* schedules an asynchronous GPU->CPU pixel copy and later calls a client callback with an AsyncReadResult and the client’s opaque context pointer. The client owns the lifetime of whatever that context points at.
GraphiteSharedContext::Delegate / IsContextLost() — GraphiteSharedContext wraps the shared graphite::Context for the GPU service and holds a delegate_ that exposes IsContextLost(). Context loss (device removal, TDR, GPU crash, forced loss) makes the GPU service unwind pending work; callers of the async API return early in that state.
Thread-safe callback trampoline — Because one graphite::Context may be shared across threads, GraphiteSharedContext wraps the client callback so results are re-posted to the originating SingleThreadTaskRunner before invocation. AsyncReadContext carries old_callback, old_context, the task_runner, and now the delegate.
Stack use-after-free — When a callback closes over or is passed a pointer into a caller’s stack frame and is invoked after that frame returns, the callback reads/writes reclaimed stack memory - a UAF whose exploitability depends on what later reuses that stack region.
Vulnerability window
- Introduction — The thread-safe readback trampoline invoked the wrapped callback unconditionally, with no check that the GPU context (and thus the caller’s stack-bound context) was still valid.
- Analysis (bug 561997427) — Identified that on context loss the async caller returns early while a scheduled callback may still fire, giving a potential stack UAF.
- Fix on main (b77d42d0, main@{#1698970}) — Le Hoang Quyen added the delegate->IsContextLost() gate in ReadPixelsCallbackThreadSafe and re-routed the cross-thread repost through the trampoline so the gate is not bypassed.
- Backport (2026-09-17, M154) — Cherry-picked to release branch 8037@{#1217} under the cherry-picker/rubber-stamper flow (bugs 562784322,561997427), indicating it was backported to a shipping milestone.
Proof of concept
The diff includes a unit test (ReadPixelsContextLost) that proves the behavioral fix - the callback fires when IsContextLost() is false and is suppressed when it is true - using a mock delegate. That is a correctness oracle, not a memory-corruption exploit. A true stack-UAF PoC would require, in a GPU-process build, issuing an async readback whose callback context lives on a soon-to-return stack frame and inducing context loss so the callback fires after the frame unwinds, then observing corruption under ASAN - dependent on precise timing and stack reuse and not demonstrated here. No exploit code is invented; the test below is reproduced verbatim from the diff.
TEST_P(GraphiteSharedContextTest, ReadPixelsContextLost) {
// ... render red 64x64 surface, snap+insert recording ...
base::test::TestFuture<void*,
std::unique_ptr<const SkSurface::AsyncReadResult>> readback_future;
// Context not lost -> callback should fire.
EXPECT_CALL(delegate_, IsContextLost()).WillOnce(testing::Return(false));
EXPECT_TRUE(graphite_shared_context_->asyncRescaleAndReadPixelsAndSubmit(
surface.get(), ii, SkIRect::MakeWH(64, 64), SkImage::RescaleGamma::kSrc,
SkImage::RescaleMode::kNearest, readback_future.GetCallback(), nullptr));
EXPECT_TRUE(readback_future.IsReady());
// Context lost -> callback should be suppressed.
readback_future.Clear();
EXPECT_CALL(delegate_, IsContextLost()).WillOnce(testing::Return(true));
EXPECT_TRUE(graphite_shared_context_->asyncRescaleAndReadPixelsAndSubmit(
surface.get(), ii, SkIRect::MakeWH(64, 64), SkImage::RescaleGamma::kSrc,
SkImage::RescaleMode::kNearest, readback_future.GetCallback(), nullptr));
EXPECT_FALSE(readback_future.IsReady());
}
Exploitation
- Process context — Executes in the GPU process. An attacker footprint here typically starts from a compromised/malicious renderer issuing GPU work (WebGL/WebGPU/2D canvas readbacks) that reaches Graphite async readback paths.
- Setup — Initiate one or more async rescale/readpixels operations so callbacks are in flight with contexts tied to soon-to-return caller stack frames.
- Trigger — Induce GPU context loss (device removal / TDR / forced loss / GPU-crash cascade) so the async caller returns early while a scheduled trampoline still holds the now-stale context - pre-fix the trampoline fires into freed stack.
- Escalation (unproven) — Turning a stack UAF into control requires grooming what reuses the returned frame and steering the callback’s dereference; triage rates this not reliably exploitable, so realistic impact is GPU-process crash with a low-probability corruption tail. The fix removes the fire-after-loss entirely.
Detection & hunting
For defenders and detection engineers:
- GPU-process crashes on context loss during readback —
- Context-loss + readback correlation —
Audit directions
- Other async GPU callbacks vs context loss —
- Callback contexts bound to stack —
- Repost paths that bypass guards —