The Calysteon Corner

A Guide to the Cyber Galaxy

Home

CVE-2026-20652: An Out-of-Bounds Read in Safari’s Web Audio Resampler

Apple Security Advisory: Safari 26.3


Introduction

This is an out-of-bounds heap read I found in WebKit’s AudioResamplerKernel.cpp, the resampler WebKit uses to convert audio buffers from one sample rate to another. It is reachable from ordinary webpage JavaScript through an OfflineAudioContext.

WebKit is developed in the open on GitHub, so the C++ that decodes, filters, and resamples audio in Safari is available to read directly rather than reverse-engineer. The Web Audio API exposes a large amount of DSP machinery to JavaScript, and much of it makes assumptions about the shape of its inputs. OfflineAudioContext in particular lets a page drive the entire audio graph as fast as the CPU allows, with no realtime clock to smooth things over, which makes it a useful tool for pushing the resampler into states it was never tested against, including the case where you ask it for less than a single frame of input.

The Vulnerability

The issue is in AudioResamplerKernel::getSourceSpan(). This function hands the caller a span of source audio to consume, along with a count of how many frames that span actually contains. Here is the vulnerable computation:

auto fillSpan = m_sourceBuffer.span().subspan(m_fillIndex);
size_t framesNeeded = 1 + endIndex - m_fillIndex; // endIndex < m_fillIndex once the cache gets ahead
if (m_fillIndex + framesNeeded > m_sourceBuffer.size())
    framesNeeded = m_sourceBuffer.size() - m_fillIndex; // still uses wrapped framesNeeded
return { fillSpan, framesNeeded };

At a glance this looks fine. framesNeeded is 1 + endIndex - m_fillIndex, and there is a bound check afterward that clamps it against the size of m_sourceBuffer.

The problem is the types. m_fillIndex is a size_t, an unsigned value. endIndex is an int propagated up from the caller. When the compiler evaluates 1 + endIndex - m_fillIndex, the presence of the size_t operand forces the whole expression into unsigned arithmetic: endIndex gets promoted to unsigned, and the subtraction happens with no notion of “negative.” So when m_fillIndex > endIndex + 1, the expression is a small negative number in the real numbers, but in unsigned size_t arithmetic small negatives do not exist. The subtraction wraps around the bottom of the range and produces a very large positive value close to SIZE_MAX.

The guard that is meant to prevent this evaluates in unsigned arithmetic too. m_fillIndex + framesNeeded itself overflows and wraps, so the sum does not reliably exceed m_sourceBuffer.size(), and the clamp does not fire as intended. The wrap that created the bug is invisible to the code meant to contain it, and the enormous framesNeeded passes through the return untouched.

Root Cause

The resampler is a caching design. It maintains an internal fill cursor, m_fillIndex, that tracks how far it has consumed into m_sourceBuffer, and it reads ahead of what the destination strictly needs so it always has interpolation context available. Under normal operation each render pulls one or more whole frames and endIndex stays ahead of m_fillIndex. But if the destination requests less than one frame of new input, the amount of new data needed is effectively zero or negative, and m_fillIndex can end up sitting past endIndex. The cache has advanced ahead of the request.

Once the cache is ahead like this, 1 + endIndex - m_fillIndex underflows through the promotion described above, framesNeeded becomes enormous and attacker-influenced, and the follow-up bound check wraps in the same way and fails to catch it. getSourceSpan() then returns { fillSpan, framesNeeded }, where fillSpan starts at m_fillIndex inside m_sourceBuffer and framesNeeded claims there are billions of valid frames there. The caller trusts that count and copies framesNeeded samples out of the span.

m_sourceBuffer only holds the small amount of audio that was actually buffered. Everything past its populated tail is unrelated memory: adjacent heap allocations, freed regions, whatever the allocator last placed next to the audio buffer. The copy walks off the end of the populated data and pulls those raw bytes back in as float samples. Because they are interpreted as IEEE-754 floats, the leak is visible without a debugger: heap bytes reinterpreted as floats routinely land outside the legal audio range of [-1, 1], and bit patterns with all exponent bits set decode to NaN and Infinity. The rendered AudioBuffer ends up carrying values drawn from memory that was never part of the audio the page submitted.

Reproduction

Conceptually, the trigger is an OfflineAudioContext whose source buffer is created at a deliberately mismatched sample rate, so resampling is forced on every frame, combined with a heavy filter cascade that loads the graph and shapes how the cache advances relative to the destination, and aggressive playbackRate automation that ramps through extreme step sizes. Together these drive the resampler into the sub-frame requests that pull m_fillIndex ahead of endIndex. After the offline render finishes, the rendered buffer shows the tell: samples well outside [-1, 1], along with NaN and Infinity spikes pulled from adjacent heap, none of which the page ever wrote.

The fix clamps framesNeeded to the data actually available in m_sourceBuffer so the count can never exceed what the buffer genuinely holds. Even confined to whole-frame counts the bug is a clean information-disclosure primitive reachable from ordinary webpage JavaScript.