Medium chrome Uninitialized Memory 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUninitialized resource in Video
DescriptionUninitialized resource in Video
ComponentVideo
Sub-componentVp8
Bug ClassUninitialized Memory
Tracker504638005
Fix commitd2413e2ca110 (webm/libvpx) +112/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
BuildInterPredictorTest
test/predict_test.cc
modified
for
test/predict_test.cc
modified
TEST_F
test/predict_test.cc
modified

Files Changed

  • test/predict_test.cc
  • vp8/common/reconinter.c
From d2413e2ca11039724ca33bb4d661ca2c94cb501e Mon Sep 17 00:00:00 2001
From: Jerome Jiang <jianj@google.com>
Date: Mon, 31 Aug 2026 16:09:13 -0400
Subject: [PATCH] vp8: clamp 16x16 chroma MV instead of returning early

vp8_build_inter16x16_predictors_mb() derives the chroma MV from the
already-clamped luma MV and then applies fullpixel_mask. With bitstream
version 3 (full_pixel == 1, mask == ~7) the round-down can move the
result just past the border tap window. The previous bounds check
(introduced in 8a800b52ea for oss-fuzz:14478) returned early in that case,
leaving dst_u and dst_v unwritten for the macroblock.

Clamp the chroma MV with clamp_uvmv_to_umv_border() instead, mirroring
what the SPLITMV path already does in build_4x4uvmvs(). The clamp uses
the same comparisons as the removed check, so output is byte-identical
whenever the check would not have fired, while ensuring dst_u/dst_v
are always written and reference reads remain in bounds.

Add BuildInterPredictorTest.FullPixelChromaMVAlwaysWritesUV to
test/predict_test.cc covering the (0, -152) and (-152, 0) edge MVs.

Bug: 504638005
TAG=agy
CONV=39df291c-bf9e-4b58-b478-c14f8a300860

Change-Id: I250617babd2ca791b68aa52f9bb6a5ecea86b4c2
---
diff --git a/vp8/common/reconinter.c b/vp8/common/reconinter.c
index 2cb0709..143d685 100644
--- a/vp8/common/reconinter.c
+++ b/vp8/common/reconinter.c
@@ -333,12 +333,10 @@
   _16x16mv.as_mv.row &= x->fullpixel_mask;
   _16x16mv.as_mv.col &= x->fullpixel_mask;
 
-  if (2 * _16x16mv.as_mv.col < (x->mb_to_left_edge - (19 << 3)) ||
-      2 * _16x16mv.as_mv.col > x->mb_to_right_edge + (18 << 3) ||
-      2 * _16x16mv.as_mv.row < (x->mb_to_top_edge - (19 << 3)) ||
-      2 * _16x16mv.as_mv.row > x->mb_to_bottom_edge + (18 << 3)) {
-    return;
-  }
+  /* Rounding to full-pel can move the chroma MV outside the border tap
+   * window even though the luma MV was already clamped, so clamp again to
+   * keep the reference read in bounds and ensure dst_u/dst_v are written. */
+  clamp_uvmv_to_umv_border(&_16x16mv.as_mv, x);
 
   pre_stride >>= 1;
   offset = (_16x16mv.as_mv.row >> 3) * pre_stride + (_16x16mv.as_mv.col >> 3);
diff --git a/test/predict_test.cc b/test/predict_test.cc
index 3be63a4..b8693dc 100644
--- a/test/predict_test.cc
+++ b/test/predict_test.cc
@@ -22,6 +22,8 @@
 #include "test/clear_system_state.h"
 #include "test/register_state_check.h"
 #include "test/util.h"
+#include "vp8/common/blockd.h"
+#include "vp8/common/reconinter.h"
 #include "vpx/vpx_integer.h"
 #include "vpx_mem/vpx_mem.h"
 
@@ -411,4 +413,110 @@
                       make_tuple(8, 4, &vp8_bilinear_predict8x4_msa),
                       make_tuple(4, 4, &vp8_bilinear_predict4x4_msa)));
 #endif
