Cherry-pick 7fdaeaab71b9. rdar://175673904
CVE: CVE-2026-43795 · Safari 26.6.1 · Released August 18, 2026 Impact: Processing maliciously crafted web content may lead to an unexpected Safari crash Apple's description: The issue was addressed with improved memory handling. Credit: wwwlk
Medium — the guard was never doing anything. A key-absence check written against the wrong map's sentinel compiles clean, always passes, and hands the decoder a past-the-end node to read as a value. Deterministic to hit, but read-only and at a fixed offset, so the ceiling is a crash plus maybe one leaked bit.
WebAuthn authenticators don't speak JSON — they speak CBOR, a compact binary map format that WebKit decodes on the boundary between an external device's response and the object model script eventually sees. Extension outputs arrive as a map-of-maps: an outer map keyed by extension name, each value its own nested map of that extension's results. AuthenticationExtensionsClientOutputs::fromCBOR() walks that structure key by key, and the one thing every branch of such a walk depends on is that a "not found" answer is checked against the container that produced it.
The angle: An authenticator response that simply omits a key inside the credProps map drives the decoder into dereferencing a past-the-end iterator and reading adjacent memory as a decoded value.
Tools/TestWebKitAPI/Tests/WebCore/CBORReaderTest.cpp
Source/WebCore/Modules/webauthn/AuthenticationExtensionsClientOutputs.cpp
Patch Details
Two files, two very different roles. The test file is what landed in the diff above: an include of <WebCore/AuthenticationExtensionsClientOutputs.h> so the test can reach the decoder directly rather than going through CBORReader alone, and a new AuthExtensionsFromCBOR_CredPropsWithoutRk case that hands fromCBOR() a hand-assembled 12-byte vector. The bytes are worth reading literally: a1 is a one-entry map, 69 is a 9-byte text string, 63726564 50726f7073 spells credProps, and a0 is an empty map. That's the entire trigger — a well-formed, spec-legal extension-output blob in which credProps exists and contains nothing. The assertions then pin down the correct post-fix behavior: decoding succeeds, credProps is populated, and rk is falsy rather than whatever the sentinel read happened to produce.
The production change lives in Source/WebCore/Modules/webauthn/AuthenticationExtensionsClientOutputs.cpp, in fromCBOR() itself, and is a three-token edit. The "rk" lookup stops reusing the function-scoped it variable and gets its own credPropsIt; the guard that follows stops comparing against decodedMap.end() and compares against credPropsMap.end(). That is the whole fix — no new validation, no restructuring of the decode loop, just the guard finally being written against the container it was always supposed to describe. The commit message is explicit that this is the idiom the same function already used for the largeBlob extension a few lines below.
Background
CBOR on the WebAuthn path. CBOR (RFC 8949) is the binary serialization format CTAP2 authenticators speak. Its major type 5 encodes maps, which is why the test vector's a1 and a0 bytes are a one-entry map and an empty map respectively. WebKit has its own reader that turns those bytes into a tree of CBORValue objects.
Extension outputs. Alongside a credential, an authenticator may return optional per-extension results. credProps is a map whose rk member reports whether the credential is a discoverable (resident) key; largeBlob is a sibling extension handled in the same decoder. Both surface to script through PublicKeyCredential.getClientExtensionResults().
fromCBOR. The WebCore helper that walks the decoded outer map, looks up each known extension key, and populates the matching optional field of the AuthenticationExtensionsClientOutputs struct. Because extension outputs are nested, the walk descends: find credProps in the outer map, then find rk inside the map that lookup produced.
CBORValue. The reader's tagged value type — a type discriminant plus a payload that may be an integer, string, byte string, array, or map. Accessors like isBool() and getBool() consult the discriminant before touching the payload.
C++ end() semantics. find() signals "not found" by returning the container's end() iterator. end() is a past-the-end position: comparable, incrementable toward, but not dereferenceable. Crucially, end() is container-relative — it is only meaningful against the specific instance it came from, and comparing iterators obtained from two different container instances is undefined behavior. In practice such a comparison degenerates into comparing two unrelated node pointers.
Node-based map internals. For a red-black-tree map, end() designates a header/sentinel node holding only the tree's link fields. A value node places its key/value pair at a fixed offset past those link fields, so reading it->second is a read at that offset from whatever node the iterator happens to name.
Analysis
This is a missing-check bug where the check is present in the source and structurally inert — the comparison executes, always evaluates true, and therefore never guards anything.
Before: After:
find("rk") in credPropsMap find("rk") in credPropsMap
└─► it = credPropsMap.end() └─► credPropsIt = credPropsMap.end()
│ │
├─ it != decodedMap.end() ── TRUE ├─ credPropsIt != credPropsMap.end() ── FALSE
│ (different container!) │
└─► it->second └─► absent-key path
└─► reads header node rk stays unset
as a CBORValue
The left column is the pre-fix control flow. fromCBOR() declares a single iterator variable it at function scope and uses it for lookups in the outer decodedMap. Inside the credProps branch it reassigns it to credPropsMap.find(CBOR("rk")) — a different container entirely, but one with an identical iterator type, so the reassignment compiles without so much as a warning. The guard on the next line was written for the outer map and never updated. It asks whether the sub-map's lookup result differs from the outer map's end sentinel. Two distinct container instances never share an end position, so the answer is unconditionally yes. The absent-key path in the right column was unreachable before this commit.
The missing invariant is small enough to state in one clause: a lookup result must be compared against the sentinel of the container it came from. Nothing in the type system enforces it. Both maps are the same instantiation, so their iterators are interchangeable to the compiler; the comparison is undefined behavior rather than a diagnosable error, which means no warning fires and no sanitizer catches it at compile time.
What happens next depends on what the sentinel node actually is. For a node-based ordered map, end() designates the tree's header node — link fields, no constructed key/value pair. Dereferencing it reads at the value-node offset, i.e. bytes lying past the sentinel's real extent, whatever happens to sit inside or after the containing object. Those bytes get handed to the decode logic as a CBORValue:
// what the branch body assumes it received
if (rkValue.isBool())
credProps.rk = rkValue.getBool();
The discriminant it consults is adjacent memory. So is the payload, if the discriminant happens to say boolean.
Reachability is the easy part: the trigger is {"credProps": {}}, a twelve-byte blob that is entirely spec-legal — an extension the authenticator acknowledges but for which it reports nothing. No malformed CBOR, no length games, no heap grooming. The realistic outcome, and the one Apple's advisory describes as "an unexpected Safari crash," is a fault on that read. The narrower but more interesting case is when the stray bytes type-check as a boolean: the decoded rk flag then carries a single bit of adjacent memory back to script via getClientExtensionResults(). That is an oracle, not a disclosure primitive — one bit, fixed offset, no attacker-chosen displacement, and no write side at all.
Which process absorbs the fault determines how much the crash is worth. WebAuthn extension-output decoding sits at the boundary between the authenticator and WebCore, and the callers of fromCBOR() — Source/WebCore/Modules/webauthn/fido/DeviceResponseConverter.cpp and the UIProcess WebAuthentication coordinator — settle whether this is a contained WebContent DoS or a whole-browser one, with the info-leak position correspondingly more valuable in the latter. Either way a read-only sentinel dereference yields no sandbox escape on its own.
The fix restores the invariant by giving the sub-map lookup its own variable and its own sentinel. With credPropsIt compared against credPropsMap.end(), the empty-credProps case takes the absent-key path, rk stays unset, and the new test asserts exactly that.
A key-absence guard compared against the wrong container's end sentinel always passes, so omitting credProps.rk steered the decoder into reading a past-the-end node as a CBORValue.
Insight
The correct idiom already existed a few lines below in the same function, in the largeBlob branch — this is copy-paste divergence inside a single decoder, not a missing concept, and the safe sibling sitting nearby is precisely what made the unsafe one look reviewed. The shape recurs in long key-by-key parsers: the first branch is written carefully, later branches reuse a function-scoped variable, and the guard quietly loses its meaning while keeping its syntax. Decoders that return std::optional from a get-style helper — or WTF's HashMap::get/getOptional idiom — structurally cannot express this bug, because the not-found signal is self-describing rather than container-relative. Worth preferring in any new CBOR or IPC decoding code sitting on a trust boundary.
Audit directions
-
A lookup result compared against a sentinel from a different container instance. The invariant is the bound you check against must be derived from the exact object you searched. Narrow: grep
Source/WebCore/Modules/webauthn(AuthenticationExtensionsClientOutputs.cpp,fido/DeviceResponseConverter.cpp,cbor/) for a second assignment to an already-declared iterator variable followed by a!= X.end()guard — the match tell is that the receiver of.end()is not the same expression that produced the iterator viafind()/begin(). Wider: the class also appears with non-iterator bounds — an index checked against a different vector'ssize(), aWTF::HashMapiterator checked against another map'send(), aStringfind result checked against another string'slength(); in code-search results the tell is any comparison whose two sides name different container expressions. Widest: this is the general "container-relative not-found sentinel checked against the wrong container" class, and it applies anywhere absence is encoded relative to a collection instead of as a self-describing optional — Chromium'sbase::flat_mapiterators, JavaindexOfresults compared to another list's size, Cstrchr/memchrresults compared against a different buffer's end. The invariant to carry across codebases: if the "absent" value is only meaningful relative to a specific instance, every comparison against it must name that same instance. -
Nested-structure decoders that hoist lookup variables to function scope and reuse them across nesting levels. The danger is that the outer-scope variable keeps its type but changes its meaning, so every guard written for the outer level keeps compiling at the inner level. Narrow: audit the remaining extension branches of
AuthenticationExtensionsClientOutputs::fromCBOR(largeBlob, plus any prf/credProtect/appid handling) and the CTAP response converters inSource/WebCore/Modules/webauthn/fido/— the match tell is a decoder that descends into a sub-map or sub-array without opening a new scope for the descent. Wider: the same shape appears in any hand-written parser for a nested wire format — plist and JSON readers, IPC argument decoders, HTTP structured-header parsers; the tell is a function spanning more than one nesting level whose lookup or cursor variables are declared at the top rather than inside the nested block. Widest: the reusable principle is "per-nesting-level state must be scoped to that level," which transfers to any manual deserializer, including Rustserdehand-impls and Go decoders that reuse a shared cursor across nested objects. -
Within-function idiom divergence, where one branch of a repeated per-key block uses the safe form and a sibling does not. Investigate by enumerating repeated sibling blocks and diffing them against each other rather than reading top to bottom. Narrow: the per-extension branches in this WebAuthn CBOR decoder — the match tell is two adjacent blocks handling different keys of the same structure whose guard expressions are not structurally identical after renaming. Wider: the per-property branches in WebCore's CSS property parsers and the per-message branches in WebKit's IPC decoders, both of which are dispatch-shaped and accumulate the same drift. Widest: "repeated validation blocks drift under copy-paste" holds for any dispatch-style function in any language, and the audit move never changes — compare siblings pairwise instead of sequentially.