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
Bug ClassUninitialized Memory
Tracker504633668
Fix commit8592391cdb3e (webm/libvpx) +105/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
TEST
test/decode_api_test.cc
modified
for
test/decode_api_test.cc
modified
for
vp8/decoder/threading.c
modified
if
vp8/decoder/threading.c
modified

Files Changed

  • test/decode_api_test.cc
  • vp8/decoder/threading.c
From 8592391cdb3ef142c56d835788d71d6d4de36a63 Mon Sep 17 00:00:00 2001
From: Marco Paniconi <[email protected]>
Date: Tue, 30 Jun 2026 15:19:40 +0000
Subject: [PATCH] vp8/mt: reset worker mbd->corrupted at start of frame

Reset for keyframe, or all frames if error concealment
is enabled.

setup_decoding_thread_data() copies a fixed list of fields
from the main MACROBLOCKD into each worker's mb_row_di[i].mbd
at the start of every frame, but does not reset mbd->corrupted.
The worker MACROBLOCKDs are allocated once and persist for the
life of the decoder, whereas the main thread's xd->corrupted
is reset at the top of vp8_decode_frame().

If a worker's bool decoder runs past the end of its token
partition on one frame, all subsequent frames are reported as
corrupted even when well-formed.

Reset mbd->corrupted for every worker at the start of each
frame, mirroring the main-thread reset, but this should only
be done for keyframes or when error concealment is enabled.

Unittest added.

Bug: 504633668
Change-Id: I9120410035afedc77c4dbf868bcad5c534d98267
---

diff --git a/test/decode_api_test.cc b/test/decode_api_test.cc
index 0f4639f..e33c463 100644
--- a/test/decode_api_test.cc
+++ b/test/decode_api_test.cc
@@ -9,13 +9,19 @@
  */
 
 #include <array>
+#include <vector>
 
 #include "gtest/gtest.h"
 
 #include "./vpx_config.h"
 #include "test/ivf_video_source.h"
+#include "test/video_source.h"
+#if CONFIG_VP8_ENCODER || CONFIG_VP9_ENCODER
+#include "vpx/vp8cx.h"
+#endif
 #include "vpx/vp8dx.h"
 #include "vpx/vpx_decoder.h"
+#include "vpx/vpx_encoder.h"
 
 namespace {
 
@@ -90,6 +96,96 @@
   EXPECT_EQ(VPX_CODEC_OK, vpx_codec_decode(&dec, nullptr, 0, nullptr, 0));
   EXPECT_EQ(VPX_CODEC_OK, vpx_codec_destroy(&dec));
 }
