← All issues

[7] Wasm OMG Tail Call F32 Spill Stack OOB Write

Severity: High | Component: JSC Wasm OMG JIT — tail call FPR spilling | a73d24d

F32 tail call argument를 갖는 Wasm 모듈을 통해 도달 가능한 JIT 생성 코드에서, 항상 동일하게 4바이트 stack 인접 write가 발생합니다. 인접 영역에는 saved register나 control flow metadata가 포함될 수 있습니다. 다만 쓰여지는 값에 대한 attacker의 제어는 제한적입니다. 해당 값은 상위 FPR 비트일 가능성이 높으며, x86 SSE에서는 대체로 zero이므로 primitive의 강도는 이에 따라 낮아집니다. High로 평가하며, confidence는 0.85입니다.

OMG tail call에서 FPR 값을 spill할 때 F32와 F64 모두 double store를 수행하고 있었습니다. F32는 spill slot 경계를 벗어나는 write를 방지하기 위해 float store를 사용해야 합니다. 이 임시 stack 손상은 안정적이고 관찰 가능한 형태로 재현할 수 없기 때문에, 별도의 테스트는 추가되지 않았습니다.

Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp

} else if (src.isFPR()) {
srcOffset = allocateSpill(dstType.width());
- if (dstType.width() <= Width::Width64)
- jit.storeDouble(src.fpr(), CCallHelpers::Address(MacroAssembler::stackPointerRegister, srcOffset));
- else
- jit.storeVector(src.fpr(), CCallHelpers::Address(MacroAssembler::stackPointerRegister, srcOffset));
+ auto dst = CCallHelpers::Address(MacroAssembler::stackPointerRegister, srcOffset);
+ if (dstType == Types::F32)
+ jit.storeFloat(src.fpr(), dst);
+ else if (dstType == Types::F64)
+ jit.storeDouble(src.fpr(), dst);
+ else {
+ ASSERT(dstType == Types::V128);
+ jit.storeVector(src.fpr(), dst);
+ }

prepareForTailCallImpl에서 OMG 계층 Wasm tail call의 FPR spill 경로는, F32를 포함한 width ≤ 64비트인 모든 FPR 타입에 대해 단일 storeDouble(8바이트 store)을 사용하고 있었습니다. 수정을 통해 이를 세 가지 경우로 분리했습니다. Types::F32에는 storeFloat(4바이트), Types::F64에는 storeDouble(8바이트), Types::V128에는 storeVector(16바이트)를 각각 사용합니다. spill slot은 allocateSpill(dstType.width())를 통해 할당되는데, F32의 경우 4바이트 slot이 반환됩니다. 따라서 기존의 8바이트 storeDouble은 4바이트 slot에 쓰면서 할당 범위를 4바이트 초과하는 write를 발생시켰습니다.

WebAssembly tail call(return_call / return_call_indirect)은 callee가 현재 stack frame을 그대로 재사용합니다. tail call 준비 과정에서 JIT는 활성 argument 값을 일시적으로 scratch stack 공간에 spill한 뒤, callee가 기대하는 위치로 재배치합니다. allocateSpill(width)는 지정된 width에 맞는 stack 영역을 예약하며, store 명령어는 이 width와 정확히 일치해야 합니다. storeFloat는 4바이트를, storeDouble은 8바이트를 씁니다. 현대 아키텍처의 FPR 레지스터는 64비트 또는 128비트 너비를 가지며 다양한 부동소수점 너비의 값을 담을 수 있습니다. 레지스터 너비와 메모리 store 너비는 동일하지 않습니다.

근본 원인은 F32와 F64 모두 64비트 FPR에 들어갈 수 있다는 이유로 이를 동일하게 처리한 width 기반 dispatch에 있습니다. spill slot 할당에서는 allocateSpill(dstType.width())가 올바르게 동작하여 F32에 4바이트를 할당했습니다. 그러나 이후 store에서는 width <= Width64인 모든 FPR 값, F32 포함, 에 대해 storeDouble(8바이트)을 사용했습니다. 결과적으로 F32 tail-call argument를 spill할 때마다 할당된 slot 끝에서 4바이트를 초과하는 write가 발생했습니다. 이 초과분은 인접한 영역, 즉 다른 spill slot, saved register, 또는 stack frame 위의 return metadata를 덮어쓰게 됩니다.

주목할 점은, 같은 함수 내 바로 아래에 위치한 constant 처리 경로가 이미 F32(Width32/storeFloat)와 F64(Width64/storeDouble)를 올바르게 구분하고 있었다는 것입니다. register 경로는 잘못되어 있고 constant 경로는 올바른 이 비대칭성은, FPR 경로가 float과 double FPR 값의 의미적 차이를 고려하지 않은 채 Width 기반 조건으로만 작성되었음을 강하게 시사합니다.

🔒

상세 취약점 분석, 공격 가능성 평가, 보안 영향 분석이 포함되어 있습니다

더 확인하려면 구독해 주세요

🔒

이 취약점 패턴의 변종을 찾기 위한 구체적인 탐색 방향이 포함되어 있습니다

더 확인하려면 구독해 주세요