← All reports

[3] Wasm block parameters keep the incoming type instead of the declared one

HighJSC WebAssembly front-endTypeConfusion

9dcbd25

High로 평가한 이유는 다음과 같습니다. validator는 loop의 back edge로 들어오는 값을 선언된 parameter 타입 기준으로 통과시킵니다. 반면 body는 fall-in edge로 들어온 더 좁은 타입에 맞춰 검증되고, 그 타입으로 컴파일된 상태입니다. 새로 추가된 첫 번째 테스트는 임의의 host 값을 구체적인 struct 타입 기준으로 검증된 struct.get까지 밀어 넣습니다. 다만 branch target 쪽 절반은 branch typing 코드를 직접 확인한 결과가 아니라, 스펙 semantics와 테스트 설계에서 따라 나오는 부분입니다.

WebAssembly의 structured control instruction은 parameter를 받을 수 있습니다. 그래서 block의 body로 들어가는 경로가 하나가 아닙니다. fall-in edge가 있고, 그 label을 target으로 삼는 모든 branch 역시 진입 경로가 됩니다. JSC의 single-pass validator인 Wasm::FunctionParser는 이 body를 타입이 붙은 값들의 abstract stack에 대해 단 한 번만 검사합니다. 동시에 같은 pass가 addBlock / addLoop / addIf / addTry를 거쳐 LLInt, BBQ, OMG tier의 code generator를 구동합니다. 스펙에서 block 진입 규칙은 push_ctrl(op, in, out)push_vals(in)을 수행하는 형태로 정의되어 있습니다. 즉 선언된 parameter 타입을 push합니다. 이렇게 하면 body는 모든 entry edge가 통과 기준으로 삼는 계약에 대해 검사됩니다. 먼저 도착한 값의 타입이 기준이 되지는 않습니다.

관전 포인트: 웹 페이지가 struct를 들고 loop (param anyref)에 진입하면, body는 그 구체적인 struct 타입에 맞춰 컴파일됩니다. 이후 back edge로 임의의 host 값을 전달하면, 실제 struct 기준으로 검증된 struct.get에 그 값이 도달합니다.

패치 이전에는 모든 block entry handler가 자체 loop를 돌면서 arity를 확인하고 isSubtype(incomingType, declaredParamType)을 검사했습니다. 검사가 끝나면 stack slot에는 더 좁은 incoming 타입이 그대로 남았습니다. 어디에도 setType 호출은 없었습니다. 이번 commit에서는 이 loop들이 제거되었고, 선언된 parameter 타입을 entry stack에 투영하는 공용 코드로 대체되었습니다. 같은 변경이 compare와 if를 결합한 두 fast path, 즉 binaryCompareCaseunaryCompareCase에도 적용되었습니다. 두 곳 모두 arity 검사와 subtype 검사를 합친 loop를 각자 복사해 들고 있었기 때문입니다.

exit 쪽에서는 선언된 result 타입으로의 widening이 이미 존재했습니다. 다만 fallthrough == MergePoint 조건에 묶여 있었습니다. 그 결과 Else, Catch, CatchAll, 그리고 ifEnd fallthrough 경로는 widening 대상에서 빠져 있었습니다. 이번 수정으로 이 경로들도 함께 widening을 수행합니다.

block 진입 시 선언된 parameter 타입 대신 들어온 값의 구체 타입을 기록해, 진입 경로가 여러 개인 body를 한 edge에 맞춰 특수화한 패턴.

이 코드가 있는 위치. Source/JavaScriptCore/wasm/WasmFunctionParser.hFunctionParser는 abstract stack 기반의 single-pass typechecker입니다. function body를 검증하면서 동시에 IR generator를 구동합니다. slot마다 갖는 TypedExpression::type()은 검증 상태인 동시에, 각 tier로 전달되는 정적 타입이기도 합니다.

Block parameter와 다중 진입 body. multi-value proposal 이후로 block은 parameter 타입을 선언할 수 있게 되었습니다. 진입 시점에 바깥 stack에서 값을 pop해 가져오는 방식입니다. 특히 loop가 흥미로운 경우입니다. loop label을 대상으로 하는 br은 loop로 다시 진입하기 때문입니다. 그래서 body가 갖는 entry edge 개수는 back edge 개수에 1을 더한 값이 됩니다.

