이 커밋을 앞서 제시된 리포트 스타일 규칙에 따라 한국어로 재작성하겠습니다.
[WebCore] TransformStream type confusion when Array.prototype[Symbol.iterator] is overridden
CVE: CVE-2026-43705 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may lead to memory corruption Apple's description: A type confusion issue was addressed with improved checks. Credit: dr3dd
High. new TransformStream()는 별도의 permission이나 user gesture 없이, 스크립트 한 줄만으로 unchecked downcast에 도달합니다. 유일한 방어 장치는 release 빌드에서는 아예 컴파일되어 사라지는 코드였습니다. Null-base crash는 조건 없이 즉시 발생합니다. 이를 counterfeit stream object로 격상시키는 데는 이 diff만으로는 확인되지 않는 heap grooming이 추가로 필요하지만,애초에 이를 막았어야 할 type check 자체가 존재하지 않았다는 점이 핵심입니다.
WebKit의 Streams 구현 중 일부는 C++가 아니라 JavaScript로 작성되어 엔진에 컴파일된 뒤 native 코드에서 private name을 통해 호출됩니다. 이런 구조 때문에 하나의 handoff 과정이 필요합니다. builtin이 internal transform-stream triple을 계산하면, C++는 이 중 readable과 writable 두 부분을 native 객체로 다시 꺼내야 합니다. 다만 이 handoff는 직접 이루어지지 않습니다. WebIDL bindings 레이어를 거치는데, convert<IDLSequence<IDLObject>>는 스펙상 JS iteration protocol로 값을 순회하도록 정의되어 있습니다. 그리고 이 protocol은 페이지가 통제할 수 있는 대상입니다.
관전 포인트: 어떤 페이지든 array iteration을 override해서 browser engine이 두 종류의 특정 stream type을 기대하는 자리에 임의의 JavaScript 객체를 밀어 넣을 수 있습니다. 결과적으로 한 줄짜리 constructor 호출이 renderer 안에서 통제 가능한 type confusion으로 이어집니다.
Source/WebCore/Modules/streams/TransformStream.cpp
LayoutTests/streams/transform-stream-poisoned-iterator-crash.html
Patch Details
이번 변경은 WebCore::createInternalTransformStream 한 곳에 국한되며, 기존에 하나 반 정도였던 guard를 두 개로 늘렸습니다.
첫 번째 guard는 ASSERT(results.size() == 3)을 release 빌드에서도 남는 if (results.size() != 3) [[unlikely]] return Exception { ExceptionCode::TypeError, ... }로 대체합니다. 검사 조건 자체는 동일하지만, 이제는 실제로 출시되는 바이너리 안에 이 검사가 존재합니다. 세 개보다 적거나 많은 entry를 가진 시퀀스는 더 이상 아래쪽의 subscript 접근까지 통과하지 못합니다.
두 번째 guard는 완전히 새로 추가되었습니다. 두 개의 dynamicDowncast 호출은 CreateInternalTransformStreamResult initializer 안에서 밖으로 꺼내져 이름이 붙은 local 변수로 분리되었고, 둘 다를 dereference하기 전에 함께 null check를 거치도록 바뀌었습니다. 이전에는 cast와 dereference가 하나의 expression 안에 함께 있었습니다. dynamicDowncast<JSReadableStream>(results[1].get())->wrapped()가 그 예입니다. dynamicDowncast는 checked cast로서 type mismatch 시 nullptr를 반환하도록 되어 있습니다. 다만 이 호출과 화살표 연산자 사이에서 그 결과값을 확인하는 코드는 어디에도 없었습니다.
Before: After:
results (size unvalidated) results
└─► results[1] ├─► size() != 3 ──► throw TypeError
└─► dynamicDowncast<JSRS> └─► results[1]
└─► ->wrapped() └─► dynamicDowncast<JSRS>
(no null test) └─► !readable ──► throw TypeError
└─► ->wrapped()
두 실패 경로 모두 이제 스크립트 쪽에 TypeError로 노출됩니다. 따라서 poisoned-iterator 페이지는 crash 대신 exception을 받게 됩니다. 이번 커밋에는 LayoutTests/streams/transform-stream-poisoned-iterator-crash.html도 함께 추가되었는데, 같은 override 기법의 세 가지 변형을 검증합니다. slot 1과 2를 plain object로 치환하는 경우, ArrayBuffer 참조를 가진 object로 치환하는 경우, 길이 0짜리 iteration을 반환하는 경우입니다. 세 경우 모두 이제 TypeError를 assert합니다.
Background
Streams API와 그 구현 분리 구조. TransformStream은 페이지 스크립트에 노출되는 Streams API 객체로, writable 쪽과 readable 쪽을 한 쌍으로 묶습니다. 한쪽에 write된 내용은 transform을 거쳐 반대쪽에서 나옵니다. new TransformStream()은 permission이나 user gesture 없이 어떤 웹 페이지에서도 호출 가능합니다. WebKit은 Streams를 C++와 JavaScript로 나누어 구현합니다. 상당 부분이 JS builtin, 즉 엔진에 컴파일되어 native 코드에서 page script에는 보이지 않는 private name을 통해 호출되는 JavaScript 소스로 작성되어 있습니다. createInternalTransformStreamFromTransformer가 바로 그런 builtin 중 하나이며, 페이지 자신의 global object와 realm 안에서 실행됩니다.
Handoff의 C++ 쪽. Source/WebCore/Modules/streams/TransformStream.cpp가 두 절반이 만나는 지점입니다. createInternalTransformStream은 builtin을 호출해 JS 값을 반환받고, 이를 native vector로 변환한 뒤, TransformStream 객체가 갖고 있는 Ref<ReadableStream>과 Ref<WritableStream> 멤버로 각 entry를 unwrap합니다.
WebIDL sequence 변환. convert<IDLSequence<IDLObject>>(globalObject, value)는 JS 값을 native entry로 구성된 WTF::Vector로 변환하는 bindings 레이어의 helper 함수입니다. WebIDL 스펙에 따라 sequence 변환은 JS iteration protocol을 사용합니다. 즉 값에서 Symbol.iterator를 찾아 반환된 iterator의 next()를 반복 호출하는 방식입니다. 이 lookup과 각 next() 호출은 모두 페이지의 realm에서 스크립트로 실행됩니다. IDLObject entry는 strong GC handle 형태로 만들어지므로, results[i].get()은 JSObject*를 반환합니다.
Checked cast와 wrapper. dynamicDowncast<T> (jsDynamicCast<T>와 동일)는 JSC cell에 대한 checked cast입니다. cell의 structure와 ClassInfo를 읽어 runtime type이 일치하지 않으면 nullptr를 반환하며, C++의 dynamic_cast와 동일한 동작입니다. JSReadableStream 같은 JS wrapper 클래스는 자신이 감싸는 WebCore 객체에 대한 참조를 갖고 있으며, JSDOMWrapper::wrapped()가 이 참조를 반환하여 C++ 호출자가 native ReadableStream에 접근할 수 있게 합니다. Ref<T>는 WebKit의 non-null refcounted smart pointer로, T&로부터 생성될 때 해당 객체의 reference count를 증가시킵니다. 이는 참조 대상 객체에 대한 write 동작에 해당합니다.
Check가 아닌 두 개의 check. ASSERT는 debug 빌드에서만 활성화되며, release 빌드에서는 아무것도 남지 않는 형태로 컴파일됩니다. 따라서 ASSERT로만 보호되는 조건은 실제 출시되는 브라우저 어디에서도 runtime enforcement를 받지 않습니다. WTF::Vector::operator[]도 마찬가지로 assertion을 통한 bounds check만 존재하므로, release 빌드에서 size()를 넘어서는 인덱싱은 그대로 raw pointer 연산과 load로 이어집니다.
Analysis
이 문제의 본질은 trust boundary의 소실입니다. C++ 작성자는 이 데이터가 엔진 builtin에서 곧바로 왔다고 믿었지만, 실제로는 페이지가 통제할 수 있는 변환 단계를 거쳐 왔습니다. 그런데 코드는 마치 그런 변환 단계가 없었던 것처럼 이 데이터를 검증했습니다.
builtin realm bindings layer C++ consumer
───────────── ────────────── ────────────
createInternal... convert<IDLSequence< results[1] ──► dynamicDowncast
Transformer() ──[JS]──► IDLObject>> ──────► results[2] ──► dynamicDowncast
returns [a,b,c] │ (no guard survives NDEBUG)
└── @@iterator lookup + next() calls
▲ PAGE-OVERRIDABLE ── trust ends here
화살표를 따라가 보겠습니다. array는 builtin이 의도한 형태로 builtin을 떠납니다. entry 3개, entry 1은 JSReadableStream, entry 2는 JSWritableStream이라는 형태입니다. 하지만 consumer에 도착할 때는 그 형태 그대로가 아닙니다. 가운데 열에서 JS iteration protocol이 실행되는데, array가 페이지 자신의 realm에서 생성되기 때문에 Array.prototype[Symbol.iterator]에 대한 assignment는 producer와 consumer 사이에 페이지 자신의 next() 메서드를 끼워 넣습니다. 몇 개의 entry를 변환 과정이 관찰할지, 각 entry가 무엇일지는 모두 페이지가 결정합니다. results가 오른쪽 열에 도달할 즈음에는 이미 builtin의 이름만 빌린 attacker-shaped 데이터가 되어 있습니다.
패치 이전 코드는 builtin의 보장 사항을 정확히 두 지점에서만 인코딩하고 있었고, 이 둘은 서로 다른 방식으로 실패했습니다.
Type 축에는 애초에 guard가 없었습니다. dynamicDowncast 호출 자체는 정확합니다. ClassInfo를 실제로 검사하고 mismatch 시 null을 반환합니다. 문제는 그 결과가 자신을 만들어낸 것과 동일한 expression 안에서 곧바로 dereference된다는 점입니다. 테스트 variant 1이 가장 기본적인 trigger입니다. poisoned iterator가 entry 3개를 반환하면서 entry 1과 2를 { fake: true }로 바꿔치기하면, cast는 nullptr를 반환하고 ->wrapped()는 null base에서 wrapper의 m_wrapped 필드를 읽으려 시도하다 renderer가 죽습니다. 이 경로는 조건 없이 발생하며, 별다른 준비 과정도 필요하지 않습니다.
Array.prototype[Symbol.iterator] = function() { /* i>=1일 때 {fake:true} 반환 */ };
new TransformStream(); // createInternalTransformStream 내부의 null-base deref
Length 축에는 guard가 존재했지만, 실제로 출시되는 빌드에는 반영되지 않는 guard였습니다. 호출 지점만 놓고 보면 ASSERT(results.size() == 3)은 내부 계약을 문서화하는 코드처럼 읽히고, 그 문서로서의 내용 자체는 정확합니다. 다만 enforcement로서의 역할은 NDEBUG 아래에서 사라져 버립니다. 테스트 variant 3은 바로 이 지점을 노립니다. iterator가 첫 next() 호출에서 done을 보고하면 results.size()는 0이 되고, results[1] / results[2]는 WTF::Vector의 논리적 끝을 넘어서 인덱싱하게 됩니다. 이 Vector의 operator[] 역시 debug assertion만으로 보호되고 있었습니다.
이 subscript 접근이 실제로 어디를 가리키게 될지는 attacker가 선택한 size에 따라 달라집니다. size가 0이면 vector에 애초에 할당된 메모리가 없을 수도 있어, 읽기 자체가 buffer 바깥을 향합니다. size가 1이나 2이면 할당은 되었지만 초기화되지 않은 spare capacity 영역을 읽게 됩니다. 어느 쪽이든, 그 바이트 위치에 우연히 들어 있는 pointer 값이 그대로 dynamicDowncast에 전달되고, 이 함수는 cell header와 ClassInfo를 읽기 위해 그 값을 dereference합니다. 이 시점에서 이미 out-of-bounds 혹은 uninitialized pointer read가 발생한 상태입니다. 만약 여기서 ClassInfo 비교가 우연히 성공한다면, exploitability 판단이 갈리는 확장 지점이 바로 여기입니다. 패치 이전 코드는 조작된 객체를 진짜 JSReadableStream으로 간주하고, 여기서 wrapped()를 가져와 그 결과인 Ref<ReadableStream>을 TransformStream::m_readable에 저장했을 것입니다. 이 Ref 생성 과정에서 refcount 증가가 일어나는데, 이는 attacker가 영향을 미친 주소에 대한 write에 해당합니다. 이후 스크립트가 .readable이나 .writable을 건드리면 저장된 참조를 통해 virtual dispatch가 발생하게 됩니다.
이런 확장 시나리오는 이 commit context만으로는 확인되지 않는 heap grooming 전제 조건에 의존합니다. vector의 spare capacity에 무엇이 들어있는지를 통제하고, 그 값의 ClassInfo pointer가 cast를 통과하도록 만드는 과정이 필요합니다. 조건 없이 성립하는 최소 시나리오는 variant 1의 null-base crash입니다. 페이지 스크립트 몇 줄만으로 안정적으로 유발 가능한 renderer DoS입니다. 여기서 다루는 모든 동작은 WebContent process 내부에 머무릅니다. TransformStream.cpp는 renderer에서 실행되는 WebCore 코드이므로 sandbox 경계를 넘지 않으며, 시스템까지 도달하려면 별도의 sandbox escape가 추가로 필요합니다.
이번 fix는 두 축의 invariant를 동시에 복원합니다. 그래서 check가 하나가 아니라 두 개 필요했던 것입니다. Length check는 원래 assertion이 의미했던 바를 release 빌드에서도 살아남는 형태로 다시 쓴 것에 가깝습니다. Null check가 다루는 type invariant는 그 이전까지 어디에도 명시적으로 기록된 적이 없었습니다. Assertion에도, 주석에도 없었고, 오직 builtin이 무엇을 반환하는지에 대한 작성자의 머릿속 모델에만 존재했습니다.
WebIDL sequence 변환은 페이지의 @@iterator를 실행하기 때문에, builtin이 반환하는 세 개짜리 [result, readable, writable] array는 C++에 도달할 때 attacker가 선택한 길이와 attacker가 선택한 element type을 가진 채로 도착합니다.
Insight
이 경우 trust boundary는 호출 지점에서 눈에 보이지 않습니다. createInternalTransformStream 어디를 봐도 input handling처럼 보이는 코드는 없습니다. 그저 engine builtin을 호출하고, 결과를 변환하고, unwrap할 뿐입니다. 신뢰할 수 있던 데이터가 조용히 신뢰할 수 없는 데이터로 바뀌는 지점은 바로 이 변환 과정이며, bindings 레이어는 이 downgrade를 어떤 문법적 표식도 없이 수행합니다. Builtin이 만들어낸 JS 값을 사용자가 override 가능한 hook을 참조하는 WebIDL 변환에 다시 통과시키는 C++ 코드라면 어디든 동일한 downgrade를 물려받습니다. @@iterator, valueOf, toString, @@toPrimitive, 혹은 Proxy trap이 모두 이런 hook에 해당하며, Streams 구현 자체가 거의 전부 이런 round-trip 위에 만들어져 있습니다.
Audit directions
-
Debug 빌드에서만 동작하는 assertion이 trust boundary를 넘는 유일한 검증 수단으로 남아있는 패턴. Invariant: NDEBUG에서 사라지는 check는 애초에 attacker가 영향을 줄 수 있는 어떤 것도 검증한 적이 없다는 뜻입니다. Narrow —
Source/WebCore/Modules/streams/와Source/WebCore/bindings/js/에서 builtin 호출로부터 유래한 값에 대한jsDynamicCast/dynamicDowncast/jsCast와 같은 블록에 있는ASSERT(를 검색합니다.ReadableStream.cpp,WritableStream.cpp,StreamTransferUtilities.cpp,ReadableStreamDefaultController가 이 정확한 호출 형태와 가장 가까운 이웃이며, shape에 대한ASSERT다음에if (!x)검사 없이 같은 표현식 안에서 곧바로 dereference되는 cast가 이어지는 형태가 특징적인 흔적입니다. Wider — 이 클래스는 release build에서 elide되는 check가 신뢰도가 낮은 데이터를 보호하는 모든 위치에서 반복됩니다.ASSERT_UNUSED, 장식적인ASSERT_WITH_SECURITY_IMPLICATION, 혹은 이후 dereference되는 필드와 다른 필드를 검사하는RELEASE_ASSERT등이 해당합니다. 코드 검색상의 특징은 validation predicate와 dereference가 서로 같은 변수를 가리키지 않는다는 점입니다. Widest — 이 원칙은 codebase에 종속되지 않습니다. parser, deserializer, IPC layer와 그 소비 지점 사이에 debug 전용 predicate(DCHECK,debug_assert!, Javaassert,-O하의 Pythonassert)가 놓여 있는 모든 프로젝트에서 점검할 필요가 있습니다. 값이 debug와 release 실행 사이에서 달라질 수 있다면, 그 debug check는 security control로 기능하지 않습니다. -
Host 코드가 자신이 만든 값이라도, user가 override 가능한 protocol hook을 거치는 변환 이후에는 동일한 shape를 유지한다고 가정하는 패턴. Invariant: script를 실행하는 모든 변환은 attacker의 realm으로 재진입하므로, producer가 보장하던 조건은 반환 시점이 아니라 변환이 일어나는 시점에 만료됩니다. Narrow —
Source/WebCore안에서 bindings가 생성한 parameter가 아니라 builtin 호출로부터 온 인자를 받는 모든convert<IDLSequence<...>>와toNativeArray호출 지점을 나열합니다. streams 내부 구현이 가장 뚜렷한 군집이며, 같은 함수 안에서...PrivateName()builtin 호출 결과가 곧바로 sequence 변환으로 이어지는 형태가 흔적입니다. Wider —@@iterator를 넘어 WebIDL 변환이 유발할 수 있는 모든 user-overridable hook으로 범위를 넓힐 필요가 있습니다.IDLLong과IDLDOMString변환에서의valueOf/toString/@@toPrimitive, dictionary 변환에서의 Proxyget/ownKeystrap 등이 해당합니다. 찾아야 할 형태는 C++가 field A를 읽고, 변환을 수행한 뒤, field B를 읽으면서 A와 B가 일관되다고 가정하는 패턴입니다. Widest — 이는 "host가 script로 override 가능한 protocol을 이용해 host 데이터를 serialize한다"는 일반적인 클래스에 해당하며, 자신이 생성한 값에 대해PyObject_GetIter를 호출하는 Python C extension,__indexmetatable을 존중하는 Lua C API 코드, user가 구현 가능한 interface를 거쳐 round-trip하는 .NET/COM interop 등에서도 반복적으로 나타납니다. 항상 던져봐야 할 질문은 이 변환의 어느 단계를 untrusted 쪽이 구현할 수 있는가입니다. -
Untrusted 입력에서 얻은 길이를 가진 container에 대해, release build에서는 bounds check가 사라지는 언어로 constant-index 접근을 수행하는 패턴. Invariant: 길이는 index가 실제로 사용되는 것과 동일한 build 구성에서 검사되어야 합니다. Narrow — WebCore에서
convert<IDLSequence<...>>결과가 곧바로 literal subscript(results[1],results[2])로 접근되거나 positional destructuring되는 곳을 검색하고, 각각에 대해 release build에서도 유효한size()비교가 그 앞에 dominate하고 있는지 확인합니다. literal subscript 위에size()검사가 없다는 것이 흔적입니다. Wider — 같은 형태가 JS에서 접근 가능한 state로부터 유래한 값으로 index되는 모든WTF::Vector/Span/FixedVector에도 적용됩니다. 더 미묘한 변형으로는 size가 실제로 검사되긴 하지만 그 check와 실제 사용 사이에 script를 실행하며 재진입 가능한 호출이 끼어드는 경우입니다.size()검사 이후 임의의 함수 호출이 있고 그다음 subscript가 오는 형태라면 다시 한번 확인해볼 필요가 있습니다. Widest — 이 원칙은 indexed access가 검사되지 않거나 선택적으로만 검사되는 모든 언어에 적용됩니다. C++의std::vector::operator[]와.at()의 차이, Rust의get_unchecked, 파싱된 길이를 기반으로 reslicing하는 Go slice 등이 해당합니다. 외부에서 도착한 길이는 반드시 배포된 binary 안에 실제로 존재하는 코드에 의해 검증되어야 하며, container를 resize할 수 있는 어떤 동작 이후에도 재검증되어야 합니다.