[1] Wasm validator omits result widening on the unreachable `End` path
A prior fix taught one `End` handler to widen. The parser has two.
Critical. 두 predecessor가 합류하는 지점에서 좁은 타입이 그대로 살아남습니다. BBQ는 이 타입을 근거로 ref.cast에 아무 작업도 필요 없다고 판단하며, 결과적으로 임의의 externref가 typed GC object인 것처럼 struct.get의 fast path에 도달합니다. heap grooming 같은 전제 조건도 없이, 평범한 모듈 하나면 충분합니다.
WebAssembly 모듈의 타입 검사는 validation 시점에 단 한 번만 수행됩니다. 그 아래의 모든 JIT tier는 기록된 타입을 다시 유도하지 않고 증명된 사실로 취급합니다. single-pass validator인 Wasm::FunctionParser는 bytecode를 순회하면서 abstract value stack을 유지하는데, 각 entry는 backend value handle과 정적 Type을 짝지어 보관합니다. 그리고 이 타입들은 code generator로 그대로 전달됩니다. 두 control-flow edge가 합류하는 지점에서는, merge된 값에 기록되는 타입이 block의 선언된 result type이어야 합니다. 즉 들어오는 모든 edge를 덮는 상한이어야 하며, 그중 한 edge가 기여한 타입이어서는 안 됩니다.
관전 포인트: WebAssembly 모듈을 instantiate할 수 있는 페이지라면, 공격자가 완전히 제어하는 64비트 값을 struct.get에 전달할 수 있습니다. 컴파일러는 그 값을 typed GC object로 믿게 되는데, 이를 검사했어야 할 cast가 컴파일 과정에서 제거되었기 때문입니다.
Source/JavaScriptCore/wasm/WasmFunctionParser.h
Patch Details
변경은 두 갈래로 나뉩니다. 먼저 기계적인 정리 작업입니다. checkExpressionStack(const ControlType&, bool forceSignature = false)가 checkBlockFallthrough(const ControlType&, FallThroughStateTag)로 바뀌면서, 기본값이 있던 boolean 인자가 두 값을 갖는 enum으로 교체되었습니다. NewSiblingBlock은 값을 sibling에게 넘기고 그 sibling의 end가 widening을 담당하는 arm에 사용됩니다(Else, Catch, CatchAll, Delegate, 그리고 reachable End 내부의 if-arm 검사). MergePoint는 reachable End의 merge 지점에 사용되며, 이 경우에만 widening이 수행됩니다. helper의 본문 자체는 그대로입니다. 여전히 !isSubtype(actualType, expectedType)일 때 validation을 실패시키고, tag가 MergePoint인 경우에 추가로 setType(expectedType)을 호출합니다.
두 번째가 실제 수정입니다. FunctionParser::End는 두 군데에 존재합니다. 하나는 주 opcode switch 안에 있고, 다른 하나는 block의 tail이 정적으로 죽은 코드일 때 진입하는 parseUnreachableExpression() 안에 있습니다. 두 구현 모두 else가 없는 if에 대해 else arm을 합성하며, if가 저장해 둔 parameter를 block의 result로 전달합니다. 이 중 unreachable 쪽 사본은 addElseToUnreachable()을 호출하고 data.elseBlockStack을 다시 설치하는 경로입니다. 여기서 widening이 기본적으로 꺼져 있던 checkExpressionStack(data.controlData) 대신, widening이 켜진 checkBlockFallthrough(data.controlData, MergePoint)를 호출하도록 변경되었습니다. 이로써 두 사본의 동작이 다시 일치하게 되었습니다.
물리적으로 분리된 두 개의 control-flow merge handler 사본 중 한쪽에서 type widening이 누락되어, merge된 값이 predecessor 하나의 타입으로 남는 패턴.
Background
이 코드가 있는 위치. Wasm::FunctionParser는 모듈을 validate하는 동시에 모든 compilation tier(IPInt/LLInt, BBQ, OMG)를 구동하는, 단일 공유 bytecode parser이자 validator입니다. 그 아래에는 두 번째 타입 검사 pass가 존재하지 않습니다.
typed expression stack. parser는 wasm operand stack을 TypedExpression entry로 모델링하며, 각 entry는 정적 Type과 backend value handle을 함께 보관합니다. 이 Type은 진단용 기록이 아닙니다. 각 generator가 runtime check가 필요한지 판단할 때 참조하는 타입 사실 그 자체입니다.
structured control과 else 없는 if. block signature는 있지만 else arm이 없는 if는 parameter와 result가 맞아떨어질 때만 유효합니다. 이때 validator는 저장된 parameter 값을 result로 전달하는 방식으로 빠진 arm을 합성합니다.
checkBlockFallthrough와 fallthrough tag. 이 helper는 fallthrough 값들을 blockSignature.returnType(i)와 대조하며 순회하고 isSubtype(actualType, expectedType)을 검증합니다. 두 번째 인자는 FallThroughStateTag입니다. NewSiblingBlock은 sibling 구조가 최종적으로 widening을 담당하게 될 arm을 표시하고, MergePoint는 실제 control-flow join을 표시합니다. 기록된 타입을 setType(expectedType)으로 다시 쓰는 것은 후자뿐입니다.
reachable 파싱과 unreachable 파싱. 코드가 정적으로 unreachable 상태가 되면 parser는 parseUnreachableExpression()으로 전환됩니다. 축소된 형태의 decoder이지만, 대응되는 End를 찾아야 하므로 control stack은 계속 추적해야 합니다. 물리적으로 분리된 두 번째 End 구현이 존재하는 이유가 여기에 있습니다.
Analysis
이 버그의 본질은 type lattice의 unsoundness입니다. parser가 해당 프로그램 지점에 도달할 수 있는 값의 집합보다 엄격히 좁은 정적 타입을 기록하고, 아래 tier들은 그 기록을 증명으로 받아들입니다.
if (param (ref 0)) (result anyref)
------------------------------------------------------
then-arm ends in `br 0` synthesized else arm
any.convert_extern forwarded param
(arbitrary JS value) typed (ref 0)
checked vs anyref OK checked vs anyref OK
| |
+---------------+---------------+
v
merge, declared result anyref
reachable End : setType(anyref) -> join type recorded
unreachable End : type stays (ref 0) -> one edge's type
v
ref.cast (ref 0) sees static type == target
BBQ emitRefTestOrCast elides IsCell /
IsWasmGCObject -> cast becomes a no-op
v
struct.get 0 0 runs its fast path
두 edge 모두 subtype 검사를 통과합니다. (ref 0)이 실제로 anyref의 subtype이기 때문입니다. 차이는 무엇이 다시 기록되느냐에 있습니다. setType widening이 없으면, 두 edge의 join이 anyref임에도 merge된 값은 (ref 0) 타입인 채로 parent stack에 올라가 block을 빠져나갑니다. 회귀 테스트에서 바로 다음에 오는 명령인 ref.cast (ref 0)은, 그래서 자신의 target과 이미 동일한 정적 operand type을 보게 됩니다. 추가된 테스트의 주석에 따르면 BBQ의 emitRefTestOrCast는 이 낡고 좁은 타입을 신뢰해 IsCell / IsWasmGCObject runtime check를 제거합니다. 그 시점부터 cast는 no-op이 되는데, 정작 그 값은 runtime에 cell도 wasm GC object도 아닐 수 있습니다.
br edge를 타고 올라오는 값은 script가 완전히 제어합니다. 테스트는 임의의 JS 값을 담은 externref를 any.convert_extern으로 변환한 뒤 그 값을 들고 분기합니다. 따라서 뒤이어 실행되는 fast path의 struct.get 0 0은 embedder가 넘긴 비트 패턴이 무엇이든 그것으로부터 필드 주소를 계산합니다.
commit message는 이 문제를 incomplete fix의 변종으로 명시하고 있습니다. 앞선 branch fix인 305413.1013@safari-7624.5-branch가 reachable merge 지점에 widening을 도입했는데, 같은 로직의 별도 사본이 다른 함수 안에 살고 있던 unreachable End handler는 누락되었습니다. 발견 경로가 여기서 드러납니다. 이 버그는 fuzzing으로 찾는 종류가 아니라, End를 구현한 코드 경로가 또 어디에 있는지를 묻는 방식으로 찾는 종류입니다.
이 vulnerability가 무너뜨리는 것은 wasm을 애초에 안전하게 실행할 수 있게 해 주는 경계입니다. validator가 기록한 타입 사실이 컴파일된 reference 연산에 전달될 수 있는 값의 범위를 한정한다는 보장이 그것입니다. 이 보장이 깨지면, script가 제어하는 비트가 아무런 runtime check도 없이 필드 주소 계산까지 도달합니다.
Audit directions
- 같은 opcode를 구현한 중복 handler. reachable decoder와 unreachable decoder가 각각
End를 구현하고 있었고, widening을 갖고 있던 쪽은 하나뿐이었습니다. 주 opcode switch에서 강제되는 validator invariant라면,WasmFunctionParser.h의parseUnreachableExpression()쪽에서도 동일하게 지켜지는지 대조해 볼 가치가 있습니다. 코드 리뷰에서의 신호는 명확합니다. 어떤 fix가case End:하나만 건드리고, 같은 헤더 안에 있는 똑같이 생긴 다른case End:는 그대로 두는 경우입니다. setType이 뒤따르지 않는 subtype 검사. 패턴은isSubtype(actual, expected)만 증명하고 거기서 멈추는 validation 지점입니다.WasmFunctionParser.h에서isSubtype호출 지점을 검색한 뒤, merge된 slot이 선언된 타입으로 다시 기록되는지 각각 확인해 보십시오. control-flow join에 해당하는 곳에서 subtype 검사만 덩그러니 놓여 있다면, 그 형태를 표시해 둘 만합니다.- check 제거의 근거로 사용되는 정적 타입.
emitRefTestOrCast와 그에 대응하는 OMG 쪽 구현이 어떤 타입 사실에 의존하는지 추적해 보십시오. generator가 parser에 기록된Type을 근거로 "runtime에 할 일이 없다"고 판단하는 지점은, 그 위쪽에 존재하는 모든 soundness 결함을 그대로 물려받습니다.