Type widening. GC 타입이 들어오면서 Wasm에도 subtype lattice가 생겼습니다. widening은 어떤 edge가 전달한 구체적인 subtype 대신, 해당 slot에 선언된 supertype을 노출하는 동작입니다. 이런 투영이 존재하는 이유는, 모든 edge에 대해 성립하는 상한 기준으로 body를 한 번만 검사하기 위해서입니다.

정적 속성으로서의 nullability. (ref $0)(ref null $0)은 서로 다른 타입입니다. JSC의 struct accessor는 정적 nullability를 컴파일 시점 입력으로 취급해, null check를 생성할지 여부를 결정합니다.

widening이 빠지면, validator가 보는 block parameter 타입은 다른 entry edge들이 통과 기준으로 삼는 타입 계약보다 항상 더 좁아집니다. 진입 경로가 하나뿐인 block이나 if에서는 스펙 위반이긴 해도 실질적인 위험으로 이어지지 않습니다. 반면 loop에서는 unsound한 상태가 됩니다. 스펙상 loop label을 향한 branch는 loop가 선언한 parameter 타입 기준으로 검사됩니다. 그런데 패치 이전의 body는 fall-in edge로 들어온 값의 타입에 맞춰 검사되고, 그대로 컴파일되었습니다. branch target 쪽 타입 검사 코드인 unifyControlBr/BrIf handler는 제공된 소스에 포함되어 있지 않습니다. 그래서 이 절반은 스펙 semantics와 새로 추가된 테스트의 설계에서 따라 나오는 내용입니다.

새로 추가된 loop-param-type-widening.js가 두 가지 어긋남을 모두 짚어냅니다.

  1. Part 1은 loop (param anyref)를 선언한 뒤 (ref $0) struct를 들고 진입합니다. 패치 이전에는 entry 경로가 해당 slot을 (ref $0) 상태로 남겨두었습니다.
  2. 그 타입 아래에서는 struct.get 0 0이 타입 검사를 통과합니다. operand 규칙이 (ref null $0) 또는 그 subtype을 요구하기 때문입니다. 테스트 주석에도 수정 이전에는 해당 module이 통과되었다고 기록되어 있습니다.
  3. back edge에서는 externrefany.convert_extern을 적용한 값을 push합니다. 타입 0의 struct가 아닌, 임의의 host 값입니다.
  4. 이 값이 구체적인 struct 타입 기준으로 검증된 field access까지 도달합니다.

Part 2는 nullability 쪽 변형을 다룹니다. loop는 (param (ref null $0))을 선언하지만, 진입은 non-null인 (ref $0)으로 이루어집니다. 그래서 패치 이전의 body는 non-nullable 타입을 기준으로 삼았습니다. 테스트는 수정 이후 back edge로 전달된 ref.null none이 정상적으로 WebAssembly.RuntimeError를 발생시킨다고 확인합니다. JSC의 struct accessor lowering이 operand의 정적 nullability를 기준으로 null check 생성 여부를 결정한다는 의미가 됩니다. 다만 해당 lowering 코드는 제공된 context에 포함되어 있지 않습니다. 이 추론은 codegen을 직접 확인한 결과가 아니라 테스트 설계에서 나온 것입니다.

exploitability는 이번 호에 실린 다른 widening 버그들과 같은 경로를 따릅니다. validator가 하위 단계로 넘기는 primitive는, 타입 검사를 한 번도 거치지 않은 값에 대한 field access입니다. Part 1의 구성에서는 externref를 통해 페이지가 그 값을 직접 제어합니다. 그래서 field address 계산이 attacker가 고른 비트를 대상으로 수행됩니다. 이번 변경으로 확인되는 범위는 type confusion 자체까지입니다. 이를 read나 write primitive로 발전시키려면, 혼동된 field offset이 어디에 떨어지는지까지 제어할 수 있어야 합니다.

동일하게 widening이 빠진 loop가 모든 block entry handler에 복제되어 있었고, 결합된 두 compare 경로에도 그대로 남아 있었습니다. 발견 경로를 짐작할 수 있는 지점입니다. 한 곳만 고친 수정이라기보다는, entry handler 전반을 패턴 단위로 점검한 결과로 읽힙니다.