← All issues

[1] Type confusion via raw Exception cell returned to script from callPromisePairFunction

navigation.navigate() with no arguments hands JavaScript a raw Exception cell

Severity: High | Component: WebCore JS bindings | e753548

High 등급으로 평가됩니다. diff에서 callPromisePairFunction을 통해 raw JSC::Exception 셀(JSType CellType, JSObject 아님)이 스크립트에 노출되는 것이 확인됩니다. 이후의 property store에서 production 빌드는 검증 없이 jsCast<JSObject*>를 실행하고, Exception::m_value에 대한 controlled write가 발생합니다. controlled GC crash 이상의 확장에는 heap grooming이 필요하나, diff에서 이는 확인되지 않습니다.

callPromisePairFunction의 sentinel check !JSValue::decode(result)는 IDL 인자 변환 실패 시 반환되는 zero empty-value sentinel만 감지합니다. 필수 인자보다 적은 수의 인자로 [ReturnsPromisePair] 작업이 호출되면, 생성된 바인딩은 CodeGeneratorJS.pm이 생성한 missing-argument check에 걸려 return throwVMError(...)를 실행합니다. 이 경우 empty sentinel이 아닌 non-zero JSC::Exception* 셀이 인코딩된 채 반환됩니다. 이후 rejectPromisesWithExceptionIfAny가 pending exception을 제거하면 sentinel check는 false로 평가되어, raw Exception 셀이 JavaScript로 그대로 반환됩니다. 이 문제는 필수 인자를 가진 모든 [ReturnsPromisePair] IDL 작업에 영향을 미치며, 특히 Navigation.navigate() / reload() / traverseTo() / back() / forward()가 해당됩니다. 수정 사항으로는 rejectPromisesWithExceptionIfAny가 exception을 제거하기 전에 catchScope.exception()을 캡처하도록 변경되었습니다. 또한 functor가 throw한 경우에는 항상 convertDictionaryToJS를 통해 유효한 result dictionary를 재구성합니다.

Source/WebCore/bindings/js/JSDOMPromiseDeferred.h

auto result = functor(globalObject, callFrame, DeferredPromise::create(...), DeferredPromise::create(...));
 
+ // functor가 throw했을 수 있습니다 — 예: 생성된 missing-argument check가
+ // throwVMError(...)를 반환하면 empty JSValue sentinel이 아닌
+ // JSC::Exception* 셀이 인코딩됩니다. rejectPromisesWithExceptionIfAny가
+ // exception을 제거하기 전에 이를 감지하여, raw Exception 셀이
+ // 아래에서 JavaScript에 노출되지 않도록 합니다.
+ bool functorThrew = !!catchScope.exception();
+
rejectPromisesWithExceptionIfAny(lexicalGlobalObject, globalObject, *promise1, *promise2, catchScope);
RETURN_IF_EXCEPTION(catchScope, JSC::encodedJSValue());
 
