CVE-2026-7335
Overview
Background
- Media Source Extensions (MSE)
- the web API letting JavaScript feed media bytes to a
SourceBuffer, buffered server-side bySourceBufferStreamas a list of timeranges_. - `SourceBufferStream` / `SourceBufferRange`
ranges_is a container ofSourceBufferRangeobjects;range_for_next_append_is an iterator intoranges_marking where the next appended data should go.- Garbage collection (GC)
- when buffered data exceeds the memory limit, the stream frees
SourceBufferRanges (popping from the front or back) to reclaim memory. - `DeleteAndRemoveRange()`
- a helper that removes a range from
ranges_and correctly fixes up dependent iterators such asrange_for_next_append_andselected_range_.
Root Cause Analysis
Inside the GC free loop (the enclosing function is not named in the shown hunk; it is the range-freeing routine in source_buffer_stream.cc), when a current_range reached zero memory usage it was removed by a bare reverse_direction ? ranges_.pop_back() : ranges_.pop_front();, guarded only by a CHECK asserting that range_for_next_append_ did not point at current_range. That assertion encoded a false invariant: after OnStartOfCodedFrameGroup sets range_for_next_append_ to a range (e.g. Range A) with last_appended_buffer_timestamp_ cleared, a subsequent GC can legitimately choose to evict exactly that range, so popping it leaves range_for_next_append_ dangling (or trips the CHECK). The regression test SourceBufferStreamTest.GarbageCollectionUpdatesRangeForNextAppend reproduces this: it points range_for_next_append_ at Range A, lowers the memory limit, GCs so Range A is evicted from the front, then appends — dereferencing the stale iterator.
The fix replaces the raw pop with DeleteAndRemoveRange(&range_to_delete) after nulling the local current_range, so removal goes through the helper that resets range_for_next_append_ to ranges_.end() when the deleted range is the one it referenced.
pop_front()/pop_back() while assuming range_for_next_append_ could never point at the victim range; the fix routes deletion through DeleteAndRemoveRange(), which correctly updates (resets to ranges_.end()) the dependent iterator.Attack Path
- Create two ranges JavaScript appends via MSE to build Range A at low timestamps and Range B far ahead, approaching/exceeding the buffer memory limit.
- Point the append iterator at the victim
Call the operation that starts a coded frame group overlapping Range A (
OnStartOfCodedFrameGroup), which setsrange_for_next_append_to Range A and clearslast_appended_buffer_timestamp_. - Force eviction of that range
Drive GC (e.g. with current media time near Range B) so the front range (Range A) is freed via the unguarded
pop_front(), leavingrange_for_next_append_dangling. - Use-after-free on next append
Append more data; the code dereferences the stale
range_for_next_append_iterator into freedranges_storage (or hits the pre-fixCHECK), yielding a UAF.
Impact Assessment
SourceBufferRange in the media pipeline; the preconditions are purely web-reachable buffer manipulation (append two distant ranges, start an overlapping coded frame group, then force GC to evict the targeted range). The vendor advisory rates this high severity with impact “Use after free in media” and reports no in-the-wild exploitation; the media stack runs in the renderer, and escalation beyond the UAF is (inferred) rather than demonstrated by the diff.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifmedia/filters/source_buffer_stream.cc |
modified | |
TEST_Fmedia/filters/source_buffer_stream_unittest.cc |
modified |
Files Changed
media/filters/source_buffer_stream.ccmedia/filters/source_buffer_stream_unittest.cc
Audit Directions
- Iterators across mutationSearch MSE/
SourceBufferStreamfor members holding iterators or pointers intoranges_(range_for_next_append_,selected_range_) that persist across any GC/eviction or range-merge operation, and verify they are updated or reset when the referenced range is removed. - Raw container removal vs. helperFlag direct
pop_front()/pop_back()/erase()onranges_that bypassDeleteAndRemoveRange(), since the helper is what maintains dependent-iterator invariants. - Assertion-as-invariantTreat a removed
CHECK/DCHECKthat asserted “state X never happens” as a hint to the real bug class: look for other code assuming an iterator can never point at a to-be-freed object.
Patch
From 055b8ce56f2e737e68730a9d3178ff4cb93fa7fb Mon Sep 17 00:00:00 2001 From: Dale Curtis <[email protected]> Date: Wed, 08 Apr 2026 10:58:16 -0700 Subject: [PATCH] [MSE] Correct range_for_next_append_ during SourceBuffer GC The range may become invalid during garbage collection. R=tmathmeyer Fixed: 498285711, 500387779 Change-Id: I2bc9150f69cf8daa92168d04022c6efd69f7ae6c Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7737855 Auto-Submit: Dale Curtis <[email protected]> Reviewed-by: Ted (Chromium) Meyer <[email protected]> Commit-Queue: Ted (Chromium) Meyer <[email protected]> Cr-Commit-Position: refs/heads/main@{#1611660} --- diff --git a/media/filters/source_buffer_stream.cc b/media/filters/source_buffer_stream.cc index 21486d00..e970821 100644 --- a/media/filters/source_buffer_stream.cc +++ b/media/filters/source_buffer_stream.cc @@ -1030,11 +1030,10 @@ if (current_range->GetMemoryUsage() == 0) { CHECK_NE(current_range, selected_range_); - CHECK(range_for_next_append_ == ranges_.end() || - range_for_next_append_->get() != current_range); - - // Delete |current_range| by popping it out of |ranges_|. - reverse_direction ? ranges_.pop_back() : ranges_.pop_front(); + auto range_to_delete = + reverse_direction ? std::prev(ranges_.end()) : ranges_.begin(); + current_range = nullptr; + DeleteAndRemoveRange(&range_to_delete); } if (reverse_direction && new_range_for_append) { diff --git a/media/filters/source_buffer_stream_unittest.cc b/media/filters/source_buffer_stream_unittest.cc index b154eeb..7cccec0 100644 --- a/media/filters/source_buffer_stream_unittest.cc +++ b/media/filters/source_buffer_stream_unittest.cc @@ -5747,4 +5747,38 @@ EXPECT_TRUE(IsRangeListSorted()); } +TEST_F(SourceBufferStreamTest, GarbageCollectionUpdatesRangeForNextAppend) { + // Set memory limit to 10 buffers. + SetMemoryLimit(10); + + // 1. Append 10 buffers to create Range A [0, 90ms]. + NewCodedFrameGroupAppend("0K 10K 20K 30K 40K 50K 60K 70K 80K 90K"); + + // 2. Append 10 buffers to create Range B [1000ms, 1090ms]. + // This exceeds the memory limit and triggers GC, but Range A is kept because + // it was recently appended. + NewCodedFrameGroupAppend( + "1000K 1010K 1020K 1030K 1040K 1050K 1060K 1070K 1080K 1090K"); + + // 3. Start a new coded frame group that overlaps Range A. + // This sets range_for_next_append_ to Range A and + // last_appended_buffer_timestamp_ to kNoTimestamp. + stream_->OnStartOfCodedFrameGroup(base::Milliseconds(0)); + + // 4. Trigger Garbage Collection. + // We want to free enough data that Range A is deleted. + // Set memory limit very low so GC must evict something. + SetMemoryLimit(5); + + // Garbage collect with media time at Range B (1000ms). + // This should evict Range A from the front since it is far behind media time. + EXPECT_TRUE(GarbageCollect(base::Milliseconds(1000), 0)); + + // 5. Append data. If the bug exists, range_for_next_append_ is dangling and + // dereferencing it will cause a UAF or hit a CHECK. + // With the fix, range_for_next_append_ is reset to ranges_.end() when + // Range A is deleted. + AppendBuffers("0K 10K"); +} + } // namespace media
Regression Test / PoC
diff --git a/media/filters/source_buffer_stream_unittest.cc b/media/filters/source_buffer_stream_unittest.cc
index b154eeb..7cccec0 100644
--- a/media/filters/source_buffer_stream_unittest.cc
+++ b/media/filters/source_buffer_stream_unittest.cc
@@ -5747,4 +5747,38 @@
EXPECT_TRUE(IsRangeListSorted());
}
+TEST_F(SourceBufferStreamTest, GarbageCollectionUpdatesRangeForNextAppend) {
+ // Set memory limit to 10 buffers.
+ SetMemoryLimit(10);
+
+ // 1. Append 10 buffers to create Range A [0, 90ms].
+ NewCodedFrameGroupAppend("0K 10K 20K 30K 40K 50K 60K 70K 80K 90K");
+
+ // 2. Append 10 buffers to create Range B [1000ms, 1090ms].
+ // This exceeds the memory limit and triggers GC, but Range A is kept because
+ // it was recently appended.
+ NewCodedFrameGroupAppend(
+ "1000K 1010K 1020K 1030K 1040K 1050K 1060K 1070K 1080K 1090K");
+
+ // 3. Start a new coded frame group that overlaps Range A.
+ // This sets range_for_next_append_ to Range A and
+ // last_appended_buffer_timestamp_ to kNoTimestamp.
+ stream_->OnStartOfCodedFrameGroup(base::Milliseconds(0));
+
+ // 4. Trigger Garbage Collection.
+ // We want to free enough data that Range A is deleted.
+ // Set memory limit very low so GC must evict something.
+ SetMemoryLimit(5);
+
+ // Garbage collect with media time at Range B (1000ms).
+ // This should evict Range A from the front since it is far behind media time.
+ EXPECT_TRUE(GarbageCollect(base::Milliseconds(1000), 0));
+
+ // 5. Append data. If the bug exists, range_for_next_append_ is dangling and
+ // dereferencing it will cause a UAF or hit a CHECK.
+ // With the fix, range_for_next_append_ is reset to ranges_.end() when
+ // Range A is deleted.
+ AppendBuffers("0K 10K");
+}
+
} // namespace media
Original Bug Report
SourceBufferStream::FreeBuffers Dangling Iterator UAF Read/Write
Report description
SourceBufferStream::FreeBuffers Dangling Iterator UAF Read/Write
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
https://chromium.googlesource.com/chromium/src
The problem
Please describe the technical details of the vulnerability
The problem
Summary
SourceBufferStream::FreeBuffers() in media/filters/source_buffer_stream.cc pops a depleted SourceBufferRange from the ranges_ list without resetting the range_for_next_append_ iterator. The iterator becomes dangling and is subsequently dereferenced, resulting in a heap-use-after-free.
The freed std::list node (operator new(24) → allocator_shim::g_roots → PartitionAlloc 32-byte bucket) is not a MiraclePtr-protected allocation. In non-ASAN release builds, the dangling iterator reaches SourceBufferRange::AppendBuffersToEnd(). The heap spray PoC (poc_spray.html) shows that the freed slot can be reclaimed via WebSocket FastMalloc(24), with the this pointer in AppendBuffersToEnd() set to 0x0EADBEEFCAFE from the spray payload.
Chrome Version
Tested on Chromium 146.0.7680.177 (Linux x86_64) with ASAN, Debug-ASAN, and non-ASAN release builds.
Root Cause
FreeBuffers() at source_buffer_stream.cc:1024-1030 pops a depleted range with only a DCHECK (no-op in release):
// source_buffer_stream.cc:1024-1030
if (current_range->GetMemoryUsage() == 0) {
DCHECK_NE(current_range, selected_range_);
DCHECK(range_for_next_append_ == ranges_.end() ||
range_for_next_append_->get() != current_range);
reverse_direction ? ranges_.pop_back() : ranges_.pop_front();
}
DeleteAndRemoveRange() at lines 1987-1993 handles the same situation by resetting the iterator:
// source_buffer_stream.cc:1987-1993
if (*itr == range_for_next_append_) {
range_for_next_append_ = ranges_.end();
ResetLastAppendedState();
}
*itr = ranges_.erase(*itr);
The two code paths that both destroy ranges from the same container handle iterator invalidation differently.
How range_for_next_append_ Becomes Stale
OnStartOfCodedFrameGroup() (line 189) sets range_for_next_append_ to an existing range. This function is called for all tracks via FrameProcessor::NotifyStartOfCodedFrameGroup() (frame_processor.cc:580-588). When a video-only cluster is appended, the audio track’s OnStartOfCodedFrameGroup is called but no audio Append() follows. This leaves range_for_next_append_ pointing to Range A and last_appended_buffer_timestamp_ set to kNoTimestamp.
The guard at line 1009 compares end_timestamp == last_appended_buffer_timestamp_, which does not match kNoTimestamp, so the branch that resets range_for_next_append_ = ranges_.end() is not taken.
Trigger Sequence
All steps use the standard MSE API from JavaScript. No user interaction beyond page navigation is required.
Step 1: Create Range A at [0, 2s] with audio+video data (~6KB audio).
Step 2: Fill Range B at [100+] with ~12MB of padded audio, near the kDemuxerStreamAudioMemoryLimitDefault limit.
Step 3: Call abort(), then append a video-only cluster at t=0. The audio OnStartOfCodedFrameGroup sets range_for_next_append_ to Range A and last_appended to kNoTimestamp.
Step 4: Seek to 50s (gap between A and B). Sets seek_pending_ = true, selected_range_ = nullptr.
Step 5: Append exceeds 12MB → EvictCodedFrames → FreeBuffers drains Range A then calls pop_front(). The iterator is now dangling.
Step 6: Subsequent operation dereferences the dangling range_for_next_append_. In AppendBuffersToEnd(), a WRITE is performed on freed memory.
Heap Spray: this Pointer Control
The heap spray PoC (poc_spray.html + ws_server.py) reclaims the freed 24-byte std::list node with WebSocket message data buffers (FastMalloc(24), same g_roots partition and 32-byte bucket).
appendBuffer() splits internally into sync and async phases:
- Sync (
PrepareAppend): GC →FreeBuffers→ list node freed updatestartevent → JavaScript execution → heap spray- Async (
AppendBufferAsyncPart):Append()→ dereferencesrange_for_next_append_
Register dump confirms this is set to the spray payload value:
r14: 00000eadbeefcafe
di: 00000eadbeefcafe ← this in AppendBuffersToEnd()
ip: <AppendBuffersToEnd+0x17> → mov 0x18(%rdi),%rax
AppendBuffersToEnd() reads this->buffers_.begin_ and this->buffers_.end_, then calls emplace_back. When this is controlled, this becomes a write to a controlled address.
Reproduction
Test Builds
Three Chromium builds were used.
-
Release-ASAN build (Chromium 146.0.7680.177, Linux x64)
python3 tools/get_asan_chrome/get_asan_chrome.py --version 146.0.7680.177 --os linuxBuild config:
is_asan=true, is_debug=false, is_component_build=falseBuildId:46d49f5b6958dc43 -
Debug-ASAN build (Chromium 146.0.7680.177, Linux x64)
python3 tools/get_asan_chrome/get_asan_chrome.py --version 146.0.7680.177 --os linux-debugBuild config:
is_asan=true, is_debug=true, is_component_build=true, symbol_level=1 -
Non-ASAN release build (Chromium 146.0.7680.0, Linux x64) Downloaded from Chrome for Testing:
wget https://storage.googleapis.com/chrome-for-testing-public/146.0.7680.0/linux64/chrome-linux64.zip unzip chrome-linux64.zip
How to Run
# Generate binary segments
python3 prepare_webm.py
# Release-ASAN → heap-use-after-free
./run_asan.sh /path/to/chromium-asan
# Debug-ASAN → DCHECK failure
./run_debug.sh /path/to/chromium-debug-asan
# Non-ASAN release → UAF WRITE crash (signal 11)
./run_noasan.sh /path/to/chrome-linux
# Heap spray → this = 0x0EADBEEFCAFE (requires: pip install websockets)
python3 run_spray.py
Crash Logs
1. Release-ASAN: heap-use-after-free READ
=================================================================
==24239==ERROR: AddressSanitizer: heap-use-after-free on address 0x7b9c906a5c00 at pc 0x5620a2163fc6 bp 0x7ffdb8c595f0 sp 0x7ffdb8c595e8
READ of size 8 at 0x7b9c906a5c00 thread T0 (chrome)
SCARINESS: 51 (8-byte-read-heap-use-after-free)
#0 0x5620a2163fc5 (chrome+0x14d8afc5)
#1 0x5620a207ea47 (chrome+0x14ca5a47)
#2 0x5620a212b0c5 (chrome+0x14d520c5)
#3 0x5620a212c762 (chrome+0x14d53762)
#4 0x5620a215c526 (chrome+0x14d83526)
#5 0x5620a215f1e8 (chrome+0x14d861e8)
#6 0x5620a1fe133f (chrome+0x14c0833f)
#7 0x5620a2022f70 (chrome+0x14c49f70)
#8 0x5620a20220e3 (chrome+0x14c490e3)
#9 0x5620a2154e91 (chrome+0x14d7be91)
#10 0x5620a208ec2d (chrome+0x14cb5c2d)
0x7b9c906a5c00 is located 16 bytes inside of 24-byte region [0x7b9c906a5bf0,0x7b9c906a5c08)
freed by thread T0 here:
#0 0x5620a216b8b9 (chrome+0x14d928b9)
#1 0x5620a216a829 (chrome+0x14d91829)
#2 0x5620a207eeff (chrome+0x14ca5eff)
#3 0x5620a2155f0f (chrome+0x14d7cf0f)
#4 0x5620a208e4ff (chrome+0x14cb54ff)
SUMMARY: AddressSanitizer: heap-use-after-free (chrome+0x14d8afc5)
MiraclePtr Status: NOT PROTECTED
No raw_ptr<T> access to this region was detected prior to this crash.
This crash is still exploitable with MiraclePtr.
2. Debug-ASAN: DCHECK failure
[20374:20374:0408/014944.743133:FATAL:media/filters/source_buffer_stream.cc(1027)]
DCHECK failed: range_for_next_append_ == ranges_.end() ||
range_for_next_append_->get() != current_range
#0 0x55a8fcbb3e56 (chrome+0xf04ae55)
#1 0x7f703ba15bdb (libbase.so+0x1363bda)
...
#11 0x7f7006a65eed (libmedia.so+0x1a60eec) ← SourceBufferStream::FreeBuffers
#12 0x7f7006a62245 (libmedia.so+0x1a5d244)
#13 0x7f7006840ef3 (libmedia.so+0x183bef2)
#14 0x7f7006a22d68 (libmedia.so+0x1a1dd67)
#15 0x7f7006857801 (libmedia.so+0x1852800)
Received signal 6
3. Non-ASAN Release: UAF WRITE crash (Signal 11)
Received signal 11 SI_KERNEL000000000000
Possibly a General Protection Fault
#0 0x55daf10b2e92 base::debug::CollectStackTrace()
#1 0x55daf109f47e base::debug::StackTrace::StackTrace()
#2 0x55daf10b2908 base::debug::(anonymous namespace)::StackDumpSignalHandler()
#3 0x7f378eabe330 (/usr/lib/x86_64-linux-gnu/libc.so.6+0x4532f)
#4 0x55daec513f57 media::SourceBufferRange::AppendBuffersToEnd()
#5 0x55daec51cb05 media::SourceBufferStream::Append()
#6 0x55daec4eb688 media::ChunkDemuxerStream::Append()
#7 0x55daec50ebc3 media::MseTrackBuffer::FlushProcessedFrames()
#8 0x55daec50efd2 media::FrameProcessor::ProcessFrames()
#9 0x55daec51bad7 media::SourceBufferState::OnNewBuffers()
#10 0x55daeb6185c7 base::RepeatingCallback<>::Run()
#11 0x55daec4d30f9 media::WebMStreamParser::ParseCluster()
#12 0x55daec4d2bce media::WebMStreamParser::Parse()
#13 0x55daec5192d3 media::SourceBufferState::RunSegmentParserLoop()
...
#16 0x55daf6b88bfc blink::SourceBuffer::AppendBufferAsyncPart_Locked()
Registers:
r14: badbad00badbad00 r15: 000017b4006a8c40
di: badbad00badbad00 (this = PartitionAlloc freed memory poison)
si: 000017b4006a8c40 (StreamParserBuffer pointer to be appended)
ax: 000017b4009e0100
ip: 000055daec513f57 → mov 0x18(%rdi),%rax
The crash instruction is mov 0x18(%rdi),%rax, reading buffers_.begin_ from this + 0x18. Since rdi is the PartitionAlloc poison 0xbadbad00badbad00, the read targets 0xbadbad00badbad18 (non-canonical), causing GPF. If this pointed to controlled memory, the value loaded into rax would become the target address for the subsequent emplace_back write, with the StreamParserBuffer pointer in si written to that address.
4. Non-ASAN + Heap Spray: this Pointer Control
Received signal 11 SEGV_MAPERR 0eadbeefcb16
#4 media::SourceBufferRange::AppendBuffersToEnd()
#5 media::SourceBufferStream::Append()
#6 media::ChunkDemuxerStream::Append()
#7 media::MseTrackBuffer::FlushProcessedFrames()
#8 media::FrameProcessor::ProcessFrames()
...
#16 blink::SourceBuffer::AppendBufferAsyncPart_Locked()
Registers:
r14: 00000eadbeefcafe r15: 000004b4008888e0
di: 00000eadbeefcafe (this)
si: 000004b4008888e0 (StreamParserBuffer pointer to be appended)
ax: 000004b400990c60
ip: → mov 0x18(%rdi),%rax
cr2: 00000eadbeefcb16 (= di + 0x18, faulting address)
cr2 = 0x0eadbeefcb16 = rdi + 0x18, the SEGV occurs reading this->buffers_.begin_. The freed list node slot was reclaimed by a WebSocket FastMalloc(24) spray payload with 0x0EADBEEFCAFE at offset 0x10, setting this to that value. If valid memory were mapped at the this address, the value loaded into rax would become the target address for emplace_back, and the StreamParserBuffer pointer in si would be written there.
Suggested Fix
Reset range_for_next_append_ before popping in FreeBuffers(), matching DeleteAndRemoveRange():
--- a/media/filters/source_buffer_stream.cc
+++ b/media/filters/source_buffer_stream.cc
@@ -1024,6 +1024,11 @@
if (current_range->GetMemoryUsage() == 0) {
DCHECK_NE(current_range, selected_range_);
- DCHECK(range_for_next_append_ == ranges_.end() ||
- range_for_next_append_->get() != current_range);
+ if (range_for_next_append_ != ranges_.end() &&
+ range_for_next_append_->get() == current_range) {
+ range_for_next_append_ = ranges_.end();
+ ResetLastAppendedState();
+ }
// Delete |current_range| by popping it out of |ranges_|.
reverse_direction ? ranges_.pop_back() : ranges_.pop_front();
Impact analysis
A heap-use-after-free in the renderer process reachable from JavaScript via the MSE API. Triggered by page navigation without further user interaction. The freed 24-byte std::list node is not MiraclePtr-protected. The heap spray PoC shows that the freed slot is reclaimed with controlled content, with the this pointer in AppendBuffersToEnd() set to the spray payload value. Since AppendBuffersToEnd() reads buffers_ members from this and calls emplace_back, controlling this provides a path to write at a controlled address.
AAW appears to be achievable, but we have not yet verified whether this leads to RCE. We plan to continue analysis from this point.
The cause
What version of Chrome have you found the security issue in?
146.0.7680.177
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption (in a sandboxed process)
How would you like to be publicly acknowledged for your report?
Jungwoo Lee (@physicube) and Wongi Lee (@_qwerty_po)