← Chrome — 2026-W38

db684a2d12d6337340638ebf51708c9333fc8a1b  [M154] [GPU Security]: Validate untrusted AHardwareBuffer description before NV12 readback.

severity high class OOB-write CVSS 8.8 confidence 0.85 signal score 6.0 gpu/command_buffer/service patch-now AI review CONFIRMED
Primitive: Attacker-forged AHardwareBuffer dimensions bypass odd-dimension checks; dest sized width*height*3/2 (floor) vs libyuv NV12Copy (ceil) -> ~1.3MB OOB write of renderer-controlled bytes in the GPU process.
Triage note: Untrusted GpuMemoryBufferHandle from a compromised renderer; Android GPU process. Renderer->GPU sandbox-escape-class OOB write with a worked overflow (5461335 vs 4096000 bytes). Bug:556576992. Backports M150/M153/M155.

The bug at a glance

This is a heap out-of-bounds write inside the Android GPU process, reached by a compromised or malicious renderer over the CopyNativeGmbToSharedMemoryAsync Mojo interface. The diff proves the primitive is powerful: for a forged 2730667x1 buffer the pre-patch size check accepts a 4096000-byte destination while libyuv::NV12Copy() writes 5461335 bytes, a ~1.33 MB linear overflow. Both the length (chosen via the attacker-controlled width/height) and the overflowing content (the renderer-supplied source buffer bytes) are attacker-controlled, which is a near-ideal write primitive for corrupting adjacent heap objects. Because the sink is the GPU process, successful exploitation is a sandbox-escape / privilege-boundary crossing, not merely a renderer crash. That combination – controlled-size, controlled-content, cross-process write – maps to a high severity, CVSS 8.8. It is not a first-stage web-content bug: it presupposes an already-compromised renderer, which is why it is rated high rather than critical.

The whole bug is one arithmetic mismatch between two places that compute the size of the same NV12 image. The destination was sized with integer floor division (width * height * 3 / 2), which truncates the half-resolution chroma plane downward. The actual copy, libyuv::NV12Copy(), sizes chroma with ceiling rounding (halfwidth = (width + 1) » 1). For even dimensions floor and ceil agree and nothing is wrong, so the code looks correct in every normal test. The divergence only appears for odd dimensions – exactly the input a renderer can forge because the dimensions come from an AHardwareBuffer description that AHardwareBuffer_describe() reports, not from a value validated on the wire. Odd sizes are rejected everywhere else on Android by IsSizeForBufferHandleValid(), but this path derived its size from the untrusted buffer and so skipped that gate. The fix’s insight is to stop hand-rolling the size math and instead reuse the exact same viz helpers the client used to allocate the region, and to reinstate the odd-dimension rejection so floor and ceil can never disagree again.

Root cause

CopyNativeBufferToSharedMemoryAsync() maps a client-provided shared-memory region as the destination and reads pixels out of an AHardwareBuffer whose AHardwareBuffer_Desc (width, height, layers, format, usage) is attacker-controlled – the commit message notes the buffer arrives over Mojo as a reconstructed Unix-socket endpoint, so neither Mojo nor the GPU process can authenticate its allocator. Pre-patch, required_size was static_cast<size_t>(desc.width) * desc.height * 3 / 2. The division is a floor, so the chroma contribution (height/2 rows of width bytes) is rounded down for odd height, and the effective UV width is rounded down for odd width. The subsequent libyuv::NV12Copy() call uses dst_stride = desc.width for both planes and locates the UV plane at dst_buffer.subspan(desc.height * dst_stride) – a 32-bit int multiply (desc.height * dst_stride) that is itself an overflow hazard – then writes ceil(height/2) chroma rows of ceil(width/2)2 bytes. The floor/ceil divergence means NV12Copy() writes past required_size. For 2730667x1: floor size = 4096000; NV12 real size = Y(2730667) + UV(13653342 * 1) = 5461335; delta = 1365335 bytes overflow, filled from the source buffer. The odd dimensions that trigger this are normally rejected by IsSizeForBufferHandleValid(), but that predicate was never applied here because the size was derived from the untrusted buffer rather than validated on the wire. The fix closes it structurally: (1) range-check desc.width/desc.height against int and reject empty gfx::Size before any narrowing; (2) call IsSizeForBufferHandleValid(size, kNV12) – the same predicate used at SharedImage creation – which rejects odd dimensions and thereby forces the destination’s ‘UV row size == width’ assumption to hold; (3) size the destination with viz::SharedMemoryPlaneSizeForSharedImageFormat() and viz::SharedMemorySizeForSharedImageFormat() – the exact ceil-rounded helpers the client used to allocate the region, which sum plane sizes with base::CheckedNumeric so the total cannot silently overflow (the CheckedNumeric summation lives inside those viz helpers) (inferred); (4) replace the raw subspan(desc.height * dst_stride) offset – computed in 32-bit arithmetic – with a bounds-checked dst_buffer.first(required_size).split_at(y_plane_size), which yields dst_y_plane and dst_uv_plane spans that cannot straddle the region boundary; (5) sanity-check the source rowStrides and destination strides fit in int (a negative int stride is interpreted by libyuv as a bottom-up stride). Together these make the destination-size computation and the copy-size computation provably identical.

