← All reports

Cherry-pick 7fdaeaab71b9. rdar://175673904

MediumWebCore WebAuthn —OOB

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

Severity: Medium | Component: WebCore WebAuthn — AuthenticationExtensionsClientOutputs::fromCBOR | 79d7541 | Bugzilla 313452

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

#if ENABLE(WEB_AUTHN)
 
+#include <WebCore/AuthenticationExtensionsClientOutputs.h>
#include <WebCore/CBORReader.h>
#include <limits>
#include <utility>
@@
+TEST(CBORReaderTest, AuthExtensionsFromCBOR_CredPropsWithoutRk)
+{
+ // CBOR encoding of {"credProps": {}} — credProps map present but no "rk" key.
+ // a1 -- map(1)
+ // 69 -- text(9)
+ // 63726564 50726f7073 -- "credProps"
+ // a0 -- map(0)
+ Vector<uint8_t> cborData { 0xa1, 0x69, 0x63, 0x72, 0x65, 0x64, 0x50, 0x72, 0x6f, 0x70, 0x73, 0xa0 };
+ auto result = WebCore::AuthenticationExtensionsClientOutputs::fromCBOR(cborData);
+ ASSERT_TRUE(result.has_value());
+ ASSERT_TRUE(result->credProps.has_value());
+ EXPECT_FALSE(result->credProps->rk);
+}

Source/WebCore/Modules/webauthn/AuthenticationExtensionsClientOutputs.cpp

- it = credPropsMap.find(CBOR("rk"));
- if (it != decodedMap.end() && ...) // sentinel belongs to the OUTER map
+ auto credPropsIt = credPropsMap.find(CBOR("rk"));
+ if (credPropsIt != credPropsMap.end() && ...)

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.

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.

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.

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.