Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Audio
DescriptionUse after free in Audio
ComponentAudio
Bug ClassUAF
Tracker495779613
Fix commitcdbd12e2b3fc (chromium/src) +177/-8
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
CatapApi
media/audio/mac/catap_audio_input_stream.h
modified
CatapIoProcProxy
media/audio/mac/catap_audio_input_stream.h
modified
PropertyListenerHelper
media/audio/mac/catap_audio_input_stream.h
modified
API_AVAILABLE
media/audio/mac/catap_audio_input_stream.mm
modified
if
media/audio/mac/catap_audio_input_stream.mm
modified

Files Changed

  • media/audio/mac/catap_audio_input_stream.h
  • media/audio/mac/catap_audio_input_stream.mm
From cdbd12e2b3fca546c032b40d955dce821712a0d1 Mon Sep 17 00:00:00 2001
From: Johannes Kron <[email protected]>
Date: Wed, 01 Apr 2026 14:51:11 -0700
Subject: [PATCH] Synchronize IoProc lifecycle with a proxy object

Introduce CatapIoProcProxy to manage synchronization between the
CoreAudio IOProc and CatapAudioInputStreamSource. This ensures that
callbacks from the OS audio thread are handled safely during and after
stream teardown.

The proxy uses a lock and a boolean flag to synchronously fence off
callbacks when the source is stopping. If the OS fails to synchronously
terminate the IO process during Close(), the proxy is intentionally.
leaked to provide the orphaned thread with a valid memory address.

Bug: 495779613
Change-Id: Ied533d2625664b4bb78a1505152a337339c8d52c
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7708301
Reviewed-by: Olga Sharonova <[email protected]>
Commit-Queue: Johannes Kron <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1608788}
---