Key code

Before: floor-rounded size check plus a raw 32-bit subspan offset into libyuv. After: even-dimension gate, client-matching ceil-rounded helper sizing, and a bounds-checked split_at feeding libyuv.

// BEFORE (vulnerable)
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;  // floor
if (dst_buffer.size() < required_size) {
  return false;
}
// ...
const int dst_stride = desc.width;
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,  // ceil write past floor size
    desc.width, desc.height);

// AFTER (fixed)
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;
constexpr viz::SharedImageFormat kFormat = viz::MultiPlaneFormat::kNV12;
if (!IsSizeForBufferHandleValid(size, kFormat)) return false;  // rejects odd dims
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;
auto [dst_y_plane, dst_uv_plane] =
    dst_buffer.first(required_size.value()).split_at(y_plane_size.value());
// ... libyuv::NV12Copy(..., dst_y_plane.data(), (int)dst_stride_y,
//                          dst_uv_plane.data(), (int)dst_stride_uv,
//                          size.width(), size.height());

Patch walkthrough

  • gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory.cc — Adds a range check that desc.width and desc.height fit in int before building a gfx::Size, and rejects an empty size. This kills the implicit uint32->int narrowing and the 32-bit multiply overflow that fed the old subspan offset (desc.height * dst_stride), and it rejects the degenerate zero-dimension inputs up front.
  • gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory.cc — Replaces the floor-rounded ‘width * height * 3 / 2’ destination sizing with the even-dimension gate IsSizeForBufferHandleValid(size, kNV12) plus viz::SharedMemoryPlaneSizeForSharedImageFormat() / SharedMemorySizeForSharedImageFormat(). These are the same ceil-rounded, checked-arithmetic helpers the client uses to size the region, so the accept-size and the copy-size are computed by identical code. The std::optional returns are checked, turning overflow/format errors into a graceful false.
  • gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory.cc — Swaps the raw pointer arithmetic ‘dst_buffer.subspan(desc.height * dst_stride)’ for dst_buffer.first(required_size).split_at(y_plane_size), yielding bounds-checked dst_y_plane and dst_uv_plane spans handed to libyuv::NV12Copy(). The copy can no longer address memory outside the validated region even if a later invariant is wrong.
  • gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory.cc — Hardens the strides: source planes[0]/planes[1].rowStride are range-checked to int (a value that would go negative as int is read by libyuv as a bottom-up stride), and destination strides are taken from viz::SharedMemoryRowSizeForSharedImageFormat() and likewise int-checked. Also converts the CHECK(desc.usage & CPU_READ_*) into a graceful failure and locks with the actual CPU-read usage bits, so an attacker-chosen usage value can no longer crash the GPU process on demand (a DoS). (The commit message also states buffers with layers != 1 are rejected; that specific check is not visible in the shown hunks.) (inferred)
  • gpu/command_buffer/service/shared_image/ahardwarebuffer_image_backing_factory_unittest.cc — Adds regression tests: RejectsOddDimensions (65x65) asserts the old floor size is smaller than the real NV12 size and that the copy is now rejected; RejectsUndersizedDestination (64x64, required-1) proves the boundary check; CopiesBothPlanes (64x64) proves a correctly sized destination receives the full Y+UV image byte-for-byte. Helpers AllocateNV12Buffer / SupportsNV12PlaneLayout / FillNV12Buffer set up real AHardwareBuffers and skip gracefully when the platform’s gralloc layout differs.

