CVE-2025-30425
Overview
Impact:
Description: A malicious website may be able to track users in Safari private browsing mode
Researchers: an anonymous researcher
Key insight. ClearKey session IDs came from a process-global static counter, so the script-visible sessionId persisted and incremented across instances and contexts; scoping the counter per CDM instance removes the cross-context tracking signal.
Patch Details
diff --git a/LayoutTests/http/tests/media/clearkey/clear-key-session-id-expected.txt b/LayoutTests/http/tests/media/clearkey/clear-key-session-id-expected.txt
new file mode 100644
index 000000000000..bcf6b799c2a9
--- /dev/null
+++ b/LayoutTests/http/tests/media/clearkey/clear-key-session-id-expected.txt
@@ -0,0 +1,11 @@
+Test that ClearKey session identifiers do not persist across MediaKeySystem instances.
+-
+Create the first pair of sessions
+EXPECTED (session1.sessionId == '0') OK
+EXPECTED (session2.sessionId == '1') OK
+-
+Create the second pair of sessions
+EXPECTED (session1.sessionId == '0') OK
+EXPECTED (session2.sessionId == '1') OK
+END OF TEST
+
diff --git a/LayoutTests/http/tests/media/clearkey/clear-key-session-id.html b/LayoutTests/http/tests/media/clearkey/clear-key-session-id.html
new file mode 100644
index 000000000000..46982acafb80
--- /dev/null
+++ b/LayoutTests/http/tests/media/clearkey/clear-key-session-id.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>clear-key-session-id</title>
+ <script src=../../media-resources/video-test.js></script>
+ <script src="support.js"></script>
+ <script>
+
+ var session1;
+ var session2;
+
+ async function runTest() {
+ let createTwoSessions = async () => {
+ const config = [{ "initDataTypes":["webm"], "audioCapabilities":[{"contentType":"audio/mp4"}]}];
+ let mediaKeySystem = await navigator.requestMediaKeySystemAccess("org.w3.clearkey", config);
+ let mediaKeys = await mediaKeySystem.createMediaKeys();
+
+ const e = new Uint8Array([0]);
+
+ session1 = await mediaKeys.createSession();
+ let request = await session1.generateRequest("webm", e);
+
+ testExpected('session1.sessionId', '0');
+
+ session2 = await mediaKeys.createSession();
+ request = await session2.generateRequest("webm", e);
+
+ testExpected('session2.sessionId', '1');
+ };
+
+ consoleWrite('-');
+ consoleWrite('Create the first pair of sessions');
+ await createTwoSessions();
+
+ consoleWrite('-');
+ consoleWrite('Create the second pair of sessions');
+ await createTwoSessions();
+ }
+
+ window.addEventListener('load', event => {
+ runTest().then(endTest).catch(failTest);
+ });
+ </script>
+</head>
+<body>
+ <div>Test that ClearKey session identifiers do not persist across MediaKeySystem instances.</div>
+</body>
+</html>
\ No newline at end of file
diff --git a/Source/WebCore/platform/encryptedmedia/clearkey/CDMClearKey.cpp b/Source/WebCore/platform/encryptedmedia/clearkey/CDMClearKey.cpp
index 45892247809f..78f0e8faaca0 100644
--- a/Source/WebCore/platform/encryptedmedia/clearkey/CDMClearKey.cpp
+++ b/Source/WebCore/platform/encryptedmedia/clearkey/CDMClearKey.cpp
@@ -448,10 +448,10 @@ RefPtr<CDMInstanceSession> CDMInstanceClearKey::createSession()
void CDMInstanceSessionClearKey::requestLicense(LicenseType, KeyGroupingStrategy, const AtomString& initDataType, Ref<SharedBuffer>&& initData, LicenseCallback&& callback)
{
- static uint32_t s_sessionIdValue = 0;
- ++s_sessionIdValue;
-
- m_sessionID = String::number(s_sessionIdValue);
+ if (RefPtr parentInstance = protectedParentInstance())
+ m_sessionID = String::number(parentInstance->getNextSessionIdValue());
+ else
+ m_sessionID = emptyString();
if (equalLettersIgnoringASCIICase(initDataType, "cenc"_s))
initData = extractKeyidsFromCencInitData(initData.get());
diff --git a/Source/WebCore/platform/encryptedmedia/clearkey/CDMClearKey.h b/Source/WebCore/platform/encryptedmedia/clearkey/CDMClearKey.h
index c504566e502e..a1824f5fd161 100644
--- a/Source/WebCore/platform/encryptedmedia/clearkey/CDMClearKey.h
+++ b/Source/WebCore/platform/encryptedmedia/clearkey/CDMClearKey.h
@@ -96,6 +96,8 @@ public:
CDMInstanceClearKey();
virtual ~CDMInstanceClearKey();
+ uint32_t getNextSessionIdValue() { return m_nextSessionIdValue++; }
+
// CDMInstance
ImplementationType implementationType() const final { return ImplementationType::ClearKey; }
void initializeWithConfiguration(const CDMKeySystemConfiguration&, AllowDistinctiveIdentifiers, AllowPersistentState, SuccessCallback&&) final;
@@ -103,6 +105,9 @@ public:
void setStorageDirectory(const String&) final;
const String& keySystem() const final;
RefPtr<CDMInstanceSession> createSession() final;
+
+private:
+ uint32_t m_nextSessionIdValue { 0 };
};
class CDMInstanceSessionClearKey final : public CDMInstanceSessionProxy {
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2025-30425 |
| Bugzilla | 286580 |
| Component | WebCore |
| Bug Class | LogicError |
| Severity | medium |
| Commit | df02f84bef7163f3… |
| Advisory | Apple Advisory |
Terminology
- ClearKey / EME — The Encrypted Media Extensions ClearKey CDM; each media key session receives an identifier exposed to script as session.sessionId.
- Process-global static counter — A single static incremented across all instances/origins, producing a value that persists across contexts.
- Private-browsing isolation — Contexts that should not share correlatable state; a global counter defeats that isolation.
Root Cause Analysis
This fixes a private-browsing tracking vector: ClearKey EME session identifiers were drawn from a process-global static counter, exposing a persistent, cross-instance value to script. CDMInstanceSessionClearKey::requestLicense assigned each Encrypted Media session an ID from static uint32_t s_sessionIdValue, incremented on every license request across ALL CDM instances and origins. The resulting session.sessionId (readable by the page) is therefore a monotonic, process-wide value that persists across MediaKeys instances and browsing contexts, letting a site correlate or fingerprint a user — including across isolation boundaries such as private browsing — by observing the counter. The fix scopes the counter to the parent CDM instance: CDMInstanceClearKey gains a per-instance member m_nextSessionIdValue and getNextSessionIdValue(), and requestLicense uses protectedParentInstance()->getNextSessionIdValue() (or an empty string if there is no parent), so session IDs restart per instance and no longer leak a global sequence. The restored invariant is that session identifiers are scoped to their CDM instance rather than a global counter. The regression test creates two MediaKeys instances and checks each yields session IDs 0 and 1 (not a continuing global sequence).
Attack Path
1. Create EME sessions
Use ClearKey EME (requestMediaKeySystemAccess org.w3.clearkey) and create media key sessions.
2. Read session.sessionId
Observe the session ID, which pre-patch came from a global static counter.
3. Correlate across contexts
Because the counter is process-global and monotonic, its value persists across MediaKeys instances and browsing contexts.
4. Track the user
Use the persistent, increasing ID to correlate visits / fingerprint the user, including in private browsing.
Impact Assessment
A privacy/tracking issue in the WebContent process with no memory corruption: a global session-ID counter leaked a persistent, cross-instance value usable to track users, including in Safari private browsing. The impact is correlation/deanonymization rather than code execution.
Audit Directions
Global counters exposed to script
Grep the EME/CDM code (and other platform modules) for static counters/IDs whose values are exposed to web content and can persist across instances/origins.
Session/identifier scoping
Audit identifier generation (session IDs, request IDs) to confirm they are scoped to an instance/origin rather than process-global.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
CDMInstanceSessionClearKey::requestLicense |
Source/WebCore/platform/encryptedmedia/clearkey/CDMClearKey.cpp |
modified | Derives the session ID from the parent instance’s per-instance counter (getNextSessionIdValue) instead of a process-global static, using an empty string when there is no parent. |
CDMInstanceClearKey::getNextSessionIdValue / m_nextSessionIdValue |
Source/WebCore/platform/encryptedmedia/clearkey/CDMClearKey.h |
modified | Adds a per-instance session-ID counter so IDs are scoped to the CDM instance. |
Files Changed
Source Files
Source/WebCore/platform/encryptedmedia/clearkey/CDMClearKey.cppSource/WebCore/platform/encryptedmedia/clearkey/CDMClearKey.h
Test Files
LayoutTests/http/tests/media/clearkey/clear-key-session-id-expected.txtLayoutTests/http/tests/media/clearkey/clear-key-session-id.html
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/LayoutTests/http/tests/media/clearkey/clear-key-session-id-expected.txt b/LayoutTests/http/tests/media/clearkey/clear-key-session-id-expected.txt
new file mode 100644
index 000000000000..bcf6b799c2a9
--- /dev/null
+++ b/LayoutTests/http/tests/media/clearkey/clear-key-session-id-expected.txt
@@ -0,0 +1,11 @@
+Test that ClearKey session identifiers do not persist across MediaKeySystem instances.
+-
+Create the first pair of sessions
+EXPECTED (session1.sessionId == '0') OK
+EXPECTED (session2.sessionId == '1') OK
+-
+Create the second pair of sessions
+EXPECTED (session1.sessionId == '0') OK
+EXPECTED (session2.sessionId == '1') OK
+END OF TEST
+
diff --git a/LayoutTests/http/tests/media/clearkey/clear-key-session-id.html b/LayoutTests/http/tests/media/clearkey/clear-key-session-id.html
new file mode 100644
index 000000000000..46982acafb80
--- /dev/null
+++ b/LayoutTests/http/tests/media/clearkey/clear-key-session-id.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>clear-key-session-id</title>
+ <script src=../../media-resources/video-test.js></script>
+ <script src="support.js"></script>
+ <script>
+
+ var session1;
+ var session2;
+
+ async function runTest() {
+ let createTwoSessions = async () => {
+ const config = [{ "initDataTypes":["webm"], "audioCapabilities":[{"contentType":"audio/mp4"}]}];
+ let mediaKeySystem = await navigator.requestMediaKeySystemAccess("org.w3.clearkey", config);
+ let mediaKeys = await mediaKeySystem.createMediaKeys();
+
+ const e = new Uint8Array([0]);
+
+ session1 = await mediaKeys.createSession();
+ let request = await session1.generateRequest("webm", e);
+
+ testExpected('session1.sessionId', '0');
+
+ session2 = await mediaKeys.createSession();
+ request = await session2.generateRequest("webm", e);
+
+ testExpected('session2.sessionId', '1');
+ };
+
+ consoleWrite('-');
+ consoleWrite('Create the first pair of sessions');
+ await createTwoSessions();
+
+ consoleWrite('-');
+ consoleWrite('Create the second pair of sessions');
+ await createTwoSessions();
+ }
+
+ window.addEventListener('load', event => {
+ runTest().then(endTest).catch(failTest);
+ });
+ </script>
+</head>
+<body>
+ <div>Test that ClearKey session identifiers do not persist across MediaKeySystem instances.</div>
+</body>
+</html>
\ No newline at end of file