Overview

Critical
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in GPU
DescriptionOut of bounds write in GPU
ComponentGPU
Sub-componentGPU Security
Bug ClassOOB
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker556576992
Fix commit1ef712bafebc (chromium/src) +248/-14
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedDavid Sievers (@loknop)
Reported2026-09-03 (per vendor advisory)
Fix landed2026-09-15 (public git)
Disclosed2026-09-22
Patch gap7 days — fix public in source before the advisory

Background

`AHardwareBuffer`
Android’s native graphics buffer; arrives over Mojo as a socket endpoint reconstructed by the platform, so everything AHardwareBuffer_describe() reports (width, height, layers, usage, strides) is attacker-controlled when it originates from a compromised renderer.
NV12
a YUV 4:2:0 format with a full-resolution luma (Y) plane followed by an interleaved half-resolution chroma (UV) plane; its size is width*height (Y) plus 2 * ((width+1)/2) * ((height+1)/2) (UV).
`libyuv::NV12Copy()`
the copy routine used for readback; it rounds chroma dimensions up via halfwidth = (width+1)>>1 and halfheight = (height+1)>>1.
`GpuMemoryBufferHandle`
the readback destination descriptor passed from the renderer to the GPU process; the shared-memory region sized from it must be large enough to hold the copied planes.

Root Cause Analysis

CopyNativeGmbToSharedMemoryAsync() / CopyNativeBufferToSharedMemoryAsync() sized the destination shared-memory region with the integer expression width * height * 3 / 2, which rounds the chroma contribution down, while the subsequent libyuv::NV12Copy() computes chroma extents by rounding up. For a forged buffer with an odd width such as 2730667x1, the size check accepts a 4096000-byte region but the copy writes 5461335 bytes, a 1365335-byte out-of-bounds write filled with renderer-controlled pixel data. The odd-dimension rejection in IsSizeForBufferHandleValid() normally prevents this, but it was bypassed here because the dimensions were read from the untrusted AHardwareBuffer description rather than validated over the Mojo wire. Additional attack surface came from a CHECK() on desc.usage, from unchecked narrowing of 64-bit-capable dimensions to int, and from unvalidated source row strides. None of these untrusted values were range-checked before being used to size allocations or drive the copy.
Key insight
The size check and the copy used different rounding conventions for chroma dimensions (/2 truncates down vs. (x+1)>>1 rounds up), so an odd dimension sourced from the attacker-controlled buffer description makes the validated region smaller than what libyuv actually writes.

Attack Path

  1. Compromise the renderer An attacker gains code execution in a renderer process able to issue GPU-service readback commands.
  2. Forge the buffer description The renderer sends a GpuMemoryBufferHandle whose reconstructed AHardwareBuffer reports odd, oversized dimensions (e.g. 2730667x1) that never pass through IsSizeForBufferHandleValid().
  3. Trigger NV12 readback The renderer invokes CopyNativeGmbToSharedMemoryAsync() / CopyNativeBufferToSharedMemoryAsync(), which sizes the destination with the truncating width * height * 3 / 2 expression.
  4. Overflow the destination libyuv::NV12Copy() rounds chroma up and writes past the undersized shared-memory region, producing a ~1.3 MB OOB write with attacker-controlled contents in the GPU process.

Impact Assessment

This is a renderer-triggerable out-of-bounds write of over a megabyte of attacker-controlled data in the GPU process, corrupting adjacent heap memory. Combined with the crash primitive from the removed CHECK() on attacker-controlled usage bits, it gives an attacker both a denial-of-service and a strong foundation for GPU-process memory corruption and privilege escalation out of the renderer sandbox.

Changed Functions

FunctionChangeNotes
if
gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory.cc
modified
if
gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc
modified
for
gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc
modified
TEST
gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc
modified

Files Changed

  • gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory.cc
  • gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc

Audit Directions

  • Mismatched rounding between validation and use
    Wherever a size check divides by 2 (or truncates) for chroma/subsampled planes while the actual copy uses (x+1)>>1 rounding (e.g. libyuv NV12/I420 helpers), the validated region can be smaller than what is written; require both paths to use the same plane-size helper such as viz::SharedMemoryPlaneSizeForSharedImageFormat().
  • Trusting reconstructed native handles
    Values from AHardwareBuffer_describe() and similar platform-reconstructed handles (width, height, layers, usage, strides) are attacker-controlled; validate them with IsSizeForBufferHandleValid() and range-check before narrowing to int rather than assuming wire-level validation applied.
  • `CHECK()` on attacker-controlled input
    Audit for CHECK()/DCHECK() guarding renderer-supplied fields (e.g. desc.usage); these turn malformed input into GPU-process crashes and should be replaced with graceful failure.
From 1ef712bafebc527732f7e769f168bafb8d1022e9 Mon Sep 17 00:00:00 2001
From: vikas soni <[email protected]>
Date: Tue, 15 Sep 2026 15:21:01 -0700
Subject: [PATCH] [GPU Security]: Validate untrusted AHardwareBuffer description before NV12 readback.

CopyNativeGmbToSharedMemoryAsync() accepts a GpuMemoryBufferHandle from
a potentially compromised renderer. An AHardwareBuffer travels over Mojo
as a Unix socket endpoint that the platform reconstructs the buffer
from, so neither Mojo nor the GPU process can authenticate who allocated
it. Everything AHardwareBuffer_describe() reports is therefore attacker
controlled input.

CopyNativeBufferToSharedMemoryAsync() sized the destination with `width
* height * 3 / 2`, which rounds the chroma planes down, and then copied
with libyuv::NV12Copy(), which rounds them up (halfwidth = (width + 1)
>> 1). For a forged 2730667x1 buffer the check accepts a 4096000 byte
region while the copy writes 5461335 bytes, an out of bounds write of
1365335 bytes whose contents come from the renderer supplied source
buffer. Odd dimensions are rejected everywhere else on Android by
IsSizeForBufferHandleValid(), but that check is bypassed here because
the size is derived from the untrusted buffer rather than sent over the
wire.

Treat the buffer description as untrusted input:
  * Reject odd dimensions with IsSizeForBufferHandleValid(), the same
    predicate used when creating such SharedImages. This removes the
    floor/ceil divergence and makes the "UV row size == width"
    assumption of the destination layout true.
  * Size the destination with
    viz::SharedMemoryPlaneSizeForSharedImageFormat(), the helper the
    client uses to allocate the region, and sum the planes with
    base::CheckedNumeric.
  * Hand libyuv bounds checked spans obtained with span::split_at()
    instead of a subspan() offset computed in 32 bit arithmetic.
  * Range check the dimensions before narrowing them to int, reject
    empty sizes, and reject buffers with layers != 1.
  * Turn the CHECK() on desc.usage into a graceful failure. The usage
    bits are attacker controlled, so the CHECK() let a renderer crash
    the GPU process at will.
  * Sanity check the source row strides.

Add regression tests covering the odd dimension overflow, the
destination size boundary, and non CPU readable buffers.

Bug: 556576992
Change-Id: I90474d22a5e1ea12235fa0ac419ff5c0c13c36ef
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8395051
Commit-Queue: vikas soni <[email protected]>
Reviewed-by: Vasiliy Telezhnikov <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1698422}
---
diff --git a/gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory.cc b/gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory.cc
index 2b80f4040..b97b6ef6 100644
--- a/gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory.cc
+++ b/gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory.cc
@@ -10,6 +10,7 @@
 
 #include <algorithm>
 #include <memory>
+#include <optional>
 #include <utility>
 #include <vector>
 
@@ -23,10 +24,12 @@
 #include "base/memory/raw_ptr.h"
 #include "base/memory/ref_counted.h"
 #include "base/memory/scoped_refptr.h"
+#include "base/numerics/safe_conversions.h"
 #include "base/posix/eintr_wrapper.h"
 #include "base/strings/string_number_conversions.h"
 #include "base/trace_event/trace_event.h"
 #include "build/build_config.h"
+#include "components/viz/common/resources/shared_image_format_utils.h"
 #include "gpu/command_buffer/common/gles2_cmd_utils.h"
 #include "gpu/command_buffer/common/shared_image_usage.h"
 #include "gpu/command_buffer/service/ahardwarebuffer_utils.h"
