Released May 13, 2024

CVE-2024-27830

CVSS 6.5 severity medium vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N

Overview

Impact:

Description: A maliciously crafted webpage may be able to fingerprint the user

Researchers: Joe Rutkowski (@Joe12387) of Crawless and @abrahamjuliot

Key insight. The canvas noise-injection salt and privacy flags were plumbed to dedicated workers but not to shared/service workers, whose remote Page had neither, so OffscreenCanvas readback there was un-noised; extending the plumbing restores noise in all worker types.

Patch Details

diff --git a/Source/WTF/wtf/PlatformEnable.h b/Source/WTF/wtf/PlatformEnable.h
index 38d367cb578c..eef9de469a96 100644
--- a/Source/WTF/wtf/PlatformEnable.h
+++ b/Source/WTF/wtf/PlatformEnable.h
@@ -421,10 +421,6 @@
 #define ENABLE_MOUSE_FORCE_EVENTS 1
 #endif
 
-#if !defined(ENABLE_NETSCAPE_PLUGIN_METADATA_CACHE)
-#define ENABLE_NETSCAPE_PLUGIN_METADATA_CACHE 0
-#endif
-
 #if !defined(ENABLE_NOTIFICATION_EVENT)
 #define ENABLE_NOTIFICATION_EVENT 0
 #endif

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2024-27830
Bugzilla 271159
Component WebCore
Bug Class LogicError
Severity medium
Commit c33df2d323602a9e…
Advisory Apple Advisory

Terminology

  • Canvas fingerprinting — Deriving a stable identifier from subtle per-device/OS differences in canvas/OffscreenCanvas pixel output.
  • Noise injection (Advanced Privacy Protections) — In Private Browsing WebKit perturbs canvas readback using a per-origin hash salt so the pixels cannot be used as a fingerprint.
  • Shared/service workers and remote Page — These workers run in a separate, potentially-remote Page; state like the noise salt must be explicitly plumbed to them or it is absent.
  • WorkerParameters plumbing — The mechanism that carries context state (including privacy flags/salt) from the originating document to a worker global scope.

Root Cause Analysis

This fixes a canvas anti-fingerprinting gap: in Private Browsing, pixel-readback noise injection was not applied to OffscreenCanvas used inside shared workers and service workers. In Private Browsing (Advanced Privacy Protections), each ScriptExecutionContext carries a per-origin noise-injection hash salt and AdvancedPrivacyProtections flags, sourced from the document loader, and these are used to perturb pixels read back from canvas/OffscreenCanvas so the output cannot be used as a stable fingerprint. For dedicated workers the salt/flags were already plumbed via WorkerParameters to the WorkerGlobalScope, so OffscreenCanvas there got noise. But shared workers and service workers run their OffscreenCanvas code in a separate, potentially-remote Page that had neither the hash salt nor the AdvancedPrivacyProtections flags, so readback in those contexts received NO noise — letting a site fingerprint the device/OS via OffscreenCanvas from a shared/service worker, bypassing the Private Browsing protection. The fix extends the AdvancedPrivacyProtections plumbing to those two worker types: it adds an override point (ScriptExecutionContext::advancedPrivacyProtections, implemented by Document via the top document’s loader and by workers via passed-in parameters), threads the flags through WorkerScriptLoader / WorkerInitializationData / WorkerParameters, and lets Page::setupForRemoteWorker receive the privacy protections when initializing the remote Page. The restored invariant is that canvas/OffscreenCanvas readback noise is applied in all worker types (dedicated, shared, service) when Private Browsing protections are active. The test verifies noise injection for OffscreenCanvas in a shared worker.

Attack Path

1. Enter Private Browsing

The user browses privately, expecting canvas readback to be noised against fingerprinting.

2. Use OffscreenCanvas in a shared/service worker

The page renders to an OffscreenCanvas inside a shared or service worker, whose remote Page lacked the salt and privacy flags.

3. Read back un-noised pixels

Because noise injection was not applied in that context, the readback returns the true, un-perturbed pixels.

4. Fingerprint the device

Derive a stable device/OS fingerprint from the un-noised output, defeating the Private Browsing protection.

Impact Assessment

A privacy/anti-fingerprinting gap in the WebContent process with no memory corruption: OffscreenCanvas readback in shared/service workers escaped Private Browsing noise injection, allowing device/OS fingerprinting that the protection is meant to prevent. The risk is tracking/deanonymization of Private Browsing users, not code execution.

Audit Directions

Privacy-flag plumbing across contexts

