CVE-2026-78969
Overview
Background
- `MACROBLOCKD`
- per-decode-context struct holding VP8 macroblock decoding state, including the
corruptedflag that records whether a bool decoder overran its data. - `corrupted` flag
- sticky boolean set when a bool (arithmetic) decoder reads past the end of its token partition; it gates error concealment and whether decoded output is trusted.
- Worker `MACROBLOCKD` (`mb_row_di[i].mbd`)
- a per-thread decode context in multithreaded VP8, allocated once and persisted for the decoder’s lifetime rather than reallocated per frame.
- `setup_decoding_thread_data`
- routine that seeds each worker’s
mbdfrom the main-threadMACROBLOCKD(xd) at the start of every frame by copying a fixed list of fields.
Root Cause Analysis
The main thread resets xd->corrupted at the top of vp8_decode_frame on every frame, but the worker copies performed by setup_decoding_thread_data propagate only a fixed field list and omit corrupted. Because each worker MACROBLOCKD is allocated once and lives for the decoder’s lifetime, a corrupted bit set by a bool-decoder overrun on one frame is never cleared for subsequent frames.
As a result, a single malformed frame permanently poisons a worker’s corrupted state: every later frame decoded by that worker is treated as corrupt even when perfectly well-formed. When error concealment is enabled, this stale flag instead drives concealment logic on valid frames, causing the decoder to substitute stale or uninitialized worker state for legitimately decoded pixels.
The fix resets each worker’s mbd->corrupted at frame start — for keyframes, or for all frames when error concealment is enabled — mirroring the main-thread reset semantics.
corrupted reflects only the current frame” held on the main thread but was violated for every worker. The stickiness turned a transient per-frame condition into a durable, cross-frame corruption of validity tracking.Attack Path
- Trip the flag
Deliver a crafted VP8 frame whose token partition is short so a worker’s bool decoder overruns and sets that worker’s
mbd->corrupted. - Persist across frames
Because
setup_decoding_thread_datanever resets it, the worker’scorruptedbit survives into subsequent, well-formed frames. - Mishandle valid frames On later valid frames the stale flag either forces them to be reported corrupt or, with error concealment on, triggers concealment paths that read stale/uninitialized worker buffers.
- Disclose stale memory Concealed output built from uninitialized or previous-frame worker state is emitted as pixels, leaking residual heap/decoder memory into the rendered image.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forvp8/decoder/threading.c |
modified | |
ifvp8/decoder/threading.c |
modified | |
TESTtest/decode_api_test.cc |
modified | |
fortest/decode_api_test.cc |
modified |
Files Changed
vp8/decoder/threading.ctest/decode_api_test.cc
Audit Directions
- Persistent-context field copiesAudit every routine that seeds long-lived per-thread state from a per-frame master (
setup_decoding_thread_dataand peers) for fields the master resets each frame but the copy omits, especially sticky validity/error flags. - Reset-symmetry between main and worker pathsCompare fields reset in
vp8_decode_frameagainst those propagated to workers, flagging any state whose lifetime differs between the single-threaded and multithreaded contexts. - Concealment gated on stale flagsReview error-concealment consumers of
corruptedto confirm they cannot act on flags carried over from a prior frame, which would surface stale or uninitialized buffers as output.
Patch
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/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; 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
Regression Test / PoC
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
Original Bug Report
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.cthird_party/libvpx/source/libvpx/vp8/decoder/decodeframe.cthird_party/libvpx/source/libvpx/vpx_scale/generic/yv12config.cthird_party/libvpx/source/libvpx/vp8/vp8_dx_iface.cmedia/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
- Uninitialized Allocation: When a video resolution change is triggered by a new keyframe,
libvpxre-allocates its frame buffers viavp8_yv12_alloc_frame_buffer. This relies onvpx_memalign, which usesmalloc(mapped to PartitionAlloc). In production Chrome builds, these allocations are not zero-initialized (yv12config.c:72-79). - 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.
- Exposure to JavaScript:
vp8_decode_framesuccessfully parses the keyframe header and returns0(success), which translates to aVPX_CODEC_OKreturn fromvpx_codec_decode. Chrome’sVpxVideoDecoder(media/filters/vpx_video_decoder.cc) assumes success and does not query theVP8D_GET_FRAME_CORRUPTEDcontrol. It wraps or copies thevpx_image_tinto amedia::VideoFrameand surfaces it to the media pipeline. - Exfiltration: A web page can read these uninitialized bytes using WebCodecs (
VideoFrame.copyTo()) or by drawing the video to a<canvas>and callinggetImageData(). 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)
- Host a WebM file with a VP8 track configured for multithreaded decoding (e.g., multiple token partitions).
- Provide an initial valid keyframe to establish the decoder state and worker threads.
- Provide a malformed frame with a bitstream error in one of the partitions. This causes the assigned worker thread to set its
corruptedflag. - Provide a valid keyframe at a new resolution. This forces a buffer reallocation.
- The macroblock rows assigned to the corrupted worker thread will contain uninitialized heap data from the freshly allocated buffer.
- 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 thecopyTo()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.