@@ -1179,23 +1182,60 @@
   CHECK(desc.usage & (AHARDWAREBUFFER_USAGE_CPU_READ_RARELY |
                       AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN));
 
-  base::span<uint8_t> dst_buffer = mapping.GetMemoryAsSpan<uint8_t>();
-  const size_t required_size =
-      static_cast<size_t>(desc.width) * desc.height * 3 / 2;
-  if (dst_buffer.size() < required_size) {
+  // gfx::Size and libyuv take the dimensions as `int`.
+  if (!base::IsValueInRangeForNumericType<int>(desc.width) ||
+      !base::IsValueInRangeForNumericType<int>(desc.height)) {
+    return false;
+  }
+  const gfx::Size size(static_cast<int>(desc.width),
+                       static_cast<int>(desc.height));
+  if (size.IsEmpty()) {
     return false;
   }
 
+  // Clients can't create a non-NV12 mappable AHB SharedImage.
+  // NV12 requires even dimensions on this platform, and the code below relies
+  // on that: it writes the destination UV rows with a stride of `width` bytes,
+  // while libyuv::NV12Copy() writes ceil(width / 2) * 2 bytes per UV row over
+  // ceil(height / 2) rows. For odd dimensions the two disagree, which is what
+  // allowed a forged buffer to overflow the destination.
+  constexpr viz::SharedImageFormat kFormat = viz::MultiPlaneFormat::kNV12;
+  if (!IsSizeForBufferHandleValid(size, kFormat)) {
+    return false;
+  }
+
+  // Size the destination with the same helpers the client used to allocate
+  // `shared_memory`. They use ceil-rounded chroma dimensions, matching what
+  // NV12Copy() writes, and checked arithmetic.
+  std::optional<size_t> y_plane_size =
+      viz::SharedMemoryPlaneSizeForSharedImageFormat(kFormat, /*plane_index=*/0,
+                                                     size);
+  std::optional<size_t> required_size =
+      viz::SharedMemorySizeForSharedImageFormat(kFormat, size);
+  if (!y_plane_size.has_value() || !required_size.has_value()) {
+    return false;
+  }
+
+  base::span<uint8_t> dst_buffer = mapping.GetMemoryAsSpan<uint8_t>();
+  if (dst_buffer.size() < required_size.value()) {
+    return false;
+  }
+  // Bounds-checked split of the destination into its Y and UV planes.
+  auto [dst_y_plane, dst_uv_plane] =
+      dst_buffer.first(required_size.value()).split_at(y_plane_size.value());
+
   AHardwareBuffer_Planes planes;
   int fence = -1;
-  int ret = AHardwareBuffer_lockPlanes(hardware_buffer,
-                                       AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN,
-                                       fence, nullptr, &planes);
+  const uint64_t cpu_read_usage =
+      desc.usage & (AHARDWAREBUFFER_USAGE_CPU_READ_RARELY |
+                    AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN);
+  int ret = AHardwareBuffer_lockPlanes(hardware_buffer, cpu_read_usage, fence,
+                                       nullptr, &planes);
   if (ret != 0) {
     return false;
   }
 
-  absl::Cleanup uncloker = [&]() {
+  absl::Cleanup unlocker = [&]() {
     AHardwareBuffer_unlock(hardware_buffer, nullptr);
   };
 
@@ -1219,14 +1259,35 @@
     return false;
   }
 
-  const int dst_stride = desc.width;
+  // libyuv takes strides as `int`. Ensure the source strides fit in `int` to
+  // prevent overflow into negative values, which libyuv interprets as bottom-up
+  // strides.
+  if (!base::IsValueInRangeForNumericType<int>(planes.planes[0].rowStride) ||
+      !base::IsValueInRangeForNumericType<int>(planes.planes[1].rowStride)) {
+    return false;
+  }
+
+  // Destination row strides must match what the client expects when mapping
+  // the shared memory, and must fit in `int` for libyuv.
+  std::optional<size_t> dst_stride_y =
+      viz::SharedMemoryRowSizeForSharedImageFormat(kFormat, /*plane_index=*/0,
+                                                   size.width());
+  std::optional<size_t> dst_stride_uv =
+      viz::SharedMemoryRowSizeForSharedImageFormat(kFormat, /*plane_index=*/1,
+                                                   size.width());
+  if (!dst_stride_y.has_value() || !dst_stride_uv.has_value() ||
+      !base::IsValueInRangeForNumericType<int>(dst_stride_y.value()) ||
+      !base::IsValueInRangeForNumericType<int>(dst_stride_uv.value())) {
+    return false;
+  }
 
   int result = libyuv::NV12Copy(
-      static_cast<uint8_t*>(planes.planes[0].data), planes.planes[0].rowStride,
-      static_cast<uint8_t*>(planes.planes[1].data), planes.planes[1].rowStride,
-      dst_buffer.data(), dst_stride,
-      dst_buffer.subspan(desc.height * dst_stride).data(), dst_stride,
-      desc.width, desc.height);
+      static_cast<uint8_t*>(planes.planes[0].data),
+      static_cast<int>(planes.planes[0].rowStride),
+      static_cast<uint8_t*>(planes.planes[1].data),
+      static_cast<int>(planes.planes[1].rowStride), dst_y_plane.data(),
+      static_cast<int>(dst_stride_y.value()), dst_uv_plane.data(),
+      static_cast<int>(dst_stride_uv.value()), size.width(), size.height());
 
   return result == 0;
 }
