CVE-2026-17865
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
FindUnexportableKeyscrypto/apple/unexportable_key_apple.mm |
modified | |
ScopedMockKeychaincrypto/apple/unexportable_key_apple_unittest.mm |
modified | |
TESTcrypto/apple/unexportable_key_apple_unittest.mm |
modified |
Files Changed
crypto/apple/unexportable_key_apple.mmcrypto/apple/unexportable_key_apple_unittest.mm
Patch
From 49d90ea1fbcbe0eb8d9cdcb9de2fb0179dc9ef1b Mon Sep 17 00:00:00 2001
From: Jan Wilken Dörrie <jdoerrie@chromium.org>
Date: Thu, 25 Jun 2026 02:04:00 -0700
Subject: [PATCH] [crypto] Restrict keychain queries on macOS
Ensure that keychain queries for unexportable keys are scoped to the
data protection keychain.
Bug: 520516655
Change-Id: I67fe92097b757d97549235ad5c4f2a7c6a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7966018
Reviewed-by: Elly <ellyjones@chromium.org>
Commit-Queue: Jan Wilken Dörrie <jdoerrie@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1652247}
---
diff --git a/crypto/apple/unexportable_key_apple.mm b/crypto/apple/unexportable_key_apple.mm
index f7abf443..f8f9cd9 100644
--- a/crypto/apple/unexportable_key_apple.mm
+++ b/crypto/apple/unexportable_key_apple.mm
@@ -150,6 +150,7 @@
FindUnexportableKeys(FindUnexportableKeysOptions options) {
auto [access_group, application_tag_prefix, wrapped_key, lacontext] = options;
NSMutableDictionary* query = [NSMutableDictionary dictionaryWithDictionary:@{
+ CFToNSPtrCast(kSecUseDataProtectionKeychain) : @YES,
CFToNSPtrCast(kSecClass) : CFToNSPtrCast(kSecClassKey),
CFToNSPtrCast(kSecAttrKeyType) :
CFToNSPtrCast(kSecAttrKeyTypeECSECPrimeRandom),
diff --git a/crypto/apple/unexportable_key_apple_unittest.mm b/crypto/apple/unexportable_key_apple_unittest.mm
index e26809c6..769b86a 100644
--- a/crypto/apple/unexportable_key_apple_unittest.mm
+++ b/crypto/apple/unexportable_key_apple_unittest.mm
@@ -9,6 +9,8 @@
#include <cstdint>
#include <vector>
+#include "base/apple/foundation_util.h"
+#include "base/check_deref.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/time/time.h"
#include "base/time/time_override.h"
@@ -673,6 +675,47 @@
key_specific->GetWrappedKey()));
}
+class ScopedMockKeychain : public crypto::apple::FakeKeychainV2 {
+ public:
+ explicit ScopedMockKeychain(const std::string& keychain_access_group)
+ : FakeKeychainV2(keychain_access_group) {
+ SetInstanceOverride(this);
+ }
+
+ ~ScopedMockKeychain() override { ClearInstanceOverride(); }
+
+ MOCK_METHOD(OSStatus,
+ ItemCopyMatching,
+ (CFDictionaryRef query, CFTypeRef* result),
+ (override));
+};
+
+// Setting `kSecUseDataProtectionKeychain` to `@YES` is required to explicitly
+// restrict the keychain search to the modern Data Protection keychain,
+// preventing fallback to legacy keychains where access group filtering is
+// broken. This test ensures the flag is always present in the query.
+TEST(UnexportableKeyMacFiltersTest, QueriesDataProtectionKeychain) {
+ ScopedMockKeychain mock_keychain(kTestKeychainAccessGroup);
+
+ EXPECT_CALL(mock_keychain, ItemCopyMatching)
+ .WillOnce([&mock_keychain](CFDictionaryRef query, CFTypeRef* result) {
+ auto use_data_protection =
+ base::apple::GetValueFromDictionary<CFBooleanRef>(
+ query, kSecUseDataProtectionKeychain);
+ EXPECT_TRUE(CFBooleanGetValue(use_data_protection));
+ return mock_keychain.FakeKeychainV2::ItemCopyMatching(query, result);
+ });
+
+ std::unique_ptr<UnexportableKeyProvider> provider =
+ GetUnexportableKeyProvider({
+ .keychain_access_group = kTestKeychainAccessGroup,
+ .application_tag = kTestApplicationTag,
+ });
+
+ // Trigger a query.
+ CHECK_DEREF(provider).AsStatefulUnexportableKeyProvider()->GetAllKeysSlowly();
+}
+
} // namespace
} // namespace crypto::apple
Regression Test / PoC
diff --git a/crypto/apple/unexportable_key_apple_unittest.mm b/crypto/apple/unexportable_key_apple_unittest.mm
index e26809c6..769b86a 100644
--- a/crypto/apple/unexportable_key_apple_unittest.mm
+++ b/crypto/apple/unexportable_key_apple_unittest.mm
@@ -9,6 +9,8 @@
#include <cstdint>
#include <vector>
+#include "base/apple/foundation_util.h"
+#include "base/check_deref.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/time/time.h"
#include "base/time/time_override.h"
@@ -673,6 +675,47 @@
key_specific->GetWrappedKey()));
}
+class ScopedMockKeychain : public crypto::apple::FakeKeychainV2 {
+ public:
+ explicit ScopedMockKeychain(const std::string& keychain_access_group)
+ : FakeKeychainV2(keychain_access_group) {
+ SetInstanceOverride(this);
+ }
+
+ ~ScopedMockKeychain() override { ClearInstanceOverride(); }
+
+ MOCK_METHOD(OSStatus,
+ ItemCopyMatching,
+ (CFDictionaryRef query, CFTypeRef* result),
+ (override));
+};
+
+// Setting `kSecUseDataProtectionKeychain` to `@YES` is required to explicitly
+// restrict the keychain search to the modern Data Protection keychain,
+// preventing fallback to legacy keychains where access group filtering is
+// broken. This test ensures the flag is always present in the query.
+TEST(UnexportableKeyMacFiltersTest, QueriesDataProtectionKeychain) {
+ ScopedMockKeychain mock_keychain(kTestKeychainAccessGroup);
+
+ EXPECT_CALL(mock_keychain, ItemCopyMatching)
+ .WillOnce([&mock_keychain](CFDictionaryRef query, CFTypeRef* result) {
+ auto use_data_protection =
+ base::apple::GetValueFromDictionary<CFBooleanRef>(
+ query, kSecUseDataProtectionKeychain);
+ EXPECT_TRUE(CFBooleanGetValue(use_data_protection));
+ return mock_keychain.FakeKeychainV2::ItemCopyMatching(query, result);
+ });
+
+ std::unique_ptr<UnexportableKeyProvider> provider =
+ GetUnexportableKeyProvider({
+ .keychain_access_group = kTestKeychainAccessGroup,
+ .application_tag = kTestApplicationTag,
+ });
+
+ // Trigger a query.
+ CHECK_DEREF(provider).AsStatefulUnexportableKeyProvider()->GetAllKeysSlowly();
+}
+
} // namespace
} // namespace crypto::apple
Original Bug Report
Potential keychain access-group bypass and key confusion in FindUnexportableKeys on macOS
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential logic issue in FindUnexportableKeys on macOS may allow a compromised network process to access and use legacy login keychain EC private keys as a signing oracle. Because the query does not isolate the data-protection keychain or verify the access group of returned items, unauthorized legacy keys can be loaded and bound. If the loaded key does not use the expected curve parameters, this behavior can also trigger a browser-process crash.
Affected files:
crypto/apple/unexportable_key_apple.mm
Estimated timestamp from git blame: 2026-01-08
Description
A potential logic and key-confusion vulnerability exists in the macOS implementation of FindUnexportableKeys in crypto/apple/unexportable_key_apple.mm.
When locating unexportable keys via the Apple Keychain API, the search query built by FindUnexportableKeys does not restrict its scope to the modern data-protection keychain. Furthermore, it lacks a manual post-filter to verify the keychain access group of the returned items. Consequently, standard software-backed EC private keys residing in the user’s legacy file-based login keychain can leak past the search filter and be returned to the caller.
Under specific conditions, an attacker who has compromised the sandboxed network process can leverage this behavior to obtain a browser-process ECDSA signing oracle over a victim’s legacy keychain keys (such as imported client certificates), bypassing sandbox and keychain access boundaries.
Note: The following steps are potential/suggested exploitation vectors identified through static analysis. Our tooling currently lacks the ability to execute code or verify these steps dynamically on a running macOS system.
Code Analysis
1. Missing Data-Protection Scoping and Access-Group Post-Filter
In crypto/apple/unexportable_key_apple.mm:150-208, FindUnexportableKeys prepares the SecItemCopyMatching query:
NSMutableDictionary* query = [NSMutableDictionary dictionaryWithDictionary:@{
CFToNSPtrCast(kSecClass) : CFToNSPtrCast(kSecClassKey),
CFToNSPtrCast(kSecAttrKeyType) :
CFToNSPtrCast(kSecAttrKeyTypeECSECPrimeRandom),
CFToNSPtrCast(kSecAttrAccessGroup) : access_group,
CFToNSPtrCast(kSecMatchLimit) : CFToNSPtrCast(kSecMatchLimitAll),
CFToNSPtrCast(kSecReturnAttributes) : @YES,
CFToNSPtrCast(kSecReturnRef) : @YES,
}];
Two critical security controls are absent here:
kSecUseDataProtectionKeychainis not set: Without@YESfor this attribute, macOS searches both the modern data-protection keychain and the legacy file-based keychain.- No post-filtering on
kSecAttrAccessGroup: As documented in sibling code (device/fido/mac/credential_store.mm), the macOSkSecAttrAccessGroupquery filter does not reliably exclude legacy keychain items. Sibling code explicitly implements manual validation of the returnedkSecAttrAccessGroupattribute, but this validation is missing inFindUnexportableKeys.
2. Reachability and Tag-Splicing
When UnexportableKeyProviderApple::FromWrappedSigningKeySlowly is called via the network-process-exposed Mojo interface, it calls FindUnexportableKeys with an empty application_tag_prefix (matching any returned item).
If a legacy key matches the requested wrapped_key (SHA-1 public key hash), the lookup succeeds. Because the key does not possess the expected DBSC application tag, execution falls into the tag-splicing branch (crypto/apple/unexportable_key_apple.mm:517-534):
NSMutableDictionary* key_attributes = [NSMutableDictionary
dictionaryWithDictionary:CFToNSPtrCast(key_dicts.front().get())];
key_attributes[CFToNSPtrCast(kSecAttrApplicationTag)] =
objc_storage_->application_tag_;
...
if (OSStatus status = crypto::apple::KeychainV2::GetInstance().ItemAdd(
NSToCFPtrCast(key_attributes), nil);
status != errSecSuccess) { return nullptr; }
return std::make_unique<UnexportableSigningKeyApple>(
NSToCFPtrCast(key_attributes));
This clones the attributes (retaining the raw SecKeyRef in kSecValueRef), stamps the expected DBSC application tag, and registers it. Since the tag is stamped on the wrapped key object, the subsequent invariant check CHECK(wrapped_key_and_tag == GetWrappedKeyAndTag(*key)) in unexportable_key_service_impl.cc:462 is successfully bypassed.
Potential Attack Scenario
- Preconditions: The victim has an EC P-256 private key in their legacy login keychain that Chrome is permitted to use without prompting (e.g., an mTLS client cert imported via Chrome). The attacker achieves RCE in the sandboxed network process.
- Key Targeting: The network process obtains the victim’s public key (e.g., from an active TLS session) and computes its SHA-1 hash to serve as the
wrapped_keyidentifier. - Mojo Request: The network process calls
FromWrappedSigningKey(wrapped_key)over the Mojo pipe. - Key Splicing: The browser process executes the lookup, matches the legacy key, stamps the DBSC tag, registers the association, and returns a valid
key_idto the network process. - Signing Oracle: The network process calls
Sign(key_id, arbitrary_data)to generate arbitrary ECDSA signatures using the legacy private key via the browser process.
Note: If the retrieved legacy key is an EC key but does not use the P-256 curve (e.g., P-384), the SPKI generation inside UnexportableSigningKeyApple’s constructor will dereference an empty std::optional returned by FromEcP256Point, resulting in a browser-process crash (Denial of Service).
Suggested Fix
Update FindUnexportableKeys in crypto/apple/unexportable_key_apple.mm to implement the same mitigations used by its sibling components:
- Explicitly restrict the search query to the Data Protection keychain by adding
kSecUseDataProtectionKeychain: @YESto the query dictionary. - Add a defensive manual post-filter to verify that the
kSecAttrAccessGroupattribute of any returned item matches the requestedaccess_groupbefore allowing it to be used.
Evaluated with Chrome root at commit: e9507a33bb4148ee071aaaf8a7e9ad68770359bf
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.