← All reports

[5] `delegate` ends a `try` without widening its result to the block signature

HighJSC WebAssembly front-endTypeConfusion

261bcb4

Severity는 High이며, 같은 계열의 widening 버그들과 정확히 같은 지점에서 좁게 발생합니다. delegate가 merge가 아니라 이어지는 arm으로 분류되어 있었습니다. 그래서 continuation 값이 fallthrough edge의 구체 타입을 그대로 유지하게 되는데, 같은 label을 향하는 br_if가 보장하는 타입은 anyref뿐입니다. 나머지는 elide된 ref.cast가 마무리합니다.

WebAssembly의 exception handling에서 try가 끝나는 방식은 여러 가지이며, 그중 일부만이 construct를 이어갑니다. catchcatch_all은 같은 try의 또 다른 arm에 해당합니다. 반면 delegatetry 자체를 종료하고, 던져진 exception을 바깥쪽 handler로 전달합니다. JSC의 validator인 Wasm::FunctionParser는 block을 마무리하는 helper에 tag를 전달하는 방식으로 이 둘을 구분합니다. 값이 어차피 교체될 arm에는 NewSiblingBlock을, continuation이 실제 control-flow join인 경우에는 MergePoint를 사용합니다. join 지점에서는 각 result에 기록되는 타입이 block의 선언된 result type이어야 합니다. join에는 fallthrough edge뿐 아니라 해당 block의 label을 겨냥한 모든 br / br_if가 도달하기 때문입니다.

관전 포인트: try (result anyref) 밖으로 branch한 뒤 delegate로 이를 종료하는 module에서는 뒤따르는 ref.cast가 아무 코드도 생성하지 않게 됩니다. 그 결과 boxed JS number가 struct.get의 field address 계산으로 흘러 들어갑니다.

Source/JavaScriptCore/wasm/WasmFunctionParser.h

- checkBlockFallthrough(controlEntry.controlData, NewSiblingBlock);
+ endBlockAndCheckResultTypes(controlEntry);

FunctionParser::parseExpressionDelegate handler는 더 이상 checkBlockFallthrough(controlEntry.controlData, NewSiblingBlock)를 호출하지 않습니다. 대신 새로 추가된 endBlockAndCheckResultTypes를 호출하는데, End가 사용하던 것과 동일한 helper입니다. 즉 이번 수정은 새로운 check를 추가한 것이 아니라 중복된 구현을 하나로 합친 것입니다. helper에 추가된 주석에는 block을 끝내기 전에 각 result를 block signature type으로 widening한다는 점이 명시되어 있습니다. Delegate 호출 지점의 주석은 이 처리가 왜 그 자리에 필요한지를 기록합니다. delegate는 try block을 종료하므로 result를 widening한다는 내용입니다. regression test의 주석은 무엇을 막으려는지를 직접적으로 밝히고 있는데, 각 tier가 IsCell / IsWasmGCObject check를 elide해서는 안 된다는 것입니다.

block terminator를 control-flow join이 아니라 이어지는 sibling arm으로 분류해, 실제 merge 지점에서 result widening을 건너뛴 패턴.

이 코드가 있는 위치. FunctionParser는 single-pass bytecode parser이자 validator로, 순수 validation 경로와 BBQ baseline JIT(WasmBBQJIT.cpp), OMG optimizing JIT(WasmOMGIRGenerator.cpp)가 함께 사용합니다. 여기서 abstract expression stack을 관리하는데, 각 항목은 값과 그 값의 정적 Type으로 구성됩니다. 또한 ControlEntry로 이루어진 control stack도 함께 유지합니다.

arm과 terminator의 차이. catchcatch_all은 같은 try에 새로운 arm을 엽니다. 이때 stack에 있던 block의 값들은 곧 해당 arm 자신의 값으로 교체되므로, 진입 시점에 기록된 타입은 버려집니다. delegate는 성격이 다릅니다. try를 종료하며, 그 continuation이 제어가 재개되는 지점이 됩니다.

Fallthrough tag. block을 종료하는 경로가 받을 수 있는 tag는 NewSiblingBlockMergePoint 두 가지입니다. 어느 쪽이 전달되는지에 따라 그 시점에 stack에 남아 있는 값의 처리 방식이 달라집니다.

check elision의 입력이 되는 정적 타입. IR generator들은 정적으로 증명 가능한 subtype 관계를 근거로 ref.cast (ref 0)에 runtime 작업이 전혀 필요 없다고 판단합니다. 그리고 그 판단의 입력이 되는 것이 parser가 기록해 둔 타입입니다.

validator의 type soundness가 깨진 사례이며, 하위 단계에서 type confusion으로 exploit될 수 있습니다. 빠져 있는 invariant는 [1]에서와 동일합니다. merge point에서 값에 기록되는 정적 타입은 block이 선언한 result type이어야 합니다. 즉 들어오는 모든 edge를 아우르는 상한이어야 하며, 특정 edge 하나가 제공한 타입이어서는 안 됩니다.

  try (result anyref)
      ...
      br_if 0        --- edge A: guaranteed only anyref
      ...
      local.get 2    --- edge B (fallthrough): typed (ref 0)
  delegate 0
      |
      +--> continuation. join of A and B is anyref.
           pre-fix, NewSiblingBlock tag -> no widening
           recorded type = (ref 0)   <- edge B's type only
      |
      +--> ref.cast (ref 0)
           statically provable -> no runtime work emitted
      |
      +--> struct.get 0 0
           field address computed from edge A's value

실행 시점에 br_if edge를 타고 들어오는 값은 any.convert_externexternref 파라미터로부터 만들어낸 JSValue입니다. JS number라면 boxed non-cell 값에 해당합니다. 뒤이어 실행되는 struct.get 0 0은 애초에 JSWebAssemblyStruct pointer였던 적이 없는 값으로부터 field address를 계산하게 됩니다.

End handler는 이미 widening 경로를 사용하고 있었습니다. 그래서 올바른 수정 방향은 별도의 check를 추가하는 쪽이 아니라, Delegate도 같은 helper를 거치도록 만드는 것이었습니다. 이 구도는 그대로 버그를 발견하는 관점이 되기도 합니다. terminator handler들을 서로 비교하면서, 각각이 자신의 continuation을 join으로 분류하고 있는지 물어보면 됩니다. 실제로 무언가를 실행해 볼 필요 없이 불일치가 드러납니다.

Exploit 가능성은 branch edge 위의 값을 module이 얼마나 제어할 수 있는지에 달려 있습니다. 함수 파라미터에 any.convert_extern을 적용하면 JS 호출자가 그 값을 직접 제어하게 됩니다. 그러면 field address 계산이 embedder가 선택한 bit pattern을 대상으로 수행됩니다. 여기서 성립하는 것은 confusion primitive까지입니다. 이를 read나 write로 확장하려면, 계산된 주소가 어떤 offset에 놓이는지까지 제어할 수 있어야 합니다.

이 vulnerability는 ref.cast가 의미를 갖는다는 보장 자체를 약화시킵니다. edge가 둘인 merge에서 validator가 한쪽 edge만 보고 기록한 타입을 근거로 cast가 no-op으로 컴파일될 수 있다면, 그 아래에 놓인 GC type check 영역 전체가 같은 구멍을 그대로 물려받게 됩니다.