← All reports

[3] Type confusion in ReadableStream when cancel returns a fake Promise

HighWebCore StreamsTypeConfusion

Streams' pipeTo checked for a fake promise with a cast that never fails

96ec73a

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 jsDynamicCast in place of jsCast to avoid type confusion.

Test: streams/readable-stream-fake-promise-crash.html

Source/WebCore/Modules/streams/StreamPipeToUtilities.cpp

static RefPtr<DOMPromise> cancelReadableStream(JSDOMGlobalObject& globalObject, ...)
if (!value)
return nullptr;
 
- auto* promise = downcast<JSC::JSPromise>(value);
+ auto* promise = dynamicDowncast<JSC::JSPromise>(value);
if (!promise)
return nullptr;
...
void StreamPipeToState::errorsMustBePropagatedForward(JSDOMGlobalObject& globalObject)
auto value = internalWritableStream->abort(*globalObject, error.get());
if (!value)
return nullptr;
 
- auto* promise = downcast<JSC::JSPromise>(value);
+ auto* promise = dynamicDowncast<JSC::JSPromise>(value);
if (!promise) {
auto [result, deferred] = createPromiseAndWrapper(*globalObject);
deferred->resolve();
...
void StreamPipeToState::closingMustBePropagatedBackward()
auto [result, deferred] = createPromiseAndWrapper(*globalObject);
 
- auto* promise = downcast<JSC::JSPromise>(value);
+ auto* promise = dynamicDowncast<JSC::JSPromise>(value);
if (!promise)
deferred->rejectWithCallback(WTF::move(getError2), RejectAsHandled::Yes);
else {
...
JSDOMGlobalObject* StreamPipeToState::globalObject()
RefPtr context = scriptExecutionContext();
 
diff
- return context ? downcast<JSDOMGlobalObject>(context->globalObject()) : nullptr;
+ return context ? dynamicDowncast<JSDOMGlobalObject>(context->globalObject()) : nullptr;

LayoutTests/streams/readable-stream-fake-promise-crash.html

+function FakePromise(executor) {
+ executor(() => {}, () => {}); // satisfy resolve/reject callability checks
+ return 0; // construct() returns this object
+}
+const readableStream = new ReadableStream({
+ cancel(reason) {
+ // Fire m_promiseSpeciesWatchpointSet *here*, after all setup .@then calls are done
+ let didCall = false;
+ Object.defineProperty(Promise, Symbol.species, {
+ configurable: true,
+ get() {
+ if (!didCall) { didCall = true; return FakePromise; }
+ return undefined; // fall back to %Promise% for every other .then
+ }
+ });
+ return new Promise(() => {}); // genuine pending JSPromise
+ }
+});
+const writableStream = new WritableStream();
+const writer = writableStream.getWriter();
+writer.close(); // @closeRequest set => closeQueuedOrInFlight() == true
+writer.releaseLock(); // destination.locked() == false
+readableStream.pipeTo(writableStream).catch(() => {}); // -> closingMustBePropagatedBackward -> jsCast

세 곳의 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을 비로소 살아있게 만든 셈입니다. cancelReadableStreamnullptr을 반환하고, errorsMustBePropagatedForward는 새로 생성한 resolved promise로 대체하며, closingMustBePropagatedBackwardrejectWithCallback(getError2, RejectAsHandled::Yes)를 통해 deferred를 reject합니다. 네 번째로, 방어적 성격의 변경도 함께 이루어졌습니다. StreamPipeToState::globalObject()dynamicDowncast<JSDOMGlobalObject>(context->globalObject())를 사용하도록 바뀌었습니다. 부수적으로 새로운 layout test와 그 expectation 파일이 추가되었는데, 이는 실제로 동작하는 PoC입니다.

Script에 의해 영향을 받는 값을 구체 타입으로 unchecked downcast하는 패턴으로, 캐스트 실패에 대비해 작성된 guard 분기가 있음에도 캐스트 연산자 자체가 실패하지 않는 variant였기 때문에 해당 분기가 결코 실행될 수 없었던 경우입니다.

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 객체가 됩니다. 따라서 F0을 반환하는 함수일 때 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입니다.

근본 원인은 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을 따라가면 다음과 같습니다.

  1. cancel(reason) 콜백을 정의한 underlying source를 가진 ReadableStream을 생성합니다.
  2. WritableStream을 생성하고 writer를 얻은 뒤 writer.close()를 호출해 내부 @closeRequest를 설정합니다. 이 시점에 상태는 여전히 writable이지만 closeQueuedOrInFlight()는 true가 됩니다. 이어서 writer.releaseLock()을 호출하면 destination.locked()가 false가 되어 pipeTo가 자체 writer를 획득할 수 있게 됩니다.
  3. readableStream.pipeTo(writableStream)을 호출합니다. StreamPipeToState::create는 네 개의 propagation 설정을 동기적으로 수행하는데, closingMustBePropagatedBackward()가 큐에 걸린 close를 감지하고 shutdown-with-action을 시작합니다. 이 과정에서 source가 cancel되며, author가 작성한 cancel()이 동기적으로 호출됩니다.
  4. cancel() 내부에서 Object.defineProperty(Promise, Symbol.species, {get})이 호출되면서, 앞선 모든 setup @then 호출이 이미 끝난 정확한 시점에 species watchpoint가 fire됩니다. 결과적으로 그 다음 species lookup만 getter를 참조하게 됩니다.
  5. 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 자체는 그 지점이 정확히 어디인지에 의존하지 않습니다.
  6. 이 평범한 객체는 closingMustBePropagatedBackwarddowncast<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.cpppipeTo를 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 타입을 확인했다"는 형태의 추론을 무력화시킵니다.