High watch CVSS 7.5 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in MediaRecording
DescriptionUse after free in MediaRecording
ComponentMediaRecording
Sub-componentMkvmuxer
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker504660052
Fix commit6184f4484a82 (webm/libwebm) +83/-0
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-20 (per vendor advisory)
Fix landed2026-04-21 (public git)
Disclosed2026-05-05
Patch gap14 days — fix public in source before the advisory

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 owning Frame* pointers; a slot whose Frame is deleted 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.

Key insight
The single mistake is freeing 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

  1. Record to WebM Content causes MediaRecorder (or any mkvmuxer consumer) to mux frames, buffering multiple frames so WriteFramesLessThan processes a batch.
  2. Induce a write/cue failure An underlying write failure (modeled by FailingWriter returning -1 once output length exceeds a threshold) makes cluster->AddFrame or AddCuePoint fail, taking the delete frame_prev; continue; path.
  3. Leave a dangling slot Because shift_left is not incremented, the freed frame_prev pointer stays in the frames_ array as a dangling reference while compaction under-shifts.
  4. Use-after-free or double-free A later frame write or Segment destruction walks the stale slot, reading the freed Frame (UAF read) or deleting it again (double-free), as the regression tests confirm.

Impact Assessment

Triggering requires a muxing session that hits a frame-write or cue-point failure, so an attacker needs to drive media recording/muxing and induce (or rely on) a writer error; the corruption occurs in the process hosting the muxer. The advisory rates this high severity and describes it as a use after free in MediaRecording; it does not report in-the-wild exploitation. Whether the UAF/double-free is escalatable to a controlled read/write or code execution is (inferred) and not demonstrated by the patch.

Changed Functions

FunctionChangeNotes
FailingWriter
testing/mkvmuxer_test.cc
modified
if
testing/mkvmuxer_test.cc
modified
TEST
testing/mkvmuxer_test.cc
modified
MuxerTest
testing/mkvmuxer_test.cc
modified

Files Changed

  • mkvmuxer/mkvmuxer.cc
  • testing/mkvmuxer_test.cc

Audit Directions

  • Delete-without-accounting
    Audit loops that delete an element on an error path and continue; verify every freed slot updates the same compaction/length bookkeeping the success path uses.
  • Owning raw-pointer arrays
    Review containers of owning raw pointers (vs. smart pointers) where compaction relies on a separate counter; prefer moving/clearing the slot to nullptr at deletion.
  • Error-path parity
    Check that each early-exit/continue branch in a resource-managing loop preserves the same invariants as the normal path, especially in muxers/parsers that process attacker-influenced frame counts.
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:
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by [email protected]

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.cc
  • chromeos/ash/services/recording/webm_encoder_muxer.cc
  • media/muxers/file_webm_muxer_delegate.cc
  • media/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.

  1. 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.
  2. User Interaction: The attacker convinces the user to start a ChromeOS Capture Mode screen recording (capturing screen and audio).
  3. 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::MakeNewCluster calls WriteFramesLessThan to flush the queued audio frames.
  4. Dangling Pointers: Because the disk is full, FileWebmMuxerDelegate::DoWrite returns -1, causing cluster->AddFrame to fail. The Frame objects are deleted, but left in the frames_ array.
  5. UAF Read: WriteFramesLessThan returns control to MakeNewCluster, which immediately executes a Use-After-Free read by accessing the timestamp of the dangling pointer (frames_[0]->timestamp()).
  6. Double-Free: MakeNewCluster returns to DoNewClusterProcessing, which then calls WriteFramesAll(). This function iterates over the corrupted frames_ array and again calls cluster->AddFrame on the dangling pointers. The write fails again (disk still full), and the error handler in WriteFramesAll executes delete frame; on the already-freed pointer.
  7. Arbitrary Free / RCE: The Frame destructor executes delete[] frame_; and delete[] additional_;. If the attacker can groom the heap to reallocate the Frame object between the two frees, they can control these raw pointers to execute an arbitrary free primitive, leading to potential RCE in the unsandboxed RecordingService.

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.

View on issue tracker