High patch-now ✔︎ verified CVSS 4.3 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Codecs
DescriptionUse after free in Codecs
ComponentCodecs
Sub-componentMedia
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — upheld by independent review by AI
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.
Independent review by AIConfirmed
Reviewer notes: Cites regression tests 'SimpleAlphaFrameReuse'/'AlphaFrameValidAfterPoolDestruction' not present in the shown (truncated) diff. Core mechanism -- single observer plus reallocating AllocateAlphaPlaneForFrameBuffer, fix adds second observer -- is supported.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker500066234
Fix commitfc79e8cc2dfc (chromium/src) +152/-64
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Reported2026-04-07 (per vendor advisory)
Fix landed2026-04-09 (public git)
Disclosed2026-04-15
Patch gap6 days — fix public in source before the advisory

Background

`FrameBufferPool`
a media helper that hands libvpx reusable external frame buffers (GetVP9FrameBuffer/ReleaseVP9FrameBuffer) and reference-counts each buffer via held_by_library and held_by_frame so storage is freed only when neither libvpx nor any output VideoFrame still references it.
Destruction observer / `CreateFrameCallback`
a closure attached to a VideoFrame via AddDestructionObserver that, when the frame is destroyed, releases the pool buffer it wraps, keeping that buffer alive for exactly the frame’s lifetime.
VP9 alpha plane
VP9-with-alpha decodes the opacity channel in a separate libvpx context (vpx_codec_alpha_), producing a second image (vpx_image_alpha) whose Y plane holds the alpha samples that the output VideoFrame exposes as VideoFrame::Plane::kA.
`WrapExternalYuvaData`
constructs a VideoFrame that points directly at externally owned Y/U/V/A buffers instead of owning copies, so the caller is responsible for keeping that backing memory valid.

Root Cause Analysis

Before the patch, the alpha decoder (vpx_codec_alpha_) did not use external pool buffers, so its output lived in libvpx-internal memory; VpxVideoDecoder copied that alpha into a side buffer, frame_buffer->alpha_data, allocated by FrameBufferPool::AllocateAlphaPlaneForFrameBuffer(alpha_plane_size, vpx_image->fb_priv) — note the key chosen was the main image’s fb_priv, hanging the alpha storage off the main frame buffer as an un-reference-counted side allocation. The output VideoFrame wrapped alpha_data but received only a single destruction observer, CreateFrameCallback(vpx_image->fb_priv), and AllocateAlphaPlaneForFrameBuffer would, on a later decode that reused the same fb_priv, execute frame_buffer->alpha_data = {} and reallocate — invalidating the pointer a still-live earlier VideoFrame had been handed, i.e. a use-after-free of the alpha plane.

The fix instead configures the alpha context to draw from the same memory_pool_ (vpx_codec_set_frame_buffer_functions in the alpha init path), wraps vpx_image_alpha->planes[VPX_PLANE_Y] zero-copy, and crucially adds a second destruction observer bound to vpx_image_alpha->fb_priv, so the alpha buffer is now an independently reference-counted pool buffer freed only when the frame that uses it is destroyed. The regression tests SimpleAlphaFrameReuse and AlphaFrameValidAfterPoolDestruction confirm alpha buffers are reused across decodes and remain valid even after the pool is torn down.

Key insight
The single mistake was tracking the alpha plane’s lifetime as a side allocation on the main frame buffer with only one destruction observer, so buffer reuse could free alpha memory still referenced by a live VideoFrame; the fix gives the alpha buffer its own pool reference and its own destruction observer so its lifetime is tracked independently.

Attack Path

  1. Serve VP9-with-alpha content Attacker-controlled media (e.g. a webm with per-packet alpha side data, as the test models via CreateBufferWithAlphaFromPacket) causes VpxVideoDecoder to take the alpha path where vpx_image_alpha is produced.
  2. Alpha wrapped off a reused frame buffer The output VideoFrame is built with WrapExternalYuvaData pointing at frame_buffer->alpha_data, whose only lifetime anchor is the main buffer’s single CreateFrameCallback(vpx_image->fb_priv) observer.
  3. Trigger buffer reuse A subsequent decode reuses the same fb_priv, and AllocateAlphaPlaneForFrameBuffer runs frame_buffer->alpha_data = {} and reallocates, freeing/relocating the alpha storage while an earlier decoded VideoFrame still references the old pointer.
  4. Use-after-free on the alpha plane Any later read or write of that earlier frame’s Plane::kA touches freed memory; the shipped test AlphaFrameValidAfterPoolDestruction writes 0xff across Plane::kA precisely to make sanitizers flag this condition.
  5. Memory corruption primitive The dangling alpha buffer yields a use-after-free in the process hosting the decoder; escalation beyond the UAF is not demonstrated by the diff (inferred).