diff --git a/media/audio/mac/catap_audio_input_stream.h b/media/audio/mac/catap_audio_input_stream.h
index 3e714c4a..3ece2e1 100644
--- a/media/audio/mac/catap_audio_input_stream.h
+++ b/media/audio/mac/catap_audio_input_stream.h
@@ -27,6 +27,8 @@
 namespace media {
 
 class CatapApi;
+class CatapIoProcProxy;
+
 class PropertyListenerHelper;
 
 // Captures audio loopback using the CoreAudio API for macOS 14.2
@@ -238,6 +240,13 @@
       kAudioObjectUnknown;
   CATapDescription* __strong tap_description_
       GUARDED_BY_CONTEXT(sequence_checker_) = nil;
+  // Tracks if the synchronous fences failed during teardown.
+  bool stop_failed_ = false;
+
+  // The proxy passed to CoreAudio.
+  std::unique_ptr<CatapIoProcProxy> io_proc_proxy_
+      GUARDED_BY_CONTEXT(sequence_checker_);
+
   bool is_device_open_ GUARDED_BY_CONTEXT(sequence_checker_) = false;
 
   SEQUENCE_CHECKER(sequence_checker_);
diff --git a/media/audio/mac/catap_audio_input_stream.mm b/media/audio/mac/catap_audio_input_stream.mm
index 3ec0028..3de2da0 100644
--- a/media/audio/mac/catap_audio_input_stream.mm
+++ b/media/audio/mac/catap_audio_input_stream.mm
@@ -14,15 +14,18 @@
 
 #include <string_view>
 
+#include "base/debug/leak_annotations.h"
 #include "base/feature_list.h"
 #include "base/functional/bind.h"
 #include "base/functional/callback.h"
 #include "base/logging.h"
+#include "base/memory/raw_ptr_exclusion.h"
 #include "base/metrics/histogram_functions.h"
 #include "base/process/process.h"
 #include "base/strings/string_util.h"
 #include "base/strings/stringprintf.h"
 #include "base/strings/sys_string_conversions.h"
+#include "base/synchronization/lock.h"
 #include "base/timer/elapsed_timer.h"
 #include "base/trace_event/trace_event.h"
 #include "media/audio/application_loopback_device_helper.h"
@@ -35,6 +38,36 @@
 #include "media/base/audio_timestamp_helper.h"
 
 namespace media {
+// Acts as a thread-safe bridge between the CoreAudio IOProc and the
+// CatapAudioInputStreamSource. If teardown fails, this object is intentionally
+// leaked to give the orphaned OS thread a valid memory address to read.
+class API_AVAILABLE(macos(14.2)) CatapIoProcProxy {
+ public:
+  CatapIoProcProxy(raw_ptr<CatapAudioInputStreamSource> source)
+      : source_(source) {}
+
+  // Called from the main sequence during teardown.
+  void Detach() {
+    base::AutoLock auto_lock(lock_);
+    source_ = nullptr;
+  }
+
+  // Called by the CoreAudio high-priority thread.
+  void ForwardSample(const AudioBuffer* input_buffer,
+                     const AudioTimeStamp* input_time) {
+    base::AutoLock auto_lock(lock_);
+    if (source_) {
+      source_->OnCatapSample(input_buffer, input_time);
+    }
+  }
+
+ private:
+  // Lock to protect access to source_ and to ensure that ForwardSample()
+  // finishes before Detach() returns.
+  base::Lock lock_;
+  raw_ptr<CatapAudioInputStreamSource> source_ GUARDED_BY(lock_);
+};
+
 namespace {
 const char kCatapAudioInputStreamUmaBaseName[] =
     "Media.Audio.Mac.CatapAudioInputStream";
@@ -115,9 +148,8 @@
                       AudioBufferList* output_data,
                       const AudioTimeStamp* output_time,
                       void* client_data) {
-  CatapAudioInputStreamSource* catap_input_stream =
-      reinterpret_cast<CatapAudioInputStreamSource*>(client_data);
-  CHECK(catap_input_stream != nullptr);
+  CatapIoProcProxy* proxy = reinterpret_cast<CatapIoProcProxy*>(client_data);
+  CHECK(proxy != nullptr);
 
   // Multiple buffers correspond to multiple streams. This is not expected
   // during system audio capture, and the OnCatapSample() function is designed
@@ -127,7 +159,7 @@
   DCHECK_EQ(input_data->mNumberBuffers, 1u);
 
   if (input_data->mNumberBuffers > 0 && input_data->mBuffers->mData != NULL) {
-    catap_input_stream->OnCatapSample(input_data->mBuffers, input_time);
+    proxy->ForwardSample(input_data->mBuffers, input_time);
   }
   return noErr;
 }
@@ -649,11 +681,13 @@
   // dialog. If the user doesn't respond to the dialog, this call will time out
   // in 60 seconds. When this happens all interactions with CoreAudio will fail
   // until the audio process is restarted.
+  io_proc_proxy_ = std::make_unique<CatapIoProcProxy>(this);
   {
     constexpr base::TimeDelta kCreateIoProcIdTimeout = base::Seconds(59);
     base::ElapsedTimer create_io_proc_id_timer;
     status = catap_api_->AudioDeviceCreateIOProcID(
-        aggregate_device_id_, DeviceIoProc, this, &tap_io_proc_id_);
+        aggregate_device_id_, DeviceIoProc, io_proc_proxy_.get(),
+        &tap_io_proc_id_);
     if (base::FeatureList::IsEnabled(
             features::kMacCatapRestartAudioProcessOnTimeout) &&
         create_io_proc_id_timer.Elapsed() > kCreateIoProcIdTimeout) {
@@ -723,6 +757,13 @@
   SendLogMessage("%s", __func__);
   base::ElapsedTimer timer;
 
+  // Instantly fence off the CoreAudio thread.
+  // If the OS thread is currently in the callback, this blocks until it
+  // finishes.
+  if (io_proc_proxy_) {
+    io_proc_proxy_->Detach();
+  }
+
   property_listener_.reset();
 
   if (!sink_) {
@@ -733,12 +774,15 @@
   CHECK_NE(tap_io_proc_id_, nullptr);
 
   // Reversing Step 4.
-  // The call to AudioDeviceStop is synchronous. It will not return until any
-  // current callbacks have finished executing. The call to AudioDeviceStop()
-  // succeeds even though AudioDeviceStart() has not been called.
+  // AudioDeviceStop is synchronous when it succeeds, but may not be if it
+  // fails. The lock above mitigates the failure case by acting as a synchronous
+  // fence, ensuring that no callbacks are actively executing before we proceed.
+  // Note: The call to AudioDeviceStop() will succeed even if AudioDeviceStart()
+  // has not been called.
   OSStatus status =
       catap_api_->AudioDeviceStop(aggregate_device_id_, tap_io_proc_id_);
   if (status != noErr) {
+    stop_failed_ = true;
     ReportStopStatus(false, timer.Elapsed());
     SendLogMessage("%s => Error stopping the device. Status: %d", __func__,
                    status);
@@ -771,6 +815,7 @@
   base::ElapsedTimer timer;
 
   is_device_open_ = false;
+  bool destroy_failed = false;
 
   if (aggregate_device_id_ != kAudioObjectUnknown &&
       tap_io_proc_id_ != nullptr) {
@@ -778,6 +823,7 @@
     OSStatus status = catap_api_->AudioDeviceDestroyIOProcID(
         aggregate_device_id_, tap_io_proc_id_);
     if (status != noErr) {
+      destroy_failed = true;
       ReportCloseStatus(CloseStatus::kErrorDestroyingIOProcID, timer.Elapsed());
       SendLogMessage("%s => Error destroying device IO process ID. Status: %d",
                      __func__, status);
@@ -814,6 +860,20 @@
     tap_description_ = nil;
   }
 
+  if (io_proc_proxy_) {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/media/audio/mac/catap_audio_input_stream_unittest.mm b/media/audio/mac/catap_audio_input_stream_unittest.mm
index 9eea760..3267baf 100644
--- a/media/audio/mac/catap_audio_input_stream_unittest.mm
+++ b/media/audio/mac/catap_audio_input_stream_unittest.mm
@@ -294,12 +294,18 @@
   }
   OSStatus AudioDeviceStop(AudioDeviceID in_device,
                            AudioDeviceIOProcID in_proc_id) override {
+    if (should_fail_audio_device_stop) {
+      return -1;
+    }
     stopped_device = in_device;
     stopped_proc_id = in_proc_id;
     return noErr;
   }
   OSStatus AudioDeviceDestroyIOProcID(AudioDeviceID in_device,
                                       AudioDeviceIOProcID in_proc_id) override {
+    if (should_fail_audio_device_destroy) {
+      return -1;
+    }
     destroyed_io_proc_id_for_device = in_device;
     destroyed_io_proc_id = in_proc_id;
     client_data = nullptr;
@@ -341,6 +347,11 @@
 
   // Public properties that can be modified by the tests.
 
+  // Controls the success of the AudioDeviceStop() call.
+  bool should_fail_audio_device_stop = false;
+  // Controls the success of the AudioDeviceDestroyIOProcID() call.
+  bool should_fail_audio_device_destroy = false;
+
   // If `true`, `AudioObjectSetPropertyData()` will return `noErr` when setting
   // the tap description. Otherwise it will return an error, which simulates
   // that the user has not given screen capture permissions.
@@ -1265,4 +1276,93 @@
   }
 }
 
+TEST_F(CatapAudioInputStreamTest, SurvivesLateCallbackIfStopFails) {
+  if (@available(macOS 14.2, *)) {
+    CreateStream();
+    EXPECT_EQ(stream_->Open(), AudioInputStream::OpenOutcome::kSuccess);
+    stream_->Start(&fake_callback_);
+
+    // Extract the OS callback and the proxy pointer (client_data) before
+    // teardown.
+    AudioDeviceIOProc audio_proc = fake_catap_api()->audio_proc;
+    void* proxy_client_data = fake_catap_api()->client_data;
+    ASSERT_NE(audio_proc, nullptr);
+    ASSERT_NE(proxy_client_data, nullptr);
+
+    // Force AudioDeviceStop to fail.
+    fake_catap_api()->should_fail_audio_device_stop = true;
+
+    // Stop and destroy the stream.
+    // Because stop fails, the proxy will be intentionally leaked.
+    stream_->Stop();
+    stream_->Close();
+    fake_catap_api_ = nullptr;
+    stream_.ClearAndDelete();
+
+    // Simulate the rogue callback.
+    const AudioTimeStamp* in_now = nullptr;
+    const uint32_t data_byte_size = kCatapLoopbackDefaultFramesPerBuffer *
+                                    sizeof(Float32) * kNumberOfChannelsStereo;
+    std::vector<uint8_t> data_buffer(data_byte_size);
+
+    AudioBufferList input_data;
+    input_data.mNumberBuffers = 1;
+    input_data.mBuffers[0].mNumberChannels = kNumberOfChannelsStereo;
+    input_data.mBuffers[0].mDataByteSize = data_byte_size;
+    input_data.mBuffers[0].mData = data_buffer.data();
+    AudioTimeStamp input_time = {};
+    AudioBufferList* output_data = nullptr;
+    const AudioTimeStamp* output_time = nullptr;
+
+    OSStatus status = audio_proc(0, in_now, &input_data, &input_time,
+                                 output_data, output_time, proxy_client_data);
+
+    EXPECT_EQ(status, noErr);
+  }
+}
+
+TEST_F(CatapAudioInputStreamTest, SurvivesLateCallbackIfDestroyFails) {
+  if (@available(macOS 14.2, *)) {
+    CreateStream();
+    EXPECT_EQ(stream_->Open(), AudioInputStream::OpenOutcome::kSuccess);
+    stream_->Start(&fake_callback_);
+
+    AudioDeviceIOProc audio_proc = fake_catap_api()->audio_proc;
+    void* proxy_client_data = fake_catap_api()->client_data;
+    ASSERT_NE(audio_proc, nullptr);
+    ASSERT_NE(proxy_client_data, nullptr);
+
+    // Force AudioDeviceDestroyIOProcID to fail.
+    fake_catap_api()->should_fail_audio_device_destroy = true;
+
+    // Stop and destroy the stream.
+    // Because destroy process IO proc fails, the proxy will be intentionally
+    // leaked.
+    stream_->Stop();
+    stream_->Close();
+    fake_catap_api_ = nullptr;
+    stream_.ClearAndDelete();
+
+    // Simulate the rogue callback.
+    const AudioTimeStamp* in_now = nullptr;
+    const uint32_t data_byte_size = kCatapLoopbackDefaultFramesPerBuffer *
+                                    sizeof(Float32) * kNumberOfChannelsStereo;
+    std::vector<uint8_t> data_buffer(data_byte_size);
+
+    AudioBufferList input_data;
+    input_data.mNumberBuffers = 1;
+    input_data.mBuffers[0].mNumberChannels = kNumberOfChannelsStereo;
+    input_data.mBuffers[0].mDataByteSize = data_byte_size;
+    input_data.mBuffers[0].mData = data_buffer.data();
+    AudioTimeStamp input_time = {};
+    AudioBufferList* output_data = nullptr;
+    const AudioTimeStamp* output_time = nullptr;
+
+    OSStatus status = audio_proc(0, in_now, &input_data, &input_time,
+                                 output_data, output_time, proxy_client_data);
+
+    EXPECT_EQ(status, noErr);
+  }
+}
+
 }  // namespace media
Loading diff…

Original Bug Report

reported by [email protected]

Potential UAF in CatapAudioInputStreamSource during CoreAudio device change

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A potential Use-After-Free (UAF) vulnerability exists in the macOS Catap audio implementation due to improper synchronization during object destruction. If AudioDeviceStop fails during a device change, CoreAudio callbacks can still execute and access the freed CatapAudioInputStreamSource object. This could allow an attacker to achieve Remote Code Execution (RCE) within the audio service utility process.

Affected files:

  • media/audio/mac/catap_audio_input_stream.mm
  • media/audio/mac/catap_audio_input_stream.h

Estimated timestamp from git blame: 2025-10-15

Description

A potential Use-After-Free (UAF) vulnerability has been identified in the macOS CatapAudioInputStreamSource implementation within the Chrome audio service (introduced for macOS 14.2+ system audio capture). The vulnerability stems from an incorrect assumption regarding the synchronous guarantees of CoreAudio’s AudioDeviceStop API when it encounters an error state, combined with a lack of thread synchronization.

CatapAudioInputStreamSource registers its raw this pointer with the CoreAudio HAL via AudioDeviceCreateIOProcID. The DeviceIoProc callback runs on a high-priority CoreAudio real-time thread and periodically calls OnCatapSample(this).

During a default audio device change, CatapAudioInputStream::OnDefaultDeviceChange() triggers RestartStream(), which synchronously calls source_->Stop() and then immediately destroys the object via source_.reset().

Inside CatapAudioInputStreamSource::Stop(), the code relies entirely on AudioDeviceStop to fence the IO thread:

  // Reversing Step 4.
  // The call to AudioDeviceStop is synchronous. It will not return until any
  // current callbacks have finished executing.
  OSStatus status =
      catap_api_->AudioDeviceStop(aggregate_device_id_, tap_io_proc_id_);
  if (status != noErr) {
    ReportStopStatus(false, timer.Elapsed());
    SendLogMessage("%s => Error stopping the device. Status: %d", __func__,
                   status);
  }

If the user disconnects the active audio device, AudioDeviceStop can fail (e.g., returning kAudioHardwareBadDeviceError). When it fails, it does not act as a synchronous fence. The implementation merely logs the error and proceeds. The subsequent Close() call in the destructor also attempts AudioDeviceDestroyIOProcID(), which will likewise fail if the IOProc is actively running or the device handle is bad.

Because the implementation lacks a fallback synchronization primitive (such as a base::Lock to synchronize the destruction path and the IOProc callback—a pattern explicitly used to prevent this exact issue in older macOS audio components like AUHALStream), the OS can invoke the DeviceIoProc callback concurrently with or after the object’s destruction.

Impact

Because the client_data pointer passed to CoreAudio crosses the FFI (Foreign Function Interface) boundary as a C-style raw pointer, it is not protected by Chrome’s BackupRefPtr (MiraclePtr) quarantine mechanism. The freed memory is immediately available for reallocation.

When the CoreAudio thread executes DeviceIoProc with the stale pointer, it invokes OnCatapSample(), which interacts with several member variables and eventually performs a virtual method call:

  sink_->OnData(audio_bus_.get(), capture_time, kMaxVolume,
                glitch_helper_.ConsumeGlitchInfo());

If an attacker sprays the heap in the audio service process, they can control the freed memory, forge the sink_ pointer, and hijack the vtable to achieve arbitrary code execution (RCE) within the sandboxed audio service utility process.

Suggested Reproduction Steps

Note: As an LLM agent, I do not have the ability to run code or provide a working proof-of-concept exploit. The following are suggested steps an attacker would potentially follow to trigger this vulnerability.

  1. An attacker hosts a malicious website and uses standard Web APIs (e.g., navigator.mediaDevices.getDisplayMedia({audio: true})) to request system audio capture.
  2. Concurrently, the attacker’s script utilizes Web Audio or other IPC primitives to spray the heap in the audio service process, preparing malicious object structures.
  3. The user changes the default audio device (e.g., plugs in or unplugs headphones, or the attacker waits for a Bluetooth audio device to naturally disconnect).
  4. This event invokes CatapAudioInputStream::OnDefaultDeviceChange() -> RestartStream(), which calls Stop() followed by the immediate destruction of the CatapAudioInputStreamSource instance.
  5. Due to the device transition, AudioDeviceStop fails, failing to fence the CoreAudio real-time thread.
  6. The CoreAudio IOProc callback executes, casting the freed client_data pointer and accessing the attacker-controlled sprayed memory.
  7. The code reaches sink_->OnData(...), dereferencing the attacker’s forged vtable and executing arbitrary code.

Evaluated with Chrome root at commit: False


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker