← All reports

[6] `try`/`catch` publishes the catch arm's type as the block result

HighJSC WebAssembly front-endTypeConfusion

The catch arm never ran, but it decided what type the block returned.

8f229fb

High. 선언된 signature로 정규화하는 처리가 else 블록에서만 수행되었습니다. 그래서 catch arm이 구체적인 array reference를 push하는 try (result anyref)의 경우, 그 구체 타입이 그대로 downstream으로 전달됩니다. 다만 런타임에 실제로 흘러나오는 값은 try arm이 caller로부터 받은 externref입니다. 테스트의 JS driver는 이 값을 원하는 64비트 non-cell 패턴으로 지정합니다.

GC 타입이 도입되기 전까지 wasm의 value type은 사실상 평평한 집합에 가까웠습니다. 그래서 "이 arm이 실제로 만들어낸 타입"과 "블록이 선언한 타입"은 거의 항상 일치했습니다. Wasm::FunctionParserend 핸들러는 이 역사를 shouldForceSignature 플래그 형태로 그대로 이어받았는데, 이 플래그가 블록의 결과 타입을 선언된 signature로 정규화할지를 결정합니다. 여기서 지켜져야 하는 invariant는, control-flow merge를 빠져나가는 static type이 선언된 join type이어야 한다는 점입니다. BBQ와 OMG generator가 reference 연산에 런타임 검사가 얼마나 필요한지 판단할 때 참조하는 것이 바로 이 static type이기 때문입니다.

관전 포인트: 모듈이 최적화 tier로 하여금 특정 레지스터가 선언된 타입의 non-null array를 담고 있다고 믿게 만들 수 있습니다. 실제로 그 레지스터에 들어 있는 것은 attacker가 전부 지정한 64비트 non-cell 값입니다.

shouldForceSignatureControlType::isElse(...)로 계산되었고, 그 결과 정규화가 else 블록에만 적용되었습니다. 패치는 이 조건을 일반화하여, 모든 structured control instruction의 end가 결과 타입을 블록의 선언된 signature로 정규화하도록 변경했습니다. 마지막으로 파싱된 arm이 expression stack에 남긴 static Type을 그대로 두지 않게 된 셈입니다.

control-flow merge에서의 결과 타입 정규화가 한 가지 construct에만 특수 처리되어, 나머지 블록의 결과 타입이 마지막으로 파싱된 arm에 의해 결정되던 패턴.

이 코드가 있는 위치. Source/JavaScriptCore/wasm/WasmFunctionParser.hFunctionParser는 compilation context에 대해 템플릿화되어 있고, 모든 wasm tier가 공유합니다. control stack과 타입이 붙은 expression stack을 소유합니다.

TypedExpression과 각 tier. 스택의 각 엔트리는 static Type과 backend handle을 한 쌍으로 묶습니다. WasmBBQJIT.cppWasmOMGIRGenerator.cpp는 코드를 생성하면서 이 Type을 읽습니다.

Subtyping과 validation. wasm validation은 각 arm이 내놓는 값이 선언된 결과 타입의 subtype이기만 하면 통과시킵니다. subtyping은 한 방향 관계입니다. 검사를 통과했다는 사실만으로는 두 타입 중 어느 쪽을 기록해야 하는지 알 수 없습니다.

else만 특수 처리된 이유. Wasm GC 이전에는 value type 사이에 사실상 subtype lattice가 없었습니다. 그래서 arm의 실제 타입과 선언된 결과 타입이 어긋나는 상황이 실무적으로 드러나는 곳은 if/else뿐이었고, 그 하나의 construct에만 플래그가 붙었습니다.

JSC의 double encoding. JSC는 double을 boxing할 때 raw bit에 2^49 offset을 더합니다. 따라서 double을 기록하기 전에 그 offset을 미리 빼두면, JS에서 원하는 JSValue 비트 패턴을 만들어낼 수 있습니다.

static type을 merge하는 과정의 오류에서 비롯된 type confusion에 해당합니다. 테스트 모듈은 static 타입과 dynamic 타입의 괴리를 가장 극단적인 형태로 드러냅니다.

  try (result anyref)                      catch
  -------------------                      -----
  any.convert_extern(local.get 0)          array.new_default $0, len 1
  -> caller's externref, internalized      -> concrete (ref $0)
     to an `any` reference                    (array (mut i64))
         |                                          |
         |  runtime: no exception thrown            |
         |  so THIS value flows out          parser's live stack
         |                                   slice at `end` is
         v                                   the CATCH arm's
  value out of block = attacker externref    -> recorded (ref $0)
         |
         v
  ref.test (ref array)
    static operand (ref $0) is a strict subtype of (ref array)
    -> premise trivially satisfied; runtime boxed-non-cell test
       can be folded away

두 흐름이 갈라지는 이유는 단순합니다. end 시점에 파서가 보고 있는 live expression stack 조각은 catch arm의 것입니다. 반면 런타임에 실제로 빠져나가는 값은 try arm의 것인데, 테스트에서는 예외가 발생하지 않기 때문입니다.

이어서 JS driver는 런타임 값을 encoding이 허용하는 한도까지 공격적으로 구성합니다:

  1. view.setBigUint64(0, 0x4141414141414141n - (1n << 49n))는 JSC의 2^49 double-encode offset이 상쇄되도록 계산된 패턴을 기록합니다.
  2. getFloat64로 이를 다시 읽으면, boxing된 JSValue 표현이 정확히 0x4141414141414141인 double을 얻게 됩니다.
  3. 이 값이 externref 파라미터로 전달되고, any.convert_extern을 거쳐 internalize됩니다.
  4. 컴파일러는 이를 $0 타입의 non-null array로 인식하지만, 레지스터에 담긴 실체는 attacker가 전부 지정한 64비트 non-cell 패턴입니다.

regression test는 결과가 0이어야 한다고 단언합니다. 다만 제공된 자료에는 패치 이전의 출력이 포함되어 있지 않습니다. 그래서 수정 전에 실제로 관찰되던 동작이 무엇인지 — 잘못된 non-zero 값이 반환되는지, 아니면 위조된 pointer에서 타입 metadata를 읽다가 fault가 발생하는지 — 는 실행 결과가 아니라 fix의 형태에서 유추한 내용입니다.

exploitability는 이번 호에서 다룬 다른 validator widening 계열 버그와 같은 부류입니다. 다만 한 가지가 더 날카롭습니다. 혼동되는 값이 모듈 내부에서 합성되지 않고 JS에서 보이는 파라미터로 전달되기 때문에, attacker가 64비트 패턴을 그대로 지정합니다. 이번 변경으로 확인되는 범위는 임의로 고른 non-cell 값과 타입이 지정된 GC reference 사이의 혼동까지입니다. 그 이상으로 확장하려면 혼동된 pointer를 제어 가능한 offset에서 dereference해야 하는데, 이 테스트 자체는 거기까지 구성하지 않습니다.

이 vulnerability로 인해, 각 tier가 런타임 reference 검사를 아예 생략할 수 있게 해주던 타입 규율이 무너집니다. 그 규율은 validator가 기록한 reference 타입이 해당 연산에 도달할 수 있는 런타임 값의 범위를 한정한다는 가정입니다.