Medium CVSS 6.5 webkit Race 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected process crash
ComponentWebCore WebAudio
Bug ClassRace
Tracker313528
Fix commit8912cf5b00c4 (WebKit/WebKit) +51/-26
CWECWE-119, CWE-416 (Buffer bounds error, Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
CreditedUtkarsh Pal, Ignacio Sanmillan (@ulexec)
Disclosed2026-06-29

Background

WaveShaperNode
A Web Audio API node that applies a nonlinear distortion defined by a user-supplied ‘curve’ Float32Array to its audio input.
Audio rendering thread
A separate real-time thread that pulls audio through the graph and runs DSP kernels like WaveShaperDSPKernel::process(), distinct from the JS main thread.
process lock (m_processLock)
A WTF::Lock guarding the processor’s shared audio parameters so the audio thread and main thread do not access them simultaneously.
WTF_IGNORES_THREAD_SAFETY_ANALYSIS
An annotation that suppresses the compiler’s static lock checking for a method, used here on a getter documented as ‘main thread only’—the exact spot where an unsynchronized cross-thread access slipped in.
Float32Array (JSC cell)
A JavaScriptCore-managed typed array object plus its backing ArrayBuffer, not designed for concurrent access from two threads, which is why sharing it with the audio thread was unsafe.

Root Cause Analysis

WaveShaperNode stores the user-supplied shaping curve in its WaveShaperProcessor. Before the patch the internal representation was a JavaScriptCore Float32Array (RefPtr<Float32Array> m_curve), a JS-heap object, that was shared between the main thread and the audio rendering thread. The audio thread reads the curve inside WaveShaperDSPKernel::processCurve() under the processor’s process lock (assertIsHeld) and obtains curve->typedMutableSpan(). The main-thread getter WaveShaperProcessor::curveForBindings() and WaveShaperNode::curveForBindings() deliberately did NOT take the lock: the accessor is annotated WTF_IGNORES_THREAD_SAFETY_ANALYSIS with the comment that it is ‘only safe to call on the main thread’, and it dereferenced m_curve to read curve->data()/curve->length() in order to clone the array for JS. The violated invariant is that data touched by the real-time audio thread must not be a JS-heap object that the main thread also dereferences concurrently: while offline (or real-time) rendering is in progress on the audio thread, JS on the main thread can repeatedly read node.curve, so the main thread dereferences and reads the same Float32Array (and its backing ArrayBuffer, a JSC-managed cell) at the same moment the audio thread is using it, an unsynchronized cross-thread access to a non-thread-safe JS object that can crash. The added layout test reproduces exactly this: it starts OfflineAudioContext rendering and, in a loop, reads node.curve 1000 times from the main thread, and asserts no crash.

The fix removes JS objects from the shared/audio path entirely: m_curve becomes a plain Vector<float> owned by the processor (WTF_GUARDED_BY_LOCK(m_processLock)). setCurveForBindings now copies the incoming Float32Array’s contents into a Vector<float> on the main thread under the lock; the getter returns a const Vector<float>& (LIFETIME_BOUND) and JS clones are produced via Float32Array::create(curve.span()); processCurve consumes waveShaperProcessor()->curve().span(). Because the audio thread now operates on a stable, engine-owned buffer rather than a shared JS cell, and the internal copy is made under the lock, the concurrent-access crash is eliminated. The includes of <JavaScriptCore/Float32Array.h> are dropped from the DSP kernel and processor since they no longer touch JS objects.

Key insight
Real-time audio DSP state must never be a JS-heap object shared with and concurrently dereferenced by the main thread; the root cause was storing the shaping curve as a Float32Array reachable from both threads (with an unlocked ‘main-thread-only’ getter), and the fix is to copy it into an engine-owned Vector<float> so nothing crosses the thread boundary as a live JS cell.

Attack Path

  1. Create a WaveShaper in a rendering context From JS, create an OfflineAudioContext (or AudioContext), createWaveShaper(), assign node.curve = new Float32Array(n), and connect it into the graph so its processor participates in rendering.
  2. Start rendering to activate the audio thread Call context.startRendering() (offline) or otherwise start audio so WaveShaperDSPKernel::processCurve() runs on the audio thread and repeatedly accesses the shared Float32Array under the process lock.
  3. Hammer the getter from the main thread While rendering is in flight, read node.curve in a tight loop from the main thread; each read enters the unlocked curveForBindings() path that dereferences the same Float32Array/ArrayBuffer the audio thread is using.
  4. Race the two accesses The unsynchronized main-thread dereference of a JSC Float32Array cell concurrent with the audio thread’s use produces a data race on a non-thread-safe object, leading to an unexpected process crash (the behavior the test guards against).

Impact Assessment

As shipped this is a LogicError / medium data race whose demonstrated primitive is an unexpected crash of the WebContent (renderer) process, triggered entirely from unprivileged web-facing JS via the Web Audio API. Whether the race is ever more than a crash (e.g., a torn read that could be shaped into a controlled corruption) is not established by the diff or test and would be inference; the commit only proves the crash and its elimination. It is confined to the WebContent sandbox and does not indicate a sandbox escape.

Changed Functions

FunctionChangeNotes
WaveShaperDSPKernel::processCurve
Source/WebCore/Modules/webaudio/WaveShaperDSPKernel.cpp
modified Stops dereferencing a Float32Array (RefPtr + typedMutableSpan); now forwards the processor's Vector<float> via curve().span() to processCurveWithData under the process lock.
WaveShaperNode::setCurveForBindings
Source/WebCore/Modules/webaudio/WaveShaperNode.cpp
modified Replaces the clone-into-Float32Array step with copying the JS array's contents into a Vector<float> (or empty) passed by move to the processor, so no JS object enters the shared state.
WaveShaperNode::curveForBindings
Source/WebCore/Modules/webaudio/WaveShaperNode.cpp
modified Reads the internal Vector<float> (isEmpty check) and returns a fresh Float32Array::create(curve.span()) clone to JS instead of cloning an internally-stored Float32Array.
WaveShaperNode::propagatesSilence
Source/WebCore/Modules/webaudio/WaveShaperNode.cpp
modified Uses waveShaperProcessor()->curve().isEmpty() under the lock rather than testing a RefPtr<Float32Array> and its length.
WaveShaperProcessor::setCurveForBindings
Source/WebCore/Modules/webaudio/WaveShaperProcessor.cpp
modified Signature changed to take Vector<float>&& and store it via WTF::move under m_processLock; no longer holds a Float32Array pointer.
WaveShaperProcessor::curve / curveForBindings accessors and m_curve
Source/WebCore/Modules/webaudio/WaveShaperProcessor.h
modified m_curve changed from RefPtr<Float32Array> to Vector<float> (still WTF_GUARDED_BY_LOCK); accessors now return const Vector<float>& (LIFETIME_BOUND); JSC Float32Array include/forward dropped in favor of <wtf/Vector.h>.
WaveShaperNode.h includes
Source/WebCore/Modules/webaudio/WaveShaperNode.h
modified Adds <JavaScriptCore/Forward.h> now that Float32Array is only referenced in the node (bindings clone), not the processor.

Files Changed

  • LayoutTests/webaudio/WaveShaper/waveshaper-curve-getter-during-rendering-crash-expected.txt
  • LayoutTests/webaudio/WaveShaper/waveshaper-curve-getter-during-rendering-crash.html
  • Source/WebCore/Modules/webaudio/WaveShaperDSPKernel.cpp
  • Source/WebCore/Modules/webaudio/WaveShaperNode.cpp
  • Source/WebCore/Modules/webaudio/WaveShaperNode.h
  • Source/WebCore/Modules/webaudio/WaveShaperProcessor.cpp
  • Source/WebCore/Modules/webaudio/WaveShaperProcessor.h

Audit Directions

  • Other WaveShaper/curve accessors
    Recheck the WaveShaper files for any remaining raw dereference of a JS array across the process lock boundary; grep for typedMutableSpan, curve->data(), and WTF_IGNORES_THREAD_SAFETY_ANALYSIS within Modules/webaudio/WaveShaper*.
  • Other audio nodes holding JS typed arrays
    Audit AudioParam, ConvolverNode (impulse response), PeriodicWave, AudioBuffer, and AnalyserNode for RefPtr<Float32Array>/RefPtr<AudioArray> members that are read on the audio thread; grep Source/WebCore/Modules/webaudio for ‘RefPtr<Float32Array>’ and ‘Float32Array*’ member fields guarded by a process lock.
  • Getters annotated as main-thread-only
    Search WebCore for WTF_IGNORES_THREAD_SAFETY_ANALYSIS combined with a comment like ‘only safe on the main thread’ returning a pointer to lock-guarded state; these are candidates for the same unlocked-cross-thread-dereference pattern.
  • JS objects on real-time threads generally
    Look for any JavaScriptCore Float32Array/ArrayBuffer or other JSCell reached from a non-main thread (audio worklets, media pipelines); grep for ‘#include <JavaScriptCore/Float32Array.h>’ or JSCell usage in files that also run on worker/rendering threads.
diff --git a/LayoutTests/webaudio/WaveShaper/waveshaper-curve-getter-during-rendering-crash-expected.txt b/LayoutTests/webaudio/WaveShaper/waveshaper-curve-getter-during-rendering-crash-expected.txt
new file mode 100644
index 000000000000..bc7912a8646c
--- /dev/null
+++ b/LayoutTests/webaudio/WaveShaper/waveshaper-curve-getter-during-rendering-crash-expected.txt
@@ -0,0 +1,10 @@
+Reading WaveShaperNode.curve on the main thread while offline rendering accesses it on the audio thread should not crash.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS Test passed because it did not crash.
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/webaudio/WaveShaper/waveshaper-curve-getter-during-rendering-crash.html b/LayoutTests/webaudio/WaveShaper/waveshaper-curve-getter-during-rendering-crash.html
new file mode 100644
index 000000000000..59f3e603c5ae
--- /dev/null
+++ b/LayoutTests/webaudio/WaveShaper/waveshaper-curve-getter-during-rendering-crash.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html>
+<head>
+<script src="../../resources/js-test-pre.js"></script>
+</head>
+<body>
+<script>
+description("Reading WaveShaperNode.curve on the main thread while offline rendering accesses it on the audio thread should not crash.");
+
+jsTestIsAsync = true;
+
+let context = new OfflineAudioContext(2, 44100, 48000);
+let node = context.createWaveShaper();
+node.curve = new Float32Array(2);
+node.connect(context.destination);
+context.startRendering().then(() => {
+    testPassed("Test passed because it did not crash.");
+    finishJSTest();
+});
+for (let i = 0; i < 1000; i++)
+    void node.curve;
+</script>
+<script src="../../resources/js-test-post.js"></script>
+</body>
+</html>
diff --git a/Source/WebCore/Modules/webaudio/WaveShaperDSPKernel.cpp b/Source/WebCore/Modules/webaudio/WaveShaperDSPKernel.cpp
index 8280ac15d11a..e509ad73d772 100644
--- a/Source/WebCore/Modules/webaudio/WaveShaperDSPKernel.cpp
+++ b/Source/WebCore/Modules/webaudio/WaveShaperDSPKernel.cpp
@@ -30,7 +30,6 @@
 
 #include "AudioUtilities.h"
 #include "WaveShaperProcessor.h"
-#include <JavaScriptCore/Float32Array.h>
 #include <algorithm>
 #include <wtf/MainThread.h>
 #include <wtf/StdLibExtras.h>
@@ -85,9 +84,7 @@ void WaveShaperDSPKernel::process(std::span<const float> source, std::span<float
 void WaveShaperDSPKernel::processCurve(std::span<const float> source, std::span<float> destination)
 {
     assertIsHeld(waveShaperProcessor()->processLock());
-    RefPtr curve = waveShaperProcessor()->curve();
-    auto curveData = curve ? curve->typedMutableSpan() : std::span<float> { };
-    processCurveWithData(source, destination, curveData);
+    processCurveWithData(source, destination, waveShaperProcessor()->curve().span());
 }
 
 void WaveShaperDSPKernel::processCurveWithData(std::span<const float> source, std::span<float> destination, std::span<const float> curveData)
diff --git a/Source/WebCore/Modules/webaudio/WaveShaperNode.cpp b/Source/WebCore/Modules/webaudio/WaveShaperNode.cpp
index 5eee28103a9e..872b213dac99 100644
--- a/Source/WebCore/Modules/webaudio/WaveShaperNode.cpp
+++ b/Source/WebCore/Modules/webaudio/WaveShaperNode.cpp
@@ -81,27 +81,22 @@ ExceptionOr<void> WaveShaperNode::setCurveForBindings(RefPtr<Float32Array>&& cur
     if (curve && curve->length() < 2)
         return Exception { ExceptionCode::InvalidStateError, "Length of curve array cannot be less than 2"_s };
 
-    if (curve) {
-        // The specification states that we should maintain an internal copy of the curve so that
-        // subsequent modifications of the contents of the array have no effect.
-        auto clonedCurve = Float32Array::create(curve->data(), curve->length());
-        curve = WTF::move(clonedCurve);
-    }
-
-    waveShaperProcessor()->setCurveForBindings(curve.get());
+    // The specification states that we should maintain an internal copy of the curve so that
+    // subsequent modifications of the contents of the array have no effect.
+    waveShaperProcessor()->setCurveForBindings(curve ? Vector<float>(curve->typedSpan()) : Vector<float>());
     return { };
 }
 
 RefPtr<Float32Array> WaveShaperNode::curveForBindings()
 {
     ASSERT(isMainThread());
-    RefPtr curve = waveShaperProcessor()->curveForBindings();
-    if (!curve)
+    auto& curve = waveShaperProcessor()->curveForBindings();
+    if (curve.isEmpty())
         return nullptr;
 
     // Make a clone of our internal array so that JS cannot modify our internal array
     // on the main thread while the audio thread is using it for rendering.
-    return Float32Array::create(curve->data(), curve->length());
+    return Float32Array::create(curve.span());
 }
 
 static inline WaveShaperProcessor::OverSampleType NODELETE processorType(OverSampleType type)
@@ -149,8 +144,7 @@ bool WaveShaperNode::propagatesSilence() const
         return false;
 
     Locker locker { AdoptLock, waveShaperProcessor()->processLock() };
-    RefPtr curve = waveShaperProcessor()->curve();
-    return !curve || !curve->length();
+    return waveShaperProcessor()->curve().isEmpty();
 }
 
 } // namespace WebCore