- if (!JSC::JSValue::decode(result)) [[unlikely]] {
+ if (functorThrew || !JSC::JSValue::decode(result)) [[unlikely]] {
result = JSC::JSValue::encode(convertDictionaryToJS(lexicalGlobalObject, globalObject, DictionaryType { DOMPromise::create(globalObject, *promise1), DOMPromise::create(globalObject, *promise2) }));
RETURN_IF_EXCEPTION(catchScope, JSC::encodedJSValue());
}

LayoutTests/navigation-api/navigation-navigate-no-arguments-crash.html

+ result = navigation.navigate(); // 인자 없음 -> throwVMError 경로
+ result.foo = 0xdead; // raw Exception 셀에 대한 property store
+ shouldBe("result.foo", "0xdead");
+ if (window.GCController)
+ GCController.collect(); // 수정 전: clobbered m_value에서 GC crash 발생
+ testPassed("did not crash");

패치는 callPromisePairFunction을 수정합니다. 수정 전에는 result dictionary를 재구성할지 여부를 !JSC::JSValue::decode(result), 즉 functor가 empty-value sentinel을 반환했는지만으로 판단했습니다. 패치는 functor 반환 직후(rejectPromisesWithExceptionIfAny가 pending exception을 제거하기 전)에 bool functorThrew = !!catchScope.exception();를 추가합니다. 그리고 재구성 조건을 if (functorThrew || !JSC::JSValue::decode(result))로 변경합니다. functor가 어떤 이유로든 throw한 경우, 이 함수는 functor의 반환값을 버리고 두 rejected promise로부터 convertDictionaryToJS를 통해 유효한 NavigationResult 형식의 dictionary를 재구성합니다. pair가 아닌 callPromiseFunction은 수정 대상이 아니었으며, 이미 functor의 반환값을 버리는 방식으로 동작하고 있었습니다.

throwVMError가 반환하는 raw Exception 셀을 인식하지 못하는 sentinel 전용 오류 감지. 비-객체 셀이 스크립트에 노출되어 JSObject type confusion이 발생하는 패턴.

[ReturnsPromisePair] IDL extended attribute는 두 promise의 dictionary를 반환하는 작업을 표시합니다. Navigation API의 NavigationResult { committed, finished }가 대표적인 예입니다. callPromisePairFunctionJSDOMPromiseDeferred.h에 있는 공통 C++ 헬퍼로, 작업 functor를 실행하고 두 promise를 JavaScript에 반환하기 위해 패키징하는 역할을 담당합니다. throwVMError는 JSC 헬퍼로, pending exception을 기록하고 JSC::Exception* 셀을 인코딩한 EncodedJSValue를 반환합니다. 이 값은 IDL 인자 변환 실패를 알리는 empty-value sentinel(인코딩 값 0)과 달리 non-zero 값입니다. JSC::ExceptionJSType == CellType인 garbage-collected JSCell로, JSObject가 아닙니다. GC가 Exception::visitChildrenImpl을 통해 방문하는 JSValue m_value 멤버를 보유합니다. JSCell::putInlineoverridesPut이 아닌 셀에 대한 property store를 asObject(this) / jsCast<JSObject*>를 통해 처리합니다. 이 캐스팅은 debug 빌드에서 ASSERT_WITH_SECURITY_IMPLICATION으로 검증되지만, production에서는 no-op으로 컴파일되어 런타임 타입 확인이 없습니다. 한편 rejectPromisesWithExceptionIfAny는 catch scope의 pending exception을 가져와 제거하고, 두 promise를 해당 exception으로 reject합니다.

근본 원인은 callPromisePairFunction이 functor 실패 여부를 empty-JSValue sentinel 하나만으로 판단했다는 점입니다. 이 sentinel은 IDL 인자 변환 실패 시 반환되는 값입니다. 그러나 인자가 부족한 상태로 [ReturnsPromisePair] 작업이 호출되면, CodeGeneratorJS.pm이 생성한 missing-argument check가 실행되어 return throwVMError(...)로 이어집니다. throwVMError는 raw JSC::Exception* 셀을 감싼 non-zero EncodedJSValue를 인코딩하여 반환합니다. 이는 diff에 추가된 주석에서 추론한 것으로, throwVMError 본문은 diff에 포함되지 않습니다. 이후 rejectPromisesWithExceptionIfAny가 pending exception을 제거하므로 RETURN_IF_EXCEPTION은 실행되지 않습니다. non-zero sentinel check도 false가 되어, raw JSC::Exception 셀이 작업의 반환값으로 JavaScript에 그대로 전달됩니다.

JSC::Exception 셀은 JSType == CellType (0)이며, JSObject가 아닙니다. 스크립트에서 이 셀에 property store(result.foo = 0xdead)를 수행하면, JSCell::putInlineoverridesPut() == false를 확인하고 asObject(this)jsCast<JSObject*>를 실행합니다. production ARM64 빌드에서 ASSERT_WITH_SECURITY_IMPLICATION((void)0)으로 컴파일된다고 가정하면 — 이는 JSC 빌드의 일반적인 특성으로, diff에서 직접 확인되지는 않습니다 — 이 캐스팅은 검증 없는 static_cast가 됩니다. type confusion이 발생하는 지점입니다.

테스트 케이스가 정확한 트리거를 드러냅니다. 인자 없이 let result = navigation.navigate()를 호출하면 throwVMError 경로를 따라 leaked Exception 셀을 획득합니다. 이후 result.foo = 0xdead는 Exception 셀을 JSObject로 취급하여 Butterfly를 할당하고, JSObject butterfly offset 위치의 word를 Butterfly pointer로 덮어씁니다. 이 위치는 Exception::m_value (+0x08)와 겹칩니다. 그 다음 해당 property slot에 attacker 값이 저장됩니다. GC를 강제 실행하면 Exception::visitChildrenImpl에 도달합니다. 이 시점에서 visitor.append(m_value)는 clobbered 필드를 JSCell로 취급하고, SlotVisitor::drain은 이를 attacker가 영향을 줄 수 있는 Structure/StructureID를 가진 살아있는 객체로 역참조합니다.

이 type confusion은 웹에서 도달 가능하며, raw JSC::Exception 셀을 스크립트에 노출시킵니다. 최소한 GC 중 안정적인 controlled crash를 유발할 수 있습니다. GC에 전달되는 fake JSCell과 m_value overwrite를 attacker가 조정할 수 있다면, 더 강력한 memory-corruption primitive로 발전할 가능성도 존재합니다. 다만 이를 실현하려면 clobbered m_value가 가리키는 위치에 controlled data를 배치하는 조건이 성립해야 합니다.

이 vulnerability는 WebContent renderer 내부의 메모리 타입 안전성 경계를 약화시킵니다. 바인딩 레이어는 JavaScript에 항상 올바르게 타입이 지정된 JSObject(NavigationResult dictionary)만 전달한다고 가정합니다. raw JSC::Exception 셀을 반환하는 것은 이 invariant를 위반하며, 일반 웹 콘텐츠에서 attacker가 JSC의 타입 시스템을 무력화할 수 있게 합니다. 다만 이것 자체로는 sandbox 경계를 넘지 않으며, 다른 프로세스에 영향을 주려면 별도의 escape가 필요합니다.

근본 결함은 바인딩 헬퍼가 단일 오류 인코딩 방식(empty-value sentinel)만 신뢰한다는 점입니다. 실제로 이 헬퍼가 래핑하는 코드 경로는 다른 방식(pending exception과 raw Exception 셀)으로도 실패를 알릴 수 있습니다. callee의 실패 방식이 다양할 때 "반환값을 검사하여 실패를 감지"하는 패턴은 취약합니다. 견고한 검사 방법은 "scope에 exception이 기록되었는가?"이며, 수정에서 추가된 것이 정확히 이것입니다. 형제 함수인 callPromiseFunction이 안전했던 이유는 functor의 반환값을 버렸기 때문이며, 이는 함수의 안전성이 강제된 invariant가 아닌 문서화되지 않은 구현 세부 사항에 달려 있을 수 있음을 상기시켜 줍니다.

Note: throwVMError 인코딩 방식, production에서의 ASSERT_WITH_SECURITY_IMPLICATION no-op 동작, [ReturnsPromisePair] 작업의 전체 목록 등 일부 구현 세부 사항은 diff 주석과 주변 코드 패턴에서 추론한 것으로, commit에서 직접 확인되는 내용은 아닙니다. 핵심 실행 흐름과 crash 조건은 패치와 테스트를 통해 일관되게 뒷받침됩니다.