+class BuildInterPredictorTest : public ::testing::Test {
+ protected:
+  enum {
+    kYStride = 64,
+    kYSize = kYStride * kYStride,
+    kUVStride = kYStride >> 1,
+    kUVSize = kUVStride * kUVStride,
+    kRef = 0x80,
+    kFill = 0xcd
+  };
+
+  void SetUp() override {
+    xd_ =
+        reinterpret_cast<MACROBLOCKD *>(vpx_memalign(32, sizeof(MACROBLOCKD)));
+    ASSERT_NE(xd_, nullptr);
+    memset(xd_, 0, sizeof(*xd_));
+
+    pre_y_ = reinterpret_cast<uint8_t *>(vpx_memalign(32, kYSize));
+    pre_u_ = reinterpret_cast<uint8_t *>(vpx_memalign(32, kUVSize));
+    pre_v_ = reinterpret_cast<uint8_t *>(vpx_memalign(32, kUVSize));
+    dst_y_ = reinterpret_cast<uint8_t *>(vpx_memalign(32, kYSize));
+    dst_u_ = reinterpret_cast<uint8_t *>(vpx_memalign(32, kUVSize));
+    dst_v_ = reinterpret_cast<uint8_t *>(vpx_memalign(32, kUVSize));
+    ASSERT_NE(pre_y_, nullptr);
+    ASSERT_NE(pre_u_, nullptr);
+    ASSERT_NE(pre_v_, nullptr);
+    ASSERT_NE(dst_y_, nullptr);
+    ASSERT_NE(dst_u_, nullptr);
+    ASSERT_NE(dst_v_, nullptr);
+    memset(pre_y_, kRef, kYSize);
+    memset(pre_u_, kRef, kUVSize);
+    memset(pre_v_, kRef, kUVSize);
+
+    memset(&mi_, 0, sizeof(mi_));
+    xd_->mode_info_context = &mi_;
+    xd_->pre.y_buffer = pre_y_ + (kYStride >> 1) * kYStride + (kYStride >> 1);
+    xd_->pre.u_buffer =
+        pre_u_ + (kUVStride >> 1) * kUVStride + (kUVStride >> 1);
+    xd_->pre.v_buffer =
+        pre_v_ + (kUVStride >> 1) * kUVStride + (kUVStride >> 1);
+    xd_->pre.y_stride = kYStride;
+  }
+
+  void TearDown() override {
+    vpx_free(xd_);
+    vpx_free(pre_y_);
+    vpx_free(pre_u_);
+    vpx_free(pre_v_);
+    vpx_free(dst_y_);
+    vpx_free(dst_u_);
+    vpx_free(dst_v_);
+    libvpx_test::ClearSystemState();
+  }
+
+  void RunAndCheck(int row, int col) {
+    memset(dst_y_, kFill, kYSize);
+    memset(dst_u_, kFill, kUVSize);
+    memset(dst_v_, kFill, kUVSize);
+
+    mi_.mbmi.mv.as_mv.row = static_cast<short>(row);
+    mi_.mbmi.mv.as_mv.col = static_cast<short>(col);
+    mi_.mbmi.need_to_clamp_mvs = 1;
+
+    uint8_t *const dst_y =
+        dst_y_ + (kYStride >> 1) * kYStride + (kYStride >> 1);
+    uint8_t *const dst_u =
+        dst_u_ + (kUVStride >> 1) * kUVStride + (kUVStride >> 1);
+    uint8_t *const dst_v =
+        dst_v_ + (kUVStride >> 1) * kUVStride + (kUVStride >> 1);
+
+    vp8_build_inter16x16_predictors_mb(xd_, dst_y, dst_u, dst_v, kYStride,
+                                       kUVStride);
+
+    for (int r = 0; r < 8; ++r) {
+      for (int c = 0; c < 8; ++c) {
+        ASSERT_EQ(kRef, dst_u[r * kUVStride + c])
+            << "U[" << r << "][" << c << "] mv=(" << row << "," << col << ")";
+        ASSERT_EQ(kRef, dst_v[r * kUVStride + c])
+            << "V[" << r << "][" << c << "] mv=(" << row << "," << col << ")";
+      }
+    }
+  }
+
+  MACROBLOCKD *xd_;
+  MODE_INFO mi_;
+  uint8_t *pre_y_;
+  uint8_t *pre_u_;
+  uint8_t *pre_v_;
+  uint8_t *dst_y_;
+  uint8_t *dst_u_;
+  uint8_t *dst_v_;
+};
+
+// With full-pixel-only chroma motion (fullpixel_mask == ~7), rounding the
+// derived chroma MV down can move it just past the border tap window. The
+// chroma destination must still be written from a clamped reference position.
+TEST_F(BuildInterPredictorTest, FullPixelChromaMVAlwaysWritesUV) {
+  xd_->mb_to_left_edge = 0;
+  xd_->mb_to_right_edge = 0;
+  xd_->mb_to_top_edge = 0;
+  xd_->mb_to_bottom_edge = 0;
+  xd_->fullpixel_mask = ~7;
+
+  RunAndCheck(0, -152);
+  RunAndCheck(-152, 0);
+}
 }  // namespace
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/predict_test.cc b/test/predict_test.cc
index 3be63a4..b8693dc 100644
--- a/test/predict_test.cc
+++ b/test/predict_test.cc
@@ -22,6 +22,8 @@
 #include "test/clear_system_state.h"
 #include "test/register_state_check.h"
 #include "test/util.h"