Impact Assessment

An attacker who gets a victim to decode crafted VP9-alpha video obtains a use-after-free of the alpha-plane buffer in whichever process runs VpxVideoDecoder (renderer or the media/utility service, depending on configuration). The advisory rates this High severity and describes it as a use-after-free in Codecs; it does not report in-the-wild exploitation, and any progression from the UAF to code execution or sandbox escape is (inferred), not shown by the patch.

Changed Functions

FunctionChangeNotes
for
media/base/frame_buffer_pool.cc
modified
for
media/base/frame_buffer_pool_unittest.cc
modified
if
media/filters/vpx_video_decoder.cc
modified

Files Changed

  • media/base/frame_buffer_pool.cc
  • media/base/frame_buffer_pool.h
  • media/base/frame_buffer_pool_unittest.cc
  • media/filters/vpx_video_decoder.cc
  • media/filters/vpx_video_decoder.h

Audit Directions

  • Side allocations keyed to another object's lifetime
    Flag buffers that are hung off a parent object (here alpha_data on the main frame buffer) and handed to consumers without their own reference count or destruction observer; the parent’s reuse can free them out from under live holders.
  • Zero-copy wrappers and observer parity
    Wherever WrapExternal*Data exposes externally owned planes, verify every distinct backing buffer has a matching destruction observer; a frame with N backing buffers needs N lifetime anchors.
  • Pool-buffer reuse across decodes
    Audit FrameBufferPool-style reuse paths for realloc/= {} patterns that invalidate pointers already published to output frames still in flight.
From fc79e8cc2dfcc8f7ec8ee9cf0acf0993f32aec27 Mon Sep 17 00:00:00 2001
From: Eugene Zemtsov <[email protected]>
Date: Wed, 08 Apr 2026 18:32:31 -0700
Subject: [PATCH] media: Zero-copy VP9 alpha decoding in VpxVideoDecoder

Configures the VP9 alpha decoder to use `memory_pool_` for external
frame buffers, eliminating the need for `libyuv::CopyPlane`.

The `VideoFrame` now wraps the alpha data directly from the pool using
a second destruction observer. `AllocateAlphaPlaneForFrameBuffer` and
`alpha_data` tracking are removed from `FrameBufferPool`.

Bug: 500066234
Change-Id: I6e7cf13bcc8a5a1759acfd51961859c4c57fcbf2
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7737984
Reviewed-by: Ted (Chromium) Meyer <[email protected]>
Commit-Queue: Eugene Zemtsov <[email protected]>
Reviewed-by: Dale Curtis <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1611919}
---

diff --git a/media/base/frame_buffer_pool.cc b/media/base/frame_buffer_pool.cc
index ceb0313c..59bc0790 100644
--- a/media/base/frame_buffer_pool.cc
+++ b/media/base/frame_buffer_pool.cc
@@ -56,7 +56,6 @@
   // Not using std::vector<uint8_t> as resize() calls take a really long time
   // for large buffers.
   BytesArray data;
-  BytesArray alpha_data;
   bool held_by_library = false;
   // Needs to be a counter since a frame buffer might be used multiple times.
   int held_by_frame = 0;
@@ -155,24 +154,6 @@
   }
 }
 