Audit every ScriptExecutionContext subtype (Document, dedicated/shared/service workers, worklets) for consistent propagation of the noise salt and AdvancedPrivacyProtections used by canvas/OffscreenCanvas.

Remote-Page state gaps

Grep Page::setupForRemoteWorker and remote-worker init for other per-origin privacy/security state that a remote Page may be missing.

Changed Functions

Function File Change Note
ScriptExecutionContext::advancedPrivacyProtections (override) / Document::advancedPrivacyProtections / noiseInjectionPolicy Source/WebCore/dom/Document.cpp modified Adds an override point returning the active advanced-privacy-protection flags; Document sources them from the top document’s loader.
Page::setupForRemoteWorker Source/WebCore/page/Page.cpp modified Lets shared/service workers pass in privacy protections when initializing the remote Page, so its contexts carry the flags.
WorkerScriptLoader (advancedPrivacyProtections) / WorkerInitializationData / WorkerParameters Source/WebCore/workers/WorkerScriptLoader.cpp modified Tracks the active privacy protections for the loading worker and plumbs them into WorkerInitializationData/WorkerParameters for shared/service workers.
WorkerGlobalScope / WorkerOrWorkletGlobalScope constructors Source/WebCore/workers/WorkerGlobalScope.cpp modified Propagates the passed-in privacy-protection state into the worker global scope so OffscreenCanvas readback applies noise.

Files Changed

Source Files

  • Source/WebCore/Modules/webaudio/AudioWorkletMessagingProxy.cpp
  • Source/WebCore/dom/Document.cpp
  • Source/WebCore/dom/Document.h
  • Source/WebCore/dom/EmptyScriptExecutionContext.h
  • Source/WebCore/dom/ScriptExecutionContext.h
  • Source/WebCore/page/Page.cpp
  • Source/WebCore/page/Page.h
  • Source/WebCore/workers/Worker.cpp
  • Source/WebCore/workers/WorkerGlobalScope.cpp
  • Source/WebCore/workers/WorkerInitializationData.h
  • Source/WebCore/workers/WorkerMessagingProxy.cpp
  • Source/WebCore/workers/WorkerOrWorkletGlobalScope.cpp
  • Source/WebCore/workers/WorkerOrWorkletGlobalScope.h
  • Source/WebCore/workers/WorkerScriptLoader.cpp
  • Source/WebCore/workers/WorkerScriptLoader.h
  • Source/WebCore/workers/WorkerThread.cpp
  • Source/WebCore/workers/WorkerThread.h
  • Source/WebCore/workers/service/ServiceWorkerClientData.cpp
  • Source/WebCore/workers/service/ServiceWorkerClientData.h
  • Source/WebCore/workers/service/context/ServiceWorkerThread.cpp
  • Source/WebCore/workers/service/context/ServiceWorkerThread.h
  • Source/WebCore/workers/service/context/ServiceWorkerThreadProxy.cpp
  • Source/WebCore/workers/service/server/SWServer.cpp
  • Source/WebCore/workers/service/server/SWServer.h
  • Source/WebCore/workers/service/server/SWServerToContextConnection.h
  • Source/WebCore/workers/shared/SharedWorkerScriptLoader.cpp
  • Source/WebCore/workers/shared/context/SharedWorkerThreadProxy.cpp
  • Source/WebCore/worklets/WorkletGlobalScope.cpp
  • Source/WebCore/worklets/WorkletParameters.h
  • Source/WebKit/NetworkProcess/ServiceWorker/WebSWServerConnection.cpp
  • Source/WebKit/NetworkProcess/ServiceWorker/WebSWServerToContextConnection.cpp
  • Source/WebKit/NetworkProcess/ServiceWorker/WebSWServerToContextConnection.h
  • Source/WebKit/Shared/WebCoreArgumentCoders.serialization.in
  • Source/WebKit/WebProcess/Storage/WebSWContextManagerConnection.cpp
  • Source/WebKit/WebProcess/Storage/WebSWContextManagerConnection.h
  • Source/WebKit/WebProcess/Storage/WebSWContextManagerConnection.messages.in
  • Source/WebKit/WebProcess/Storage/WebSharedWorkerContextManagerConnection.cpp

Test Files

  • Tools/TestWebKitAPI/Tests/WebKit/AdvancedPrivacyProtections.mm

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 625ac7438b10..907dd2c05883 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/AdvancedPrivacyProtections.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/AdvancedPrivacyProtections.mm
@@ -1157,6 +1157,96 @@ TEST(AdvancedPrivacyProtections, Canvas2DQuirks)
     EXPECT_TRUE(finishedSuccessfully);
 }
 