+#include "vp8/common/blockd.h"
+#include "vp8/common/reconinter.h"
 #include "vpx/vpx_integer.h"
 #include "vpx_mem/vpx_mem.h"
 
@@ -411,4 +413,110 @@
                       make_tuple(8, 4, &vp8_bilinear_predict8x4_msa),
                       make_tuple(4, 4, &vp8_bilinear_predict4x4_msa)));
 #endif
+class BuildInterPredictorTest : public ::testing::Test {
+ protected:
+  enum {
+    kYStride = 64,
+    kYSize = kYStride * kYStride,
+    kUVStride = kYStride >> 1,
+    kUVSize = kUVStride * kUVStride,
+    kRef = 0x80,
+    kFill = 0xcd
+  };
+
+  void SetUp() override {
+    xd_ =
+        reinterpret_cast<MACROBLOCKD *>(vpx_memalign(32, sizeof(MACROBLOCKD)));
+    ASSERT_NE(xd_, nullptr);
+    memset(xd_, 0, sizeof(*xd_));
+
+    pre_y_ = reinterpret_cast<uint8_t *>(vpx_memalign(32, kYSize));
+    pre_u_ = reinterpret_cast<uint8_t *>(vpx_memalign(32, kUVSize));
+    pre_v_ = reinterpret_cast<uint8_t *>(vpx_memalign(32, kUVSize));
+    dst_y_ = reinterpret_cast<uint8_t *>(vpx_memalign(32, kYSize));
+    dst_u_ = reinterpret_cast<uint8_t *>(vpx_memalign(32, kUVSize));
+    dst_v_ = reinterpret_cast<uint8_t *>(vpx_memalign(32, kUVSize));
+    ASSERT_NE(pre_y_, nullptr);
+    ASSERT_NE(pre_u_, nullptr);
+    ASSERT_NE(pre_v_, nullptr);
+    ASSERT_NE(dst_y_, nullptr);
+    ASSERT_NE(dst_u_, nullptr);
+    ASSERT_NE(dst_v_, nullptr);
+    memset(pre_y_, kRef, kYSize);
+    memset(pre_u_, kRef, kUVSize);
+    memset(pre_v_, kRef, kUVSize);
+
+    memset(&mi_, 0, sizeof(mi_));
+    xd_->mode_info_context = &mi_;
+    xd_->pre.y_buffer = pre_y_ + (kYStride >> 1) * kYStride + (kYStride >> 1);
+    xd_->pre.u_buffer =
+        pre_u_ + (kUVStride >> 1) * kUVStride + (kUVStride >> 1);
+    xd_->pre.v_buffer =
+        pre_v_ + (kUVStride >> 1) * kUVStride + (kUVStride >> 1);
+    xd_->pre.y_stride = kYStride;
+  }
+
+  void TearDown() override {
+    vpx_free(xd_);
+    vpx_free(pre_y_);
+    vpx_free(pre_u_);
+    vpx_free(pre_v_);
+    vpx_free(dst_y_);
+    vpx_free(dst_u_);
+    vpx_free(dst_v_);
+    libvpx_test::ClearSystemState();
+  }
+
+  void RunAndCheck(int row, int col) {
+    memset(dst_y_, kFill, kYSize);
+    memset(dst_u_, kFill, kUVSize);
+    memset(dst_v_, kFill, kUVSize);
+
+    mi_.mbmi.mv.as_mv.row = static_cast<short>(row);
+    mi_.mbmi.mv.as_mv.col = static_cast<short>(col);
+    mi_.mbmi.need_to_clamp_mvs = 1;
+
+    uint8_t *const dst_y =
+        dst_y_ + (kYStride >> 1) * kYStride + (kYStride >> 1);
+    uint8_t *const dst_u =
+        dst_u_ + (kUVStride >> 1) * kUVStride + (kUVStride >> 1);
+    uint8_t *const dst_v =
+        dst_v_ + (kUVStride >> 1) * kUVStride + (kUVStride >> 1);
+
+    vp8_build_inter16x16_predictors_mb(xd_, dst_y, dst_u, dst_v, kYStride,
+                                       kUVStride);
+
+    for (int r = 0; r < 8; ++r) {
+      for (int c = 0; c < 8; ++c) {
+        ASSERT_EQ(kRef, dst_u[r * kUVStride + c])
+            << "U[" << r << "][" << c << "] mv=(" << row << "," << col << ")";
+        ASSERT_EQ(kRef, dst_v[r * kUVStride + c])
+            << "V[" << r << "][" << c << "] mv=(" << row << "," << col << ")";
+      }
+    }
+  }
+
+  MACROBLOCKD *xd_;
+  MODE_INFO mi_;
+  uint8_t *pre_y_;
+  uint8_t *pre_u_;
+  uint8_t *pre_v_;
+  uint8_t *dst_y_;
+  uint8_t *dst_u_;
+  uint8_t *dst_v_;
+};
+
+// With full-pixel-only chroma motion (fullpixel_mask == ~7), rounding the
+// derived chroma MV down can move it just past the border tap window. The
+// chroma destination must still be written from a clamped reference position.
+TEST_F(BuildInterPredictorTest, FullPixelChromaMVAlwaysWritesUV) {
+  xd_->mb_to_left_edge = 0;
+  xd_->mb_to_right_edge = 0;
+  xd_->mb_to_top_edge = 0;
+  xd_->mb_to_bottom_edge = 0;
+  xd_->fullpixel_mask = ~7;
+
+  RunAndCheck(0, -152);
+  RunAndCheck(-152, 0);
+}
 }  // namespace