-base::span<uint8_t> FrameBufferPool::AllocateAlphaPlaneForFrameBuffer(
-    size_t min_size,
-    void* fb_priv) {
-  base::AutoLock lock(lock_);
-  DCHECK(fb_priv);
-
-  auto* frame_buffer = static_cast<FrameBuffer*>(fb_priv);
-  DCHECK(IsUsedLocked(frame_buffer));
-  if (frame_buffer->alpha_data.size() < min_size) {
-    // Free the existing |alpha_data| first so that the memory can be reused,
-    // if possible.
-    frame_buffer->alpha_data = {};
-    frame_buffer->alpha_data = AllocateMemory(min_size, zero_initialize_memory_,
-                                              force_allocation_error_);
-  }
-  return frame_buffer->alpha_data;
-}
-
 base::OnceClosure FrameBufferPool::CreateFrameCallback(void* fb_priv) {
   base::AutoLock lock(lock_);
 
@@ -210,10 +191,9 @@
   size_t bytes_reserved = 0;
   for (const auto& frame_buffer : frame_buffers_) {
     if (IsUsedLocked(frame_buffer.get())) {
-      bytes_used += frame_buffer->data.size() + frame_buffer->alpha_data.size();
+      bytes_used += frame_buffer->data.size();
     }
-    bytes_reserved +=
-        frame_buffer->data.size() + frame_buffer->alpha_data.size();
+    bytes_reserved += frame_buffer->data.size();
   }
 
   memory_dump->AddScalar(base::trace_event::MemoryAllocatorDump::kNameSize,
diff --git a/media/base/frame_buffer_pool.h b/media/base/frame_buffer_pool.h
index ac839b8..2ccb0167 100644
--- a/media/base/frame_buffer_pool.h
+++ b/media/base/frame_buffer_pool.h
@@ -48,11 +48,6 @@
   // Called when a frame buffer allocation is no longer needed.
   void ReleaseFrameBuffer(void* fb_priv);
 
-  // Allocates (or reuses) room for an alpha plane on a given frame buffer.
-  // |fb_priv| must be a value previously returned by GetFrameBuffer().
-  base::span<uint8_t> AllocateAlphaPlaneForFrameBuffer(size_t min_size,
-                                                       void* fb_priv);
-
   // Generates a "no_longer_needed" closure that holds a reference to this pool;
   // |fb_priv| must be a value previously returned by GetFrameBuffer(). The
   // callback may be called on any thread.
diff --git a/media/base/frame_buffer_pool_unittest.cc b/media/base/frame_buffer_pool_unittest.cc
index 893e941..8b50896e7 100644
--- a/media/base/frame_buffer_pool_unittest.cc
+++ b/media/base/frame_buffer_pool_unittest.cc
@@ -32,12 +32,6 @@
   EXPECT_NE(buf1.data(), buf2.data());
   std::ranges::fill(buf2, 0);
 
-  auto alpha = pool->AllocateAlphaPlaneForFrameBuffer(kBufferSize, priv1);
-  ASSERT_FALSE(alpha.empty());
-  EXPECT_NE(alpha.data(), buf1.data());
-  EXPECT_NE(alpha.data(), buf2.data());
-  std::ranges::fill(alpha, 0);
-
   EXPECT_EQ(2u, pool->get_pool_size_for_testing());
 
   // Frames are not released immediately, so this should still show two frames.
@@ -52,7 +46,6 @@
   EXPECT_EQ(1u, pool->get_pool_size_for_testing());
 
   std::ranges::fill(buf1, 0);
-  std::ranges::fill(alpha, 0);
 
   // This will release all memory since we're in the shutdown state.
   std::move(frame_release_cb).Run();
@@ -132,13 +125,6 @@
   }
   EXPECT_FALSE(nonzero);
 
-  auto alpha_buf = pool->AllocateAlphaPlaneForFrameBuffer(kBufferSize, priv1);
-  nonzero = false;
-  for (size_t i = 0; i < kBufferSize; i++) {
-    nonzero |= !!alpha_buf[i];
-  }
-  EXPECT_FALSE(nonzero);
-
   pool->Shutdown();
 }
 
diff --git a/media/filters/vpx_video_decoder.cc b/media/filters/vpx_video_decoder.cc
index ca1e45e..fe1b8b9b 100644
--- a/media/filters/vpx_video_decoder.cc
+++ b/media/filters/vpx_video_decoder.cc
@@ -250,7 +250,21 @@
 
   DCHECK(!vpx_codec_alpha_);
   vpx_codec_alpha_ = InitializeVpxContext(config);
