675c62d66e3cfa45f2a6206bdd5f1ca9fd59cc09 App Badge origin spoofing from `window` contexts
Triage note: setAppBadge (and setAppBadgeFromWorker) lacked an origin-to-process check; fix adds allowsFirstPartyAccess(RegistrableDomain{origin}) with MESSAGE_CHECK so a process cannot set a badge for an origin it has not committed, preventing origin spoofing.
Contents
The bug at a glance
A compromised (or IPC-fuzzing) WebContent process could forge a WebFrameProxy::SetAppBadge or WebProcessProxy::SetAppBadgeFromWorker message naming an arbitrary origin - e.g. https://apple.com - that the process never committed, causing the UI process to display an application badge attributed to a spoofed first party. Reachability presupposes a process already able to craft IPC (a compromised renderer or the IPC testing API), and the impact is a UI-level origin-attribution spoof (integrity of the badge, potential for social-engineering), not memory corruption or data theft, so the assigned CVSS 5.3 / Medium is retained.
The Badging API lets a page set an application badge tied to its origin, and the UI process is the source of truth for that origin-to-badge mapping. But WebFrameProxy::setAppBadge and WebProcessProxy::setAppBadgeFromWorker trusted the SecurityOriginData carried in the IPC message verbatim - they never asked whether the sending process was actually allowed to speak for that origin. A compromised renderer could therefore emit a SetAppBadge message stamped with any origin it liked and have the UI process badge the app as though a trusted site (apple.com) had done it. The fix introduces WebProcessProxy::allowsFirstPartyAccess(RegistrableDomain) and MESSAGE_CHECKs it on both entry points, tying the claimed origin to the process’s committed site.
Root cause
The vulnerable state is the absence of a sender-authorization check on two UI-process IPC endpoints that accept an origin from the WebContent process. WebFrameProxy::setAppBadge(const SecurityOriginData& origin, …) forwarded straight to webPageProxy->uiClient().updateAppBadge(*webPageProxy, origin, badge), and WebProcessProxy::setAppBadgeFromWorker(const SecurityOriginData& origin, …) forwarded straight to websiteDataStore().workerUpdatedAppBadge(origin, badge). In both, origin is attacker-supplied IPC data.
The reaching path is a WebContent process (legitimately compromised, or exercised via ENABLE(IPC_TESTING_API)) constructing the message by hand. The added tests do exactly this with CoreIPC: CoreIPC.UI.WebProcessProxy.SetAppBadgeFromWorker(0, { origin: {… host: ‘apple.com’ …}, badge: { optionalValue: 1337 } }) and CoreIPC.UI.WebFrameProxy.SetAppBadge(IPC.frameID[0], { origin: {… host: ‘apple.com’ …}, … }) from a page not served by apple.com. Pre-patch, the UI process accepted the spoofed origin and the badge delegate was invoked attributing badge 1337 to apple.com.
Why unsafe: the UI process is the trust boundary that must not take a renderer’s word for which origin it represents. A WebContent process is bound to a site (or a defined multi-site/shared state), and the origin it may act as first party for is constrained by that binding. Accepting an unbound origin lets one process assert another origin’s identity - classic origin spoofing across the process/origin boundary.
What the fix changes: it adds WebProcessProxy::allowsFirstPartyAccess(const RegistrableDomain&) returning a tri-state FirstPartyAccessResult { Pass, SilentFailure, HardFailure }. If the process has a committed m_site, only that site’s domain Passes, else HardFailure. If m_site is unset, the SiteState error is consulted: NotYetSpecified Passes (nothing committed yet), MultipleSites returns SilentFailure (a multi-site process should not be badging - ignored quietly because it can happen transiently as a load starts), and SharedProcess Passes only if sharedProcessDomains().contains(domain). WebFrameProxy::setAppBadge now converts origin to RegistrableDomain, returns early on SilentFailure, and MESSAGE_CHECKs Pass; setAppBadgeFromWorker MESSAGE_CHECKs Pass and additionally hardens the data-store fetch with a null-checked RefPtr. A MESSAGE_CHECK failure terminates the misbehaving WebContent process.
Key code
MESSAGE_CHECK the claimed origin against the sending process’s committed site
// WebFrameProxy::setAppBadge
+ Ref protectedProcess = process();
+ auto firstPartyAccessResult = protectedProcess->allowsFirstPartyAccess(WebCore::RegistrableDomain { origin });
+ if (firstPartyAccessResult == WebProcessProxy::FirstPartyAccessResult::SilentFailure)
+ return;
+ MESSAGE_CHECK(firstPartyAccessResult == WebProcessProxy::FirstPartyAccessResult::Pass);
// WebProcessProxy::allowsFirstPartyAccess
+ if (m_site)
+ return domain == m_site->domain() ? FirstPartyAccessResult::Pass : FirstPartyAccessResult::HardFailure;
+ switch (m_site.error()) {
+ case SiteState::NotYetSpecified: return FirstPartyAccessResult::Pass;
+ case SiteState::MultipleSites: return FirstPartyAccessResult::SilentFailure;
+ case SiteState::SharedProcess: return sharedProcessDomains().contains(domain) ? FirstPartyAccessResult::Pass : FirstPartyAccessResult::HardFailure;
+ }
// WebProcessProxy::setAppBadgeFromWorker
+ MESSAGE_CHECK(allowsFirstPartyAccess(WebCore::RegistrableDomain { origin }) == FirstPartyAccessResult::Pass);
Patch walkthrough
Source/WebKit/UIProcess/WebFrameProxy.cpp— setAppBadge now protects the sending process, computes allowsFirstPartyAccess(RegistrableDomain{origin}), returns silently on FirstPartyAccessResult::SilentFailure (the transient MultipleSites case), and MESSAGE_CHECKs that the result is Pass before forwarding to uiClient().updateAppBadge. A HardFailure thus kills the process rather than letting a spoofed origin reach the UI client.Source/WebKit/UIProcess/WebProcessProxy.cpp— Adds the allowsFirstPartyAccess implementation mapping the process’s site binding (m_site present -> exact-domain match; NotYetSpecified -> Pass; MultipleSites -> SilentFailure; SharedProcess -> membership in sharedProcessDomains()) to the tri-state result. setAppBadgeFromWorker now MESSAGE_CHECKs allowsFirstPartyAccess == Pass and null-checks websiteDataStore() via a RefPtr before calling workerUpdatedAppBadge.Source/WebKit/UIProcess/WebProcessProxy.h— Declares the enum class FirstPartyAccessResult { Pass, SilentFailure, HardFailure } and the FirstPartyAccessResult allowsFirstPartyAccess(const WebCore::RegistrableDomain&) const member, giving both badge paths (and future callers) a shared origin-to-process authorization primitive.Tools/TestWebKitAPI/Tests/WebKit/WKWebView/Badging.mm— Adds two IPC_TESTING_API tests, SetAppBadgeFromWorkerOriginSpoof and SetAppBadgeFromFrameOriginSpoof, that hand-craft CoreIPC SetAppBadgeFromWorker / SetAppBadge messages claiming origin apple.com from an unrelated page and assert the badge delegate’s appBadgeIndex stays 0. The pre-existing Badging.Origin test (which relied on cross-origin iframes reaching setAppBadge) is disabled with a comment noting such calls should be rejected at the DOM level anyway.
Background
Badging API — navigator.setAppBadge() lets an installed web app set a numeric/dot badge on its icon. The badge is attributed to the calling document’s origin, so the origin passed to the UI process must be genuinely the caller’s - making it an origin-attribution surface an attacker would like to spoof.
MESSAGE_CHECK — A WebKit UI-process macro that validates an invariant about an incoming IPC message; on failure it treats the message as malicious and terminates the offending WebContent process rather than proceeding, converting an authorization gap into a fatal fault for the attacker.
RegistrableDomain and process site binding — A WebProcessProxy is associated with a site (registrable domain) it hosts. allowsFirstPartyAccess uses this binding as the authority for which origins the process may act as first party for; RegistrableDomain{origin} reduces the claimed origin to its eTLD+1 for comparison.
SiteState (m_site error states) — When a process has no single committed site, m_site holds an error: NotYetSpecified (nothing loaded yet - permissive), MultipleSites (a process serving several sites - should not badge, silently ignored), or SharedProcess (a shared process whose permitted domains live in sharedProcessDomains()).
Vulnerability window
- Prior fix — An earlier branch fix (305413.558@safari-7624-branch) closed a similar App Badge origin spoof on a different message, establishing the message-check-the-origin pattern.
- This variant found — rdar://173194716: a different pair of messages - WebFrameProxy::SetAppBadge and WebProcessProxy::SetAppBadgeFromWorker - reach the UI process with an attacker-supplied origin and no sender authorization.
- Attack construction — Using ENABLE(IPC_TESTING_API)/CoreIPC (or a real compromised renderer), a page forges the badge message stamped with origin apple.com and badge 1337.
- Pre-patch acceptance — The UI process forwards the spoofed origin to updateAppBadge / workerUpdatedAppBadge, badging the app as apple.com.
- Fix landed — allowsFirstPartyAccess is added and MESSAGE_CHECKed on both endpoints; the spoof tests assert appBadgeIndex remains 0, and the origin-reliant Badging.Origin test is disabled.
Proof of concept
Lifted directly from the shipped tests, which drive the two IPC endpoints via CoreIPC from a page whose committed origin is not apple.com and assert the badge delegate never fires. It demonstrates the origin-spoof primitive but requires the IPC testing API or an already-compromised renderer to forge the message - it is not by itself a renderer compromise.
// Served from an attacker page (NOT apple.com), with IPC_TESTING_API enabled.
// Reconstructed from Badging.mm SetAppBadgeFromWorkerOriginSpoof / FromFrameOriginSpoof.
const CoreIPC = new CoreIPCClass();
// Variant A: worker path
CoreIPC.UI.WebProcessProxy.SetAppBadgeFromWorker(0, {
origin: { data: { variantType: 'WebCore::SecurityOriginData::Tuple',
variant: { protocol: 'https', host: 'apple.com', port: {} } } },
badge: { optionalValue: 1337 }
});
// Variant B: frame path
CoreIPC.UI.WebFrameProxy.SetAppBadge(IPC.frameID[0], {
origin: { data: { variantType: 'WebCore::SecurityOriginData::Tuple',
variant: { protocol: 'https', host: 'apple.com', port: {} } } },
badge: { optionalValue: 1337 }
});
// Pre-patch: UI process badges the app as apple.com (delegate appBadgeIndex == 1337).
// Post-patch: MESSAGE_CHECK fails -> WebContent process terminated; appBadgeIndex stays 0.
Exploitation
- Precondition — The attacker must already be able to send arbitrary IPC from a WebContent process - i.e. a compromised renderer (chained from a separate memory-safety bug) or a build with ENABLE(IPC_TESTING_API). There is no path from ordinary JavaScript, since the DOM layer would not offer a cross-origin origin.
- Forge the message — Craft SetAppBadge / SetAppBadgeFromWorker with a SecurityOriginData::Tuple naming a victim origin the process never committed (e.g. https://apple.com) and an arbitrary badge value.
- Impact — Pre-patch, the UI process attributes the badge to the spoofed origin, enabling deceptive UI (a trusted app appearing to signal notifications) for social-engineering; it does not yield data disclosure or code execution. Post-patch the forged message is a fatal MESSAGE_CHECK, costing the attacker their process.
Detection & hunting
For defenders and SOC / detection engineers:
- MESSAGE_CHECK terminations —
- Origin/site mismatch —
- Badge delegate anomalies —
Audit directions
- Other origin-bearing IPC endpoints — Enumerate UI-process message handlers (WebFrameProxy, WebProcessProxy, WebPageProxy) that accept a SecurityOriginData or URL from WebContent and forward it to embedder/UI clients or the website data store; verify each gates on allowsFirstPartyAccess or an equivalent sender-authorization check rather than trusting the argument.
- Worker-path parity — Audit all worker-initiated UI-process calls (notifications, push, storage, permissions) for the same missing origin-to-process check that setAppBadgeFromWorker had; worker paths historically lag behind their frame-path counterparts.
- SharedProcess / MultipleSites handling — Review other consumers that will adopt allowsFirstPartyAccess to ensure they treat SilentFailure vs HardFailure correctly - silently ignoring transient MultipleSites states while still terminating on genuine HardFailure spoofs - so the tri-state is not collapsed into a permissive check.