CVE-2024-27830
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.cppSource/WebCore/dom/Document.cppSource/WebCore/dom/Document.hSource/WebCore/dom/EmptyScriptExecutionContext.hSource/WebCore/dom/ScriptExecutionContext.hSource/WebCore/page/Page.cppSource/WebCore/page/Page.hSource/WebCore/workers/Worker.cppSource/WebCore/workers/WorkerGlobalScope.cppSource/WebCore/workers/WorkerInitializationData.hSource/WebCore/workers/WorkerMessagingProxy.cppSource/WebCore/workers/WorkerOrWorkletGlobalScope.cppSource/WebCore/workers/WorkerOrWorkletGlobalScope.hSource/WebCore/workers/WorkerScriptLoader.cppSource/WebCore/workers/WorkerScriptLoader.hSource/WebCore/workers/WorkerThread.cppSource/WebCore/workers/WorkerThread.hSource/WebCore/workers/service/ServiceWorkerClientData.cppSource/WebCore/workers/service/ServiceWorkerClientData.hSource/WebCore/workers/service/context/ServiceWorkerThread.cppSource/WebCore/workers/service/context/ServiceWorkerThread.hSource/WebCore/workers/service/context/ServiceWorkerThreadProxy.cppSource/WebCore/workers/service/server/SWServer.cppSource/WebCore/workers/service/server/SWServer.hSource/WebCore/workers/service/server/SWServerToContextConnection.hSource/WebCore/workers/shared/SharedWorkerScriptLoader.cppSource/WebCore/workers/shared/context/SharedWorkerThreadProxy.cppSource/WebCore/worklets/WorkletGlobalScope.cppSource/WebCore/worklets/WorkletParameters.hSource/WebKit/NetworkProcess/ServiceWorker/WebSWServerConnection.cppSource/WebKit/NetworkProcess/ServiceWorker/WebSWServerToContextConnection.cppSource/WebKit/NetworkProcess/ServiceWorker/WebSWServerToContextConnection.hSource/WebKit/Shared/WebCoreArgumentCoders.serialization.inSource/WebKit/WebProcess/Storage/WebSWContextManagerConnection.cppSource/WebKit/WebProcess/Storage/WebSWContextManagerConnection.hSource/WebKit/WebProcess/Storage/WebSWContextManagerConnection.messages.inSource/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)