[3] Type confusion in ReadableStream when cancel returns a fake Promise
Streams' pipeTo checked for a fake promise with a cast that never fails
High에 해당합니다. 일반 웹 콘텐츠만으로도 짧고 항상 동일하게 재현되는 PoC로 도달 가능하며, native 코드는 attacker가 형태를 조작한 plain object를 마치 promise인 것처럼 다루게 됩니다. Critical 등급 아래로 묶어두는 지점은, attacker가 위조된 object의 slot에 원하는 값을 채워 넣을 수 있는지 여부입니다. 이 조건이 성립하면 단순한 crash를 넘어 fake-cell primitive로 이어질 가능성이 있습니다.
WebKit은 Streams 명세를 일부는 privileged JavaScript builtin으로, 일부는 C++로 구현하고 있으며, 값은 이 두 영역을 오가게 됩니다. Symbol.species는 well-known symbol로, 내장 machinery가 "같은 종류"의 파생 object를 만들어야 할 때 constructor에서 읽어오는 대상입니다. Promise의 경우 chaining 연산은 Promise[Symbol.species]를 참조해 어떤 constructor가 파생 promise를 만들지 결정하며, script는 이 accessor를 직접 정의할 수 있습니다. JSPromise는 JSC의 internal-field object로, 상태 플래그와 reaction/result가 고정된 slot에 저장되어 있고 promise machinery는 접근할 때마다 별도의 타입 체크 없이 이 slot을 직접 읽습니다. C++ 쪽에서는 builtin이 promise로 넘겨주는 값이 실제로 JSPromise cell이라는 전제를 갖고 있습니다.
관전 포인트: 어떤 페이지든 Promise[Symbol.species] getter를 정의해 native pipeTo 코드에 plain object를 넘길 수 있으며, 이 object는 promise의 internal field로 읽혀 promise-then machinery에 그대로 전달됩니다.
Use
jsDynamicCastin place ofjsCastto avoid type confusion.Test:
streams/readable-stream-fake-promise-crash.html
Source/WebCore/Modules/streams/StreamPipeToUtilities.cpp
LayoutTests/streams/readable-stream-fake-promise-crash.html
Patch Details
세 곳의 unchecked downcast가 checked downcast로 변경되었습니다. cancelReadableStream()에서는 InternalReadableStream::cancel()의 결과를 downcast<JSC::JSPromise>(value)로 캐스팅하던 부분이 dynamicDowncast<JSC::JSPromise>(value)로 바뀌었습니다. 동일한 치환이 StreamPipeToState::errorsMustBePropagatedForward()(internalWritableStream->abort(...)의 결과)와 StreamPipeToState::closingMustBePropagatedBackward()(shutdown action이 반환하는 value로, 이후 promise-then 처리 로직에 전달됨)에도 적용되었습니다.
세 호출 지점 모두 이미 if (!promise) fallback 분기를 갖고 있었습니다. 그런데 downcast를 사용하는 한, non-null 입력에 대해서는 이 분기가 도달 불가능한 상태였습니다. Debug 빌드에서는 캐스트가 assert를 발생시키고, release 빌드에서는 그대로 재해석해버리기 때문입니다. 결국 이번 fix는 원래 존재하던 error handling을 비로소 살아있게 만든 셈입니다. cancelReadableStream은 nullptr을 반환하고, errorsMustBePropagatedForward는 새로 생성한 resolved promise로 대체하며, closingMustBePropagatedBackward는 rejectWithCallback(getError2, RejectAsHandled::Yes)를 통해 deferred를 reject합니다. 네 번째로, 방어적 성격의 변경도 함께 이루어졌습니다. StreamPipeToState::globalObject()가 dynamicDowncast<JSDOMGlobalObject>(context->globalObject())를 사용하도록 바뀌었습니다. 부수적으로 새로운 layout test와 그 expectation 파일이 추가되었는데, 이는 실제로 동작하는 PoC입니다.
Script에 의해 영향을 받는 값을 구체 타입으로 unchecked downcast하는 패턴으로, 캐스트 실패에 대비해 작성된 guard 분기가 있음에도 캐스트 연산자 자체가 실패하지 않는 variant였기 때문에 해당 분기가 결코 실행될 수 없었던 경우입니다.
Background
The two cast families.
WebKit에서 downcast<T>(v)는 실패하지 않는 캐스트로, debug 빌드에서만 타입을 검증합니다(JSC 타입의 경우 jsCast 계열). Debug 빌드에서는 타입을 assert하고, release 빌드에서는 포인터를 그대로 재해석합니다. 반면 dynamicDowncast<T>(v)(jsDynamicCast 계열)는 런타임 타입 검사를 수행하며, 타입이 일치하지 않으면 null을 반환합니다.
Symbol.species and its watchpoint.
Symbol.species는 built-in 로직이 동일한 종류의 파생 객체를 생성해야 할 때 constructor로부터 읽어오는 속성입니다. Promise의 경우 then/catch와 내부 promise-chaining 로직이 constructor를 선택하기 위해 Promise[Symbol.species]를 참조합니다. JSC는 이 속성에 watchpoint를 설치해, 값이 기본값을 유지하는 동안에는 promise-chaining fast path가 species lookup 자체를 건너뛰도록 합니다. 이 속성에 대해 own accessor를 정의하면 watchpoint가 fire(invalidate)되며, 이후의 chaining은 실제로 getter를 호출하는 generic path로 넘어가게 됩니다. 이 watchpoint는 순수하게 최적화 목적으로 존재합니다. Lookup 자체는 chaining 단계마다 발생하는데, 실제 코드에서 species를 override하는 경우는 거의 없기 때문입니다.
[[Construct]] and primitive returns.
JavaScript에서 constructor 함수의 body가 primitive 값을 반환하면, construct call의 결과값은 새로 생성된 this 객체가 됩니다. 따라서 F가 0을 반환하는 함수일 때 new F()는 평범한 JSFinalObject를 만들어냅니다.
JSPromise internal fields.
JSPromise는 JSC의 internal-field 객체입니다. 상태 플래그와 reaction/result 값은 고정된 internal-field slot에 저장되며, performPromiseThen이 접근 시점마다 별도의 타입 검사 없이 이를 직접 읽습니다. Internal field는 C++ 전용 slot이라 script가 이름으로 읽거나 쓸 수 없습니다. 바로 이 지점 때문에, JSPromise를 흉내 낸 위조 객체가 위험해집니다. 엔진이 자체적으로 private하다고 믿는 slot을 그대로 읽어버리기 때문입니다.
The pipeTo split.
WebKit은 Streams를 일부는 privileged JS builtin(InternalReadableStream, InternalWritableStream)으로, 일부는 C++로 구현합니다. readableStreamPipeTo()는 StreamPipeToState를 생성하는데, 이 생성 시점에 네 가지 propagation 경로 — errorsMustBePropagatedForward, errorsMustBePropagatedBackward, closingMustBePropagatedForward, closingMustBePropagatedBackward — 를 동기적으로 연결한 뒤 read/write loop에 진입합니다. closingMustBePropagatedBackward는 destination writable stream이 이미 closed 상태이거나 close가 큐에 걸려 있는 경우를 처리합니다. 이 경우 pipe를 shutdown시키는데, 이 과정에서 source ReadableStream이 cancel되면서 author가 제공한 cancel() 콜백이 호출됩니다. DeferredPromise/DOMPromise는 이런 JSValue들을 C++ continuation으로 연결하기 위해 WebCore가 사용하는 refcounted wrapper입니다.
Analysis
근본 원인은 native pipeTo 구현이 JS-builtin streams 레이어에서 돌아오는 모든 JSValue를 당연히 JSPromise라고 전제하고, 이 가정을 unchecked downcast로 표현했다는 데 있습니다. 여기서 누락된 invariant는 다음과 같습니다. Builtin stream 코드가 생성하는 promise의 객체 identity는 WebKit이 온전히 통제할 수 있는 대상이 아닙니다. Builtin 내부의 promise 생성이 Promise[Symbol.species]를 경유하는데, 이 속성은 script에서 관찰 가능하고 교체도 가능하기 때문입니다.
script builtin JS streams native C++
────── ────────────────── ──────────
define Promise
[Symbol.species]
getter (fires WP)
chaining step needs
a derived promise
─► species lookup
(slow path now)
getter -> FakePromise ◄──┘
new FakePromise(exec)
body returns 0
=> plain JSFinalObject
─► downcast<JSPromise>
reinterprets it
─► performPromiseThen
reads slots 0/1 as
flags + reactions
Type confusion 자체는 단순한 형태입니다. 다만 흥미로운 부분은 타이밍에 있습니다. JSC의 species watchpoint 덕분에 평소에는 lookup 자체가 생략됩니다. 그런데 PoC는 이 watchpoint를 cancel() 콜백 내부에서, 즉 모든 setup 단계의 @then 호출이 이미 끝난 시점에 fire시킵니다. 그 결과 바로 다음 species lookup만이 slow path를 타면서 attacker의 getter를 호출하게 됩니다. Getter는 최초 한 번은 FakePromise를 반환하고 이후로는 undefined를 반환하므로, 다른 모든 chaining 단계는 %Promise%로 fallback되어 나머지 로직은 정상적으로 동작합니다. FakePromise는 body가 primitive 0을 반환하는 평범한 함수이기 때문에, [[Construct]]는 새로 생성된 this — 즉 평범한 JSFinalObject — 를 결과값으로 만듭니다. 이 객체가 C++ 쪽으로 전파되어 unchecked cast를 통과하게 됩니다.
각 호출 지점에 이미 존재하던 if (!promise) 분기는, 원래 작성자가 이 캐스트가 실패할 수 있다고 의도했음을 보여줍니다. 문제는 캐스트 연산자 선택이 잘못되었다는 점뿐입니다.
PoC는 특별한 권한이 필요 없는 순수 script입니다. readable-stream-fake-promise-crash.html을 따라가면 다음과 같습니다.
cancel(reason)콜백을 정의한 underlying source를 가진ReadableStream을 생성합니다.WritableStream을 생성하고 writer를 얻은 뒤writer.close()를 호출해 내부@closeRequest를 설정합니다. 이 시점에 상태는 여전히writable이지만closeQueuedOrInFlight()는 true가 됩니다. 이어서writer.releaseLock()을 호출하면destination.locked()가 false가 되어pipeTo가 자체 writer를 획득할 수 있게 됩니다.readableStream.pipeTo(writableStream)을 호출합니다.StreamPipeToState::create는 네 개의 propagation 설정을 동기적으로 수행하는데,closingMustBePropagatedBackward()가 큐에 걸린 close를 감지하고 shutdown-with-action을 시작합니다. 이 과정에서 source가 cancel되며, author가 작성한cancel()이 동기적으로 호출됩니다.cancel()내부에서Object.defineProperty(Promise, Symbol.species, {get})이 호출되면서, 앞선 모든 setup@then호출이 이미 끝난 정확한 시점에 species watchpoint가 fire됩니다. 결과적으로 그 다음 species lookup만 getter를 참조하게 됩니다.cancel()은 정상적인 pending promise를 반환하므로promiseResolve를 그대로 통과합니다. 하지만 이어지는 builtin chaining 단계는 species를 통해 파생 promise를 생성합니다.new FakePromise(executor)는 resolve/reject callability 검사를 만족시키기 위해 executor를 실행한 뒤0을 반환하므로,[[Construct]]의 평가 결과는 평범한this객체가 됩니다. PoC 자체의 주석이 species를 참조하는 chaining 단계를 명시하고 있으며, streams builtin이 정확한 construction 지점을 고정합니다. Fix 자체는 그 지점이 정확히 어디인지에 의존하지 않습니다.- 이 평범한 객체는
closingMustBePropagatedBackward의downcast<JSC::JSPromise>(value)에 도달해 재해석되고, 그 결과가performPromiseThen에 전달됩니다.
이 테스트가 목표로 하는 ASan/assertion crash를 넘어서는 확장 가능성은, 이번 변경이 확정 짓지 않는 두 가지 조건에 달려 있습니다. 첫째는 attacker가 confused 객체의 storage slot을 원하는 JSValue로 채울 수 있는지 여부입니다. FakePromise의 body는 객체가 빠져나가기 전에 실행되므로, 그 시점에 own property를 할당하면 JSPromise가 status flag 및 reaction/result 필드로 읽는 inline slot에 attacker가 선택한 값을 채울 수 있는 가능성이 존재합니다. 둘째는 species hook을 non-cell JSValue를 반환하도록 유도할 수 있는지 여부입니다. PoC의 return 0은 [[Construct]] 시맨틱에 의해 무력화되지만, 동일한 slow path는 primitive가 그대로 흘러갈 수 있는 .then 스타일 chaining에서도 도달 가능합니다. 만약 첫 번째 조건이 성립한다면, promise-then 경로가 reaction/result internal field로 attacker가 선택한 JSValue를 읽고, status flag로도 다른 값을 읽게 될 가능성이 있습니다. 이후 해당 reaction 필드가 cell로 취급되는 시점에서, fake-object primitive를 얻을 가능성도 고려할 수 있습니다. Species로 위조된 internal-field 객체가 controlled pointer dereference로 이어지는 전형적인 경로입니다. 반대로 두 번째 조건이 성립하는 경우라면, confused pointer는 cell조차 아니게 되고, 즉각적인 영향은 attacker가 영향을 줄 수 있는 주소에 대한 wild dereference가 됩니다.
발견 경위는 새로 작성된 native streams 코드에 대한 패턴 감사에 가까워 보입니다. StreamPipeToUtilities.cpp는 pipeTo를 C++로 새로 재구현한 파일로, 이런 파일에 대해 자연스럽게 던지게 되는 감사 질문은 "builtin으로부터 돌아오는 어떤 JSValue가 있고, 그것이 어떻게 narrowing되는가"입니다. downcast<JSC::JSPromise>가 도달 불가능한 if (!promise) 분기 바로 위에 놓여 있는 모양새는 시각적으로 강한 단서입니다. PoC에 담긴 species-watchpoint 타이밍 디테일은, 발견자가 JSC의 promise fast path를 상당히 깊이 이해한 상태에서 trigger를 의도적으로 구성했음을 시사합니다. 이는 무작위 grammar fuzzer보다는 hand-auditing이나 이전의 Symbol.species fake-object 버그에 대한 variant analysis에 가까운 접근에 부합합니다. writer.close() + releaseLock() + cancel 내부에서의 species override라는 정교한 시퀀스를 fuzzer가 우연히 맞춰내기는 어렵습니다.
이 vulnerability는 WebContent process 내부에서 JS-builtin streams 레이어와 native WebCore 코드 사이의 type-safety 경계를 약화시킵니다. Security model이 전제하는 바는, streams builtin이 "promise"로 돌려주는 값이 실제로 JSPromise cell이라는 것입니다. 그러나 script는 Promise[Symbol.species] getter를 정의하는 것만으로 이 전제를 무너뜨릴 수 있습니다. Builtin이 파생 promise를 species를 통해 생성하기 때문입니다. Confusion에 성공한 attacker는 평범하면서도 attacker가 형태를 조작한 객체를 native 코드가 JSPromise인 것처럼 다루게 만들 수 있습니다. 따라서 현실적으로 얻을 수 있는 것은 renderer 내부의 controlled type-confusion primitive입니다. Internal field 오해석, 그리고 non-cell 값에 대해서는 wild pointer dereference로 이어지며, 이는 WebContent 내 메모리 disclosure나 corruption을 의미할 뿐, 그 자체로 sandbox escape는 아닙니다.
Insight: JSC의 species watchpoint는 이런 부류의 버그를 미묘하게 더 위험하게 만듭니다. Watchpoint가 타이밍으로 선택 가능한 스위치 역할을 하기 때문입니다. Watchpoint가 유지되는 동안에는 fast path가 사용되므로, attacker는 모든 setup chaining이 안전한 경로로 완료되도록 놔둔 뒤, 콜백 내부에서 watchpoint를 fire시켜 정확히 하나의 후속 species lookup만 slow path로 보낼 수 있습니다. 이는 "script가 species를 override할 수 있는가?"라는 질문을 "script가 정확히 선택한 한 순간에 species를 override할 수 있는가?"라는 질문으로 바꿔놓으며, "이 함수 앞부분에서 이미 promise 타입을 확인했다"는 형태의 추론을 무력화시킵니다.
Audit directions
-
실패 가능성이 있어 보이도록 작성되었지만 실제로는 실패하지 않는 연산자를 사용해, 그 아래의 error 분기가 dead code가 된 패턴. 여기서 지켜야 할 invariant는 다음과 같습니다. 호출 지점에
if (!x)형태의 복구 경로가 있다면, 그 위의 캐스트는 반드시 checked variant여야 합니다. 이 패턴은 dead 분기가 바로 눈앞에 있기 때문에 자기 설명적이고 grep으로 찾기도 쉽습니다. 좁게 보면:Source/WebCore/Modules/streams와 더 넓은Source/WebCore/bindings트리에서 결과가 곧바로 null 검사되는downcast</jsCast<패턴을 grep해볼 수 있습니다. 코드 리뷰 관점에서,downcast<T>(...)대입 직후 몇 줄 안에if (!ptr)나ptr ? ... : ...삼항식이 이어진다면, 이는 구조적으로 도달 불가능한 코드이므로 한 번 더 살펴볼 가치가 있습니다. 넓게 보면: 실패하지 않는 narrowing 연산자가 failure handler와 짝지어진 모든 경우에 적용됩니다. Base pointer에 대한static_cast뒤에 null 검사가 이어지는 경우,ASSERT로만 검증된reinterpret_cast, 혹은 Objective-C에서id타입 값을 typed 변수에 대입하면서respondsToSelector:fallback을 두는 경우 등입니다. 가장 넓게 보면: 복구 경로가 존재한다는 사실 자체가, 작성자가 해당 연산이 실패할 수 있다고 믿었다는 근거이므로, 실제로 그 연산이 실패를 보고할 수 있는지 검증해야 합니다. 이는 Rust의unwrap()/expect()옆에if let None분기가 있는 경우, 그리고 Java에서 unchecked cast가 사실상 도달 불가능한catch (ClassCastException)으로 감싸져 있는 경우로도 그대로 이어집니다. -
Native code가, builtin이 script에서 관찰 가능한 species/constructor hook을 통해 생성한 object의 concrete type을 신뢰하는 패턴. 여기서 지켜야 할 invariant는 다음과 같습니다.
Symbol.species,constructorproperty lookup, 또는 subclass 가능한 builtin을 통해 도달 가능한 모든 것은, 그것을 생성한 코드의 권한 수준과 무관하게 script-typed로 취급되어야 합니다. 좁게 보면, streams builtin의 다른 C++ consumer들을 점검할 필요가 있습니다.InternalReadableStream,InternalWritableStream,InternalWritableStreamWriter,ReadableStreamDefaultReader, 그리고createPromiseAndWrapper호출부가 대상입니다. builtin JS에서 되돌아온 JSValue가dynamicDowncast없이JSPromise,JSArray, 혹은 concreteJSDOMWrapper로 downcast되는 지점이 있는지 확인해야 합니다. 이런 지점이 바로 문제의 tell입니다. 좀 더 넓게 보면, WebKit builtin이 native consumer를 위해 derived object를 생성하는 모든 곳이 대상이 됩니다. builtin array helper 안의Array[Symbol.species],String.prototype.split계열 경로의RegExp[Symbol.species],slice에서의ArrayBuffer[Symbol.species], 그리고Source/JavaScriptCore/builtins와Source/WebCore/Modules/*/*.js아래.jsbuiltin 파일 내부의@Constructor기반 생성 코드가 모두 해당합니다. 코드 검색 시 찾아야 할 패턴은, builtin이@speciesConstructor나new @Constructor(...)를 값에 대해 호출한 뒤 그 값이 C++로 흘러나가는 형태입니다. 가장 넓은 관점에서 보면, user code가 내부적으로 생성되는 object의 concrete type을 선택할 수 있게 하는 language-level extension hook은, 그 hook 이후의 모든 type guarantee를 무너뜨립니다. 이는 V8/SpiderMonkey의 species 처리, .NET/Java의 factory-method injection 및 deserialization gadget surface, 그리고 host가 user-supplied configuration으로부터 구현 클래스를 선택하는 모든 plugin registry에 동일하게 적용됩니다. -
Watchpoint나 memoized invariant로 check를 생략했는데, script가 callback 내부에서 원하는 시점에 그 invariant를 무효화할 수 있는 패턴. 여기서 지켜야 할 invariant는 다음과 같습니다. global watchpoint에 의존한 check elision은, elision 결정과 그에 의존하는 모든 사용 지점 사이에 attacker-controlled code가 실행될 수 없을 때만 안전합니다. 좁게 보면, JSC의 species 및 prototype-chain watchpoint를 살펴볼 필요가 있습니다.
JSGlobalObject의m_promiseSpeciesWatchpointSet과 array/regexp/typed-array 계열의 대응 watchpoint들이 대상이며, 이들을 참조하는 fast path를 모두 나열해야 합니다. 여기서 tell은, fast path가 watchpoint 상태를 한 번 읽은 뒤 여러 개의 의존적인 연산을 수행하는데, 그 중 최소 하나가 user JS로 진입할 수 있는 경우입니다. 좀 더 넓게 보면, re-entrant script에 의해 무효화될 수 있는 모든 cached-validity token이 대상이 됩니다. callback 이전에 확인하고 이후에도 유효하다고 신뢰하는 DOM style/layout validity flag, user-code 호출을 사이에 둔 inline-cache/structure-validity check, 그리고toString/valueOf/Symbol.toPrimitive에 대한 memoized "is default" flag가 여기에 해당합니다. 주목해야 할 패턴은, validity read가 일어난 뒤 author code에 도달할 수 있는 호출이 이어지고, 그 다음 앞서 읽은 값을 사용하는 흐름입니다. 가장 넓은 관점에서 보면, cache-validity check와 그에 의존하는 사용 지점 사이에 re-entrancy point가 끼어들어서는 안 됩니다. 이 원칙은 feedback-vector나 type-barrier elision을 사용하는 모든 JIT, 이벤트 dispatch 전반에 걸쳐 layout validity를 caching하는 모든 GUI toolkit, 그리고 TOCTOU 형태의 모든 최적화에 동일하게 적용됩니다. 다만 이 부분의 검증은 실제로 쉽지 않습니다. Watchpoint가 시퀀스 도중에 fire되지 않는다는 것을 증명하려면 대체로 static inspection이 아니라, watchpoint-firing trace를 확인할 수 있는 debug build가 필요합니다.