← All issues

[2] DFG/FTL stack corruption from 9-argument ObjectDefinePropertyFromFields helper

Severity: High | Component: JSC DFG/FTL JITs | 59a20dd

diff가 9번째 C 인자를 가장 낮은 DFG spill slot과 겹치는 stack slot에 기록하는 JIT-emitted 코드를 수정하기 때문에 High로 평가됩니다. 손상되는 비트는 공격자가 선택한 EncodedJSValue descriptor encoding이며, spill된 값은 이후 동일한 JIT 코드에 의해 다시 읽힙니다. 결과적으로 WebContent 내부에서 제어 가능한 spill-slot clobber primitive가 만들어집니다.

ObjectDefinePropertyFromFields는 9개 매개변수로 연산을 호출하고 있었습니다. 그러나 ARM64는 최대 8개, x86_64는 최대 6개의 register 매개변수만 지원합니다. DFG JIT는 runtime helper가 stack 인자를 필요로 하지 않는다고 가정합니다. 이번 수정에서는 descriptor 필드에 scratch buffer를 사용하도록 변경되었습니다.

Source/JavaScriptCore/dfg/DFGOperations.h

-JSC_DECLARE_JIT_OPERATION(operationObjectDefinePropertyFromFields, void, (JSGlobalObject*, JSObject*, EncodedJSValue, EncodedJSValue, EncodedJSValue, EncodedJSValue, EncodedJSValue, EncodedJSValue, EncodedJSValue));
+JSC_DECLARE_JIT_OPERATION(operationObjectDefinePropertyFromFields, void, (JSGlobalObject*, JSObject*, EncodedJSValue, EncodedJSValue*));

Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp

- JSValueOperand enumerable(this, m_graph.varArgChild(node, 2));
- ...
- JSValueOperand setter(this, m_graph.varArgChild(node, 7));
+ GPRTemporary buffer(this);
+ constexpr size_t scratchSize = sizeof(EncodedJSValue) * Node::numberOfDescriptorSlots;
+ ScratchBuffer* scratchBuffer = vm().scratchBufferForSize(scratchSize);
+ EncodedJSValue* scratchData = static_cast<EncodedJSValue*>(scratchBuffer->dataBuffer());
+ move(TrustedImmPtr(scratchData), bufferGPR);
+ for (unsigned slot = 0; slot < Node::numberOfDescriptorSlots; ++slot) {
+ JSValueOperand operand(this, m_graph.varArgChild(node, slot + 2));
+ storeValue(operand.jsValueRegs(), Address(bufferGPR, sizeof(EncodedJSValue) * slot));
+ operand.use();
+ }
- callOperation(operationObjectDefinePropertyFromFields, ..., enumerableRegs, configurableRegs, valueRegs, writableRegs, getterRegs, setterRegs);
+ callOperation(operationObjectDefinePropertyFromFields, LinkableConstant::globalObject(*this, node), targetGPR, keyRegs, bufferGPR);

JSTests/stress/object-define-property-fields-spilled-arg.js

+// 9번째 인자가 [sp + 0]에 저장되었지만, 해당 대상에서 maxFrameExtentForSlowPathCall은 0이므로
+// [sp + 0]이 가장 낮은 spill slot과 겹쳐 spill되어 있던 값을 손상시킵니다.
+// 여기서 spill된 값은 이후 ValueAdd로 전달되는 함수 인자이며,
+// 손상된 slot을 읽어 operationValueAddNotNumber에서 crash가 발생합니다.

runtime helper 시그니처가 9개의 개별 EncodedJSValue 인자에서 단일 EncodedJSValue* descriptorBuffer로 변경되었습니다. 이로 인해 호출이 4개의 GPR 인자로 줄었습니다. DFGSpeculativeJIT::compileObjectDefinePropertyFromFieldsFTL::LowerDFGToB3::compileObjectDefinePropertyFromFieldsNode::numberOfDescriptorSlots 항목의 ScratchBuffer를 확보하고, 각 descriptor 필드를 Node::DescriptorSlot 인덱스 위치에 저장한 뒤 buffer pointer를 네 번째 인자로 전달하도록 재구성되었습니다. runtime helper는 ActiveScratchBufferScope 안에서 slot을 다시 디코딩합니다(GC가 buffer를 스캔할 수 있도록).

runtime helper가 argument register 안에 수용되어야 한다는 JIT slow-path calling-convention invariant를 위반하여, stack으로 전달된 인자가 JIT spill slot과 겹치는 문제.

