CVE-2026-106261
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
BuildInterPredictorTesttest/predict_test.cc |
modified | |
fortest/predict_test.cc |
modified | |
TEST_Ftest/predict_test.cc |
modified |
Files Changed
test/predict_test.ccvp8/common/reconinter.c
Patch
From d2413e2ca11039724ca33bb4d661ca2c94cb501e Mon Sep 17 00:00:00 2001 From: Jerome Jiang <[email protected]> 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
Regression Test / PoC
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
Original Bug Report
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.cthird_party/libvpx/source/libvpx/vp8/decoder/decodeframe.cthird_party/libvpx/source/libvpx/vp8/common/alloccommon.cthird_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).
- 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 maskxd->fullpixel_mask = ~7(0xFFFFFFF8). - Left-Edge Macroblock: The attacker targets a macroblock at the extreme left edge of the frame (
xd->mb_to_left_edge = 0). - 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 isif (mv->col < (xd->mb_to_left_edge - (19 << 3))). Since19 << 3 = 152, this evaluates toif (-152 < -152). This is false, so the luma MV is not clamped. - Chroma MV Derivation: The chroma MV is derived by adding a rounding bias and halving:
(-152 + -1) / 2 = -76. - Mask Application: The full pixel mask is applied:
-76 & ~7evaluates to-80. - 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 toif (2 * -80 < -152), which is-160 < -152. This is TRUE. - Early Return: The check failure triggers an early
return;(line 340), entirely skipping the prediction functions that write to thedst_uanddst_vchroma buffers. - Bypassing Subsequent Writes: By setting
mb_skip_coeff = 1and ensuring loop filtering is disabled (filter_level = 0), the attacker guarantees no residual IDCT or loop filter operations touch these uninitialized blocks. - Information Disclosure: The
vpx_image_tcontaining uninitialized memory is returned to Chrome. Inmedia::VpxVideoDecoder::CopyVpxImageToVideoFrame,libyuv::CopyPlanecopies the uninitialized data into amedia::VideoFrame, which the attacker can extract usingVideoFrame.copyTo().
Potential Steps to Reproduce
Note: These are potential steps as our tooling agent cannot execute code to verify the exploit end-to-end.
- In a malicious webpage, instantiate a WebCodecs
VideoDecoderfor thevp8codec. - Feed it a crafted VP8 bitstream with
version = 3specified in the frame header. - Ensure the bitstream contains a macroblock at column
0with a luma motion vector of-152. - Set
mb_skip_coeff = 1andfilter_level = 0for this macroblock. - Wait for the
VideoFrameto be output by the decoder. - Call
VideoFrame.copyTo()and inspect the U and V planes in the resultingArrayBufferto 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.