diff --git a/Source/WebCore/Modules/webaudio/WaveShaperNode.h b/Source/WebCore/Modules/webaudio/WaveShaperNode.h
index aee319c0512d..f0c7cd664eca 100644
--- a/Source/WebCore/Modules/webaudio/WaveShaperNode.h
+++ b/Source/WebCore/Modules/webaudio/WaveShaperNode.h
@@ -30,6 +30,7 @@
 #include "OverSampleType.h"
 #include "WaveShaperOptions.h"
 #include "WaveShaperProcessor.h"
+#include <JavaScriptCore/Forward.h>
 #include <wtf/Forward.h>
 
 namespace WebCore {
diff --git a/Source/WebCore/Modules/webaudio/WaveShaperProcessor.cpp b/Source/WebCore/Modules/webaudio/WaveShaperProcessor.cpp
index 682d466d94c1..1d4b6dae893e 100644
--- a/Source/WebCore/Modules/webaudio/WaveShaperProcessor.cpp
+++ b/Source/WebCore/Modules/webaudio/WaveShaperProcessor.cpp
@@ -29,7 +29,6 @@
 #include "WaveShaperProcessor.h"
 
 #include "WaveShaperDSPKernel.h"
-#include <JavaScriptCore/Float32Array.h>
 #include <wtf/TZoneMallocInlines.h>
 
 namespace WebCore {
@@ -52,13 +51,13 @@ std::unique_ptr<AudioDSPKernel> WaveShaperProcessor::createKernel()
     return makeUnique<WaveShaperDSPKernel>(this);
 }
 
-void WaveShaperProcessor::setCurveForBindings(Float32Array* curve)
+void WaveShaperProcessor::setCurveForBindings(Vector<float>&& curve)
 {
     ASSERT(isMainThread());
     // This synchronizes with process().
     Locker locker { m_processLock };
 
-    m_curve = curve;
+    m_curve = WTF::move(curve);
 }
 
 void WaveShaperProcessor::setOversampleForBindings(OverSampleType oversample)
diff --git a/Source/WebCore/Modules/webaudio/WaveShaperProcessor.h b/Source/WebCore/Modules/webaudio/WaveShaperProcessor.h
index 3c1e8762fc3a..a1d4ce94ab6a 100644
--- a/Source/WebCore/Modules/webaudio/WaveShaperProcessor.h
+++ b/Source/WebCore/Modules/webaudio/WaveShaperProcessor.h
@@ -27,11 +27,10 @@
 #include "AudioDSPKernel.h"
 #include "AudioDSPKernelProcessor.h"
 #include "AudioNode.h"
-#include <JavaScriptCore/Forward.h>
 #include <memory>
 #include <wtf/Lock.h>
-#include <wtf/RefPtr.h>
 #include <wtf/TZoneMalloc.h>
+#include <wtf/Vector.h>
 
 namespace WebCore {
 
@@ -55,9 +54,9 @@ class WaveShaperProcessor final : public AudioDSPKernelProcessor {
 
     void process(const AudioBus& source, AudioBus& destination, size_t framesToProcess) final;
 
-    void setCurveForBindings(Float32Array*);
-    Float32Array* curveForBindings() WTF_IGNORES_THREAD_SAFETY_ANALYSIS { ASSERT(isMainThread()); return m_curve.get(); } // Doesn't grab the lock, only safe to call on the main thread.
-    Float32Array* curve() const WTF_REQUIRES_LOCK(m_processLock) { return m_curve.get(); }
+    void setCurveForBindings(Vector<float>&&);
+    const Vector<float>& curveForBindings() const LIFETIME_BOUND WTF_IGNORES_THREAD_SAFETY_ANALYSIS { ASSERT(isMainThread()); return m_curve; } // Doesn't grab the lock, only safe to call on the main thread.
+    const Vector<float>& curve() const LIFETIME_BOUND WTF_REQUIRES_LOCK(m_processLock) { return m_curve; }
 
     void setOversampleForBindings(OverSampleType);
     OverSampleType oversampleForBindings() const WTF_IGNORES_THREAD_SAFETY_ANALYSIS { ASSERT(isMainThread()); return m_oversample; } // Doesn't grab the lock, only safe to call on the main thread.
@@ -69,7 +68,7 @@ class WaveShaperProcessor final : public AudioDSPKernelProcessor {
     Type processorType() const final { return Type::WaveShaper; }
 
     // m_curve represents the non-linear shaping curve.
-    RefPtr<Float32Array> m_curve WTF_GUARDED_BY_LOCK(m_processLock);
+    Vector<float> m_curve WTF_GUARDED_BY_LOCK(m_processLock);
 
     OverSampleType m_oversample WTF_GUARDED_BY_LOCK(m_processLock) { OverSampleNone };
 
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.