Loading diff…

Original Bug Report

reported by vm...@google.com

Uninitialized heap memory disclosure in libvpx VP8 decoder via MV bounds check inconsistency

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A rounding inconsistency in libvpx’s VP8 decoder can cause it to skip writing to chroma planes for specific macroblocks. Because VP8 frame buffers are not zero-initialized, this can leak renderer heap memory via WebCodecs.

Affected files:

  • third_party/libvpx/source/libvpx/vp8/common/reconinter.c
  • third_party/libvpx/source/libvpx/vp8/decoder/decodeframe.c
  • third_party/libvpx/source/libvpx/vp8/common/alloccommon.c
  • third_party/libvpx/source/libvpx/vpx_scale/generic/yv12config.c

Estimated timestamp from git blame: 2019-05-02

Description

There is a potential uninitialized heap memory disclosure vulnerability in the libvpx VP8 decoder. It is possible to craft a VP8 bitstream that causes the decoder to bypass writing to the U and V (chroma) planes for specific macroblocks. Because Chrome’s libvpx integration for VP8 allocates frame buffers using malloc (via vpx_memalign) without zero-initialization, these unwritten planes will contain uninitialized memory from the renderer process heap. This memory is subsequently copied into a media::VideoFrame and can be extracted using WebCodecs APIs (e.g., VideoFrame.copyTo), providing an attacker with a reliable renderer heap leak, which is a powerful primitive for bypassing ASLR.