Background

AHardwareBuffer / GpuMemoryBufferHandle — An Android graphics buffer handle. Per the commit, it crosses Mojo as a reconstructed Unix-socket endpoint, so the GPU process cannot authenticate its allocator; every field AHardwareBuffer_describe() returns (width, height, layers, format, usage) is untrusted renderer input.

NV12 pixel format — A semi-planar YUV 4:2:0 layout: a full-resolution Y plane of widthheight bytes, then a half-resolution interleaved UV plane. Total ’nominal’ size is widthheight*3/2, but the chroma plane’s real dimensions are ceil(width/2) and ceil(height/2), so integer floor of *3/2 under-counts for odd dimensions.

libyuv::NV12Copy() ceil rounding — libyuv computes chroma extents with halfwidth = (width + 1) » 1 and halfheight = (height + 1) » 1 – ceiling division. It writes ceil(height/2) UV rows of ceil(width/2)*2 bytes, which exceeds the floor-rounded destination when dimensions are odd.

IsSizeForBufferHandleValid() — The Android predicate that validates a size against a buffer format at SharedImage creation, rejecting odd NV12 dimensions among other things. It was applied on the creation path but bypassed on this readback path because the size came from the buffer, not from a wire-validated parameter.

viz SharedMemory*SizeForSharedImageFormat helpers — SharedMemoryPlaneSizeForSharedImageFormat / SharedMemorySizeForSharedImageFormat / SharedMemoryRowSizeForSharedImageFormat compute plane, image, and row sizes with the same ceil rounding and checked arithmetic the client uses to allocate the region. Using them on both ends removes any divergence between the size checked and the size written.

base::span::split_at / CheckedNumeric — split_at partitions a span at an offset with bounds checks, producing sub-spans that cannot exceed the parent; base::CheckedNumeric performs overflow-detecting integer math (used inside the viz size helpers to sum plane sizes) (inferred). Together they replace the ad-hoc 32-bit pointer arithmetic that made the overflow possible.

