Medium chrome Memory Corruption 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMemory corruption in Video
DescriptionMemory corruption in Video
ComponentVideo
Sub-componentVp8
Bug ClassMemory Corruption
Tracker559707641
Fix commit5e680f30801d (webm/libvpx) +119/-0
CISA KEVNot listed
Creditedweihengqiuu
Disclosed2026-10-20

Changed Functions

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

Files Changed

  • vp8/decoder/threading.c
  • test/decode_api_test.cc
From 5e680f30801d03c21078f8c4b772464752516211 Mon Sep 17 00:00:00 2001
From: Marco Paniconi <[email protected]>
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
 
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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

No, it is not related to a crash.

Choose the type of vulnerability

Information Leak

View on issue tracker