← WebKit Silent-Fix Report — 2026-W23

675c62d66e3cfa45f2a6206bdd5f1ca9fd59cc09  App Badge origin spoofing from `window` contexts

severity medium class CrossOrigin confidence 0.90 WebKit WebFrameProxy AppBadge exploitable-grade
Brady Eidson Wed Jun 3 09:01:15 2026 -0700 full: 675c62d66e3cfa45f2a6206bdd5f1ca9fd59cc09 view on GitHub ↗
Primitive: 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

  1. 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.
  2. 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.
  3. 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.
  4. Pre-patch acceptance — The UI process forwards the spoofed origin to updateAppBadge / workerUpdatedAppBadge, badging the app as apple.com.
  5. 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

  1. 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.
  2. 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.
  3. 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.

Before / after

Loading diff…