← All issues

[6] Wasm OMG Tail Call Scratch Register Corruption on ARM

Severity: High | Component: JSC Wasm OMG JIT — tail call implementation | 4572dd4

ARM 플랫폼(iOS, Apple Silicon macOS)에서 tail call setup 도중 scratch register가 항상 동일하게 손상되는 현상이 관찰되어 High로 평가했습니다. 제어된 인자 치환으로의 확장 가능성은 신뢰도 0.82로 평가됩니다. ARM macro assembler 명령어의 implicit scratch 사용 방식은 잘 알려져 있지만, diff에서 직접 확인되지는 않습니다.

OMG tailcall patchpoint는 scratch register를 사용합니다. x64에서는 scratch를 미리 clobber할 경우 사용 가능한 register가 모두 소진되기 때문에, 이를 일찍 clobber하지 않습니다. 이로 인해 prepareTailCallImpl은 scratch가 입력 중 하나와 alias 관계인 경우를 대비해 저장/복원 처리를 별도로 포함합니다. 그러나 ARM에서는 이 방식이 제대로 동작하지 않습니다. stack pointer 산술 연산 자체가 scratch를 사용할 수 있어 복원 과정이 복잡해지기 때문입니다. 이 PR은 저장/복원의 복잡성을 x64에 한정하기 위해 tail call patchpoint 코드를 아키텍처별로 분리합니다.

Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp

AllowMacroScratchRegisterUsage allowScratch(jit);
auto tmp = jit.scratchRegister();
 
+#if CPU(X86_64)
+ // x64에서는 scratch register가 입력 중 하나와 alias 관계가 될 수 있어 별도 저장이 필요합니다.
+ //
+ // 아래에서 이 값을 clobber하지 않도록 주의해야 합니다.
+ // patchpoint body가 이 값을 사용하는 경우에도 반드시 보존되어야 합니다.
bool tmpNeedsSaving = false;
int tmpSpillOffsetRelativeToOriginalSP = 0;
...
jit.storePtr(tmp, CCallHelpers::Address(MacroAssembler::stackPointerRegister, tmpSpillOffsetRelativeToOriginalSP));
}
+#else
+ constexpr bool tmpNeedsSaving = false;
+ constexpr int tmpSpillOffsetRelativeToOriginalSP = 0;
+
+ // 현재 frame을 clobber할 수 있도록 유효한 frame을 먼저 설정합니다.
+ jit.emitRestore(calleeSaves);
+
+#if ASSERT_ENABLED
+ for (unsigned i = 0; i < params.size(); ++i) {
+ auto arg = params[i];
+ if (arg.isGPR()) {
+ ASSERT(!calleeSaves.find(arg.gpr()));
+ ASSERT(arg.gpr() != tmp);
+ continue;
+ }
+ ...
+ }
+ ASSERT(!calleeSaves.find(tmp));
+#endif // ASSERT_ENABLED
+#endif // CPU(X86_64)
...
- // Nothing after restoring tmp can use the scratch register since it might clobber an input.
{
+#if CPU(X86_64)
+ // x64에서는 tmp 복원 이후 scratch register 사용을 금지합니다. 입력값을 clobber할 수 있기 때문입니다.
DisallowMacroScratchRegisterUsage disallowScratch(jit);
+#endif
jit.addPtr(MacroAssembler::TrustedImm32(newSPAtPrologueOffsetFromSP), MacroAssembler::stackPointerRegister);

수정 방식은 prepareForTailCallImpl을 아키텍처별로 분리하는 것입니다. x64 이외(ARM)에서는 인자 shuffling 전에 callee save를 먼저 복원해 scratch register를 미리 clobber합니다. 이렇게 하면 register allocator가 scratch를 patchpoint 입력에 할당하는 상황이 원천적으로 방지됩니다. scratch 복원 이후의 DisallowMacroScratchRegisterUsage guard도 x64 전용으로 변경되었습니다. 비-x64 경로에는 ASSERT_ENABLED guard가 추가되어 두 가지 불변 조건을 기계적으로 검증합니다. scratch register가 patchpoint 입력과 alias 관계가 없을 것, 그리고 callee save register가 scratch와 충돌하지 않을 것입니다. x64 경로는 기존의 save/restore 로직을 그대로 유지합니다. x64에서는 scratch를 일찍 clobber하면 사용 가능한 GPR이 부족해지기 때문입니다.

WebAssembly tail call(return_call / return_call_indirect)은 새로운 stack frame을 push하는 대신 현재 frame을 재사용해 다른 함수로 제어를 이전하는 방식입니다. tail call 준비 단계에서 JIT는 live 인자 값들을 임시로 scratch stack 공간에 spill한 뒤 callee가 기대하는 위치로 재배치해야 합니다. source와 destination 슬롯이 겹칠 수 있기 때문입니다(순열 문제).

OMG tier는 tail call을 patchpoint를 통해 구현합니다. patchpoint는 register allocation 이후에 플랫폼별 machine code를 생성하는 late-bound 코드 생성 callback입니다. prepareForTailCallImpl은 현재 frame에서 caller frame 위치로 인자를 shuffling하면서 stack pointer를 조정합니다. patchpoint의 params 목록은 B3 register allocator가 patchpoint 입력에 할당한 register를 담고 있습니다.

ARM에서는 macro assembler가 address materialization과 단일 명령어로 처리할 수 없는 산술 연산에 지정된 scratch register(주로 ip0/ip1)를 암묵적으로 사용할 수 있습니다. 이 implicit 사용은 JIT emission 레벨에서 드러나지 않습니다. jit.addPtr(TrustedImm32(largeValue), sp)는 내부적으로 scratch를 사용해 immediate를 materialize할 수 있고, 이 과정에서 scratch에 담겨 있던 값이 손상됩니다.

근본 원인은 prepareForTailCallImpl이 모든 아키텍처에서 동일한 코드 경로를 통해 tail call setup 도중 scratch register를 처리했다는 데 있습니다. scratch register는 B3 register allocator가 할당한 tail call 입력 인자 중 하나와 alias 관계가 될 수 있습니다. x64에서는 scratch 값을 clobber 전에 spill하고 이후 복원하는 특별한 save/restore 로직으로 이 문제를 처리했습니다. 그러나 ARM에서는 이 방식이 충분하지 않습니다. ARM의 stack pointer 산술 연산 자체가 macro assembler 내부에서 scratch register를 사용할 수 있기 때문입니다. scratch가 입력 인자와 alias 관계일 때, large immediate를 사용하는 stack pointer 조정(addPtr)이 scratch를 손상시킵니다. 이후 코드가 scratch에서 원래 입력값을 읽으려 하면 손상된 값을 읽게 됩니다.

수정은 구현을 올바르게 분리합니다. ARM에서는 인자 shuffling 전에 callee save를 먼저 복원해 scratch를 미리 clobber합니다. 이렇게 하면 register allocator가 scratch를 patchpoint 입력에 할당하는 상황 자체가 불가능해집니다. callee save와 scratch가 shuffling 코드 실행 전에 이미 복원된 상태이기 때문입니다. 비-x64 경로의 assertion guard(ASSERT(arg.gpr() != tmp))는 이 불변 조건을 기계적으로 검증합니다.

🔒

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

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

🔒

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

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