[4] OMG tail call restores a stale wasm bounds-size register over the callee's
A cross-instance tail call installs the callee's bounds limit, then overwrites it.
High. tail-callee 안의 모든 explicit bounds check가 비교 대상으로 삼는 register가 덮어써집니다. tail-call sequence가 이미 올바른 값을 설정해 둔 뒤에, 그 register를 소유할 이유가 없는 callee-save 복원이 값을 다시 기록합니다. 그 자리에 남는 limit은 다른 instance의 memory에 속한 값입니다. 그래서 ABI 처리상의 실수가 linear memory에 대한 out-of-bounds 접근으로 이어집니다.
WebAssembly의 bounds check가 저렴한 이유는 비교 대상이 되는 limit을 접근할 때마다 instance에서 다시 로드하지 않기 때문입니다. 대신 ABI가 함수 전 구간에 걸쳐 고정해 둔 register에 그 값이 상주합니다. JSC의 최상위 wasm 컴파일러인 OMG는 B3/Air 기반 optimizing backend인데, 여기서는 GPRInfo::wasmBoundsCheckingSizeRegister(regCS4)가 그 limit을 담당합니다. 컴파일러는 m_proc.pinRegister(...)를 호출해 이 register를 register allocator의 pool에서 제외합니다. 다만 이 처리는 컴파일 대상 함수 자체가 MemoryMode::BoundsChecking일 때에만 수행됩니다. 한편 tail call은 caller의 stack frame을 그대로 재사용하고, 이후 caller 쪽 보정 작업 없이 곧바로 jump합니다. 그래서 callee의 memory register를 설정할 수 있는 마지막 기회는 tail call을 수행하는 sequence 자체입니다.
관전 포인트: fast memory를 사용하는 함수에서 다른 instance로 tail call을 수행하는 wasm module을 가정할 수 있습니다. 이 경우 callee는 모든 load와 store를 남의 linear memory에 속한 bounds limit과 비교하게 됩니다.
Patch Details
prepareForTailCallImpl의 callee-save 복원 루프에 continue가 추가되었습니다. 이를 통해 wasmBoundsCheckingSizeRegister는 복원 대상에서 제외됩니다. 결과적으로 tail-call sequence가 callee를 위해 설정한 값이 shuffle 이후에도 그대로 유지됩니다. 함께 추가된 regression test는 필요한 tier 구성을 고정합니다. 피해 함수는 반드시 BBQ로 컴파일되어야 하는데, 새 테스트의 주석에 따르면 IPInt의 prologue는 진입 시 regCS4를 다시 로드하지만 BBQ의 prologue는 그렇지 않기 때문입니다. 즉 BBQ 코드는 caller가 register에 남겨둔 값을 그대로 신뢰합니다.
특정 컴파일 모드에서만 allocator pool에 남겨진 pinned ABI register가, call sequence가 방금 설정한 값 위에 일반 callee-save처럼 복원된 패턴.
Background
두 가지 memory mode. wasm 함수는 두 가지 memory mode 중 하나로 컴파일됩니다. BoundsChecking에서는 접근할 때마다 현재 linear memory limit과 비교하는 explicit compare가 생성됩니다. 반면 Signaling은 fast memory를 사용하며, 범위를 벗어난 접근은 guard page가 잡아냅니다. 이 모드에서는 explicit check 자체가 아예 생성되지 않습니다.
Register를 pin한다는 것. pinRegister는 해당 physical register가 살아 있는 ABI 상태를 담고 있으므로 컴파일 전 구간에서 allocator pool에서 제외해야 한다는 사실을 B3에 알립니다. OMG는 함수가 BoundsChecking mode일 때에만 wasmBoundsCheckingSizeRegister를 pin합니다. Signaling mode에서는 이 register가 Air의 가변 집합에 그대로 남고, B3가 평범한 general-purpose register로 할당할 수 있습니다.
clobberEarly와 callee-save 목록. commit message에 따르면 createTailCallPatchpoint는 callee-save 집합 전체를 clobberEarly로 선언합니다. tail call의 parallel move가 곧 덮어쓸 register에 B3가 tail-call 입력값을 올려두지 않도록 하기 위해서입니다. 그런데 이 선언 때문에 AirHandleCalleeSaves가 regCS4를 해당 함수의 calleeSaveRegisterAtOffsetList()에 추가합니다.
Tail call의 frame 재사용. return_call과 return_call_indirect는 새 frame을 push하지 않고 caller의 frame을 재사용합니다. 실제 shuffle은 prepareForTailCallImpl이 담당합니다. 인자를 제자리로 옮기고, jump 직전에 callee-save 목록을 복원합니다.
Analysis
생성된 코드에서 발생한 logic 오류이자 ABI 오류입니다. 직접적인 보안 영향은 bounds check 우회입니다.
Signaling-mode caller (OMG) regCS4 / wasmBoundsCheckingSizeRegister
--------------------------- ---------------------------------------
prologue
AirHandleCalleeSaves saw
regCS4 in the callee-save
list (via clobberEarly) spill caller-entry value into frame
return_call, cross-instance
install callee memory regs <- callee instance's bounds limit (OK)
prepareForTailCallImpl
restore callee-save list <- caller-entry value (STALE)
jump; callee executes (BBQ)
every explicit bounds check compares against the stale limit
핵심은 순서입니다. wasm tail call은 jump 이전에 대상의 memory register, 즉 base pointer와 bounds size를 설정해야 합니다. tail-callee가 caller의 frame을 재사용하고, 이후 caller 쪽 setup이 다시 실행되지 않기 때문입니다. 위 그림의 취약한 순서에서는 cross-instance 경로가 wasmBoundsCheckingSizeRegister에 callee instance의 memory size를 설정합니다. 그런데 곧바로 prepareForTailCallImpl 내부의 callee-save shuffle이 그 값을 덮어씁니다. 이때 기록되는 값은 OMG prologue가 caller 진입 시점에 spill해 두었던 값입니다.
전제 조건은 구체적이지만, 전부 script로 구성 가능한 범위 안에 있습니다. 먼저 caller가 Signaling mode에서 OMG로 컴파일되어야 합니다. 그래야 regCS4가 pin되지 않은 상태로 남아 callee-save 목록에 들어갈 수 있습니다. 다음으로 호출은 instance를 넘나드는 return_call 또는 return_call_indirect여야 합니다. 그래야 caller와 callee의 memory register가 실제로 달라집니다. 마지막으로 callee는 BBQ여야 합니다. BBQ의 prologue는 regCS4를 다시 로드하지 않으므로, stale한 값이 callee 본문까지 그대로 살아남습니다.
그 결과 tail-callee는 다른 context에 속한 bounds limit을 들고 실행을 시작합니다. 이 값은 자신의 linear memory와 아무 관계가 없습니다. 이후 그 callee 안의 모든 explicit bounds check가 이 값을 기준으로 비교를 수행합니다. out-of-bounds read가 되는지 write가 되는지는 불일치의 방향에 달려 있습니다. stale한 limit이 callee의 실제 memory보다 큰 경우, buffer 끝을 넘어선 접근이 허용됩니다.
wasm의 memory safety는 하나의 invariant 위에 서 있습니다. explicit bounds check가 index를 비교하는 대상은 반드시 그 index가 가리키는 memory의 limit이어야 한다는 것입니다. 이 vulnerability는 바로 그 전제를 약화시킵니다.
Audit directions
- 조건부로 pin되는 ABI register. 어떤 컴파일 모드에서는 살아 있는 ABI 상태를 담지만 다른 모드에서는 그렇지 않은 register라면, 모두 동일한 버그의 후보입니다. pin되지 않은 모드에서 allocator가 그 register를 아무 제약 없이 사용하기 때문입니다. OMG generator 안의
pinRegister호출 지점을 모두 나열하고, 각각의 조건이 해당 register의 실제 live range와 일치하는지 확인해 볼 필요가 있습니다. - call sequence setup과 callee-save 복원의 순서. 패턴은 이렇습니다. call sequence의 한 부분이 값을 설정하고, 같은 sequence의 뒷부분이 그 값을 다시 덮어씁니다.
prepareForTailCallImpl과 유사한 코드에서는 memory register setup이 기록하는 register가 callee-save 목록과 겹치지 않는지 확인해야 합니다. 코드 리뷰에서의 신호는 예외 처리 없이calleeSaveRegisterAtOffsetList()전체를 도는 복원 루프입니다. 특히 pinned register를 수동으로 기록하는 코드 뒤에 그 루프가 놓여 있다면 살펴볼 만합니다. - tier 간 prologue 가정. BBQ는 넘어온 regCS4를 그대로 신뢰하지만, IPInt는 이를 다시 로드합니다. 한 tier의 생성 코드가 다른 tier의 prologue가 무엇을 하고 무엇을 하지 않는지에 의존한다면, 그 의존 관계는 문서화되지 않은 계약에 해당합니다. pinned register를 모두 나열한 뒤, tier별로 어떤 register가 진입 시 다시 로드되고 어떤 register가 그대로 상속되는지 정리해 둘 필요가 있습니다.