← WebKit Silent-Fix Report — 2026-W21

8c860928af  Fix heap-use-after-free in AudioVideoRendererAVFObjC::setTimeObserver when callback re-entrantly reinstalls the time observer

severity medium class UAF confidence 0.75 WebCore AVFoundation media exploitable-grade
Jean-Yves Avenard Fri May 22 00:19:10 2026 -0700 full: 8c860928affff1fd35c969c385e10f94b8cdcc36 bug report ↗ view on GitHub ↗
Primitive: heap-UAF via re-entrant time-observer reinstall
Triage note: Commit explicitly fixes a heap-use-after-free from callback re-entrancy (member -> block-captured Function).
Contents

The bug at a glance

This is a genuine heap use-after-free reachable purely from playback timing in the media pipeline, but the reentrancy window is narrow and depends on a client re-installing a time observer from inside its own periodic callback. The freed object is a WTF::Function (a heap-allocated callable with captured state), and the stale operator() continues executing on the stack against freed storage, which is a controllable-lifetime primitive rather than a mere null deref. It is rated medium because reaching it requires a specific re-entrant setTimeObserver() call pattern that most MediaPlayer clients do not exercise, and there is no demonstrated attacker-controlled heap grooming path in the patch. It is nonetheless a real teardown/reentrancy UAF in AVFoundation media code that runs in the WebContent process.

The bug is not about object destruction but about a callable object destroying itself mid-invocation: the periodic time-observer block reached back through the C++ object to invoke a Function member, and if that Function re-entered setTimeObserver() it move-assigned the member and freed the very Function whose operator() was still on the stack. The fix eliminates the shared member entirely by move-capturing the callback into each Objective-C block, so each installed observer owns its own callback for the life of an in-flight call.

Root cause

Before the patch, AudioVideoRendererAVFObjC held the periodic-time callback in a data member, Function<void(const MediaTime&)> m_currentTimeDidChangeCallback. setTimeObserver() first move-assigned the incoming callback into that member (m_currentTimeDidChangeCallback = WTF::move(callback)), then registered an AVFoundation periodic time observer whose block captured only a __block ThreadSafeWeakPtr weakThis. When the block fired on the main dispatch queue, it resolved weakThis to a RefPtr protectedThis and then invoked protectedThis->m_currentTimeDidChangeCallback(clampedTime) — dereferencing the member through the live C++ object.

The defect is a self-referential lifetime hazard. WTF::Function is a heap-backed type-erased callable: assigning a new Function into m_currentTimeDidChangeCallback destroys the previously stored callable, freeing its captured environment. If the stored callback, during its own execution, called back into setTimeObserver() (a re-entrant reinstall), the first line m_currentTimeDidChangeCallback = WTF::move(callback) would run the move-assignment operator, destroying the Function object whose operator() was currently executing further up the same call stack. On return from the inner call, control unwinds back into the destroyed callable’s body and the freed captured state, a classic use-after-free where the freed allocation is the callable itself.

The ThreadSafeWeakPtr guard did not help: it only proved the enclosing AudioVideoRendererAVFObjC was still alive, not that the member Function had not been reassigned underneath the running invocation. The inner null-check (if (!protectedThis->m_currentTimeDidChangeCallback) return;) likewise ran before the reinstall in the outer frame, so it could not observe the mid-call free.

The fix removes m_currentTimeDidChangeCallback from the header entirely and rewrites setTimeObserver() to move-capture the callback directly into the Objective-C block via makeBlockPtr([weakThis = ThreadSafeWeakPtr { *this }, callback = WTF::move(callback)](CMTime time) mutable { … }). Each setTimeObserver() call now builds a fresh block that owns its own copy of the callback. A re-entrant setTimeObserver() replaces m_timeChangedObserver with a new observer/block, but the previously registered block — retained by AVFoundation/dispatch for the duration of the in-flight invocation — continues to run against its own captured callback until it returns, then is released normally. The shared member that two invocations could fight over is gone, so there is no window in which a running callable frees itself. The patch also adds an early return (if (!callback) return;) after cancelTimeObserver() so a null callback still cancels the observer without installing a fresh one, mirroring the pre-patch behavior. This block-capture idiom explicitly mirrors setPerformTaskAtTime() a few lines above.

Key code

setTimeObserver now move-captures the callback into the block instead of a shared member (AudioVideoRendererAVFObjC.mm)

void AudioVideoRendererAVFObjC::setTimeObserver(Seconds interval, Function<void(const MediaTime&)>&& callback)
{
    cancelTimeObserver();

    if (!callback)
        return;

    m_timeChangedObserver = [m_synchronizer addPeriodicTimeObserverForInterval:PAL::toCMTime(MediaTime::createWithSeconds(interval)) queue:mainDispatchQueueSingleton() usingBlock:makeBlockPtr([weakThis = ThreadSafeWeakPtr { *this }, callback = WTF::move(callback)](CMTime time) mutable {
        RefPtr protectedThis = weakThis.get();
        if (!protectedThis)
            return;
        auto clampedTime = CMTIME_IS_NUMERIC(time) ? protectedThis->clampTimeToLastSeekTime(PAL::toMediaTime(time)) : MediaTime::zeroTime();
        callback(clampedTime);
    }).get()];
}

