CVE-2024-27850
Overview
Impact:
Description: A maliciously crafted webpage may be able to fingerprint the user
Researchers: an anonymous researcher
Key insight. Fixed, uniform audio noise gated by a single flag could be cancelled by looping one sample and averaging; making the noise normally-distributed, deterministic per repeated value, and loop-count-amplified for AudioBufferSourceNode defeats the averaging attack.
Patch Details
diff --git a/Source/WebKit/ChangeLog b/Source/WebKit/ChangeLog
index 30328cf6e845..c83f5658a6fb 100644
--- a/Source/WebKit/ChangeLog
+++ b/Source/WebKit/ChangeLog
@@ -1,3 +1,13 @@
+2020-12-14 Carlos Garcia Campos <[email protected]>
+
+ [WPE][GTK] Should enable WebProcessCache
+ https://bugs.webkit.org/show_bug.cgi?id=219689
+
+ Reviewed by Adrian Perez de Castro.
+
+ * UIProcess/API/glib/WebKitWebContext.cpp:
+ (webkitWebContextConstructed):
+
2020-12-13 Yusuke Suzuki <[email protected]>
[JSC] Introduce vmEntryCustomAccessor and vmEntryHostFunction for JITCage
diff --git a/Source/WebKit/UIProcess/API/glib/WebKitWebContext.cpp b/Source/WebKit/UIProcess/API/glib/WebKitWebContext.cpp
index f7faa5880bf0..acf9ccdb6d12 100644
--- a/Source/WebKit/UIProcess/API/glib/WebKitWebContext.cpp
+++ b/Source/WebKit/UIProcess/API/glib/WebKitWebContext.cpp
@@ -391,6 +391,7 @@ static void webkitWebContextConstructed(GObject* object)
API::ProcessPoolConfiguration configuration;
configuration.setInjectedBundlePath(FileSystem::stringFromFileSystemRepresentation(bundleFilename.get()));
+ configuration.setUsesWebProcessCache(true);
#if PLATFORM(GTK)
configuration.setProcessSwapsOnNavigation(priv->psonEnabled);
#if !USE(GTK4)
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2024-27850 |
| Bugzilla | 270767 |
| Component | WebCore |
| Bug Class | LogicError |
| Severity | medium |
| Commit | 775bac300c8b0068… |
| Advisory | Apple Advisory |
Terminology
- Audio fingerprinting — Deriving a stable device/OS identifier from subtle differences in Web Audio processing output.
- Noise injection (Private Browsing) — WebKit perturbs audio readback with random noise so the output cannot be used as a stable fingerprint.
- Averaging attack — Repeating a computation and averaging the noised results to cancel zero-mean noise and recover the true value; looping a sample is one way to repeat cheaply.
- Normally-distributed vs uniform noise — Normal (Gaussian) noise resists simple min/max averaging far better than uniform noise, raising the cost of the attack.
Root Cause Analysis
This hardens Web Audio anti-fingerprinting noise injection in Private Browsing against an averaging attack. To defeat audio fingerprinting, WebKit injects noise into audio readback (e.g. via OfflineAudioContext) in Private Browsing. The pre-patch scheme used a fixed, uniformly-distributed noise level (0.001) gated by a single boolean flag (m_needsAdditionalNoise); an attacker could negate it by looping one small audio sample many times within a single audio buffer and averaging the repeated readbacks, converging on the true (un-noised) value and recovering a stable fingerprint. The fix implements several mitigations. It replaces the boolean m_needsAdditionalNoise with an m_noiseInjectionMultiplier (the standard deviation of the injected noise) so noise magnitude can vary. It switches from uniform to NORMALLY-distributed noise, which does not converge under simple min/max averaging and requires far more iterations to average out. It caches previously-generated random values and reapplies the same noise to values encountered repeatedly, so recomputing the same value many times in one buffer yields no new information. And it lets node types known to expose hardware/OS differences inject more noise than the 0.1% baseline: DynamicsCompressorNode and OscillatorNode raise the level, and crucially AudioBufferSourceNode amplifies noise with loop count — for a source looping more than 200 times it boosts noise by a factor of 0.005 per loop, so looping a tiny sample back-to-back in a large buffer (the exact averaging technique) produces a massive amount of noise instead of a recoverable average. New plumbing (AudioNode::noiseInjectionMultiplier subclassing hook, AudioNodeOutput::forEachInputNode) propagates the multiplier through the processing graph. The restored invariant is that averaging/looping cannot cancel the injected noise, so audio fingerprinting stays defeated in Private Browsing.
Attack Path
1. Build a looping audio graph
In Private Browsing, use OfflineAudioContext with an AudioBufferSourceNode that loops a tiny sample many times in a large buffer.
2. Read back repeatedly
Read the rendered audio buffer, obtaining many noised copies of the same underlying value.
3. Average out the noise
Average the repeated readbacks; with uniform fixed noise the mean converges on the true value.
4. Recover the fingerprint
Reconstruct the un-noised audio characteristics to fingerprint the device/OS despite the anti-fingerprinting noise.
Impact Assessment
A privacy/anti-fingerprinting robustness fix in the WebContent process with no memory corruption: the prior noise injection could be averaged away by looping an audio buffer, letting a site fingerprint users in Private Browsing. The mitigations make that averaging impractical; the risk is tracking/deanonymization, not code execution.
Audit Directions
Same module: noise coverage
Audit AudioBuffer/AudioNode readback paths (worklets, OfflineAudioContext render, copyTo) to confirm every readback applies the multiplier-scaled noise and reuses cached randoms for repeated values.
Other averageable anti-fingerprinting noise
Review canvas/WebGL/other fingerprinting mitigations that add fixed, independent noise per read, which a repeat-and-average attack could similarly cancel.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
AudioBuffer::applyNoiseIfNeeded / copyToChannel / copyTo / zero / releaseMemory |
Source/WebCore/Modules/webaudio/AudioBuffer.cpp |
modified | Uses normally-distributed noise scaled by m_noiseInjectionMultiplier (replacing the fixed-level boolean), and caches/reapplies random values so repeated values get identical noise. |
AudioBuffer noise multiplier accessors |
Source/WebCore/Modules/webaudio/AudioBuffer.h |
modified | Replaces setNeedsAdditionalNoise with increaseNoiseInjectionMultiplier / noiseInjectionMultiplier (the noise standard deviation). |
AudioBufferSourceNode::noiseInjectionMultiplier |
Source/WebCore/Modules/webaudio/AudioBufferSourceNode.cpp |
modified | Boosts noise for a source looping >200 times (factor 0.005 per loop), so looping a tiny sample in a large buffer yields massive noise instead of an averageable value. |
AudioNode::noiseInjectionMultiplier / AudioNodeOutput::forEachInputNode |
Source/WebCore/Modules/webaudio/AudioNode.h |
modified | Adds a subclassing hook and graph-traversal helper so specific node types (DynamicsCompressorNode, OscillatorNode) can inject extra noise propagated through the processing graph. |
Files Changed
Source Files
Source/WebCore/Modules/webaudio/AudioBasicProcessorNode.hSource/WebCore/Modules/webaudio/AudioBuffer.cppSource/WebCore/Modules/webaudio/AudioBuffer.hSource/WebCore/Modules/webaudio/AudioBufferSourceNode.cppSource/WebCore/Modules/webaudio/AudioBufferSourceNode.hSource/WebCore/Modules/webaudio/AudioNode.hSource/WebCore/Modules/webaudio/AudioNodeOutput.cppSource/WebCore/Modules/webaudio/AudioNodeOutput.hSource/WebCore/Modules/webaudio/AudioWorkletNode.cppSource/WebCore/Modules/webaudio/BaseAudioContext.hSource/WebCore/Modules/webaudio/DynamicsCompressorNode.hSource/WebCore/Modules/webaudio/OfflineAudioContext.cppSource/WebCore/Modules/webaudio/OfflineAudioContext.hSource/WebCore/Modules/webaudio/OscillatorNode.hSource/WebCore/platform/audio/AudioUtilities.cppSource/WebCore/platform/audio/AudioUtilities.h
Test Files
Tools/TestWebKitAPI/Tests/WebKit/AdvancedPrivacyProtections.mmTools/TestWebKitAPI/Tests/WebKitCocoa/audio-fingerprinting.html
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/AdvancedPrivacyProtections.mm b/Tools/TestWebKitAPI/Tests/WebKit/AdvancedPrivacyProtections.mm
index a72c56140bbb..e322eb865738 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/AdvancedPrivacyProtections.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/AdvancedPrivacyProtections.mm
@@ -877,6 +877,7 @@ TEST(AdvancedPrivacyProtections, AddNoiseToWebAudioAPIs)
checkFingerprintForNoise(@"testOscillatorCompressor");
checkFingerprintForNoise(@"testOscillatorCompressorWorklet");
checkFingerprintForNoise(@"testOscillatorCompressorAnalyzer");
+ checkFingerprintForNoise(@"testLoopingOscillatorCompressorBiquadFilter");
}
// FIXME when rdar://115137641 is resolved.
diff --git a/Tools/TestWebKitAPI/Tests/WebKitCocoa/audio-fingerprinting.html b/Tools/TestWebKitAPI/Tests/WebKitCocoa/audio-fingerprinting.html
index 11201a33ae54..108373919a07 100644
--- a/Tools/TestWebKitAPI/Tests/WebKitCocoa/audio-fingerprinting.html
+++ b/Tools/TestWebKitAPI/Tests/WebKitCocoa/audio-fingerprinting.html
@@ -32,8 +32,10 @@ button {
<script>
function combineSamples(samples) {
let result = 0;
- for (const value of samples)
- result += value;
+ for (const value of samples) {
+ if (isFinite(value))
+ result += value;
+ }
return result;
}
@@ -145,6 +147,109 @@ async function testOscillatorCompressorAnalyzer() {
return combineSamples(bins);
}
+
+async function testLoopingOscillatorCompressorBiquadFilter() {
+ const sampleRate = 44100;
+ const cloneCount = 40000;
+ const targetSampleIndex = 3395;
+ const stabilizationPrecision = 6.2;
+
+ async function getBaseAudioFingerprint() {
+ const baseSignal = await (async function() {
+ const context = new OfflineAudioContext(1, targetSampleIndex + 1, 44100);
+
+ const oscillator = context.createOscillator();
+ oscillator.type = "square";
+ oscillator.frequency.value = 1000;
+
+ const compressor = context.createDynamicsCompressor();
+ compressor.threshold.value = -70;
+ compressor.knee.value = 40;
+ compressor.ratio.value = 12;
+ compressor.attack.value = 0;
+ compressor.release.value = 0.25;
+
+ const filter = context.createBiquadFilter();
+ filter.type = "allpass";
+ filter.frequency.value = 5.239622852977861;
+ filter.Q.value = 0.1;
+
+ oscillator.connect(compressor);
+ compressor.connect(filter);
+ filter.connect(context.destination);
+ oscillator.start(0);
+
+ return await context.startRendering();
+ })();
+
+ const context = new OfflineAudioContext(1, baseSignal.length - 1 + cloneCount, sampleRate);
+ const sourceNode = context.createBufferSource();
+ sourceNode.buffer = baseSignal;
+ sourceNode.loop = true;
+ sourceNode.loopStart = (baseSignal.length - 1) / sampleRate;
+ sourceNode.loopEnd = baseSignal.length / sampleRate;
+ sourceNode.connect(context.destination);
+ sourceNode.start();
+
+ const clonedSignal = await context.startRendering();
+ const fingerprint = extractFingerprint(baseSignal, clonedSignal.getChannelData(0).subarray(baseSignal.length - 1));
+ return Math.abs(fingerprint);
+ }
+
+ function extractFingerprint(baseSignal, clonedSample) {
+ let fingerprint;
+ let needsDenoising = false;
+
+ for (let i = 0; i < clonedSample.length; i += Math.floor(clonedSample.length / 10)) {
+ if (clonedSample[i] === 0) {
+ // Intentionally ignored.
+ continue;
+ }
+
+ if (fingerprint === undefined) {
+ fingerprint = clonedSample[i];
+ continue;
+ }
+
+ if (fingerprint !== clonedSample[i]) {
+ needsDenoising = true;
+ break;
+ }
+ }
+
+ if (fingerprint === undefined)
+ fingerprint = baseSignal.getChannelData(0)[baseSignal.length - 1];
+ else if (needsDenoising)
+ fingerprint = getMean(clonedSample);
+
+ return fingerprint;
+ }
+
+ function getMean(signal) {
+ let total = 0;
+ let finiteValueCount = 0;
+ for (let i = 0; i < signal.length; i++) {
+ const value = signal[i];
+ if (!isFinite(value))
+ continue;
+ total += value;
+ finiteValueCount++;
+ }
+ return finiteValueCount ? (total / finiteValueCount) : 0;
+ }
+
+ function stabilize(value, precision) {
+ if (value === 0)
+ return value;
+ const power = Math.floor(Math.log10(Math.abs(value)));
+ const precisionPower = power - Math.floor(precision) + 1;
+ const precisionBase = 10 ** -precisionPower * ((precision * 10) % 10 || 1);
+ return Math.round(value * precisionBase) / precisionBase;
+ }
+
+ const rawFingerprint = await getBaseAudioFingerprint();
+ return stabilize(rawFingerprint, stabilizationPrecision);
+}
</script>
</body>
</html>