CVE-2026-10948
Overview
Background
- Sync group
- a named grouping (
sync_group) used by WebRTC to A/V-synchronize an audio receive stream with a video receive stream that share the same label. - `AudioReceiveStreamImpl`
- the worker-thread implementation object for an audio receive stream, whose sync group can be reassigned at runtime via
SetSyncGroup. - `ConfigureSync`
- the routine that (re)builds the synchronization association for a given sync-group name based on the streams currently claiming to belong to it.
- `OnUpdateSyncGroup`
- the worker-thread handler that changes an audio receive stream’s sync group in response to an update.
Root Cause Analysis
OnUpdateSyncGroup updated an audio stream’s membership by calling receive_stream.SetSyncGroup(sync_group) and then ConfigureSync(sync_group) for the new group only. Because it never reconfigured the stream’s previous group, the old sync group retained a reference to a stream that no longer belonged to it (the commit message: “the old group does not have an invalid reference”). When the audio stream was subsequently destroyed, the stale reference held by the old sync group became a dangling pointer, and any later synchronization work touching that group would use the freed stream — a use-after-free.
The fix captures std::string old_sync_group(receive_stream.sync_group()) before the change, and when old_sync_group != sync_group calls ConfigureSync(old_sync_group) in addition to ConfigureSync(sync_group), so the old group is rebuilt without the moved-away stream and no longer holds an invalid reference.
Attack Path
- Create two streams in one sync group
An audio receive stream and a video receive stream are created sharing sync group
"group1"(as in the regression testHandlesAudioSyncGroupUpdate), forming a sync association referencing both. - Move the audio stream to a new group
OnUpdateSyncGroup(audio_stream, "group2")callsSetSyncGroup("group2")and, pre-patch, onlyConfigureSync("group2"), leaving the old"group1"association still referencing the audio stream. - Destroy the audio stream
DestroyAudioReceiveStreamfrees the audio stream while"group1"still holds a reference to it, creating a dangling reference in the old sync group. - Old group uses the freed stream
Later synchronization activity involving
"group1"(e.g. as time advances, or when the remaining video stream is handled) dereferences the freed audio stream, causing a use-after-free (the specific field/sink reached through the stale reference is not shown in the diff) (inferred).
Impact Assessment
sync_group labels) on a Call can leave a stale reference in an old sync group and trigger a use-after-free in the process running WebRTC (the renderer for browser WebRTC) on the worker thread. The advisory classifies this as a use-after-free in WebRTC and does not state exploited-in-the-wild status; escalation from the UAF to memory-corruption primitives or code execution is not demonstrated by the patch (inferred). The precondition is updating an audio receive stream’s sync group to a different value and then destroying that stream while its former group is still referenced.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcall/call.cc |
modified | |
TESTcall/call_unittest.cc |
modified |
Files Changed
call/call.cccall/call_unittest.cc
Audit Directions
- Move-between-collections without source cleanupLook for reassignment operations that add an object to a new grouping/container but never remove it from or reconfigure the old one, leaving stale back-references.
- Group/association reconfigure scopeAudit sync-group, demux, and routing updates to confirm both the source and destination keys are reconfigured on any change, not just the destination.
- Reference lifetime across destroyReview destroy paths for streams/objects that may still be referenced by auxiliary structures (sync groups, mixers, renderers) that were not updated when membership changed.
Patch
From b5bd19b75c8c417d9e4d5a25ce55eea6b17b9c94 Mon Sep 17 00:00:00 2001
From: Erik Språng <sprang@webrtc.org>
Date: Tue, 21 Apr 2026 15:12:32 +0200
Subject: [PATCH] Remove audio stream from old sync group on update.
If the sync group for an audio receive stream is updated, it must be
removed from the old sync group as well so that the old group does not
have an invalid reference.
Bug: chromium:504599749
Change-Id: I548e194d4f8ac6461388d639836e1f62f6b7f7e7
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/465663
Reviewed-by: Tomas Gunnarsson <tommi@webrtc.org>
Commit-Queue: Tomas Gunnarsson <tommi@webrtc.org>
Cr-Commit-Position: refs/heads/main@{#47504}
---
diff --git a/call/call.cc b/call/call.cc
index 0efef93..39905c0 100644
--- a/call/call.cc
+++ b/call/call.cc
@@ -1229,7 +1229,11 @@
RTC_DCHECK_RUN_ON(worker_thread_);
webrtc::AudioReceiveStreamImpl& receive_stream =
static_cast<webrtc::AudioReceiveStreamImpl&>(stream);
+ std::string old_sync_group(receive_stream.sync_group());
receive_stream.SetSyncGroup(sync_group);
+ if (old_sync_group != sync_group) {
+ ConfigureSync(old_sync_group);
+ }
ConfigureSync(sync_group);
}
diff --git a/call/call_unittest.cc b/call/call_unittest.cc
index 1a4f1e5..e024898 100644
--- a/call/call_unittest.cc
+++ b/call/call_unittest.cc
@@ -28,6 +28,7 @@
#include "api/test/mock_audio_mixer.h"
#include "api/test/mock_video_decoder_factory.h"
#include "api/test/video/function_video_encoder_factory.h"
+#include "api/units/time_delta.h"
#include "api/units/timestamp.h"
#include "api/video/builtin_video_bitrate_allocator_factory.h"
#include "api/video_codecs/sdp_video_format.h"
@@ -55,6 +56,7 @@
#include "test/mock_audio_decoder_factory.h"
#include "test/mock_transport.h"
#include "test/run_loop.h"
+#include "test/time_controller/simulated_time_controller.h"
#include "video/config/video_encoder_config.h"
namespace webrtc {
@@ -614,4 +616,53 @@
un_demuxable_packet_handler_.AsStdFunction());
}
+TEST(CallTest, HandlesAudioSyncGroupUpdate) {
+ // Set up a call with an audio stream and a video stream in the same sync
+ // group.
+ GlobalSimulatedTimeController time_controller(Timestamp::Seconds(1));
+ Environment env = CreateTestEnvironment({.time = &time_controller});
+
+ AudioState::Config audio_state_config;
+ audio_state_config.audio_mixer = make_ref_counted<MockAudioMixer>();
+ audio_state_config.audio_device_module =
+ make_ref_counted<MockAudioDeviceModule>();
+ CallConfig config(env);
+ config.audio_state = AudioState::Create(audio_state_config);
+ std::unique_ptr<Call> call(Call::Create(std::move(config)));
+
+ AudioReceiveStreamInterface::Config audio_config;
+ MockTransport rtcp_send_transport;
+ audio_config.rtp.remote_ssrc = 42;
+ audio_config.rtcp_send_transport = &rtcp_send_transport;
+ audio_config.decoder_factory = make_ref_counted<MockAudioDecoderFactory>();
+ audio_config.sync_group = "group1";
+ AudioReceiveStreamInterface* audio_stream =
+ call->CreateAudioReceiveStream(audio_config);
+ ASSERT_NE(audio_stream, nullptr);
+
+ MockTransport video_rtcp_transport;
+ test::FakeVideoRenderer video_renderer;
+ VideoReceiveStreamInterface::Config video_config(&video_rtcp_transport);
+ video_config.rtp.remote_ssrc = 43;
+ video_config.sync_group = "group1";
+ video_config.renderer = &video_renderer;
+ MockVideoDecoderFactory video_decoder_factory;
+ video_config.decoder_factory = &video_decoder_factory;
+ VideoReceiveStreamInterface::Decoder decoder;
+ decoder.payload_type = 96;
+ decoder.video_format = SdpVideoFormat("VP8");
+ video_config.decoders.push_back(decoder);
+ VideoReceiveStreamInterface* video_stream =
+ call->CreateVideoReceiveStream(std::move(video_config));
+ ASSERT_NE(video_stream, nullptr);
+
+ // Move the audio stream to a new sync group and then remove it.
+ call->OnUpdateSyncGroup(*audio_stream, "group2");
+ call->DestroyAudioReceiveStream(audio_stream);
+
+ // The video stream should not be affected.
+ time_controller.AdvanceTime(TimeDelta::Seconds(2));
+ call->DestroyVideoReceiveStream(video_stream);
+}
+
} // namespace webrtc
Regression Test / PoC
diff --git a/call/call_unittest.cc b/call/call_unittest.cc
index 1a4f1e5..e024898 100644
--- a/call/call_unittest.cc
+++ b/call/call_unittest.cc
@@ -28,6 +28,7 @@
#include "api/test/mock_audio_mixer.h"
#include "api/test/mock_video_decoder_factory.h"
#include "api/test/video/function_video_encoder_factory.h"
+#include "api/units/time_delta.h"
#include "api/units/timestamp.h"
#include "api/video/builtin_video_bitrate_allocator_factory.h"
#include "api/video_codecs/sdp_video_format.h"
@@ -55,6 +56,7 @@
#include "test/mock_audio_decoder_factory.h"
#include "test/mock_transport.h"
#include "test/run_loop.h"
+#include "test/time_controller/simulated_time_controller.h"
#include "video/config/video_encoder_config.h"
namespace webrtc {
@@ -614,4 +616,53 @@
un_demuxable_packet_handler_.AsStdFunction());
}
+TEST(CallTest, HandlesAudioSyncGroupUpdate) {
+ // Set up a call with an audio stream and a video stream in the same sync
+ // group.
+ GlobalSimulatedTimeController time_controller(Timestamp::Seconds(1));
+ Environment env = CreateTestEnvironment({.time = &time_controller});
+
+ AudioState::Config audio_state_config;
+ audio_state_config.audio_mixer = make_ref_counted<MockAudioMixer>();
+ audio_state_config.audio_device_module =
+ make_ref_counted<MockAudioDeviceModule>();
+ CallConfig config(env);
+ config.audio_state = AudioState::Create(audio_state_config);
+ std::unique_ptr<Call> call(Call::Create(std::move(config)));
+
+ AudioReceiveStreamInterface::Config audio_config;
+ MockTransport rtcp_send_transport;
+ audio_config.rtp.remote_ssrc = 42;
+ audio_config.rtcp_send_transport = &rtcp_send_transport;
+ audio_config.decoder_factory = make_ref_counted<MockAudioDecoderFactory>();
+ audio_config.sync_group = "group1";
+ AudioReceiveStreamInterface* audio_stream =
+ call->CreateAudioReceiveStream(audio_config);
+ ASSERT_NE(audio_stream, nullptr);
+
+ MockTransport video_rtcp_transport;
+ test::FakeVideoRenderer video_renderer;
+ VideoReceiveStreamInterface::Config video_config(&video_rtcp_transport);
+ video_config.rtp.remote_ssrc = 43;
+ video_config.sync_group = "group1";
+ video_config.renderer = &video_renderer;
+ MockVideoDecoderFactory video_decoder_factory;
+ video_config.decoder_factory = &video_decoder_factory;
+ VideoReceiveStreamInterface::Decoder decoder;
+ decoder.payload_type = 96;
+ decoder.video_format = SdpVideoFormat("VP8");
+ video_config.decoders.push_back(decoder);
+ VideoReceiveStreamInterface* video_stream =
+ call->CreateVideoReceiveStream(std::move(video_config));
+ ASSERT_NE(video_stream, nullptr);
+
+ // Move the audio stream to a new sync group and then remove it.
+ call->OnUpdateSyncGroup(*audio_stream, "group2");
+ call->DestroyAudioReceiveStream(audio_stream);
+
+ // The video stream should not be affected.
+ time_controller.AdvanceTime(TimeDelta::Seconds(2));
+ call->DestroyVideoReceiveStream(video_stream);
+}
+
} // namespace webrtc
Original Bug Report
Potential UAF in WebRTC RtpStreamsSynchronizer due to stale sync group pointer
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 potential Use-After-Free (UAF) vulnerability exists in WebRTC’s A/V synchronization logic. Changing an audio stream’s sync group fails to clear the reference held by video streams in the old sync group. A subsequent stream destruction leaves a dangling raw pointer that is dereferenced by a periodic task, potentially leading to Remote Code Execution.
Affected files:
third_party/webrtc/call/call.ccthird_party/webrtc/video/rtp_streams_synchronizer2.ccthird_party/webrtc/video/rtp_streams_synchronizer2.hthird_party/webrtc/pc/channel.ccthird_party/webrtc/media/engine/webrtc_voice_engine.ccthird_party/webrtc/audio/audio_receive_stream.hthird_party/webrtc/video/video_receive_stream2.cc
Estimated timestamp from git blame: 2024-09-02
Root Cause
A potential Use-After-Free (UAF) vulnerability exists in the WebRTC implementation within Chrome’s renderer process. The issue arises from how Call::OnUpdateSyncGroup and Call::DestroyAudioReceiveStream handle the transition of an AudioReceiveStreamImpl between synchronization groups.
When an audio stream’s sync group is changed, Call::OnUpdateSyncGroup updates the stream’s internal configuration and calls ConfigureSync for the new group only. ConfigureSync then iterates through all video streams, but only updates those whose sync_group() matches the provided argument.
// third_party/webrtc/call/call.cc
void Call::OnUpdateSyncGroup(webrtc::AudioReceiveStreamInterface& stream,
absl::string_view sync_group) {
webrtc::AudioReceiveStreamImpl& receive_stream =
static_cast<webrtc::AudioReceiveStreamImpl&>(stream);
receive_stream.SetSyncGroup(sync_group); // Updates config_.sync_group
ConfigureSync(sync_group); // Reconfigures NEW group only
}
If a VideoReceiveStream2 was previously synchronized with the audio stream in its old group, its internal reference (RtpStreamsSynchronizer::syncable_audio_) remains pointing to the audio stream because ConfigureSync is never called for the old group.
When the audio stream is later destroyed, Call::DestroyAudioReceiveStream again uses the updated sync_group() value, skipping the stale reference in the video stream once more. This leaves a dangling bare C++ pointer in RtpStreamsSynchronizer, which is not protected by MiraclePtr:
// third_party/webrtc/video/rtp_streams_synchronizer2.h
Syncable* syncable_audio_ RTC_GUARDED_BY(main_checker_) = nullptr;
This dangling pointer is dereferenced via a virtual call on the worker thread by a periodic 1 Hz task:
// third_party/webrtc/video/rtp_streams_synchronizer2.cc
std::optional<Syncable::Info> audio_info = syncable_audio_->GetInfo(); // Virtual call
Potential Exploit Scenario
An attacker can trigger this state by carefully maneuvering the stream through an unsignaled state to bypass standard teardown routines. These are suggested steps to trigger the vulnerability:
- The attacker establishes a WebRTC
PeerConnection. - The attacker sends a crafted SDP offer configuring an audio stream with sync group “group1” but omitting any signaled SSRCs (an unsignaled stream).
WebRtcVoiceReceiveChannel::AddRecvStreamcaches this configuration. - The attacker negotiates a signaled video stream associated with the same sync group “group1”.
- The attacker sends an RTP audio packet with a new SSRC.
MaybeCreateDefaultReceiveStreamhandles the packet, uses the cached parameters to spawn anAudioReceiveStreamImpl, and syncs it with the video stream. - The attacker sends a new valid SDP offer containing the now-signaled audio stream with a new sync group (“group2”), alongside a dummy unsignaled audio stream. The dummy stream bypasses
ResetUnsignaledRecvStream()(which would otherwise destroy the old stream safely). - The stream is promoted.
Call::OnUpdateSyncGroupis called with “group2”, leaving the video stream in “group1” with a stale pointer to the audio stream. - The attacker sends a final SDP offer removing the audio stream. The audio stream is deleted, but the video stream still holds the pointer.
- The attacker performs heap spraying in the renderer to reclaim the freed memory with a controlled vtable.
- The 1 Hz
repeating_task_inRtpStreamsSynchronizerexecutes a virtual function call on the attacker-controlled vtable, leading to Control Flow Hijacking and Remote Code Execution in the renderer process.
Suggested Fix
Modify Call::OnUpdateSyncGroup to clear the synchronization state of the old sync group before setting the new one. Call should either track the previous sync group and explicitly call ConfigureSync(old_sync_group), or it should iterate through all video_receive_streams_ and set syncable_audio_ to nullptr for any video stream that currently points to the audio stream being updated, regardless of its sync_group string.
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.