+
+#if CONFIG_VP8_ENCODER && CONFIG_MULTITHREAD
+// Encodes a sequence of key frames using two token partitions, truncates the
+// second token partition of one frame so that the worker thread's bool decoder
+// runs past the end of its partition, and then verifies that the
+// multi-threaded decoder still reports subsequent well-formed key frames as
+// not corrupted.
+TEST(DecodeAPI, Vp8MultiThreadedCorruptedStateResetAcrossFrames) {
+  constexpr int kWidth = 16;
+  constexpr int kHeight = 32;
+  constexpr int kFrames = 4;
+
+  std::vector<std::vector<uint8_t>> frames;
+  {
+    vpx_codec_ctx_t enc;
+    vpx_codec_enc_cfg_t cfg;
+    ASSERT_EQ(vpx_codec_enc_config_default(&vpx_codec_vp8_cx_algo, &cfg, 0),
+              VPX_CODEC_OK);
+    cfg.g_w = kWidth;
+    cfg.g_h = kHeight;
+    cfg.g_lag_in_frames = 0;
+    ASSERT_EQ(vpx_codec_enc_init(&enc, &vpx_codec_vp8_cx_algo, &cfg, 0),
+              VPX_CODEC_OK);
+    ASSERT_EQ(vpx_codec_control(&enc, VP8E_SET_TOKEN_PARTITIONS,
+                                VP8_TWO_TOKENPARTITION),
+              VPX_CODEC_OK);
+
+    libvpx_test::RandomVideoSource video;
+    video.SetSize(kWidth, kHeight);
+    video.set_limit(kFrames);
+    for (video.Begin(); video.img() != nullptr; video.Next()) {
+      ASSERT_EQ(
+          vpx_codec_encode(&enc, video.img(), video.pts(), video.duration(),
+                           VPX_EFLAG_FORCE_KF, VPX_DL_REALTIME),
+          VPX_CODEC_OK);
+      vpx_codec_iter_t iter = nullptr;
+      const vpx_codec_cx_pkt_t *pkt;
+      while ((pkt = vpx_codec_get_cx_data(&enc, &iter)) != nullptr) {
+        if (pkt->kind != VPX_CODEC_CX_FRAME_PKT) continue;
+        ASSERT_NE(pkt->data.frame.flags & VPX_FRAME_IS_KEY, 0u);
+        const uint8_t *buf = static_cast<const uint8_t *>(pkt->data.frame.buf);
+        frames.emplace_back(buf, buf + pkt->data.frame.sz);
+      }
+    }
+    ASSERT_EQ(vpx_codec_destroy(&enc), VPX_CODEC_OK);
+  }
+  ASSERT_EQ(frames.size(), static_cast<size_t>(kFrames));
+
+  // Truncate frame 1 so that only a single byte of the second token partition
+  // remains. The first token partition (used by the main thread) is left
+  // intact.
+  {
+    std::vector<uint8_t> &f = frames[1];
+    ASSERT_GT(f.size(), 10u);
+    const size_t first_part_sz =
+        (static_cast<size_t>(f[0]) | (static_cast<size_t>(f[1]) << 8) |
+         (static_cast<size_t>(f[2]) << 16)) >>
+        5;
+    const size_t part_sizes = 10 + first_part_sz;
+    ASSERT_LT(part_sizes + 3, f.size());
+    const size_t part0_sz = static_cast<size_t>(f[part_sizes]) |
+                            (static_cast<size_t>(f[part_sizes + 1]) << 8) |
+                            (static_cast<size_t>(f[part_sizes + 2]) << 16);
+    const size_t part1_start = part_sizes + 3 + part0_sz;
+    ASSERT_LT(part1_start, f.size());
+    f.resize(part1_start + 1);
+  }
+
+  vpx_codec_ctx_t dec;
+  vpx_codec_dec_cfg_t dec_cfg = { /*threads=*/4, /*w=*/0, /*h=*/0 };
+  ASSERT_EQ(vpx_codec_dec_init(&dec, &vpx_codec_vp8_dx_algo, &dec_cfg, 0),
+            VPX_CODEC_OK);
+  for (int i = 0; i < kFrames; ++i) {
+    const vpx_codec_err_t res = vpx_codec_decode(
+        &dec, frames[i].data(), static_cast<unsigned int>(frames[i].size()),
+        /*user_priv=*/nullptr, /*deadline=*/0);
+    vpx_codec_iter_t iter = nullptr;
+    while (vpx_codec_get_frame(&dec, &iter)) {
+    }
+    if (i == 1) continue;
+    EXPECT_EQ(res, VPX_CODEC_OK)
+        << "frame " << i << ": " << vpx_codec_error_detail(&dec);
+    int corrupted = -1;
+    EXPECT_EQ(vpx_codec_control(&dec, VP8D_GET_FRAME_CORRUPTED, &corrupted),
+              VPX_CODEC_OK);
+    EXPECT_EQ(corrupted, 0) << "frame " << i;
+  }
+  EXPECT_EQ(vpx_codec_destroy(&dec), VPX_CODEC_OK);
+}
+#endif  // CONFIG_VP8_ENCODER && CONFIG_MULTITHREAD
 #endif  // CONFIG_VP8_DECODER
 
 #if CONFIG_VP9_DECODER
