← Chrome — 2026-W38

f3958d766c60b467f4b2dbd2dc415d5c55307aa6  [M154] Skip Graphite async read pixels callback when context is lost

severity medium class UAF CVSS 6.0 confidence 0.55 signal score 7.0 gpu/command_buffer/service patch-now AI review CONFIRMED
Primitive: ReadPixelsCallbackThreadSafe could invoke a callback after the caller returned early on context loss -> potential stack use-after-free.
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 early if (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

  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. 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.
  2. Setup — Initiate one or more async rescale/readpixels operations so callbacks are in flight with contexts tied to soon-to-return caller stack frames.
  3. 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.
  4. 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 —

Before / after

Loading diff…