CVE-2026-39872
Overview
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.
Attack Path
- 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.
- 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.
- 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.
- 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
WaveShaperDSPKernel::processCurveSource/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::setCurveForBindingsSource/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::curveForBindingsSource/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::propagatesSilenceSource/WebCore/Modules/webaudio/WaveShaperNode.cpp |
modified | Uses waveShaperProcessor()->curve().isEmpty() under the lock rather than testing a RefPtr<Float32Array> and its length. |
WaveShaperProcessor::setCurveForBindingsSource/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_curveSource/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 includesSource/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.txtLayoutTests/webaudio/WaveShaper/waveshaper-curve-getter-during-rendering-crash.htmlSource/WebCore/Modules/webaudio/WaveShaperDSPKernel.cppSource/WebCore/Modules/webaudio/WaveShaperNode.cppSource/WebCore/Modules/webaudio/WaveShaperNode.hSource/WebCore/Modules/webaudio/WaveShaperProcessor.cppSource/WebCore/Modules/webaudio/WaveShaperProcessor.h
Audit Directions
- Other WaveShaper/curve accessorsRecheck 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 arraysAudit 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-onlySearch 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 generallyLook 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.
Patch
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 };