diff --git a/vp8/decoder/threading.c b/vp8/decoder/threading.c
index d16284d..d9182dd 100644
--- a/vp8/decoder/threading.c
+++ b/vp8/decoder/threading.c
@@ -47,6 +47,15 @@
 
   for (i = 0; i < count; ++i) {
     MACROBLOCKD *mbd = &mbrd[i].mbd;
+
+    // The worker MACROBLOCKDs persist across frames, so reset the per-frame
+    // error state in line with the main thread (see vp8_decode_frame()).
+    // This should only be done for keyframes or when error concealment
+    // is active.
+    if (pc->frame_type == KEY_FRAME || pbi->ec_active) {
+      mbd->corrupted = 0;
+    }
+
     mbd->subpixel_predict = xd->subpixel_predict;
     mbd->subpixel_predict8x4 = xd->subpixel_predict8x4;
     mbd->subpixel_predict8x8 = xd->subpixel_predict8x8;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/decode_api_test.cc b/test/decode_api_test.cc
index 0f4639f..e33c463 100644
--- a/test/decode_api_test.cc
+++ b/test/decode_api_test.cc
@@ -9,13 +9,19 @@
  */
 
 #include <array>
+#include <vector>
 
 #include "gtest/gtest.h"
 
 #include "./vpx_config.h"
 #include "test/ivf_video_source.h"
+#include "test/video_source.h"
+#if CONFIG_VP8_ENCODER || CONFIG_VP9_ENCODER
+#include "vpx/vp8cx.h"
+#endif
 #include "vpx/vp8dx.h"
 #include "vpx/vpx_decoder.h"
+#include "vpx/vpx_encoder.h"
 
 namespace {
 
@@ -90,6 +96,96 @@
   EXPECT_EQ(VPX_CODEC_OK, vpx_codec_decode(&dec, nullptr, 0, nullptr, 0));
   EXPECT_EQ(VPX_CODEC_OK, vpx_codec_destroy(&dec));
 }
+
+#if CONFIG_VP8_ENCODER && CONFIG_MULTITHREAD
+// Encodes a sequence of key frames using two token partitions, truncates the
+// second token partition of one frame so that the worker thread's bool decoder
+// runs past the end of its partition, and then verifies that the
+// multi-threaded decoder still reports subsequent well-formed key frames as
+// not corrupted.
+TEST(DecodeAPI, Vp8MultiThreadedCorruptedStateResetAcrossFrames) {
+  constexpr int kWidth = 16;
+  constexpr int kHeight = 32;
+  constexpr int kFrames = 4;
+
+  std::vector<std::vector<uint8_t>> frames;
+  {
+    vpx_codec_ctx_t enc;
+    vpx_codec_enc_cfg_t cfg;
+    ASSERT_EQ(vpx_codec_enc_config_default(&vpx_codec_vp8_cx_algo, &cfg, 0),
+              VPX_CODEC_OK);
+    cfg.g_w = kWidth;
+    cfg.g_h = kHeight;
+    cfg.g_lag_in_frames = 0;
+    ASSERT_EQ(vpx_codec_enc_init(&enc, &vpx_codec_vp8_cx_algo, &cfg, 0),
+              VPX_CODEC_OK);
+    ASSERT_EQ(vpx_codec_control(&enc, VP8E_SET_TOKEN_PARTITIONS,
+                                VP8_TWO_TOKENPARTITION),
+              VPX_CODEC_OK);
+
+    libvpx_test::RandomVideoSource video;
+    video.SetSize(kWidth, kHeight);
+    video.set_limit(kFrames);
+    for (video.Begin(); video.img() != nullptr; video.Next()) {
+      ASSERT_EQ(
+          vpx_codec_encode(&enc, video.img(), video.pts(), video.duration(),
+                           VPX_EFLAG_FORCE_KF, VPX_DL_REALTIME),
+          VPX_CODEC_OK);
+      vpx_codec_iter_t iter = nullptr;
+      const vpx_codec_cx_pkt_t *pkt;
+      while ((pkt = vpx_codec_get_cx_data(&enc, &iter)) != nullptr) {
+        if (pkt->kind != VPX_CODEC_CX_FRAME_PKT) continue;
+        ASSERT_NE(pkt->data.frame.flags & VPX_FRAME_IS_KEY, 0u);
+        const uint8_t *buf = static_cast<const uint8_t *>(pkt->data.frame.buf);
+        frames.emplace_back(buf, buf + pkt->data.frame.sz);
+      }
+    }
+    ASSERT_EQ(vpx_codec_destroy(&enc), VPX_CODEC_OK);
+  }
+  ASSERT_EQ(frames.size(), static_cast<size_t>(kFrames));
+
+  // Truncate frame 1 so that only a single byte of the second token partition
+  // remains. The first token partition (used by the main thread) is left
+  // intact.
+  {
+    std::vector<uint8_t> &f = frames[1];
+    ASSERT_GT(f.size(), 10u);
+    const size_t first_part_sz =
+        (static_cast<size_t>(f[0]) | (static_cast<size_t>(f[1]) << 8) |
+         (static_cast<size_t>(f[2]) << 16)) >>
+        5;
+    const size_t part_sizes = 10 + first_part_sz;
+    ASSERT_LT(part_sizes + 3, f.size());
+    const size_t part0_sz = static_cast<size_t>(f[part_sizes]) |
+                            (static_cast<size_t>(f[part_sizes + 1]) << 8) |
+                            (static_cast<size_t>(f[part_sizes + 2]) << 16);
+    const size_t part1_start = part_sizes + 3 + part0_sz;
+    ASSERT_LT(part1_start, f.size());
+    f.resize(part1_start + 1);
+  }
+
+  vpx_codec_ctx_t dec;
+  vpx_codec_dec_cfg_t dec_cfg = { /*threads=*/4, /*w=*/0, /*h=*/0 };
+  ASSERT_EQ(vpx_codec_dec_init(&dec, &vpx_codec_vp8_dx_algo, &dec_cfg, 0),
+            VPX_CODEC_OK);
+  for (int i = 0; i < kFrames; ++i) {
+    const vpx_codec_err_t res = vpx_codec_decode(
+        &dec, frames[i].data(), static_cast<unsigned int>(frames[i].size()),
+        /*user_priv=*/nullptr, /*deadline=*/0);
+    vpx_codec_iter_t iter = nullptr;
+    while (vpx_codec_get_frame(&dec, &iter)) {
+    }
+    if (i == 1) continue;
+    EXPECT_EQ(res, VPX_CODEC_OK)
+        << "frame " << i << ": " << vpx_codec_error_detail(&dec);
+    int corrupted = -1;
+    EXPECT_EQ(vpx_codec_control(&dec, VP8D_GET_FRAME_CORRUPTED, &corrupted),
+              VPX_CODEC_OK);
+    EXPECT_EQ(corrupted, 0) << "frame " << i;
+  }
+  EXPECT_EQ(vpx_codec_destroy(&dec), VPX_CODEC_OK);
+}
+#endif  // CONFIG_VP8_ENCODER && CONFIG_MULTITHREAD
 #endif  // CONFIG_VP8_DECODER
 
 #if CONFIG_VP9_DECODER
Loading diff…

Original Bug Report

reported by [email protected]

Libvpx VP8 MT decoder uninitialized heap memory leak

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: In the multithreaded VP8 decoder, worker threads do not reset their internal corrupted flag between frames. If a worker encounters an error, it skips decoding its assigned regions for all subsequent frames. When the video resolution changes, new frame buffers are allocated without zero-initialization, leading to a leak of uninitialized PartitionAlloc memory to JavaScript.

Affected files:

  • third_party/libvpx/source/libvpx/vp8/decoder/threading.c
  • third_party/libvpx/source/libvpx/vp8/decoder/decodeframe.c
  • third_party/libvpx/source/libvpx/vpx_scale/generic/yv12config.c
  • third_party/libvpx/source/libvpx/vp8/vp8_dx_iface.c
  • media/filters/vpx_video_decoder.cc

Estimated timestamp from git blame: 2023-06-07

Summary

A vulnerability in the multithreaded VP8 decoder in libvpx causes a corruption flag in worker threads to persist across frames. When a worker thread encounters a malformed bitstream, its internal corrupted flag is set. Due to a missing reset in the per-frame setup routine, this flag remains set for all subsequent frames. This causes the worker thread to skip decoding its assigned macroblock rows in every future frame. Since VP8 frame buffers are allocated via vpx_memalign (using PartitionAlloc in Chrome) without zero-initialization, the skipped regions contain raw heap memory. Chrome’s VpxVideoDecoder surfaces these uninitialized regions to JavaScript, enabling an information leak of renderer-process heap memory.

Root Cause Analysis

In third_party/libvpx/source/libvpx/vp8/decoder/threading.c, the setup_decoding_thread_data function prepares worker threads for each frame by copying fields from the main thread’s MACROBLOCKD structure. However, it fails to reset or copy the mbd->corrupted flag:

static void setup_decoding_thread_data(VP8D_COMP *pbi, MACROBLOCKD *xd,
                                       MB_ROW_DEC *mbrd, int count) {
  for (i = 0; i < count; ++i) {
    MACROBLOCKD *mbd = &mbrd[i].mbd;
    mbd->subpixel_predict      = xd->subpixel_predict;
    // ... (other fields initialized, but mbd->corrupted is omitted)
  }
}

In contrast, the main thread’s xd->corrupted is explicitly reset at the start of every frame in third_party/libvpx/source/libvpx/vp8/decoder/decodeframe.c (line 896).

During decoding, if a worker thread sets xd->corrupted to 1 (e.g., due to a bitstream error in its assigned partition), it enters an error path (threading.c:409) that calls vpx_internal_error(). This performs a longjmp back to the thread’s setjmp point. Because the flag is never reset in setup_decoding_thread_data, once a worker’s corrupted bit is set, it becomes “sticky.” For all subsequent frames, the worker immediately sees xd->corrupted == 1, calls vpx_internal_error(), and aborts decoding its assigned interleaved rows before any pixel data is written.

Heap Memory Leak Mechanism

  1. Uninitialized Allocation: When a video resolution change is triggered by a new keyframe, libvpx re-allocates its frame buffers via vp8_yv12_alloc_frame_buffer. This relies on vpx_memalign, which uses malloc (mapped to PartitionAlloc). In production Chrome builds, these allocations are not zero-initialized (yv12config.c:72-79).
  2. Skipped Rows: The worker with the sticky corrupted state immediately bails on the new frame, leaving its portion of the newly allocated buffer as untouched, raw PartitionAlloc memory.
  3. Exposure to JavaScript: vp8_decode_frame successfully parses the keyframe header and returns 0 (success), which translates to a VPX_CODEC_OK return from vpx_codec_decode. Chrome’s VpxVideoDecoder (media/filters/vpx_video_decoder.cc) assumes success and does not query the VP8D_GET_FRAME_CORRUPTED control. It wraps or copies the vpx_image_t into a media::VideoFrame and surfaces it to the media pipeline.
  4. Exfiltration: A web page can read these uninitialized bytes using WebCodecs (VideoFrame.copyTo()) or by drawing the video to a <canvas> and calling getImageData(). By manipulating the video resolution, an attacker can target specific PartitionAlloc bucket sizes to leak pointers or sensitive data from recently freed objects.

Suggested Reproduction Steps

(Note: These are potential steps, as our tooling cannot currently execute a PoC)

  1. Host a WebM file with a VP8 track configured for multithreaded decoding (e.g., multiple token partitions).
  2. Provide an initial valid keyframe to establish the decoder state and worker threads.
  3. Provide a malformed frame with a bitstream error in one of the partitions. This causes the assigned worker thread to set its corrupted flag.
  4. Provide a valid keyframe at a new resolution. This forces a buffer reallocation.
  5. The macroblock rows assigned to the corrupted worker thread will contain uninitialized heap data from the freshly allocated buffer.
  6. In JavaScript, use a <video> element or WebCodecs to decode the stream and exfiltrate the uninitialized data from the affected rows using a <canvas> or the copyTo() method.

Suggested Fix

Explicitly initialize the corrupted flag in the worker threads’ setup routine.

In third_party/libvpx/source/libvpx/vp8/decoder/threading.c:

static void setup_decoding_thread_data(VP8D_COMP *pbi, MACROBLOCKD *xd,
                                       MB_ROW_DEC *mbrd, int count) {
  for (i = 0; i < count; ++i) {
    MACROBLOCKD *mbd = &mbrd[i].mbd;
    mbd->subpixel_predict      = xd->subpixel_predict;
    // ... existing initializations ...
    mbd->corrupted = 0; // Explicitly reset the corrupted flag
  }
}

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