diff --git a/gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc b/gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc
index f6733840..4fa7c8e 100644
--- a/gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc
+++ b/gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc
@@ -6,10 +6,16 @@
 
 #include <android/hardware_buffer.h>
 
+#include <vector>
+
 #include "base/android/scoped_hardware_buffer_fence_sync.h"
+#include "base/android/scoped_hardware_buffer_handle.h"
 #include "base/compiler_specific.h"
+#include "base/containers/span.h"
 #include "base/functional/callback_helpers.h"
 #include "base/memory/raw_ptr.h"
+#include "base/memory/unsafe_shared_memory_region.h"
+#include "base/numerics/safe_conversions.h"
 #include "components/viz/common/resources/shared_image_format_utils.h"
 #include "gpu/command_buffer/common/mailbox.h"
 #include "gpu/command_buffer/common/shared_image_info.h"
@@ -784,5 +790,172 @@
                                          GrContextType::kGraphiteDawn),
                          testing::PrintToStringParamName());
 
+// Usage bits of a real NV12 buffer that is read back to shared memory: it is
+// sampled by the GPU and read by the CPU. Allocating with GPU usage matters,
+// since a CPU-only Y8Cb8Cr8_420 allocation is free to come back as a planar
+// (YV12) layout, which this code path does not support.
+constexpr uint64_t kNV12ReadbackUsage =
+    AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE |
+    AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN;
+
+// Allocates an NV12 AHardwareBuffer of `size`, or returns an empty handle if
+// the platform refuses the allocation (e.g. for odd dimensions).
+base::android::ScopedHardwareBufferHandle AllocateNV12Buffer(
+    const gfx::Size& size,
+    uint64_t usage = kNV12ReadbackUsage) {
+  AHardwareBuffer_Desc desc = {
+      .width = static_cast<uint32_t>(size.width()),
+      .height = static_cast<uint32_t>(size.height()),
+      .layers = 1,
+      .format = AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420,
+      .usage = usage,
+  };
+  AHardwareBuffer* buffer = nullptr;
+  AHardwareBuffer_allocate(&desc, &buffer);
+  if (!buffer) {
+    return base::android::ScopedHardwareBufferHandle();
+  }
+  return base::android::ScopedHardwareBufferHandle::Adopt(buffer);
+}
+
+// Whether the platform allocator hands back the interleaved NV12 plane layout
+// that CopyNativeBufferToSharedMemoryAsync() supports. Gralloc is free to pick
+// a planar layout instead, in which case the copy legitimately fails and the
+// tests below that expect success have nothing to assert.
+bool SupportsNV12PlaneLayout(AHardwareBuffer* buffer) {
+  AHardwareBuffer_Planes planes;
+  if (AHardwareBuffer_lockPlanes(buffer, AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN,
+                                 /*fence=*/-1, nullptr, &planes) != 0) {
+    return false;
+  }
+  const bool supported =
+      planes.planeCount == 3 && planes.planes[0].pixelStride == 1 &&
+      planes.planes[1].pixelStride == 2 && planes.planes[2].pixelStride == 2 &&
+      planes.planes[1].rowStride == planes.planes[2].rowStride &&
+      (static_cast<uint8_t*>(planes.planes[2].data) -
+       static_cast<uint8_t*>(planes.planes[1].data)) == 1;
+  AHardwareBuffer_unlock(buffer, nullptr);
+  return supported;
+}
+
+// Writes a distinct value into every byte the copy is supposed to read, and
+// returns those bytes in the order the destination is supposed to hold them:
+// `height` rows of `width` Y bytes, followed by `height / 2` rows of `width`
+// interleaved UV bytes. Returns an empty vector if the buffer can't be locked.
+std::vector<uint8_t> FillNV12Buffer(AHardwareBuffer* buffer,
+                                    const gfx::Size& size) {
+  AHardwareBuffer_Planes planes;
+  if (AHardwareBuffer_lockPlanes(buffer, AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN,
+                                 /*fence=*/-1, nullptr, &planes) != 0) {
+    return {};
+  }
+
+  const size_t width = base::checked_cast<size_t>(size.width());
+  const size_t height = base::checked_cast<size_t>(size.height());
+  std::vector<uint8_t> expected;
+  auto fill_plane = [&](const AHardwareBuffer_Plane& src, size_t rows,
+                        uint8_t seed) {
+    const size_t row_stride = src.rowStride;
+    // SAFETY: A locked plane holds `rows` rows at `rowStride` byte intervals,
+    // the last of which is at least `width` bytes long.
+    base::span<uint8_t> plane = UNSAFE_BUFFERS(base::span(
+        static_cast<uint8_t*>(src.data), (rows - 1) * row_stride + width));
+    for (size_t row = 0; row < rows; ++row) {
+      base::span<uint8_t> src_row = plane.subspan(row * row_stride, width);
+      for (size_t col = 0; col < width; ++col) {
+        src_row[col] = static_cast<uint8_t>(seed + row * 7 + col);
+        expected.push_back(src_row[col]);
+      }
+    }
+  };
+  fill_plane(planes.planes[0], height, /*seed=*/0);
+  fill_plane(planes.planes[1], height / 2, /*seed=*/128);
+
+  AHardwareBuffer_unlock(buffer, nullptr);
+  return expected;
+}
+
+// Regression test for the out-of-bounds write reachable from a compromised
+// renderer via the CopyNativeGmbToSharedMemoryAsync IPC. The destination size
+// used to be computed as `width * height * 3 / 2`, which rounds *down* for odd
+// dimensions, while libyuv::NV12Copy() writes ceil-rounded chroma planes. A
+// buffer with odd dimensions must be rejected outright.
+TEST(AHardwareBufferImageBackingFactoryCopyTest, RejectsOddDimensions) {
+  constexpr gfx::Size kOddSize(65, 65);
+  auto ahb_handle = AllocateNV12Buffer(kOddSize);
+  if (!ahb_handle.is_valid()) {
+    GTEST_SKIP() << "Odd-sized NV12 AHardwareBuffer allocation unsupported";
+  }
+
+  // This is the size that the old (truncating) check would have accepted; it is
+  // smaller than what NV12Copy() would write, so accepting it overflows.
+  const size_t undersized =
+      static_cast<size_t>(kOddSize.width()) * kOddSize.height() * 3 / 2;
+  ASSERT_LT(undersized, viz::SharedMemorySizeForSharedImageFormat(
+                            viz::MultiPlaneFormat::kNV12, kOddSize)
+                            .value());
+  auto region = base::UnsafeSharedMemoryRegion::Create(undersized);
+  ASSERT_TRUE(region.IsValid());
+
+  EXPECT_FALSE(
+      AHardwareBufferImageBackingFactory::CopyNativeBufferToSharedMemoryAsync(
+          gfx::GpuMemoryBufferHandle(std::move(ahb_handle)),
+          std::move(region)));
+}
+
+// A destination smaller than the full NV12 image must be rejected.
+TEST(AHardwareBufferImageBackingFactoryCopyTest, RejectsUndersizedDestination) {
+  constexpr gfx::Size kSize(64, 64);
+  auto ahb_handle = AllocateNV12Buffer(kSize);
+  if (!ahb_handle.is_valid()) {
+    GTEST_SKIP() << "NV12 AHardwareBuffer allocation unsupported";
+  }
+  const size_t required = viz::SharedMemorySizeForSharedImageFormat(
+                              viz::MultiPlaneFormat::kNV12, kSize)
+                              .value();
+  auto region = base::UnsafeSharedMemoryRegion::Create(required - 1);
+  ASSERT_TRUE(region.IsValid());
+
+  EXPECT_FALSE(
+      AHardwareBufferImageBackingFactory::CopyNativeBufferToSharedMemoryAsync(
+          gfx::GpuMemoryBufferHandle(std::move(ahb_handle)),
+          std::move(region)));
+}
+
+// A correctly sized destination must receive the whole image in the layout the
+// client expects: a tightly packed Y plane followed by a tightly packed UV
+// plane, both with a row size of `width` bytes.
+TEST(AHardwareBufferImageBackingFactoryCopyTest, CopiesBothPlanes) {
+  constexpr gfx::Size kSize(64, 64);
+  auto ahb_handle = AllocateNV12Buffer(
+      kSize, kNV12ReadbackUsage | AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN);
+  if (!ahb_handle.is_valid()) {
+    GTEST_SKIP() << "NV12 AHardwareBuffer allocation unsupported";
+  }
+  if (!SupportsNV12PlaneLayout(ahb_handle.get())) {
+    GTEST_SKIP() << "Platform does not provide an interleaved NV12 layout";
+  }
+
+  const std::vector<uint8_t> expected = FillNV12Buffer(ahb_handle.get(), kSize);
+  ASSERT_EQ(expected.size(), viz::SharedMemorySizeForSharedImageFormat(
+                                 viz::MultiPlaneFormat::kNV12, kSize)
+                                 .value());
+
+  auto region = base::UnsafeSharedMemoryRegion::Create(expected.size());
+  ASSERT_TRUE(region.IsValid());
+  base::UnsafeSharedMemoryRegion region_for_reading = region.Duplicate();
+  ASSERT_TRUE(region_for_reading.IsValid());
+
+  ASSERT_TRUE(
+      AHardwareBufferImageBackingFactory::CopyNativeBufferToSharedMemoryAsync(
+          gfx::GpuMemoryBufferHandle(std::move(ahb_handle)),
+          std::move(region)));
+
+  base::WritableSharedMemoryMapping mapping = region_for_reading.Map();
+  ASSERT_TRUE(mapping.IsValid());
+  EXPECT_EQ(mapping.GetMemoryAsSpan<uint8_t>().first(expected.size()),
+            base::span(expected));
+}
+
 }  // anonymous namespace
 }  // namespace gpu
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc b/gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc
index f6733840..4fa7c8e 100644
--- a/gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc
+++ b/gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc
@@ -6,10 +6,16 @@
 
 #include <android/hardware_buffer.h>
 