-  return !!vpx_codec_alpha_;
+  if (!vpx_codec_alpha_) {
+    return false;
+  }
+
+  if (config.codec() == VideoCodec::kVP9) {
+    if (vpx_codec_set_frame_buffer_functions(
+            vpx_codec_alpha_.get(), &GetVP9FrameBuffer, &ReleaseVP9FrameBuffer,
+            memory_pool_.get())) {
+      DLOG(ERROR) << "Failed to configure external buffers for alpha. "
+                  << vpx_codec_error(vpx_codec_alpha_.get());
+      return false;
+    }
+  }
+
+  return true;
 }
 
 void VpxVideoDecoder::CloseDecoder() {
@@ -546,20 +560,13 @@
   if (memory_pool_) {
     DCHECK_EQ(VideoCodec::kVP9, config_.codec());
     if (vpx_image_alpha) {
+      CHECK_GT(vpx_image_alpha->stride[VPX_PLANE_Y], 0);
       size_t alpha_plane_size =
           vpx_image_alpha->stride[VPX_PLANE_Y] * vpx_image_alpha->d_h;
-      auto alpha_plane = memory_pool_->AllocateAlphaPlaneForFrameBuffer(
-          alpha_plane_size, vpx_image->fb_priv);
-      if (alpha_plane.empty()) {
-        error_status_ = DecoderStatus::Codes::kOutOfMemory;
-        // In case of OOM, abort copy.
-        return false;
-      }
-      libyuv::CopyPlane(vpx_image_alpha->planes[VPX_PLANE_Y],
-                        vpx_image_alpha->stride[VPX_PLANE_Y],
-                        alpha_plane.data(),
-                        vpx_image_alpha->stride[VPX_PLANE_Y],
-                        vpx_image_alpha->d_w, vpx_image_alpha->d_h);
+      // SAFETY: libvpx guarantees that the Y plane has at least `stride * d_h`
+      // bytes available.
+      auto alpha_plane = UNSAFE_BUFFERS(base::span<uint8_t>(
+          vpx_image_alpha->planes[VPX_PLANE_Y], alpha_plane_size));
       *video_frame = VideoFrame::WrapExternalYuvaData(
           codec_format, coded_size, gfx::Rect(visible_size), natural_size,
           vpx_image->stride[VPX_PLANE_Y], vpx_image->stride[VPX_PLANE_U],
@@ -575,8 +582,14 @@
     if (!(*video_frame))
       return false;
 
-    video_frame->get()->AddDestructionObserver(
-        memory_pool_->CreateFrameCallback(vpx_image->fb_priv));
+    (*video_frame)
+        ->AddDestructionObserver(
+            memory_pool_->CreateFrameCallback(vpx_image->fb_priv));
+    if (vpx_image_alpha) {
+      (*video_frame)
+          ->AddDestructionObserver(
+              memory_pool_->CreateFrameCallback(vpx_image_alpha->fb_priv));
+    }
     return true;
   }
 
diff --git a/media/filters/vpx_video_decoder.h b/media/filters/vpx_video_decoder.h
index f53da976..8f8f07e 100644
--- a/media/filters/vpx_video_decoder.h
+++ b/media/filters/vpx_video_decoder.h
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/media/base/frame_buffer_pool_unittest.cc b/media/base/frame_buffer_pool_unittest.cc
index 893e941..8b50896e7 100644
--- a/media/base/frame_buffer_pool_unittest.cc
+++ b/media/base/frame_buffer_pool_unittest.cc
@@ -32,12 +32,6 @@
   EXPECT_NE(buf1.data(), buf2.data());
   std::ranges::fill(buf2, 0);
 
-  auto alpha = pool->AllocateAlphaPlaneForFrameBuffer(kBufferSize, priv1);
-  ASSERT_FALSE(alpha.empty());
-  EXPECT_NE(alpha.data(), buf1.data());
-  EXPECT_NE(alpha.data(), buf2.data());
-  std::ranges::fill(alpha, 0);
-
   EXPECT_EQ(2u, pool->get_pool_size_for_testing());
 
   // Frames are not released immediately, so this should still show two frames.
@@ -52,7 +46,6 @@
   EXPECT_EQ(1u, pool->get_pool_size_for_testing());
 
   std::ranges::fill(buf1, 0);
-  std::ranges::fill(alpha, 0);
 
   // This will release all memory since we're in the shutdown state.
   std::move(frame_release_cb).Run();
@@ -132,13 +125,6 @@
   }
   EXPECT_FALSE(nonzero);
 
-  auto alpha_buf = pool->AllocateAlphaPlaneForFrameBuffer(kBufferSize, priv1);
-  nonzero = false;
-  for (size_t i = 0; i < kBufferSize; i++) {
-    nonzero |= !!alpha_buf[i];
-  }
-  EXPECT_FALSE(nonzero);
-
   pool->Shutdown();
 }
diff --git a/media/filters/vpx_video_decoder_unittest.cc b/media/filters/vpx_video_decoder_unittest.cc
index 8fba2b4..bb32fa8e 100644
--- a/media/filters/vpx_video_decoder_unittest.cc
+++ b/media/filters/vpx_video_decoder_unittest.cc
@@ -175,6 +175,28 @@
     output_frames_.push_back(std::move(frame));
   }
 
