CVE-2026-113267
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifvp8/decoder/threading.c |
modified | |
forvp8/decoder/threading.c |
modified | |
TESTtest/decode_api_test.cc |
modified | |
PatternVideoSourcetest/decode_api_test.cc |
modified | |
fortest/decode_api_test.cc |
modified | |
iftest/decode_api_test.cc |
modified |
Files Changed
vp8/decoder/threading.ctest/decode_api_test.cc
Patch
From 5e680f30801d03c21078f8c4b772464752516211 Mon Sep 17 00:00:00 2001
From: Marco Paniconi <marpan@google.com>
Date: Mon, 14 Sep 2026 21:35:29 +0000
Subject: [PATCH] vp8: propagate worker thread errors in vp8mt_decode_mb_rows
If a worker thread encounters a bitstream error
(e.g. truncated/corrupt partition), it longjmps to
thread_decoding_proc() and records the error code in
mbd->error_info. However, this error was never checked or
propagated back to the main thread in vp8mt_decode_mb_rows().
As a result, the multi-threaded decoder treated the corrupted
frame as successfully decoded and returned a partially written
frame containing uninitialized or stale memory.
Fix is to reset mbd->error_info.error_code per frame in
setup_decoding_thread_data(), and check each worker thread's
error_info after worker threads finish decoding. If any worker
encountered an error, propagate it back to the caller and fail
the frame.
Unittest added.
Bug: 559707641
Change-Id: I8f50fbc35f0ce6e59718a5e7445b20969133299f
---
diff --git a/vp8/decoder/threading.c b/vp8/decoder/threading.c
index 99cf5a8..43d5649 100644
--- a/vp8/decoder/threading.c
+++ b/vp8/decoder/threading.c
@@ -55,6 +55,7 @@
if (pc->frame_type == KEY_FRAME || pbi->ec_active) {
mbd->corrupted = 0;
}
+ mbd->error_info.error_code = 0;
mbd->subpixel_predict = xd->subpixel_predict;
mbd->subpixel_predict8x4 = xd->subpixel_predict8x4;
@@ -915,5 +916,12 @@
for (i = 0; i < pbi->decoding_thread_count + 1; ++i)
vp8_sem_wait(&pbi->h_event_end_decoding); /* add back for each frame */
+ for (i = 0; i < pbi->decoding_thread_count; ++i) {
+ if (pbi->mb_row_di[i].mbd.error_info.error_code) {
+ xd->error_info = pbi->mb_row_di[i].mbd.error_info;
+ return -1;
+ }
+ }
+
return 0;
}
diff --git a/test/decode_api_test.cc b/test/decode_api_test.cc
index 477f3b8..b95d519 100644
--- a/test/decode_api_test.cc
+++ b/test/decode_api_test.cc
@@ -242,6 +242,117 @@
}
EXPECT_EQ(vpx_codec_destroy(&dec), VPX_CODEC_OK);
}
+
+// Issue: 559707641
+// Encodes a sequence of frames with two token partitions, truncates the second
+// token partition of one frame (used by a worker thread), and verifies that
+// the multi-threaded decoder returns VPX_CODEC_CORRUPT_FRAME and does not emit
+// a partially written frame. Also verifies that subsequent well-formed frames
+// still decode correctly.
+TEST(DecodeAPI, Vp8MultiThreadedWorkerDecodeErrorFailsFrame) {
+ constexpr int kWidth = 32;
+ constexpr int kHeight = 32;
+ class PatternVideoSource : public libvpx_test::DummyVideoSource {
+ protected:
+ void FillFrame() override {
+ if (!img_) return;
+ // Keep row 0 flat so partition 0 size is small.
+ memset(img_->planes[VPX_PLANE_Y], 128, img_->stride[VPX_PLANE_Y] * 16);
+ // Fill row 1 with varying pattern so partition 1 has tokens to truncate.
+ for (int y = 16; y < 32; ++y) {
+ uint8_t *row =
+ img_->planes[VPX_PLANE_Y] + y * img_->stride[VPX_PLANE_Y];
+ for (int x = 0; x < 32; ++x) {
+ row[x] = static_cast<uint8_t>((x * 37 + y * 73) ^ (frame_ * 19));
+ }
+ }
+ memset(img_->planes[VPX_PLANE_U], 128, img_->stride[VPX_PLANE_U] * 16);
+ memset(img_->planes[VPX_PLANE_V], 128, img_->stride[VPX_PLANE_V] * 16);
+ }
+ };
+
+ 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);
+
+ PatternVideoSource 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;
+ const vpx_image_t *img = vpx_codec_get_frame(&dec, &iter);
+ if (i == 1) {
+ EXPECT_EQ(res, VPX_CODEC_CORRUPT_FRAME);
+ EXPECT_EQ(img, nullptr);
+ continue;
+ }
+ EXPECT_EQ(res, VPX_CODEC_OK)
+ << "frame " << i << ": " << vpx_codec_error_detail(&dec);
+ EXPECT_NE(img, nullptr) << "frame " << i;
+ 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
Regression Test / PoC
diff --git a/test/decode_api_test.cc b/test/decode_api_test.cc
index 477f3b8..b95d519 100644
--- a/test/decode_api_test.cc
+++ b/test/decode_api_test.cc
@@ -242,6 +242,117 @@
}
EXPECT_EQ(vpx_codec_destroy(&dec), VPX_CODEC_OK);
}
+
+// Issue: 559707641
+// Encodes a sequence of frames with two token partitions, truncates the second
+// token partition of one frame (used by a worker thread), and verifies that
+// the multi-threaded decoder returns VPX_CODEC_CORRUPT_FRAME and does not emit
+// a partially written frame. Also verifies that subsequent well-formed frames
+// still decode correctly.
+TEST(DecodeAPI, Vp8MultiThreadedWorkerDecodeErrorFailsFrame) {
+ constexpr int kWidth = 32;
+ constexpr int kHeight = 32;
+ class PatternVideoSource : public libvpx_test::DummyVideoSource {
+ protected:
+ void FillFrame() override {
+ if (!img_) return;
+ // Keep row 0 flat so partition 0 size is small.
+ memset(img_->planes[VPX_PLANE_Y], 128, img_->stride[VPX_PLANE_Y] * 16);
+ // Fill row 1 with varying pattern so partition 1 has tokens to truncate.
+ for (int y = 16; y < 32; ++y) {
+ uint8_t *row =
+ img_->planes[VPX_PLANE_Y] + y * img_->stride[VPX_PLANE_Y];
+ for (int x = 0; x < 32; ++x) {
+ row[x] = static_cast<uint8_t>((x * 37 + y * 73) ^ (frame_ * 19));
+ }
+ }
+ memset(img_->planes[VPX_PLANE_U], 128, img_->stride[VPX_PLANE_U] * 16);
+ memset(img_->planes[VPX_PLANE_V], 128, img_->stride[VPX_PLANE_V] * 16);
+ }
+ };
+
+ 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);
+
+ PatternVideoSource 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;
+ const vpx_image_t *img = vpx_codec_get_frame(&dec, &iter);
+ if (i == 1) {
+ EXPECT_EQ(res, VPX_CODEC_CORRUPT_FRAME);
+ EXPECT_EQ(img, nullptr);
+ continue;
+ }
+ EXPECT_EQ(res, VPX_CODEC_OK)
+ << "frame " << i << ": " << vpx_codec_error_detail(&dec);
+ EXPECT_NE(img, nullptr) << "frame " << i;
+ 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
Original Bug Report
VP8 multithreaded decode presents a partially written frame containing a cross-origin video's pixels
Report description
VP8 multithreaded decode presents a partially written frame containing a cross-origin video’s pixels
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
The problem
Please describe the technical details of the vulnerability
A VP8 decode worker abandons the macroblock rows it owns in the middle of a frame, Chrome presents that partially written frame, and the rows it never wrote still hold the decoded pixels of a cross-origin video that was destroyed a moment earlier.
The problem
libvpx decodes VP8 with two threads and macroblock rows alternate between them, each reading
its own token partition. When a worker’s bool decoder runs off the end of its partition, the
worker marks every row it still owns as complete so the main thread will not block, then
longjmps out. Its handler posts the completion semaphore and continues without setting any
frame-level failure, so vp8_decode_frame() returns 0 and the frame is emitted as a normal
decoded frame. Chrome never consults the one bit libvpx does set.
VP8 frame buffers are allocated with vpx_memalign and are not cleared. When a page moves
video.src from a no-CORS cross-origin resource to its own MediaSource, the old decoder is
destroyed and the new one allocates same-size buffers on the same sequence, so the rows the
worker abandoned read back the destroyed decoder’s pixels.
The page cannot read the cross-origin video directly. The PoC asserts this in the same run and both paths throw:
getImageData() -> SecurityError: Failed to execute 'getImageData' on 'CanvasRenderingContext2D':
The canvas has been tainted by cross-origin data.
VideoFrame() -> SecurityError: Failed to construct 'VideoFrame':
VideoFrames can't be created from tainted sources.
It then reads those same pixels out of its own same-origin frame with VideoFrame.copyTo().
secret.png is the cross-origin video’s decoded frame; it carries the words LEAK and
SECRET, placed on the two macroblock rows the worker abandons. recovered.png is the frame
the attacker page read out of its own video: the rows the main thread decoded show the page’s
own test pattern and timecode, and both cross-origin words are legible in the rows the worker
abandoned. recovered_verified_only.png replaces every byte that is not identical to the
cross-origin original with grey — what survives is exactly those two words: 2372 of 6144 luma
bytes, 3527 of 9216 bytes over all three planes.
Analysis
Line numbers are libvpx 3ffe23d874a1b454e02be57f3e025d4aa70d6de0. vp8/decoder/threading.c, vp8/decoder/decodeframe.c and
vpx_scale/generic/yv12config.c are byte-identical between that revision and d2413e2ca110,
the revision src/DEPS pins at chromium fab1b7a88d9 (2026-09-08), verified with git diff.
1. A bool-decoder overrun is recorded after one macroblock and consumed before the next.
threading.c:441-446:
mt_decode_macroblock(pbi, xd, 0);
xd->left_available = 1;
/* check if the boolean decoder has suffered an error */
xd->corrupted |= vp8dx_bool_error(xd->current_bc);
threading.c:418-428, reached early in the next macroblock iteration, before
mt_decode_macroblock():
if (xd->corrupted) {
// Move current decoding marcoblock to the end of row for all rows
// assigned to this thread, such that other threads won't be waiting.
for (; mb_row < pc->mb_rows;
mb_row += (pbi->decoding_thread_count + 1)) {
current_mb_col = &pbi->mt_current_mb_col[mb_row];
vpx_atomic_store_release(current_mb_col, pc->mb_cols + nsync);
}
vpx_internal_error(&xd->error_info, VPX_CODEC_CORRUPT_FRAME,
"Corrupted reference frame");
}
The loop marks all of this worker’s remaining rows complete, so their destination pixels are
never written. Chrome asks for limits::kMinVideoDecodeThreads = 2 for VP8
(media/base/limits.h:97, media/filters/vpx_video_decoder.cc:37-54), so one worker owns
every other macroblock row — the two bands visible in the attached images.
2. The worker’s error never becomes a frame failure. threading.c:608-616:
if (setjmp(xd->error_info.jmp)) {
xd->error_info.setjmp = 0;
// Signal the end of decoding for current thread.
vp8_sem_post(&pbi->h_event_end_decoding);
continue;
}
vp8mt_decode_mb_rows returns -1 only from the main thread’s own error context
(:900-908); the worker case falls through to return 0; (:918). vp8_decode_frame then
only folds the worker’s flag into output metadata (decodeframe.c:1218, :1231) and reaches
return 0; (:1260).
3. Chrome does not read that metadata. VP8D_GET_FRAME_CORRUPTED appears nowhere under
media/; vpx_codec_dec_init is called with flags = 0
(media/filters/vpx_video_decoder.cc:65-70), and CopyVpxImageToVideoFrame (:345) copies
every visible row of all three planes.
4. The buffer is not cleared. In vp8_yv12_realloc_frame_buffer
(vpx_scale/generic/yv12config.c) the allocation is vpx_memalign(32, frame_size) and the
only initialization is a memset guarded by __has_feature(memory_sanitizer), additionally
inside the first-allocation branch.
The existing fix for this worker flag,
8592391c,
is present in that revision and in current main. It resets mbd->corrupted in
setup_decoding_thread_data() (threading.c:55-57), i.e. at the start of a frame, which a
flag set and consumed within the same frame never reaches.
Impact analysis
Bisection
| commit | date | change |
|---|---|---|
67fb3a515 |
2010-12-16 | “Implement error tracking in the decoder” — introduces xd->corrupted |= vp8dx_bool_error(...) |
f3a027a46 |
2018-08-07 | “vp8: fix to address overflow in decoder.” — introduces the worker setjmp handler, the end-of-row loop with vpx_internal_error, and the main thread’s -1 return |
git log -S "setjmp(xd->error_info.jmp)" -- vp8/decoder/threading.c and
git log -S "xd->error_info.setjmp = 1" -- vp8/decoder/threading.c each return f3a027a46
alone. Its message states the intent:
> When corrupted frame detected, the decoder thread returns to its own context and signal > completion of decoding for current frame. The main decoding thread will detect error too > and return error code to decoding API call.
The main thread detects only errors raised in its own error context, so a worker-only abort
produces no error code. pbi->common.error is raised in exactly two places in that file,
:649 and :677, and both are memory-allocation failures.
8592391c
(2026-06-30) and its cherry-pick
188ebeca
(2026-07-28) are both ancestors of 3ffe23d and of current main
(git merge-base --is-ancestor). Their regression test uses kWidth = 16, kHeight = 32 — a
frame one macroblock wide, which has no second macroblock column for a flag to be set in and
consumed in within the same frame.
Reproduction
Windows 11 x64, no flags, no user gesture, no extension, no special response headers; the
victim origin sends no CORS headers. The PoC is two files, index.html and serve.py; the
three media assets are embedded in serve.py, so nothing else is needed. In the directory
holding them:
python3 serve.py 8000 8001
Port 8000 is the attacker origin, port 8001 serves the victim video. Open
http://127.0.0.1:8000/ and let it run; the page renders its result in the page and the title
becomes done.
Verified on Chrome 155.0.8040.2 (Chrome for Testing, win64, chrome.exe SHA-256
E1FF21D9CDBA36C0D704ADCF1D3DFDDB05ECB1E0425807660DB848C1A3CC2711), three consecutive runs
identical:
victim.canvasSecurityError / videoFrameSecurityError true / true
target.yuv.mbRowMatchEqual [7, 818, 11, 1536] (1536 bytes per row)
cycles[0].recovered 3527 of 9216
qualification.strictS1Gate true
Rows 1 and 3 are the worker’s; row 3 comes back whole (SECRET) and row 1 from the macroblock
where the worker stopped (LEAK, placed in that half of the victim frame for this reason).
Rows 0 and 2 are the main thread’s and are correctly decoded page content.
The page, in one pass: loads the cross-origin video and waits for its third frame; asserts that
getImageData() and new VideoFrame(video) on it both throw SecurityError; detaches that
source and attaches a same-origin MediaSource whose final VP8 keyframe keeps its mode
partition and all of token partition 0 but only part of token partition 1; then reads the
presented frame with VideoFrame.copyTo() and compares it against the decoded cross-origin
frame.
Attachments
serve.pyis the two-origin server and carries the three VP8/WebM assets inline
(cross-origin victim video, malformed same-origin stream, and the victim’s decoded frames used
as ground truth for the byte comparison); index.html carries their expected SHA-256s and
re-hashes what it loads.
secret.png is the cross-origin video’s decoded frame; recovered.png and
recovered_verified_only.png were rendered from the frame a run of this PoC recovered.
The cause
What version of Chrome have you found the security issue in?
155.0.8040.2
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Information Leak