Patch walkthrough

  • Source/WebCore/platform/graphics/avfoundation/AudioVideoRendererAVFObjC.h — Deletes the member Function<void(const MediaTime&)> m_currentTimeDidChangeCallback. This is the crux: removing the shared storage means no invocation can move-assign over a callback another invocation is still running, eliminating the self-free.
  • Source/WebCore/platform/graphics/avfoundation/AudioVideoRendererAVFObjC.mm — Rewrites setTimeObserver(): removes the member move-assign, adds an early return on a null callback (so cancelTimeObserver() still runs but no observer is reinstalled), and installs the periodic observer with a makeBlockPtr block that move-captures callback = WTF::move(callback) alongside a ThreadSafeWeakPtr. The block now invokes its own captured callback(clampedTime) rather than reaching back through the object to a shared member.

Background

WTF::Function and self-destruction — WTF::Function is WebKit’s type-erased callable, analogous to std::function; it heap-allocates storage for the captured environment of the callable it wraps. Move-assigning a new Function into a variable destroys the previously held callable and frees that environment. When a Function is stored in a member and the member is reassigned while the stored callable’s operator() is still executing on the stack, the running code’s own captures are freed underneath it — the callable destroys itself mid-call.

AVFoundation periodic time observers — addPeriodicTimeObserverForInterval:queue:usingBlock: registers a block with an AVSampleBufferRenderSynchronizer that is invoked repeatedly on the given dispatch queue as media time advances. The returned observer token is stored so it can later be removed. The block is retained by AVFoundation/dispatch for as long as it may fire, and an in-flight invocation runs to completion against whatever it captured even if the observer is subsequently removed.

Objective-C block capture vs C++ member storage — An Objective-C block copied to the heap (as makeBlockPtr does) takes ownership of its captured variables for the life of the block. Capturing callback = WTF::move(callback) into the block means each installed observer carries its own independent callback whose lifetime is bound to that block, not to a shared C++ object member. This is strictly safer than reaching through a captured this/weakThis to read a mutable member, because a second installation cannot mutate the first block’s captured state.

ThreadSafeWeakPtr and reentrancy — ThreadSafeWeakPtr lets a callback verify the target object is still alive before use, protecting against object destruction. It does not, however, protect against a member of that still-alive object being reassigned or freed during the callback. Here the object stayed alive but its callback member was overwritten, so the weak pointer guard was necessary but insufficient — the true fix removes the shared member rather than adding more guards.

Re-entrant reinstall pattern — A callback that, while running, calls the API that installs it (setTimeObserver from inside the time-changed callback) is a re-entrant reinstall. Such patterns are common in media controllers that adjust observation intervals in response to time updates. The original code assumed installation and invocation never interleave on the same callback storage, which the reinstall violates.

Vulnerability window

  1. Design — setTimeObserver stores the periodic callback in the member m_currentTimeDidChangeCallback and the observer block invokes it through the C++ object rather than owning it.
  2. Trigger setup — A client installs a time observer; the periodic block begins firing on the main dispatch queue as media time advances.
  3. Reentry — Inside a firing invocation, the callback calls setTimeObserver() again (a reinstall), executing m_currentTimeDidChangeCallback = WTF::move(callback), which destroys the Function whose operator() is still on the stack.
  4. Use-after-free — Control unwinds back into the destroyed callable’s body / freed captured environment, reading or executing freed heap memory.
  5. Fix — Commit 313715@main removes the member and move-captures the callback into each block, so re-entry installs a new block while the old one runs to completion against its own captures.
  6. Release — Landed May 22 2026 (bug 315339 / rdar://177666693), reviewed by Youenn Fablet; covered by existing tests.

Triggering

No test was added (commit states ‘Covered by existing tests’). Trigger conceptually: drive a MediaSource/AVFoundation playback so AudioVideoRendererAVFObjC::setTimeObserver installs a periodic observer, then from within the time-changed callback re-enter setTimeObserver() with a new Function so the member is move-assigned while the original callback’s operator() is still executing. Reliable reproduction requires the internal C++ reinstall path, not a direct JS API, so this is not scriptable from page content in the general case.

Exploitation

  1. Reach — Requires driving the media pipeline (AVFoundation/MSE) into a state where a periodic time observer is installed and a client re-enters setTimeObserver() from within the callback. Not directly exposed as a JS primitive.
  2. Free — The re-entrant move-assignment frees the WTF::Function’s captured environment while its operator() is executing — the freed allocation is the callable/closure state.
  3. Groom — Exploitation would require reclaiming the freed Function environment with attacker-controlled data before the stale operator() reads/uses it; the patch contains no evidence of a practical grooming path and the window is a single synchronous unwind.
  4. Outcome — Realistically crash-only (heap-use-after-free) in the WebContent/media process; escalation to control flow would depend on reclaiming the callable’s captured pointers, which is unproven here.

Detection & hunting

For defenders and SOC / detection engineers:

  • WebContent/media process crashes
  • ASan/GuardMalloc UAF on Function storage
  • Re-entrant setTimeObserver call chains

Audit directions

  • Callbacks stored in members then invoked via captured this
  • setPerformTaskAtTime and siblings
  • Reinstall-from-callback APIs
  • ThreadSafeWeakPtr false-safety

Before / after

Loading diff…