+  // Extracts the compressed video data from the AVPacket and also checks for
+  // side data containing an alpha channel. If found, it copies the alpha data
+  // into the DecoderBuffer's side data. This is necessary because FFmpeg
+  // demuxes alpha channel data as side data associated with the video packet.
+  static scoped_refptr<DecoderBuffer> CreateBufferWithAlphaFromPacket(
+      const AVPacket* packet) {
+    auto buffer = DecoderBuffer::CopyFrom(AVPacketData(*packet));
+    size_t side_data_size = 0;
+    uint8_t* side_data_ptr = av_packet_get_side_data(
+        packet, AV_PKT_DATA_MATROSKA_BLOCKADDITIONAL, &side_data_size);
+    if (side_data_size > 8) {
+      // SAFETY: The best we can do here is trust the size reported by ffmpeg.
+      auto side_data =
+          UNSAFE_BUFFERS(base::span(side_data_ptr, side_data_size));
+      if (base::U64FromBigEndian(side_data.first<8u>()) == 1) {
+        buffer->WritableSideData().alpha_data =
+            base::HeapArray<uint8_t>::CopiedFrom(side_data.subspan(8u));
+      }
+    }
+    return buffer;
+  }
+
   MOCK_METHOD1(DecodeDone, void(DecoderStatus));
 
   base::test::TaskEnvironment task_env_;
@@ -292,6 +314,68 @@
   EXPECT_EQ(old_y_data, output_frames_.back()->data(VideoFrame::Plane::kY));
 }
 