+#include <vector>
+
 #include "base/android/scoped_hardware_buffer_fence_sync.h"
+#include "base/android/scoped_hardware_buffer_handle.h"
 #include "base/compiler_specific.h"
+#include "base/containers/span.h"
 #include "base/functional/callback_helpers.h"
 #include "base/memory/raw_ptr.h"
+#include "base/memory/unsafe_shared_memory_region.h"
+#include "base/numerics/safe_conversions.h"
 #include "components/viz/common/resources/shared_image_format_utils.h"
 #include "gpu/command_buffer/common/mailbox.h"
 #include "gpu/command_buffer/common/shared_image_info.h"
@@ -784,5 +790,172 @@
                                          GrContextType::kGraphiteDawn),
                          testing::PrintToStringParamName());
 
+// Usage bits of a real NV12 buffer that is read back to shared memory: it is
+// sampled by the GPU and read by the CPU. Allocating with GPU usage matters,
+// since a CPU-only Y8Cb8Cr8_420 allocation is free to come back as a planar
+// (YV12) layout, which this code path does not support.
+constexpr uint64_t kNV12ReadbackUsage =
+    AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE |
+    AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN;
+
+// Allocates an NV12 AHardwareBuffer of `size`, or returns an empty handle if
+// the platform refuses the allocation (e.g. for odd dimensions).
+base::android::ScopedHardwareBufferHandle AllocateNV12Buffer(
+    const gfx::Size& size,
+    uint64_t usage = kNV12ReadbackUsage) {
+  AHardwareBuffer_Desc desc = {
+      .width = static_cast<uint32_t>(size.width()),
+      .height = static_cast<uint32_t>(size.height()),
+      .layers = 1,
+      .format = AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420,
+      .usage = usage,
+  };
+  AHardwareBuffer* buffer = nullptr;
+  AHardwareBuffer_allocate(&desc, &buffer);
+  if (!buffer) {
+    return base::android::ScopedHardwareBufferHandle();
+  }
+  return base::android::ScopedHardwareBufferHandle::Adopt(buffer);
+}
+
+// Whether the platform allocator hands back the interleaved NV12 plane layout
+// that CopyNativeBufferToSharedMemoryAsync() supports. Gralloc is free to pick
+// a planar layout instead, in which case the copy legitimately fails and the
+// tests below that expect success have nothing to assert.
+bool SupportsNV12PlaneLayout(AHardwareBuffer* buffer) {
+  AHardwareBuffer_Planes planes;
+  if (AHardwareBuffer_lockPlanes(buffer, AHARDWAREBUFFER_USAGE_CPU_READ_OFTEN,
+                                 /*fence=*/-1, nullptr, &planes) != 0) {
+    return false;
+  }
+  const bool supported =
+      planes.planeCount == 3 && planes.planes[0].pixelStride == 1 &&
+      planes.planes[1].pixelStride == 2 && planes.planes[2].pixelStride == 2 &&
+      planes.planes[1].rowStride == planes.planes[2].rowStride &&
+      (static_cast<uint8_t*>(planes.planes[2].data) -
+       static_cast<uint8_t*>(planes.planes[1].data)) == 1;
+  AHardwareBuffer_unlock(buffer, nullptr);
+  return supported;
+}
+
+// Writes a distinct value into every byte the copy is supposed to read, and
+// returns those bytes in the order the destination is supposed to hold them:
+// `height` rows of `width` Y bytes, followed by `height / 2` rows of `width`
+// interleaved UV bytes. Returns an empty vector if the buffer can't be locked.
+std::vector<uint8_t> FillNV12Buffer(AHardwareBuffer* buffer,
+                                    const gfx::Size& size) {
+  AHardwareBuffer_Planes planes;
+  if (AHardwareBuffer_lockPlanes(buffer, AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN,
+                                 /*fence=*/-1, nullptr, &planes) != 0) {
+    return {};
+  }
+
+  const size_t width = base::checked_cast<size_t>(size.width());
+  const size_t height = base::checked_cast<size_t>(size.height());
+  std::vector<uint8_t> expected;
+  auto fill_plane = [&](const AHardwareBuffer_Plane& src, size_t rows,
+                        uint8_t seed) {
+    const size_t row_stride = src.rowStride;
+    // SAFETY: A locked plane holds `rows` rows at `rowStride` byte intervals,
+    // the last of which is at least `width` bytes long.
+    base::span<uint8_t> plane = UNSAFE_BUFFERS(base::span(
+        static_cast<uint8_t*>(src.data), (rows - 1) * row_stride + width));
+    for (size_t row = 0; row < rows; ++row) {
+      base::span<uint8_t> src_row = plane.subspan(row * row_stride, width);
+      for (size_t col = 0; col < width; ++col) {
+        src_row[col] = static_cast<uint8_t>(seed + row * 7 + col);
+        expected.push_back(src_row[col]);
+      }
+    }
+  };
+  fill_plane(planes.planes[0], height, /*seed=*/0);
+  fill_plane(planes.planes[1], height / 2, /*seed=*/128);
+
+  AHardwareBuffer_unlock(buffer, nullptr);
+  return expected;
+}
+
+// Regression test for the out-of-bounds write reachable from a compromised
+// renderer via the CopyNativeGmbToSharedMemoryAsync IPC. The destination size
+// used to be computed as `width * height * 3 / 2`, which rounds *down* for odd
+// dimensions, while libyuv::NV12Copy() writes ceil-rounded chroma planes. A
+// buffer with odd dimensions must be rejected outright.
+TEST(AHardwareBufferImageBackingFactoryCopyTest, RejectsOddDimensions) {
+  constexpr gfx::Size kOddSize(65, 65);
+  auto ahb_handle = AllocateNV12Buffer(kOddSize);
+  if (!ahb_handle.is_valid()) {
+    GTEST_SKIP() << "Odd-sized NV12 AHardwareBuffer allocation unsupported";
+  }
+
+  // This is the size that the old (truncating) check would have accepted; it is
+  // smaller than what NV12Copy() would write, so accepting it overflows.
+  const size_t undersized =
+      static_cast<size_t>(kOddSize.width()) * kOddSize.height() * 3 / 2;
+  ASSERT_LT(undersized, viz::SharedMemorySizeForSharedImageFormat(
+                            viz::MultiPlaneFormat::kNV12, kOddSize)
+                            .value());
+  auto region = base::UnsafeSharedMemoryRegion::Create(undersized);
+  ASSERT_TRUE(region.IsValid());
+
+  EXPECT_FALSE(
+      AHardwareBufferImageBackingFactory::CopyNativeBufferToSharedMemoryAsync(
+          gfx::GpuMemoryBufferHandle(std::move(ahb_handle)),
+          std::move(region)));
+}
+
+// A destination smaller than the full NV12 image must be rejected.
+TEST(AHardwareBufferImageBackingFactoryCopyTest, RejectsUndersizedDestination) {
+  constexpr gfx::Size kSize(64, 64);
+  auto ahb_handle = AllocateNV12Buffer(kSize);
+  if (!ahb_handle.is_valid()) {
+    GTEST_SKIP() << "NV12 AHardwareBuffer allocation unsupported";
+  }
+  const size_t required = viz::SharedMemorySizeForSharedImageFormat(
+                              viz::MultiPlaneFormat::kNV12, kSize)
+                              .value();
+  auto region = base::UnsafeSharedMemoryRegion::Create(required - 1);
+  ASSERT_TRUE(region.IsValid());
+
+  EXPECT_FALSE(
+      AHardwareBufferImageBackingFactory::CopyNativeBufferToSharedMemoryAsync(
+          gfx::GpuMemoryBufferHandle(std::move(ahb_handle)),
+          std::move(region)));
+}
+
+// A correctly sized destination must receive the whole image in the layout the
+// client expects: a tightly packed Y plane followed by a tightly packed UV
+// plane, both with a row size of `width` bytes.
+TEST(AHardwareBufferImageBackingFactoryCopyTest, CopiesBothPlanes) {
+  constexpr gfx::Size kSize(64, 64);
+  auto ahb_handle = AllocateNV12Buffer(
+      kSize, kNV12ReadbackUsage | AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN);
+  if (!ahb_handle.is_valid()) {
+    GTEST_SKIP() << "NV12 AHardwareBuffer allocation unsupported";
+  }
+  if (!SupportsNV12PlaneLayout(ahb_handle.get())) {
+    GTEST_SKIP() << "Platform does not provide an interleaved NV12 layout";
+  }
+
+  const std::vector<uint8_t> expected = FillNV12Buffer(ahb_handle.get(), kSize);
+  ASSERT_EQ(expected.size(), viz::SharedMemorySizeForSharedImageFormat(
+                                 viz::MultiPlaneFormat::kNV12, kSize)
+                                 .value());
+
+  auto region = base::UnsafeSharedMemoryRegion::Create(expected.size());
+  ASSERT_TRUE(region.IsValid());
+  base::UnsafeSharedMemoryRegion region_for_reading = region.Duplicate();
+  ASSERT_TRUE(region_for_reading.IsValid());
+
+  ASSERT_TRUE(
+      AHardwareBufferImageBackingFactory::CopyNativeBufferToSharedMemoryAsync(
+          gfx::GpuMemoryBufferHandle(std::move(ahb_handle)),
+          std::move(region)));
+
+  base::WritableSharedMemoryMapping mapping = region_for_reading.Map();
+  ASSERT_TRUE(mapping.IsValid());
+  EXPECT_EQ(mapping.GetMemoryAsSpan<uint8_t>().first(expected.size()),
+            base::span(expected));
+}
+
 }  // anonymous namespace
 }  // namespace gpu
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.