← All reports

[4] OMG tail call restores a stale wasm bounds-size register over the callee's

HighJSC WebAssembly OMG tierOOB

A cross-instance tail call installs the callee's bounds limit, then overwrites it.

929f6e8

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과 비교하게 됩니다.

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처럼 복원된 패턴.

두 가지 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_callreturn_call_indirect는 새 frame을 push하지 않고 caller의 frame을 재사용합니다. 실제 shuffle은 prepareForTailCallImpl이 담당합니다. 인자를 제자리로 옮기고, jump 직전에 callee-save 목록을 복원합니다.

생성된 코드에서 발생한 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는 바로 그 전제를 약화시킵니다.