Technical Details

The vulnerability stems from an inconsistency in motion vector bounds checking between luma and chroma prediction in vp8_build_inter16x16_predictors_mb (third_party/libvpx/source/libvpx/vp8/common/reconinter.c).

  1. Version 3 Bitstream: An attacker provides a VP8 bitstream with version = 3, which enables full-pixel motion vectors (cm->full_pixel = 1) and sets the subpixel mask xd->fullpixel_mask = ~7 (0xFFFFFFF8).
  2. Left-Edge Macroblock: The attacker targets a macroblock at the extreme left edge of the frame (xd->mb_to_left_edge = 0).
  3. Luma Motion Vector Check: The attacker encodes a luma motion vector of exactly mv.col = -152. During Luma clamping (clamp_mv_to_umv_border), the boundary check is if (mv->col < (xd->mb_to_left_edge - (19 << 3))). Since 19 << 3 = 152, this evaluates to if (-152 < -152). This is false, so the luma MV is not clamped.
  4. Chroma MV Derivation: The chroma MV is derived by adding a rounding bias and halving: (-152 + -1) / 2 = -76.
  5. Mask Application: The full pixel mask is applied: -76 & ~7 evaluates to -80.
  6. Chroma Bounds Check Failure: The decoder performs a bounds check on the derived chroma MV: if (2 * _16x16mv.as_mv.col < (x->mb_to_left_edge - (19 << 3))). This evaluates to if (2 * -80 < -152), which is -160 < -152. This is TRUE.
  7. Early Return: The check failure triggers an early return; (line 340), entirely skipping the prediction functions that write to the dst_u and dst_v chroma buffers.
  8. Bypassing Subsequent Writes: By setting mb_skip_coeff = 1 and ensuring loop filtering is disabled (filter_level = 0), the attacker guarantees no residual IDCT or loop filter operations touch these uninitialized blocks.
  9. Information Disclosure: The vpx_image_t containing uninitialized memory is returned to Chrome. In media::VpxVideoDecoder::CopyVpxImageToVideoFrame, libyuv::CopyPlane copies the uninitialized data into a media::VideoFrame, which the attacker can extract using VideoFrame.copyTo().

Potential Steps to Reproduce

Note: These are potential steps as our tooling agent cannot execute code to verify the exploit end-to-end.

  1. In a malicious webpage, instantiate a WebCodecs VideoDecoder for the vp8 codec.
  2. Feed it a crafted VP8 bitstream with version = 3 specified in the frame header.
  3. Ensure the bitstream contains a macroblock at column 0 with a luma motion vector of -152.
  4. Set mb_skip_coeff = 1 and filter_level = 0 for this macroblock.
  5. Wait for the VideoFrame to be output by the decoder.
  6. Call VideoFrame.copyTo() and inspect the U and V planes in the resulting ArrayBuffer to read raw renderer heap memory.

Suggested Fix

Chrome’s vpx_video_decoder.cc should be updated to use a zero-initialized FrameBufferPool for VP8 decoding, similar to how it currently configures external buffers for VP9 (using vpx_codec_set_frame_buffer_functions). Alternatively, libvpx should be patched to either correctly clamp the luma motion vector to prevent the chroma bounds check failure, or to explicitly zero the chroma planes before returning early.

Evaluated with Chrome root at commit: 7353d249d9cacf9c7218e1d7b8a39cf39c72d646


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker