← All reports

[1] Promise-pair bindings return a raw exception cell to script

Omit one argument and the promise-pair bindings hand you an exception cell.

Severity: High | Component: WebCore JS bindings | e753548

Severity는 High입니다. 여기서 escape되는 값은 손상된 객체가 아니라, 애초에 객체였던 적이 없는 cell입니다. 이 cell에 대한 첫 property store가 실행되면, cell 자신의 첫 번째 필드가 있던 자리에 butterfly pointer가 기록됩니다. Trigger 방법도 단순합니다. 필수 인자를 생략한 채로 일반적인 API 호출을 하기만 하면 되며, primitive에 도달하기 위해 별도의 heap grooming도 필요하지 않습니다.

WebKit의 IDL bindings 계층은 machine-generated glue 코드입니다. generator가 Web API operation마다 JS 인자를 변환하고 WebCore를 호출한 뒤 결과를 다시 변환하는 C++ 함수를 만들어냅니다. [ReturnsPromisePair]로 선언된 최신 operation들은 두 개의 promise를 담은 dictionary를 반환하는데, NavigationResult { committed, finished }가 실제로 배포된 예시입니다. 이 operation들은 공유 helper인 callPromisePairFunction을 거치며, 이 helper가 생성된 body를 실행하고 script에 무엇을 반환할지 결정합니다. 여기서의 계약은 명확합니다. body 내부에서 발생한 실패는 반드시 promise rejection으로 처리되어야 하며, 값으로 반환되어서는 안 됩니다.

관전 포인트: [ReturnsPromisePair] operation을 필수 인자를 생략한 채 호출하면, engine 내부의 exception cell이 평범한 값처럼 script로 그대로 전달됩니다. 그리고 이 cell에 대한 첫 property store가 cell의 첫 번째 필드 위에 butterfly pointer를 기록합니다.

기존의 callPromisePairFunction은 반환된 EncodedJSValue가 비어 있는지를 검사하는 방식으로 functor의 실패 여부를 판단했습니다 (!JSC::JSValue::decode(result)). 그런데 이 검사는 rejectPromisesWithExceptionIfAny를 호출한 이후에야 수행되었습니다. 이번 패치는 생성된 body가 반환되는 즉시 catch scope에서 명시적인 functorThrew 신호를 캡처하도록 추가했습니다. 이 캡처는 rejection helper보다 앞에 위치하므로, 신호를 읽는 시점에는 pending exception이 아직 scope에 남아 있습니다. 또한 rebuild 조건도 !JSC::JSValue::decode(result) 단독 검사에서 functorThrew || !JSC::JSValue::decode(result)로 확장되어, empty-sentinel 케이스와 thrown-exception 케이스가 모두 함께 처리됩니다. Rebuild 분기는 결과값을 이제 reject된 두 promise로 구성한 정상적인 convertDictionaryToJS NavigationResult로 교체합니다.

서로 다른 두 개의 실패 신호가 하나의 sentinel 검사로 합쳐졌고, 그마저도 두 신호를 구분할 수 있었던 유일한 경로가 이미 지워진 뒤에야 평가되었습니다.

이 코드가 있는 위치. CodeGeneratorJS.pm이 operation별 binding body를 생성하며, JSDOMPromiseDeferred.h에는 이 body들이 호출하는 공유 helper가 정의되어 있습니다. callPromisePairFunction은 이 중에서도 두 개의 promise를 동시에 반환하는 operation들이 사용하는 helper입니다.

Encoded value와 empty sentinel. JSC는 C++/JS 경계를 넘나드는 값을 machine word 하나인 EncodedJSValue로 전달합니다. 이때 모든 비트가 0인 encoding은 empty value sentinel로 취급됩니다. 즉 "결과 없음"을 의미하는 내부 전용의 특수 값이며, script에서 관찰 가능한 정상적인 값이 될 수 없습니다. IDL 인자 conversion이 실패하면 생성된 binding 바깥으로 JSC::encodedJSValue(), 즉 empty encoding이 전파됩니다.

throwVMError. generator가 만들어내는 또 다른 실패 형태는 필수 인자 누락 검사로, 이 경우 return throwVMError(...)가 실행됩니다. throwVMError는 현재 scope에 exception을 throw하면서, encoding된 JSC::Exception* cell 자체를 반환합니다. 이 값은 pointer 형태를 가진, 0이 아닌 EncodedJSValue입니다.

Cell과 object의 차이. Exception.h에서 ExceptionJSObject가 아니라 class Exception final : public JSCell로 선언되어 있고, Exception::createStructure는 이를 TypeInfo(CellType, StructureFlags)로 만듭니다. CellTypeJSType tag 중 가장 낮은 값으로, ObjectType보다 한참 아래에 위치합니다. isCell()은 true이면서 string, symbol, bigint 어디에도 속하지 않는 값에 대한 JS-visible operation은 모두 object 경로를 거치게 됩니다.

Butterfly. JSObject의 out-of-line property와 indexed storage는 별도로 할당된 Butterfly에 저장되며, 이 pointer는 object header 내 고정된 슬롯에 보관됩니다. 한편 Exception의 member layout에서는 JSCell header 바로 다음에 WriteBarrier<Unknown> m_value;가 위치하며, valueOffset()을 통해 노출됩니다.

ASSERT_WITH_SECURITY_IMPLICATION. Debug 빌드에서만 동작하는 assertion macro입니다. Release 빌드에서는 완전히 컴파일에서 제거되므로, 이 macro만으로 보호되는 downcast는 실제 배포 코드에서는 아무런 검사 없이 수행됩니다.

이 버그의 본질은 type confusion입니다. 내부 GC cell이 script에서 관찰 가능한 JSValue 공간으로 escape되는 것이 문제입니다. 패치 이전의 callPromisePairFunction은 "functor가 실패했다"는 조건과 "functor가 empty sentinel을 반환했다"는 조건을 동일하게 취급했습니다. 그런데 이 동치 관계는 생성된 binding이 만들어낼 수 있는 두 가지 실패 모드 중 정확히 하나에 대해서만 성립합니다.

  Argument-conversion failure          Missing-argument failure
  ---------------------------          ------------------------
  return encodedJSValue()              return throwVMError(...)
        |  (zero bits)                       |  (Exception* cell)
        v                                    v
  rejectPromisesWithExceptionIfAny ---> clears pending exception
        |                                    |
        v                                    v
  !decode(result) == true              !decode(result) == false
        |                                    |
        v                                    v
  return empty  (safe)                 return Exception cell to JS

두 경로 모두 먼저 동일한 rejection helper를 거치며, 이 helper는 catchScope에서 exception을 비워냅니다. 그 결과 오른쪽 경로의 sentinel 검사에 도달할 즈음에는 관찰할 exception이 이미 남아 있지 않습니다. 남은 신호는 반환값 하나뿐이며, 이 값은 비어 있지 않은 살아있는 cell입니다. Guard는 false로 평가되었고, 결국 raw JSC::Exception*이 마치 NavigationResult dictionary인 것처럼 JS 호출자에게 그대로 반환되었습니다.

Script가 받는 값은 JSTypeCellType인 cell입니다. Commit message에 따르면, escape된 이 cell에 대한 property store는 JSCell::putInline에 도달합니다. 이 지점에서 overridesPut()은 false이고, receiver는 asObject(this) / jsCast<JSObject*>를 거쳐 처리됩니다. Release 빌드에서는 이 downcast를 보호하는 guard가 컴파일 시 제거되므로 아무런 검사도 이뤄지지 않습니다. 이후 store는 Exception allocation을 마치 JSObject layout을 가진 것처럼 다룹니다. 즉 Butterfly를 할당하고 그 pointer를 object의 butterfly slot에 기록하는데, Exception.h의 member layout상 이 슬롯은 m_value와 겹칩니다.

그 결과는 다음 GC collection 시점에 나타납니다. Exception.cppException::visitChildrenImplvisitor.append(thisObject->m_value)를 실행하는데, 이때 collector는 raw Butterfly pointer를 마치 JSCell인 것처럼 마킹하게 됩니다. 그러면서 butterfly의 첫 word를 StructureID로 해석해 dereference합니다. Attacker는 일반적인 property store를 통해 butterfly의 내용을 제어할 수 있으므로, GC가 structure identifier로 해석하는 값 자체가 script에 의해 영향을 받습니다.

이 vulnerability는 JSC 내부 cell type을 script의 손이 닿지 않는 곳에 묶어두던 경계를 약화시킵니다. Type system이 JavaScript에서는 절대 관찰될 수 없다고 보장하던 값이 평범한 operand가 되어버리고, 여기에 object 대상 operation이 적용되면서 GC가 추적하는 필드가 손상됩니다.

특정 부분(WebKit Web Audio 보안 커밋)을 한국어 리포트 스타일로 재작성하겠습니다.