CVE-2026-7929
Overview
Background
- `mkvmuxer` (libwebm)
- the WebM/Matroska muxer bundled in Chrome and used by MediaRecorder to write recorded media into a container.
- `Segment::WriteFramesLessThan`
- the muxer routine (named in the commit subject) that flushes buffered frames up to a timestamp into clusters, writing each frame and then compacting the internal
frames_pointer array. - `shift_left`
- a counter tracking how many leading slots of the
frames_array have been consumed/deleted and must be shifted left during compaction, so each handled frame’s slot is accounted for exactly once. - Owning raw-pointer array
frames_holds owningFrame*pointers; a slot whoseFrameisdeleted must be removed from the compaction accounting or it becomes a dangling entry.
Root Cause Analysis
On the success path each buffered frame is written, deleted, and counted so shift_left reflects it, keeping the later array compaction consistent with which pointers were freed. But on two error-handling paths — when cluster->AddFrame(frame_prev) fails and when AddCuePoint(...) fails — the code did delete frame_prev; continue; without incrementing shift_left, so the freed frame_prev pointer was left in the frames_ array while the compaction counter under-counted the deletions. The invariant that “every deleted slot is reflected in shift_left” was violated, leaving a dangling pointer in the array; when subsequent frames are written or the Segment is destroyed, the compaction/cleanup walks the stale slot and either re-reads the freed Frame (heap use-after-free) or deletes it a second time (double-free), exactly as the two shipped regression tests (WriteFramesLessThanDanglingSlot_UseAfterFreeRead and _DoubleFree) demonstrate under a FailingWriter.
The fix adds ++shift_left; on both failing continue paths so the deleted slots are compacted away correctly and no dangling pointer remains.
frame_prev on error paths without incrementing the shift_left compaction counter, so a dangling pointer survives in frames_; adding ++shift_left; on each such path keeps deletions and compaction accounting in sync.Attack Path
- Record to WebM
Content causes MediaRecorder (or any mkvmuxer consumer) to mux frames, buffering multiple frames so
WriteFramesLessThanprocesses a batch. - Induce a write/cue failure
An underlying write failure (modeled by
FailingWriterreturning-1once output length exceeds a threshold) makescluster->AddFrameorAddCuePointfail, taking thedelete frame_prev; continue;path. - Leave a dangling slot
Because
shift_leftis not incremented, the freedframe_prevpointer stays in theframes_array as a dangling reference while compaction under-shifts. - Use-after-free or double-free
A later frame write or
Segmentdestruction walks the stale slot, reading the freedFrame(UAF read) or deleting it again (double-free), as the regression tests confirm.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
FailingWritertesting/mkvmuxer_test.cc |
modified | |
iftesting/mkvmuxer_test.cc |
modified | |
TESTtesting/mkvmuxer_test.cc |
modified | |
MuxerTesttesting/mkvmuxer_test.cc |
modified |
Files Changed
mkvmuxer/mkvmuxer.cctesting/mkvmuxer_test.cc
Audit Directions
- Delete-without-accountingAudit loops that
deletean element on an error path andcontinue; verify every freed slot updates the same compaction/length bookkeeping the success path uses. - Owning raw-pointer arraysReview containers of owning raw pointers (vs. smart pointers) where compaction relies on a separate counter; prefer moving/clearing the slot to
nullptrat deletion. - Error-path parityCheck that each early-exit/
continuebranch in a resource-managing loop preserves the same invariants as the normal path, especially in muxers/parsers that process attacker-influenced frame counts.
Patch
From 6184f4484a826724b5293837134ab9492261b941 Mon Sep 17 00:00:00 2001 From: [email protected] <[email protected]> Date: Tue, 21 Apr 2026 09:35:31 -0700 Subject: [PATCH] mkvmuxer: fix UAF and double-free in WriteFramesLessThan Failures in writing frames within `Segment::WriteFramesLessThan` failed to increment the `shift_left` compaction counter. This left deleted frame pointers in the array as dangling references, triggering heap-use-after-free or double-free on cleanup when subsequent frames are written or the segment is destroyed. This patch ensures the `shift_left` array shift bounds counter is incremented for each error handling failure path where deletion is triggered without immediate break from loop. Also add unit tests that will fail without this fix. Bug: 504660052 Change-Id: I345c90b1ff10bfe82c7ede1f5ec5a4d5f8ee318a --- diff --git a/mkvmuxer/mkvmuxer.cc b/mkvmuxer/mkvmuxer.cc index 56e21fb..9006ef0 100644 --- a/mkvmuxer/mkvmuxer.cc +++ b/mkvmuxer/mkvmuxer.cc @@ -4259,12 +4259,14 @@ doc_type_version_ = 4; if (!cluster->AddFrame(frame_prev)) { delete frame_prev; + ++shift_left; continue; } if (new_cuepoint_ && cues_track_ == frame_prev->track_number()) { if (!AddCuePoint(frame_prev->timestamp(), cues_track_)) { delete frame_prev; + ++shift_left; continue; } } diff --git a/testing/mkvmuxer_test.cc b/testing/mkvmuxer_test.cc index 824bd1f..71719cc 100644 --- a/testing/mkvmuxer_test.cc +++ b/testing/mkvmuxer_test.cc @@ -39,6 +39,87 @@ namespace test { +class FailingWriter : public mkvmuxer::IMkvWriter { + public: + mkvmuxer::int32 Write(const void* /*buf*/, mkvmuxer::uint32 len) override { + if (fail_ && len >= fail_min_len_) { + return -1; + } + pos_ += len; + return 0; + } + mkvmuxer::int64 Position() const override { return pos_; } + mkvmuxer::int32 Position(mkvmuxer::int64 position) override { + pos_ = position; + return 0; + } + bool Seekable() const override { return true; } + void ElementStartNotify(mkvmuxer::uint64, mkvmuxer::int64) override {} + + void StartFailing(mkvmuxer::uint32 min_len) { + fail_ = true; + fail_min_len_ = min_len; + } + + private: + mkvmuxer::int64 pos_ = 0; + bool fail_ = false; + mkvmuxer::uint32 fail_min_len_ = 0; +}; + +constexpr uint64_t kMs = 1000000ULL; // 1 ms in ns (default timecode_scale). +constexpr uint64_t kAudioFrameLen = 100; // > all EBML metadata writes. +constexpr uint64_t kVideoFrameLen = 8; + +// Test case from crbug.com/504660052. +TEST(MkvmuxerUafTest, WriteFramesLessThanDanglingSlot_UseAfterFreeRead) { + FailingWriter writer; + mkvmuxer::Segment segment; + ASSERT_TRUE(segment.Init(&writer)); + + const uint64_t vtrack = segment.AddVideoTrack(320, 240, 1); + const uint64_t atrack = segment.AddAudioTrack(48000, 2, 2); + ASSERT_NE(vtrack, 0u); + ASSERT_NE(atrack, 0u); + + uint8_t vdata[kVideoFrameLen] = {}; + uint8_t adata[kAudioFrameLen] = {}; + + ASSERT_TRUE(segment.AddFrame(vdata, kVideoFrameLen, vtrack, 0 * kMs, true)); + ASSERT_TRUE(segment.AddFrame(adata, kAudioFrameLen, atrack, 10 * kMs, true)); + ASSERT_TRUE(segment.AddFrame(adata, kAudioFrameLen, atrack, 20 * kMs, true)); + ASSERT_TRUE(segment.AddFrame(adata, kAudioFrameLen, atrack, 30 * kMs, true)); + + writer.StartFailing(/*min_len=*/50); + + // It does not matter whether this succeeds or fails, it should not crash. + segment.AddFrame(vdata, kVideoFrameLen, vtrack, 40 * kMs, true); +} + +// Test case from crbug.com/504660052. +TEST(MkvmuxerUafTest, WriteFramesLessThanDanglingSlot_DoubleFree) { + FailingWriter writer; + mkvmuxer::Segment segment; + ASSERT_TRUE(segment.Init(&writer)); + + const uint64_t vtrack = segment.AddVideoTrack(320, 240, 1); + const uint64_t atrack = segment.AddAudioTrack(48000, 2, 2); + ASSERT_NE(vtrack, 0u); + ASSERT_NE(atrack, 0u); + + uint8_t vdata[kVideoFrameLen] = {}; + uint8_t adata[kAudioFrameLen] = {}; + + ASSERT_TRUE(segment.AddFrame(vdata, kVideoFrameLen, vtrack, 0 * kMs, true)); + ASSERT_TRUE(segment.AddFrame(adata, kAudioFrameLen, atrack, 10 * kMs, true)); + ASSERT_TRUE(segment.AddFrame(adata, kAudioFrameLen, atrack, 20 * kMs, true)); + ASSERT_TRUE(segment.AddFrame(adata, kAudioFrameLen, atrack, 30 * kMs, true)); + + writer.StartFailing(/*min_len=*/0); + + EXPECT_FALSE(segment.AddFrame(vdata, kVideoFrameLen, vtrack, 40 * kMs, true)); +} + // Base class containing boiler plate stuff. class MuxerTest : public testing::Test { public:
Regression Test / PoC
diff --git a/testing/mkvmuxer_test.cc b/testing/mkvmuxer_test.cc
index 824bd1f..71719cc 100644
--- a/testing/mkvmuxer_test.cc
+++ b/testing/mkvmuxer_test.cc
@@ -39,6 +39,87 @@
namespace test {
+class FailingWriter : public mkvmuxer::IMkvWriter {
+ public:
+ mkvmuxer::int32 Write(const void* /*buf*/, mkvmuxer::uint32 len) override {
+ if (fail_ && len >= fail_min_len_) {
+ return -1;
+ }
+ pos_ += len;
+ return 0;
+ }
+ mkvmuxer::int64 Position() const override { return pos_; }
+ mkvmuxer::int32 Position(mkvmuxer::int64 position) override {
+ pos_ = position;
+ return 0;
+ }
+ bool Seekable() const override { return true; }
+ void ElementStartNotify(mkvmuxer::uint64, mkvmuxer::int64) override {}
+
+ void StartFailing(mkvmuxer::uint32 min_len) {
+ fail_ = true;
+ fail_min_len_ = min_len;
+ }
+
+ private:
+ mkvmuxer::int64 pos_ = 0;
+ bool fail_ = false;
+ mkvmuxer::uint32 fail_min_len_ = 0;
+};
+
+constexpr uint64_t kMs = 1000000ULL; // 1 ms in ns (default timecode_scale).
+constexpr uint64_t kAudioFrameLen = 100; // > all EBML metadata writes.
+constexpr uint64_t kVideoFrameLen = 8;
+
+// Test case from crbug.com/504660052.
+TEST(MkvmuxerUafTest, WriteFramesLessThanDanglingSlot_UseAfterFreeRead) {
+ FailingWriter writer;
+ mkvmuxer::Segment segment;
+ ASSERT_TRUE(segment.Init(&writer));
+
+ const uint64_t vtrack = segment.AddVideoTrack(320, 240, 1);
+ const uint64_t atrack = segment.AddAudioTrack(48000, 2, 2);
+ ASSERT_NE(vtrack, 0u);
+ ASSERT_NE(atrack, 0u);
+
+ uint8_t vdata[kVideoFrameLen] = {};
+ uint8_t adata[kAudioFrameLen] = {};
+
+ ASSERT_TRUE(segment.AddFrame(vdata, kVideoFrameLen, vtrack, 0 * kMs, true));
+ ASSERT_TRUE(segment.AddFrame(adata, kAudioFrameLen, atrack, 10 * kMs, true));
+ ASSERT_TRUE(segment.AddFrame(adata, kAudioFrameLen, atrack, 20 * kMs, true));
+ ASSERT_TRUE(segment.AddFrame(adata, kAudioFrameLen, atrack, 30 * kMs, true));
+
+ writer.StartFailing(/*min_len=*/50);
+
+ // It does not matter whether this succeeds or fails, it should not crash.
+ segment.AddFrame(vdata, kVideoFrameLen, vtrack, 40 * kMs, true);
+}
+
+// Test case from crbug.com/504660052.
+TEST(MkvmuxerUafTest, WriteFramesLessThanDanglingSlot_DoubleFree) {
+ FailingWriter writer;
+ mkvmuxer::Segment segment;
+ ASSERT_TRUE(segment.Init(&writer));
+
+ const uint64_t vtrack = segment.AddVideoTrack(320, 240, 1);
+ const uint64_t atrack = segment.AddAudioTrack(48000, 2, 2);
+ ASSERT_NE(vtrack, 0u);
+ ASSERT_NE(atrack, 0u);
+
+ uint8_t vdata[kVideoFrameLen] = {};
+ uint8_t adata[kAudioFrameLen] = {};
+
+ ASSERT_TRUE(segment.AddFrame(vdata, kVideoFrameLen, vtrack, 0 * kMs, true));
+ ASSERT_TRUE(segment.AddFrame(adata, kAudioFrameLen, atrack, 10 * kMs, true));
+ ASSERT_TRUE(segment.AddFrame(adata, kAudioFrameLen, atrack, 20 * kMs, true));
+ ASSERT_TRUE(segment.AddFrame(adata, kAudioFrameLen, atrack, 30 * kMs, true));
+
+ writer.StartFailing(/*min_len=*/0);
+
+ EXPECT_FALSE(segment.AddFrame(vdata, kVideoFrameLen, vtrack, 40 * kMs, true));
+}
+
// Base class containing boiler plate stuff.
class MuxerTest : public testing::Test {
public:
Original Bug Report
Potential UAF and Double-Free in libwebm Segment::WriteFramesLessThan
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 logic error in libwebm’s Segment::WriteFramesLessThan fails to correctly compact a frame queue when disk writes fail, leaving dangling pointers. In ChromeOS, an attacker can trigger this by filling the disk and waiting for the user to initiate a screen recording. This results in a Use-After-Free and Double-Free in the unsandboxed RecordingService utility process.
Affected files:
third_party/libwebm/source/mkvmuxer/mkvmuxer.ccchromeos/ash/services/recording/webm_encoder_muxer.ccmedia/muxers/file_webm_muxer_delegate.ccmedia/muxers/muxer_timestamp_adapter.cc
Estimated timestamp from git blame: 2020-11-24
Summary
A potential Use-After-Free (UAF) and Double-Free vulnerability exists in libwebm, specifically within the mkvmuxer::Segment::WriteFramesLessThan function. When the muxer attempts to write queued frames to a cluster and the write fails (e.g., due to an I/O error like ENOSPC), the code deletes the frame object but fails to properly update the queue state. This leaves dangling pointers in the array, which are subsequently accessed and freed again.
Root Cause Analysis
In third_party/libwebm/source/mkvmuxer/mkvmuxer.cc, the function Segment::WriteFramesLessThan iterates through a queue of audio frames (frames_). If writing the frame to the cluster fails, the frame is deleted. However, the shift_left variable, which tracks how many frames have been removed from the queue for subsequent array compaction, is not incremented:
// third_party/libwebm/source/mkvmuxer/mkvmuxer.cc:4153-4156
if (!cluster->AddFrame(frame_prev)) {
delete frame_prev; // The object is deleted.
continue; // BUG: shift_left is NOT incremented.
}
Because shift_left remains 0 (if all queued frames fail to write), the array compaction logic at the end of the function is bypassed:
// mkvmuxer.cc:4175
if (shift_left > 0) { // Evaluates to false
// ... array compaction logic ...
frames_size_ = new_frames_size;
}
Consequently, frames_size_ remains unchanged, and the frames_ array contains dangling pointers to the already-deleted Frame objects. The function then incorrectly returns true (success).
Suggested Exploitation Path
Note: These are potential steps based on static code analysis; a working proof-of-concept has not been executed.
- Disk Exhaustion: An attacker on a malicious webpage aggressively consumes the ChromeOS device’s local disk space (e.g., via the Cache API) until the device is entirely out of space.
- User Interaction: The attacker convinces the user to start a ChromeOS Capture Mode screen recording (capturing screen and audio).
- Muxer Failure: The browser launches the
RecordingService, which runs as an unsandboxed utility process (kNoSandbox). As recording progresses, audio frames are queued. When a video key-frame triggers the creation of a new cluster,Segment::MakeNewClustercallsWriteFramesLessThanto flush the queued audio frames. - Dangling Pointers: Because the disk is full,
FileWebmMuxerDelegate::DoWritereturns-1, causingcluster->AddFrameto fail. TheFrameobjects are deleted, but left in theframes_array. - UAF Read:
WriteFramesLessThanreturns control toMakeNewCluster, which immediately executes a Use-After-Free read by accessing the timestamp of the dangling pointer (frames_[0]->timestamp()). - Double-Free:
MakeNewClusterreturns toDoNewClusterProcessing, which then callsWriteFramesAll(). This function iterates over the corruptedframes_array and again callscluster->AddFrameon the dangling pointers. The write fails again (disk still full), and the error handler inWriteFramesAllexecutesdelete frame;on the already-freed pointer. - Arbitrary Free / RCE: The
Framedestructor executesdelete[] frame_;anddelete[] additional_;. If the attacker can groom the heap to reallocate theFrameobject between the two frees, they can control these raw pointers to execute an arbitrary free primitive, leading to potential RCE in the unsandboxedRecordingService.
Suggested Fix
Ensure shift_left is incremented when a frame write fails so the array is correctly compacted:
if (!cluster->AddFrame(frame_prev)) {
delete frame_prev;
++shift_left; // FIX: Increment counter to ensure compaction
continue;
}
The same fix should be applied to the !AddCuePoint(...) failure case immediately below it.
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.