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 — guard가 처음부터 아무 역할도 하지 못하던 케이스입니다. 엉뚱한 map의 sentinel을 기준으로 작성된 key 부재 검사는 경고 없이 컴파일되고, 항상 통과하며, decoder에게 past-the-end 노드를 값으로 읽히게 넘깁니다. 유발은 항상 동일하게 가능하지만 읽기 전용이고 offset도 고정이라, 상한은 crash와 많아야 1비트 유출 수준입니다.
WebAuthn authenticator는 JSON을 쓰지 않습니다. 대신 CBOR라는 compact binary map 포맷을 사용하며, WebKit은 외부 장치의 응답과 스크립트가 최종적으로 보게 될 object model 사이의 경계에서 이를 디코딩합니다. Extension output은 map-of-maps 형태로 전달되는데, 바깥 map은 extension 이름을 key로 갖고 각 값은 해당 extension의 결과를 담은 중첩 map입니다. AuthenticationExtensionsClientOutputs::fromCBOR()은 이 구조를 key 단위로 순회합니다. 이런 순회의 모든 분기가 의존하는 전제는 하나입니다. "찾지 못했다"는 답은 반드시 그 답을 만들어낸 컨테이너를 기준으로 검사되어야 한다는 것입니다.
관전 포인트: credProps map 안에서 key 하나를 생략하기만 한 authenticator 응답이면 충분합니다. 이것만으로 decoder가 past-the-end iterator를 역참조하고, 인접 메모리를 디코딩된 값으로 읽게 됩니다.
Tools/TestWebKitAPI/Tests/WebCore/CBORReaderTest.cpp
Source/WebCore/Modules/webauthn/AuthenticationExtensionsClientOutputs.cpp
Patch Details
파일은 두 개지만 역할은 전혀 다릅니다. 위 diff에 드러난 쪽은 테스트 파일입니다. 먼저 <WebCore/AuthenticationExtensionsClientOutputs.h> include가 추가되었는데, 테스트가 CBORReader만 거치지 않고 decoder에 직접 접근할 수 있도록 하기 위함입니다. 그리고 AuthExtensionsFromCBOR_CredPropsWithoutRk 케이스가 새로 추가되어, 손으로 조립한 12바이트 vector를 fromCBOR()에 전달합니다. 이 바이트들은 그대로 읽어볼 가치가 있습니다. a1은 항목이 하나인 map이고, 69는 9바이트 text string이며, 63726564 50726f7073가 credProps를 철자하고, a0는 빈 map입니다. 트리거는 이게 전부입니다. 형식이 온전하고 스펙상으로도 적법한 extension-output blob으로, credProps는 존재하지만 내용은 비어 있습니다. 이어지는 assertion은 fix 이후의 올바른 동작을 고정합니다. 디코딩은 성공하고, credProps는 채워지며, rk는 sentinel 읽기가 우연히 만들어낸 값이 아니라 falsy 상태로 남습니다.
프로덕션 쪽 변경은 Source/WebCore/Modules/webauthn/AuthenticationExtensionsClientOutputs.cpp의 fromCBOR() 안에 있으며, 토큰 세 개 규모의 수정입니다. "rk" 조회는 함수 스코프의 it 변수를 재사용하지 않고 자체 credPropsIt을 갖게 되었습니다. 뒤따르는 guard도 decodedMap.end()가 아니라 credPropsMap.end()와 비교하도록 수정되었습니다. fix는 이것이 전부입니다. 새 validation도, decode loop 재구성도 없고, guard가 애초에 가리켰어야 할 컨테이너를 기준으로 작성되도록 바로잡았을 뿐입니다. commit message는 이 방식이 몇 줄 아래 largeBlob extension 처리에서 같은 함수가 이미 쓰고 있던 관용구라는 점을 명시합니다.
Background
CBOR on the WebAuthn path. CBOR(RFC 8949)는 CTAP2 authenticator가 사용하는 binary serialization 포맷입니다. major type 5가 map을 인코딩하며, 테스트 벡터의 a1과 a0가 각각 항목 하나짜리 map과 빈 map인 것도 그 때문입니다. WebKit은 자체 reader를 두고 이 바이트열을 CBORValue 객체 트리로 변환합니다.
Extension outputs. Authenticator는 credential과 함께 extension별 선택적 결과를 반환할 수 있습니다. credProps는 rk 멤버를 갖는 map으로, 해당 credential이 discoverable(resident) key인지를 보고합니다. largeBlob은 같은 decoder가 처리하는 형제 extension입니다. 둘 다 PublicKeyCredential.getClientExtensionResults()를 통해 스크립트로 노출됩니다.
fromCBOR. 디코딩된 바깥 map을 순회하면서 알려진 extension key를 각각 조회하고, AuthenticationExtensionsClientOutputs 구조체의 대응되는 optional 필드를 채우는 WebCore helper입니다. Extension output이 중첩 구조인 만큼 순회도 아래로 내려갑니다. 바깥 map에서 credProps를 찾고, 그 조회가 만들어낸 map 안에서 다시 rk를 찾는 식입니다.
CBORValue. Reader가 사용하는 tagged value 타입으로, 타입 판별자와 payload로 구성됩니다. payload는 정수, 문자열, byte string, 배열, map 중 하나일 수 있습니다. isBool()이나 getBool() 같은 accessor는 payload를 건드리기 전에 판별자를 먼저 확인합니다.
C++ end() semantics. find()는 컨테이너의 end() iterator를 반환하는 방식으로 "찾지 못함"을 알립니다. end()는 past-the-end 위치라서 비교나 그쪽으로의 증가는 가능하지만 역참조는 불가능합니다. 여기서 핵심은 end()가 컨테이너 상대적이라는 점입니다. 오직 그 값이 나온 특정 인스턴스에 대해서만 의미를 가지며, 서로 다른 컨테이너 인스턴스에서 얻은 iterator를 비교하는 것은 undefined behavior에 해당합니다. 실제 동작 수준에서 보면 이런 비교는 아무 관계 없는 두 노드 포인터를 비교하는 것으로 전락합니다.
Node-based map internals. Red-black tree 기반 map에서 end()는 트리의 link 필드만 담고 있는 header/sentinel 노드를 가리킵니다. 값 노드는 그 link 필드들로부터 고정된 offset 위치에 key/value pair를 배치합니다. 따라서 it->second를 읽는 동작은, iterator가 우연히 가리키게 된 노드가 무엇이든 그 노드에서 해당 offset만큼 떨어진 곳을 읽는 것과 같습니다.
Analysis
검사가 누락된 버그이면서도, 정작 소스에는 검사가 존재하는 형태입니다. 구조적으로 무력화되어 있을 뿐입니다. 비교 자체는 실행되지만 항상 참으로 평가되므로, 결국 아무것도 막지 못합니다.
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
왼쪽 칸이 fix 이전의 제어 흐름입니다. fromCBOR()은 함수 스코프에 iterator 변수 it 하나만 선언해 두고, 바깥 decodedMap에 대한 조회에 사용합니다. 그런데 credProps 분기 안에서 이 it에 credPropsMap.find(CBOR("rk")) 결과를 다시 대입합니다. 완전히 다른 컨테이너지만 iterator 타입이 동일하기 때문에, 이 재대입은 경고 하나 없이 컴파일됩니다. 다음 줄의 guard는 바깥 map을 위해 작성된 뒤 갱신되지 않은 상태로 남아 있습니다. 결국 sub-map의 조회 결과가 바깥 map의 end sentinel과 다른지를 묻게 됩니다. 서로 다른 두 컨테이너 인스턴스는 end 위치를 공유하지 않으므로, 답은 무조건 "그렇다"입니다. 오른쪽 칸의 key 부재 경로는 이 commit 이전까지 도달 불가능했습니다.
깨진 invariant는 한 절로 서술될 만큼 단순합니다. 조회 결과는 그 결과가 나온 컨테이너의 sentinel과 비교되어야 한다는 것입니다. 다만 타입 시스템은 이를 강제하지 않습니다. 두 map이 같은 instantiation이라서 컴파일러 입장에서 iterator는 서로 교환 가능합니다. 이 비교는 진단 가능한 오류가 아니라 undefined behavior이며, 그래서 경고도 뜨지 않고 컴파일 타임에 잡아내는 sanitizer도 없습니다.
그다음에 무슨 일이 벌어지는지는 sentinel 노드의 실체에 달려 있습니다. node 기반 ordered map에서 end()는 트리의 header 노드를 가리키는데, 여기에는 link 필드만 있을 뿐 생성된 key/value pair가 없습니다. 이를 역참조하면 값 노드 기준 offset 위치를 읽게 되고, 이는 sentinel의 실제 범위를 넘어선 바이트에 해당합니다. 그 자리에 무엇이 있든, 즉 해당 객체 내부든 그 뒤에 이어지는 영역이든 상관없이 읽힙니다. 그리고 그 바이트들이 CBORValue로서 decode 로직에 전달됩니다.
// what the branch body assumes it received
if (rkValue.isBool())
credProps.rk = rkValue.getBool();
여기서 확인하는 판별자가 곧 인접 메모리입니다. 판별자가 우연히 boolean이라고 말한다면, payload 역시 마찬가지로 인접 메모리입니다.
도달 가능성은 쉬운 축에 속합니다. 트리거는 {"credProps": {}}, 12바이트짜리 blob이며 스펙상 완전히 적법합니다. Authenticator가 extension의 존재는 인정하되 보고할 내용은 없는 상황에 해당합니다. 잘못된 CBOR도, 길이 장난도, heap grooming도 필요 없습니다. 현실적인 결과이자 Apple 권고문이 "an unexpected Safari crash"로 기술한 것은 그 읽기에서 발생하는 fault입니다. 범위는 더 좁지만 흥미로운 쪽은 잔여 바이트가 boolean으로 타입 검사를 통과하는 경우입니다. 이때 디코딩된 rk 플래그가 getClientExtensionResults()를 거쳐 인접 메모리 1비트를 스크립트로 전달하게 됩니다. 다만 이는 disclosure primitive가 아니라 oracle에 가깝습니다. 1비트, 고정 offset, 공격자가 고를 수 있는 변위 없음, 쓰기 측면은 전무합니다.
어느 프로세스가 이 fault를 떠안는지가 crash의 가치를 결정합니다. WebAuthn extension-output 디코딩은 authenticator와 WebCore 사이 경계에 위치합니다. fromCBOR()의 호출 지점인 Source/WebCore/Modules/webauthn/fido/DeviceResponseConverter.cpp와 UIProcess WebAuthentication coordinator가, 이것이 WebContent에 국한된 DoS인지 브라우저 전체 DoS인지를 가릅니다. 후자라면 info leak 위치의 가치도 그만큼 올라갑니다. 어느 쪽이든 읽기 전용 sentinel 역참조만으로 sandbox escape에 이르지는 않습니다.
Fix는 sub-map 조회에 자체 변수와 자체 sentinel을 부여해 invariant를 복원합니다. credPropsIt이 credPropsMap.end()와 비교되면서, credProps가 비어 있는 경우는 key 부재 경로를 타고 rk는 unset 상태로 남습니다. 새로 추가된 테스트가 검증하는 것도 정확히 이 동작입니다.
엉뚱한 컨테이너의 end sentinel과 비교되는 key 부재 guard는 항상 통과하므로, credProps.rk를 생략하는 것만으로 decoder가 past-the-end 노드를 CBORValue로 읽게 됩니다.
Insight
올바른 관용구는 이미 같은 함수의 몇 줄 아래 largeBlob 분기에 존재했습니다. 개념이 없어서 생긴 버그가 아니라, decoder 하나 안에서 발생한 copy-paste divergence인 셈입니다. 그리고 바로 옆에 놓인 안전한 형제 코드야말로, 안전하지 않은 쪽이 검토를 거친 것처럼 보이게 만든 요인입니다. 이 형태는 key를 하나씩 훑는 긴 parser에서 반복적으로 나타납니다. 첫 분기는 신중하게 작성되지만, 이후 분기들이 함수 스코프 변수를 재사용하면서 guard는 문법만 유지한 채 의미를 조용히 잃습니다. 반대로 get 계열 helper가 std::optional을 반환하는 decoder, 혹은 WTF의 HashMap::get/getOptional 관용구는 구조상 이 버그를 표현할 수 없습니다. 부재 신호가 컨테이너 상대적이지 않고 그 자체로 의미를 갖기 때문입니다. 신뢰 경계에 놓인 새 CBOR/IPC 디코딩 코드라면 이쪽을 우선 고려할 만합니다.
Audit directions
-
조회 결과를 다른 컨테이너 인스턴스의 sentinel과 비교하는 패턴. 지켜야 할 invariant는 검사 기준이 되는 경계값은 실제로 검색한 바로 그 객체에서 나와야 한다는 것입니다. Narrow:
Source/WebCore/Modules/webauthn(AuthenticationExtensionsClientOutputs.cpp,fido/DeviceResponseConverter.cpp,cbor/)에서 이미 선언된 iterator 변수에 두 번째로 대입한 뒤!= X.end()guard가 뒤따르는 형태를 검색해 볼 필요가 있습니다. 판별 기준은.end()의 수신자가find()/begin()으로 그 iterator를 만들어낸 표현식과 동일하지 않다는 점입니다. Wider: 같은 부류는 iterator가 아닌 경계값에서도 나타납니다. 다른 vector의size()와 비교되는 index, 다른 map의end()와 비교되는WTF::HashMapiterator, 다른 문자열의length()와 비교되는String검색 결과 등입니다. 코드 검색 결과에서의 판별 기준은 비교의 양쪽이 서로 다른 컨테이너 표현식을 지칭하는지 여부입니다. Widest: 일반화하면 "컨테이너 상대적인 부재 sentinel을 엉뚱한 컨테이너 기준으로 검사하는" 부류이며, 부재를 self-describing optional이 아니라 특정 컬렉션에 상대적으로 인코딩하는 모든 곳에 적용됩니다. Chromium의base::flat_mapiterator, 다른 리스트의 size와 비교되는 JavaindexOf결과, 다른 버퍼의 끝과 비교되는 Cstrchr/memchr결과가 그 예입니다. 코드베이스를 넘어 가져갈 invariant는 이것입니다. "부재" 값이 특정 인스턴스에 대해서만 의미를 갖는다면, 그 값과의 모든 비교는 반드시 같은 인스턴스를 지칭해야 합니다. -
조회용 변수를 함수 스코프로 끌어올린 뒤 중첩 레벨을 넘나들며 재사용하는 nested-structure decoder. 위험한 지점은 바깥 스코프 변수가 타입은 그대로 유지한 채 의미만 바뀐다는 데 있습니다. 그 결과 바깥 레벨을 위해 작성된 guard가 안쪽 레벨에서도 그대로 컴파일됩니다. Narrow:
AuthenticationExtensionsClientOutputs::fromCBOR의 나머지 extension 분기(largeBlob 및 prf/credProtect/appid 처리가 있다면 그것까지)와Source/WebCore/Modules/webauthn/fido/의 CTAP response converter를 점검해 볼 필요가 있습니다. 판별 기준은 sub-map이나 sub-array로 내려가면서 그 하강을 위한 새 스코프를 열지 않는 decoder입니다. Wider: 같은 형태는 중첩 wire format을 손으로 파싱하는 코드 어디에서나 나타납니다. plist·JSON reader, IPC argument decoder, HTTP structured-header parser 등이 해당합니다. 판별 기준은 하나 이상의 중첩 레벨에 걸쳐 있으면서 조회 변수나 cursor 변수를 중첩 블록 내부가 아니라 함수 상단에 선언한 함수입니다. Widest: 재사용 가능한 원칙은 "중첩 레벨별 상태는 그 레벨에 스코프되어야 한다"이며, 수작업 deserializer 전반에 적용됩니다. Rustserdehand-impl이나, 중첩 객체 사이에서 공유 cursor를 재사용하는 Go decoder도 여기에 포함됩니다. -
함수 내부에서 관용구가 어긋나는 패턴으로, 키마다 반복되는 블록 중 한쪽은 안전한 형태를 쓰고 옆에 있는 형제 블록은 그렇지 않은 경우입니다. 점검할 때는 위에서 아래로 순서대로 읽지 마십시오. 반복되는 형제 블록을 모두 뽑아낸 뒤 서로 맞대어 비교하는 방식이 효과적입니다. Narrow: 이번 WebAuthn CBOR 디코더의 extension별 분기입니다. 같은 구조체의 서로 다른 키를 처리하는 인접 블록 두 개를 찾아, 이름만 바꿔놓고 보았을 때 guard 표현식의 구조가 일치하지 않는지 확인하면 됩니다. Wider: WebCore CSS property parser의 property별 분기, 그리고 WebKit IPC 디코더의 message별 분기가 여기에 해당합니다. 양쪽 모두 dispatch 형태를 띠고 있어 동일한 종류의 drift가 쌓이게 됩니다. Widest: "반복되는 validation 블록은 복사-붙여넣기 과정에서 어긋난다"는 명제는 언어를 가리지 않고 dispatch 형태의 함수라면 어디에나 적용됩니다. 점검 방법도 달라지지 않습니다. 순차적으로 읽는 대신 형제끼리 짝지어 비교하면 됩니다.