[1] Type confusion via raw Exception cell returned to script from callPromisePairFunction
navigation.navigate() with no arguments hands JavaScript a raw Exception cell
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
LayoutTests/navigation-api/navigation-navigate-no-arguments-crash.html
Patch Details
패치는 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이 발생하는 패턴.
Background
[ReturnsPromisePair] IDL extended attribute는 두 promise의 dictionary를 반환하는 작업을 표시합니다. Navigation API의 NavigationResult { committed, finished }가 대표적인 예입니다. callPromisePairFunction은 JSDOMPromiseDeferred.h에 있는 공통 C++ 헬퍼로, 작업 functor를 실행하고 두 promise를 JavaScript에 반환하기 위해 패키징하는 역할을 담당합니다. throwVMError는 JSC 헬퍼로, pending exception을 기록하고 JSC::Exception* 셀을 인코딩한 EncodedJSValue를 반환합니다. 이 값은 IDL 인자 변환 실패를 알리는 empty-value sentinel(인코딩 값 0)과 달리 non-zero 값입니다. JSC::Exception은 JSType == CellType인 garbage-collected JSCell로, JSObject가 아닙니다. GC가 Exception::visitChildrenImpl을 통해 방문하는 JSValue m_value 멤버를 보유합니다. JSCell::putInline은 overridesPut이 아닌 셀에 대한 property store를 asObject(this) / jsCast<JSObject*>를 통해 처리합니다. 이 캐스팅은 debug 빌드에서 ASSERT_WITH_SECURITY_IMPLICATION으로 검증되지만, production에서는 no-op으로 컴파일되어 런타임 타입 확인이 없습니다. 한편 rejectPromisesWithExceptionIfAny는 catch scope의 pending exception을 가져와 제거하고, 두 promise를 해당 exception으로 reject합니다.
Analysis
근본 원인은 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::putInline은 overridesPut() == 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 조건은 패치와 테스트를 통해 일관되게 뒷받침됩니다.
Audit directions
-
exception scope가 아닌 sentinel 반환값으로 실패를 추론하는 패턴. magic value 반환값 기반으로 분기하는 바인딩 및 glue 코드는 pending exception을 통해 실패를 알리는 경로를 놓칠 수 있습니다.
Source/WebCore/bindings/js/(JSDOMPromiseDeferred.h/.cpp포함)에서!JSC::JSValue::decode(result)또는 유사한 검사로 분기하는 헬퍼를 점검하고, 각각이scope.exception()설정을 실패로 처리하는지 확인해야 합니다. 생성된 바인딩 코드 및 수동으로 작성된 바인딩 코드에서JSValue::decode(result)와throwVMError가 함께 나타나는 위치를 검색하십시오. -
반환 경로를 통해 스크립트에 도달하는 raw non-
JSObjectJSCell.JSC::Exception(JSType CellType)은JSObject가 아니므로, 호출자가 exception scope를 재확인하지 않는 상태에서throwVMError/throwException으로부터 유래한EncodedJSValue를 반환하는 코드는 모두 의심 대상입니다. 필수 인자 및 변환 실패 관련CodeGeneratorJS.pm생성 지점에서 promise-pair 및 기타 집합 반환 헬퍼로 이어지는 흐름을 검토해야 합니다. -
스크립트 제어 셀에서 도달 가능한 검증 없는
jsCast<JSObject*>.ASSERT_WITH_SECURITY_IMPLICATION은 production에서 no-op이므로, 비-object 셀이 JS에 노출될 수 있을 때 도달 가능한 다른JSCell::putInline/asObject/jsCast<JSObject*>지점을 조사해야 합니다. release 모드 타입 guard가 필요한 위치가 있는지도 검토하십시오. JavaScriptCore의JSCell::putInline과asObject()호출 지점에서 시작하십시오. -
[ReturnsPromisePair]목록 검증. IDL 트리에서ReturnsPromisePair를 검색하고, Navigation API 진입점(navigate,reload,traverseTo,back,forward) 및 향후 추가될 항목이 모두callPromisePairFunction을 통해 처리되는지 확인하십시오.