JSC의 optimizing JIT에서 runtime helper(operationXxx)는 JIT-emitted 코드에서 일반 C 함수로 호출됩니다. JIT는 OSR 가능한 영역에 진입할 때 maxFrameExtentForSlowPathCall 상수에 의존하여 outgoing argument stack 공간을 예약합니다. ARM64와 x86_64에서 이 값은 0으로, helper가 stack에 인자를 전달하지 않는다는 invariant를 나타냅니다. Spill slot은 물리 register에 들어가지 못한 JSValue를 DFG가 배치하는 위치로, 현재 stack pointer 근처에 존재합니다.

ARM64에서는 처음 8개의 정수 인자를 x0x7에 전달하며, x86_64 SysV에서는 처음 6개를 rdi/rsi/rdx/rcx/r8/r9에 전달합니다. 이를 초과하는 인자는 [sp + 0]부터 stack에 배치됩니다. ScratchBuffer는 VM이 소유하며 GC가 스캔하는 scratch 영역으로, argument register를 소비하지 않고 임의 데이터를 runtime helper에 전달하기 위해 사용됩니다. ActiveScratchBufferScope는 helper 실행 중 보수적 스캔(conservative scanning)을 위해 buffer를 live 상태로 표시합니다.

operationObjectDefinePropertyFromFields는 포인터 크기의 인자 9개로 선언되어 있었습니다. x86_64에서는 7번째부터 9번째 인자가 [sp + 0], [sp + 8], [sp + 16]에 stack으로 spill됩니다. 해당 대상 아키텍처에서 maxFrameExtentForSlowPathCall이 0이므로, 컴파일러는 outgoing C-call stack 인자를 위한 stack pointer 아래 공간을 예약하지 않습니다. 대신 DFG는 [sp + 0..](및 현재 SP 바로 아래의 word들)를 실행 중인 JIT 값의 가장 낮은 spill slot으로 사용합니다. 따라서 JIT가 해당 호출 코드를 생성할 때, stack으로 전달된 인자들이 실행 중이던 JSValue를 보관한 spill slot을 덮어쓰게 됩니다.

테스트 코드는 이 손상을 명확히 보여줍니다. 호출 전에 spill된 값이 이후 ValueAdd에서 사용되지만, 해당 소비자는 손상된 slot을 읽어 operationValueAddNotNumber 내부에서 crash가 발생합니다. 이 trigger는 constant-shape descriptor literal을 6개 필드로 분해하는 DFG/FTL 특수화(ObjectDefinePropertyFromFields 노드)를 활용합니다. defineProperty 호출 전반에 걸쳐 여러 JSValue를 live 상태로 유지하는 hot 함수를 대상으로 삼아 spill을 강제하면서, 공격자가 선택한 64비트 bit pattern을 value/get/set slot에 담은 descriptor literal로 Object.defineProperty를 호출하는 방식입니다.

손상되는 비트는 공격자가 제어합니다. descriptor의 value, writable, enumerable, configurable, get, setter slot의 EncodedJSValue encoding이 그 값입니다. spill된 값이 포인터 타입 JSValue(object pointer, boxed integer, double)인 경우, 손상으로 인해 위조된 JSValue로 교체될 가능성이 있습니다. 이후 해당 spill slot을 다시 읽는 JIT 코드는 공격자가 선택한 bit pattern을 역참조하게 됩니다. 무기화가 가능한 경우, 이 경로는 전형적인 JSC type-confusion exploit과 유사한 방식을 따릅니다. 가짜 JSCell pointer를 만들어 JIT가 원래 타입으로 역참조하도록 유도하면, WebContent 내부에서 arbitrary read/write로 이어질 가능성이 있습니다. 다만 host에 도달하려면 별도의 sandbox escape가 필요합니다.

이 vulnerability는 hot 코드에서 분해된 descriptor literal로 Object.defineProperty를 호출하는 모든 DFG/FTL code path에서 WebContent renderer 내부의 type safety를 약화시켰습니다. 이 패턴은 optimizing JIT에서 반복적으로 나타납니다. runtime helper의 C 시그니처가 인자 하나씩 늘어가는 동안, JIT의 calling-convention invariant가 재확인되지 않는 문제입니다. maxFrameExtentForSlowPathCall == 0 invariant는 빌드 전체의 전역 속성이며 호출별 assertion이 아닙니다. 따라서 9번째 인자를 가진 helper를 추가해도 컴파일은 정상적으로 이루어지고, 대부분의 입력에서도 정상적으로 실행됩니다. 단, 주변 코드가 마침 spill을 수행하는 경우에만 조용히 spill slot을 손상시킵니다.