+TEST_F(VpxVideoDecoderTest, SimpleAlphaFrameReuse) {
+  VideoDecoderConfig config = TestVideoConfig::Normal(VideoCodec::kVP9);
+  config.Initialize(
+      config.codec(), config.profile(),
+      VideoDecoderConfig::AlphaMode::kHasAlpha, config.color_space_info(),
+      config.video_transformation(), config.coded_size(), config.visible_rect(),
+      config.natural_size(), config.extra_data(), config.encryption_scheme());
+  InitializeWithConfig(config);
+  scoped_refptr<DecoderBuffer> alpha_frame = ReadTestDataFile("bear-vp9a.webm");
+
+  // Read frames from the webm file.
+  InMemoryUrlProtocol protocol(*alpha_frame, false);
+  FFmpegGlue glue(&protocol);
+  ASSERT_TRUE(glue.OpenContext());
+
+  auto packet = ScopedAVPacket::Allocate();
+
+  // Decode first frame
+  ASSERT_GE(av_read_frame(glue.format_context(), packet.get()), 0);
+  auto buffer = CreateBufferWithAlphaFromPacket(packet.get());
+  Decode(buffer);
+  av_packet_unref(packet.get());
+
+  ASSERT_EQ(1u, output_frames_.size());
+  scoped_refptr<VideoFrame> frame = std::move(output_frames_.front());
+  EXPECT_EQ(PIXEL_FORMAT_I420A, frame->format());
+  const uint8_t* old_y_data = frame->data(VideoFrame::Plane::kY);
+  const uint8_t* old_a_data = frame->data(VideoFrame::Plane::kA);
+  output_frames_.pop_back();
+
+  // Clear frame reference to return the frame to the pool.
+  frame = nullptr;
+
+  // Decode second frame.
+  Decode(buffer);
+  const uint8_t* mid_y_data =
+      output_frames_.front()->data(VideoFrame::Plane::kY);
+  const uint8_t* mid_a_data =
+      output_frames_.front()->data(VideoFrame::Plane::kA);
+  output_frames_.clear();
+
+  // Issuing another decode should reuse buffers from the pool.
+  Decode(buffer);
+
+  ASSERT_EQ(1u, output_frames_.size());
+  const uint8_t* new_y_data =
+      output_frames_.back()->data(VideoFrame::Plane::kY);
+  const uint8_t* new_a_data =
+      output_frames_.back()->data(VideoFrame::Plane::kA);
+
+  // The pool is shared, so buffers might be reused in a different order (e.g. Y
+  // might get the buffer previously used for A). Because libvpx allocates the
+  // new frame before releasing the old reference frame, we need to check across
+  // all previously allocated buffers.
+  bool reused_y = new_y_data == old_y_data || new_y_data == old_a_data ||
+                  new_y_data == mid_y_data || new_y_data == mid_a_data;
+  bool reused_a = new_a_data == old_y_data || new_a_data == old_a_data ||
+                  new_a_data == mid_y_data || new_a_data == mid_a_data;
+  EXPECT_TRUE(reused_y);
+  EXPECT_TRUE(reused_a);
+}
+
 TEST_F(VpxVideoDecoderTest, SimpleFormatChange) {
   scoped_refptr<DecoderBuffer> large_frame =
       ReadTestDataFile("vp9-I-frame-1280x720");
@@ -311,10 +395,41 @@
 
   // Write to the Y plane. The memory tools should detect a
   // use-after-free if the storage was actually removed by pool destruction.
-  UNSAFE_TODO(
-      memset(output_frames_.front()->writable_data(VideoFrame::Plane::kY), 0xff,
-             output_frames_.front()->rows(VideoFrame::Plane::kY) *
-                 output_frames_.front()->stride(VideoFrame::Plane::kY)));
+  std::ranges::fill(
+      output_frames_.front()->writable_span(VideoFrame::Plane::kY), 0xff);
+}
+
+TEST_F(VpxVideoDecoderTest, AlphaFrameValidAfterPoolDestruction) {
+  VideoDecoderConfig config = TestVideoConfig::Normal(VideoCodec::kVP9);
+  config.Initialize(
+      config.codec(), config.profile(),
+      VideoDecoderConfig::AlphaMode::kHasAlpha, config.color_space_info(),
+      config.video_transformation(), config.coded_size(), config.visible_rect(),
+      config.natural_size(), config.extra_data(), config.encryption_scheme());
+  InitializeWithConfig(config);
+  scoped_refptr<DecoderBuffer> alpha_frame = ReadTestDataFile("bear-vp9a.webm");
+
+  InMemoryUrlProtocol protocol(*alpha_frame, false);
+  FFmpegGlue glue(&protocol);
+  ASSERT_TRUE(glue.OpenContext());
+
+  auto packet = ScopedAVPacket::Allocate();
+  ASSERT_GE(av_read_frame(glue.format_context(), packet.get()), 0);
+  auto buffer = CreateBufferWithAlphaFromPacket(packet.get());
+  Decode(std::move(buffer));
+  av_packet_unref(packet.get());
+
+  ASSERT_EQ(1u, output_frames_.size());
+  EXPECT_EQ(PIXEL_FORMAT_I420A, output_frames_.front()->format());
+
+  Destroy();
+
+  // Write to the Y and A planes. The memory tools should detect a
+  // use-after-free if the storage was actually removed by pool destruction.
+  std::ranges::fill(
+      output_frames_.front()->writable_span(VideoFrame::Plane::kY), 0xff);
+  std::ranges::fill(
+      output_frames_.front()->writable_span(VideoFrame::Plane::kA), 0xff);
 }
 
 // The test stream uses profile 2, which needs high bit depth support in libvpx.
@@ -362,8 +477,7 @@
   Destroy();
 
   // ASAN will be very unhappy with this line if the above is incorrect.
-  UNSAFE_TODO(memset(last_frame->writable_data(VideoFrame::Plane::kY), 0,
-                     last_frame->row_bytes(VideoFrame::Plane::kY)));
+  std::ranges::fill(last_frame->writable_span(VideoFrame::Plane::kY), 0);
 }
 #endif  // !defined(LIBVPX_NO_HIGH_BIT_DEPTH) && !defined(ARCH_CPU_ARM_FAMILY)
Loading diff…

Original Bug Report

reported by [email protected]

VP9 alpha plane use-after-free via show_existing_frame reuse of FrameBufferPool storage

VP9 alpha plane use-after-free via show_existing_frame reuse of FrameBufferPool storage

Summary

A use-after-free read of VP9 alpha plane data occurs when a crafted WebM triggers VP9’s show_existing_frame mechanism while alternating the alpha side-stream’s bit depth. The FrameBufferPool stores alpha data keyed by fb_priv, and show_existing_frame causes consecutive output frames to share the same fb_priv. When a subsequent frame’s alpha plane requires a larger allocation (due to a bit-depth change from 8-bit to 10-bit), the pool frees the old alpha buffer while a previously emitted VideoFrame still holds a dangling base::span into it. The compositor thread then reads 92160 bytes from freed memory during YUV texture upload. The vulnerability is not mitigated by MiraclePtr. It affects all desktop platforms (Linux, macOS, Windows) and requires no special GPU or hardware.

Bisect

Introducing Commit: f12d64fb2c759a2e62653b08b663a79299f63d4c

This commit introduced VP9 alpha channel support by adding per-fb_priv alpha storage to the decoder’s memory pool. The design assumed that each fb_priv would only be associated with one live output frame at a time, which is violated by show_existing_frame.

Root Cause

Chrome’s VP9 decoder uses libvpx’s external frame buffer API. Each decoded frame carries an opaque fb_priv pointer identifying its backing FrameBuffer in the FrameBufferPool. When a VP9 bitstream signals show_existing_frame, libvpx does not allocate a new buffer; it increments the reference count on an existing reference frame buffer and returns the same fb_priv:

// third_party/libvpx/source/libvpx/vp9/decoder/vp9_decodeframe.c
cm->show_existing_frame = vpx_rb_read_bit(rb);
if (cm->show_existing_frame) {
  const int frame_to_show = cm->ref_frame_map[vpx_rb_read_literal(rb, 3)];
  ref_cnt_fb(frame_bufs, &cm->new_fb_idx, frame_to_show);
}

Chrome handles VP9 alpha as a separate side-stream whose decoded pixels are stored per fb_priv in the pool’s alpha_data field. In VpxVideoDecoder::CopyVpxImageToVideoFrame, the alpha plane size is computed from the alpha image’s stride and height, then passed to AllocateAlphaPlaneForFrameBuffer using the main image’s fb_priv as the key:

// media/filters/vpx_video_decoder.cc:580-598
size_t alpha_plane_size =
    vpx_image_alpha->stride[VPX_PLANE_Y] * vpx_image_alpha->d_h;
auto alpha_plane = memory_pool_->AllocateAlphaPlaneForFrameBuffer(
    alpha_plane_size, vpx_image->fb_priv);
libyuv::CopyPlane(vpx_image_alpha->planes[VPX_PLANE_Y],
                  vpx_image_alpha->stride[VPX_PLANE_Y],
                  alpha_plane.data(),
                  vpx_image_alpha->stride[VPX_PLANE_Y],
                  vpx_image_alpha->d_w, vpx_image_alpha->d_h);
*video_frame = VideoFrame::WrapExternalYuvaData(
    codec_format, coded_size, gfx::Rect(visible_size), natural_size,
    ..., alpha_plane, kNoTimestamp);

The resulting VideoFrame stores the alpha plane pointer as a base::span<const uint8_t> in its data_ array, which is a raw span with no reference-counting or lifetime tracking on the underlying buffer.

When the pool sees a request for a larger alpha allocation on the same fb_priv, it frees the old buffer and allocates a new one:

// media/base/frame_buffer_pool.cc:158-174
base::span<uint8_t> FrameBufferPool::AllocateAlphaPlaneForFrameBuffer(
    size_t min_size, void* fb_priv) {
  base::AutoLock lock(lock_);
  auto* frame_buffer = static_cast<FrameBuffer*>(fb_priv);
  if (frame_buffer->alpha_data.size() < min_size) {
    frame_buffer->alpha_data = {};   // frees the old buffer
    frame_buffer->alpha_data = AllocateMemory(min_size, ...);
  }
  return frame_buffer->alpha_data;
}

The held_by_frame counter on the FrameBuffer only protects the FrameBuffer struct itself from deletion; it does not prevent alpha_data from being freed and reallocated. At the point of reallocation, held_by_frame is 1 (the previous VideoFrame is still alive in the rendering pipeline), yet the old alpha buffer is freed unconditionally.

The PoC constructs a WebM with a VP9 keyframe paired with 8-bit alpha (stride 384, alpha size 92160 bytes), followed by show_existing_frame entries paired with 10-bit alpha (stride 768, alpha size 184320 bytes). Because show_existing_frame reuses the same fb_priv, the second decode call hits the alpha_data.size() < min_size path and frees the 92160-byte buffer. The first VideoFrame, still queued for compositing, holds a dangling span into the freed region.

The freed alpha buffer is then read on the VideoFrameCompositor thread when the compositor uploads the stale frame’s YUV planes to a GPU texture. In WriteYUVPixelsForAllPlanesToTexture, each plane’s data is accessed through a raw const uint8_t* returned by video_frame->data(plane):

// media/renderers/video_resource_updater.cc:1075-1078
const uint8_t* pixels;
if (!needs_conversion) {
    pixels = video_frame->data(frame_planes[plane_index]);
    ...
}

This pointer is not wrapped in raw_ptr<T>, so MiraclePtr/BackupRefPtr does not observe the access. Furthermore, at the time the alpha buffer is freed, no raw_ptr references it (the VideoFrame stores it in a base::span), so PartitionAlloc’s BRP quarantine mechanism does not engage and the memory is returned to the allocator immediately. ASAN confirms MiraclePtr Status: NOT PROTECTED.

Reproduce

Tested at commit 5e60c832cb8d7cddd0bc4f84d3c8864c80649afb on Linux x86_64.

Build with is_asan = true and is_debug = false. Serve the attached poc.html and poc.webm from the same directory over HTTP. Launch:

ASAN_OPTIONS=detect_odr_violation=0 ~/chromium/src/out/asan-release/chrome \
  --no-sandbox \
  --user-data-dir=/tmp/poc-$(date +%s) \
  http://localhost:8899/poc.html

Click the page to start playback if autoplay is blocked. The renderer crashes within seconds. ASAN reports:

==2816922==ERROR: AddressSanitizer: heap-use-after-free on address 0x7eea6a240800
READ of size 92160 at 0x7eea6a240800 thread T12 (VideoFrameCompo)
    #0 __asan_memcpy
    #1 gpu::raster::RasterImplementation::WritePixelsYUV gpu/command_buffer/client/raster_implementation.cc:1317
    #2 media::VideoResourceUpdater::WriteYUVPixelsForAllPlanesToTexture media/renderers/video_resource_updater.cc:1166

freed by thread T11 (Media):
    #0 free
    #1 media::FrameBufferPool::AllocateAlphaPlaneForFrameBuffer
    #2 media::VpxVideoDecoder::CopyVpxImageToVideoFrame media/filters/vpx_video_decoder.cc:589

previously allocated by thread T11 (Media):
    #0 calloc
    #1 base::UncheckedCalloc
    #2 media::AllocateMemory media/base/frame_buffer_pool.cc:78
    #3 media::FrameBufferPool::AllocateAlphaPlaneForFrameBuffer media/base/frame_buffer_pool.cc:181

MiraclePtr Status: NOT PROTECTED
No raw_ptr<T> access to this region was detected prior to this crash.
This crash is still exploitable with MiraclePtr.

The complete ASAN log is in the attached asan.log.

Credit

Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.

View on issue tracker