Vulnerability window

  1. Introduction — The readback path shipped with a hand-rolled destination size of widthheight3/2 and a libyuv copy that sizes chroma with ceil rounding, plus a subspan offset in 32-bit arithmetic – the latent floor/ceil mismatch. Exact introducing commit is not in this diff. (inferred)
  2. Discovery / triage — Tracked as Chromium bug 556576992 (release blocker/security tracked also under 562784777). Analysis identified that odd-dimension AHardwareBuffer descriptions bypass IsSizeForBufferHandleValid() on this path and drive an OOB write; the 2730667x1 example (4096000 accepted vs 5461335 written = 1365335 OOB) is documented in the commit message.
  3. Fix — Commit by vikas soni, 2026-09-18, reviewed by Vasiliy Telezhnikov (main@{#1698422}), cherry-picked to the M154 branch (branch-heads/8037, e10b20e6…). Change-Id I90474d22a5e1ea12235fa0ac419ff5c0c13c36ef. Adds validation, helper-based sizing, bounds-checked spans, and regression tests.
  4. Disclosure posture — Landed as a security fix with the bug still restricted (a ‘silent-fix’ shape: security commit message, tracked bug id, no public advisory text in the diff). Users are protected once the M154 milestone with this cherry-pick reaches Android channels. (inferred)

Proof of concept

The diff ships a regression test, RejectsOddDimensions, that demonstrates the boundary the overflow crosses rather than a full exploit. It allocates a real 65x65 NV12 AHardwareBuffer, computes the old truncating size (65653/2 = 6337 bytes), and ASSERTs that this is strictly less than the true NV12 size viz::SharedMemorySizeForSharedImageFormat(kNV12, 65x65) (6403 bytes) – i.e. a copy into a 6337-byte region would have written 66 bytes out of bounds. It then creates exactly that undersized region and asserts CopyNativeBufferToSharedMemoryAsync() now returns false. RejectsUndersizedDestination (64x64, required-1) proves the size boundary and CopiesBothPlanes proves correct behavior byte-for-byte on a valid buffer. This proves the size-computation overflow (floor < ceil, and that the fix rejects it); it does not attempt the much larger controlled corruption the commit message describes (2730667x1 -> ~1.33 MB), nor does it turn the write into control flow – that full exploitation is not demonstrated. No exploit is reconstructed here beyond the shipped test logic.

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";

  // Size the old (truncating) check would have accepted; smaller than what
  // NV12Copy() writes, 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)));
}

Exploitation

  1. Precondition: compromised renderer — The attacker must already control a renderer process (e.g. via a separate first-stage web-content bug) so it can craft and send an arbitrary GpuMemoryBufferHandle over the CopyNativeGmbToSharedMemoryAsync Mojo interface. This is a second-stage / sandbox-escape primitive, not reachable from plain web content alone. (inferred)
  2. Forge the buffer description — Send an AHardwareBuffer whose description reports odd dimensions with a large width and tiny height (the commit’s 2730667x1), and CPU-readable usage bits so lockPlanes succeeds. The floor size (4096000) passes the old check while NV12Copy writes 5461335 bytes. The exact reachable width/height range and gralloc’s willingness to allocate such shapes is device-dependent. (inferred)
  3. Control the overflow contents — The overflowing bytes are copied from the renderer-supplied source buffer, so the attacker controls both the ~1.33 MB length and the written data – a controlled-content linear heap write past the shared-memory destination in the GPU process. (grounded: commit states contents come from the renderer-supplied source buffer)
  4. Heap grooming and corruption — Adjacent GPU-process heap/allocation state would need to be groomed so the ~1.33 MB write lands on a target object (function pointer, vtable, size/length field, or another mapping), then converted to arbitrary read/write or control flow. The diff does not demonstrate any of this. (inferred)
  5. Bonus DoS — Independently, before the fix the CHECK(desc.usage & CPU_READ_*) let a renderer crash the GPU process at will by omitting CPU-read usage bits – a reliable denial of service that needs no memory corruption. (grounded: commit message and CHECK->graceful change)

Detection & hunting

For defenders and detection engineers:

  • GPU-process crash telemetry on Android — Watch for GPU-process crashes/aborts in NV12 readback paths (libyuv::NV12Copy frames, AHardwareBufferImageBackingFactory) and for CHECK failures on desc.usage – the latter is the trivially reachable DoS turned into graceful failure by this patch.
  • Odd or extreme AHardwareBuffer dimensions over Mojo — Instrument/validate the readback IPC for buffer descriptions with odd width/height, layers != 1, height of 1 with very large width, or dimensions near INT_MAX. Such shapes are illegitimate for NV12 SharedImages and indicate a forged handle.
  • Destination-vs-copy size mismatch — Assert or log whenever a client-provided region size is smaller than viz::SharedMemorySizeForSharedImageFormat() for the declared format/size; any acceptance of an undersized region on a copy path is a red flag for exactly this class of overflow.
  • ASAN/heap-guard fuzzing of the IPC — Fuzz CopyNativeGmbToSharedMemoryAsync with attacker-shaped GpuMemoryBufferHandles under AddressSanitizer; a ~1.33 MB linear overflow of controlled bytes is loud and easily caught by a redzone or guard-page allocator.

Audit directions

  • Other AHardwareBuffer / GMB readback and upload paths — Search the GPU service for any place that derives a buffer size from an untrusted AHardwareBuffer_Desc or GpuMemoryBufferHandle rather than from wire-validated parameters, and confirm IsSizeForBufferHandleValid() (or equivalent) is applied before use.
  • Floor vs ceil size divergences — Grep for hand-rolled YUV/planar sizing like ‘w * h * 3 / 2’, ‘* 3 » 1’, or per-plane arithmetic that floors chroma, especially where the actual copy (libyuv, memcpy) rounds up. Replace with the viz SharedMemory*Size helpers so allocate/check/copy share one computation.
  • 32-bit stride/offset arithmetic feeding pointer math — Audit uses of int/uint32 strides and offsets (e.g. subspan(height * stride)) that can overflow or go negative before indexing a buffer or being passed to libyuv, which treats negative strides as bottom-up. Prefer span::split_at / first and range-checked int casts.
  • CHECK() on attacker-controlled fields — Find CHECK()/DCHECK() calls in GPU IPC handlers whose predicate depends on renderer-supplied values (usage bits, formats, dimensions). Each is a renderer-triggerable GPU-process crash and should be converted to graceful failure, as this patch did for desc.usage.

Before / after

Loading diff…