+inline static String sharedWorkerMainBytes()
+{
+    return R"TESTRESOURCE(
+        <!DOCTYPE html>
+        <html>
+        <head>
+        <script>
+        const worker = new SharedWorker("SharedWorker.js");
+        worker.port.onmessage = function(event) {
+            document.querySelector("code").textContent = event.data;
+        };
+        worker.port.postMessage({ command: "draw" });
+        </script>
+        </head>
+        <body>
+            <h1>SharedWorker</h1>
+            The result is: <code></code>
+        </body>
+        </html>
+        )TESTRESOURCE"_s;
+}
+
+inline static String sharedWorkerBytes()
+{
+    return R"TESTRESOURCE(
+        onconnect = (event) => {
+            const port = event.ports[0];
+            port.onmessage = function(event) {
+                if (event.data.command !== "draw")
+                    return;
+
+                const offscreen = new OffscreenCanvas(200, 200);
+                const context = offscreen.getContext("2d");
+
+                const gradient = context.createLinearGradient(0, 0, 200, 0);
+                gradient.addColorStop(0, "red");
+                gradient.addColorStop(1, "blue");
+
+                context.fillStyle = gradient;
+                context.fillRect(0, 0, 200, 200);
+
+                context.fillStyle = "white";
+                context.font = "48px serif";
+                context.fillText("🙃", 10, 50);
+
+                offscreen.convertToBlob().then(blob => {
+                    const reader = new FileReader;
+                    reader.onloadend = async () => {
+                        const base64 = reader.result.split(",")[1];
+                        const bytes = new Uint8Array(atob(base64).split("").map(char => char.charCodeAt(0)));
+                        const hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
+                        const hashArray = Array.from(new Uint8Array(hashBuffer));
+                        const hexRepresentation = hashArray.map(byte => byte.toString(16).padStart(2, "0")).join("");
+                        port.postMessage(hexRepresentation);
+                    };
+                    reader.readAsDataURL(blob);
+                });
+            };
+        };
+        )TESTRESOURCE"_s;
+}
+
+TEST(AdvancedPrivacyProtections, NoiseInjectionForOffscreenCanvasInSharedWorker)
+{
+    auto server = HTTPServer { {
+        { "/"_s, { sharedWorkerMainBytes() } },
+        { "/SharedWorker.js"_s, { { { "Content-Type"_s, "text/javascript"_s } }, sharedWorkerBytes() } }
+    } };
+
+    auto computeHash = [&](TestWKWebView *webView) {
+        [webView synchronouslyLoadRequest:server.request("/"_s)];
+
+        RetainPtr<NSString> result;
+        Util::waitForConditionWithLogging([&] {
+            result = [webView stringByEvaluatingJavaScript:@"document.querySelector('code').textContent"];
+            return [result length] > 0;
+        }, 3, @"Failed to compute hash using OffscreenCanvas.");
+        return result.autorelease();
+    };
+
+    RetainPtr hashWithoutNoiseInjectionInEphemeralStore = computeHash(createWebViewWithAdvancedPrivacyProtections(NO, nil, WKWebsiteDataStore.nonPersistentDataStore).get());
+    RetainPtr hashWithoutNoiseInjectionInDefaultStore = computeHash(createWebViewWithAdvancedPrivacyProtections(NO, nil, WKWebsiteDataStore.defaultDataStore).get());
+    RetainPtr hashWithNoiseInjectionInEphemeralStore = computeHash(createWebViewWithAdvancedPrivacyProtections(YES, nil, WKWebsiteDataStore.nonPersistentDataStore).get());
+    EXPECT_NOT_NULL(hashWithoutNoiseInjectionInEphemeralStore);
+    EXPECT_NOT_NULL(hashWithoutNoiseInjectionInDefaultStore);
+    EXPECT_NOT_NULL(hashWithNoiseInjectionInEphemeralStore);
+    EXPECT_FALSE([hashWithNoiseInjectionInEphemeralStore isEqual:hashWithoutNoiseInjectionInDefaultStore.get()]);
+    EXPECT_FALSE([hashWithNoiseInjectionInEphemeralStore isEqual:hashWithoutNoiseInjectionInEphemeralStore.get()]);
+}
+
 } // namespace TestWebKitAPI
 
 #endif // ENABLE(ADVANCED_PRIVACY_PROTECTIONS)