[2] DFG/FTL stack corruption from 9-argument ObjectDefinePropertyFromFields helper
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
Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp
JSTests/stress/object-define-property-fields-spilled-arg.js
Patch Details
runtime helper 시그니처가 9개의 개별 EncodedJSValue 인자에서 단일 EncodedJSValue* descriptorBuffer로 변경되었습니다. 이로 인해 호출이 4개의 GPR 인자로 줄었습니다. DFGSpeculativeJIT::compileObjectDefinePropertyFromFields와 FTL::LowerDFGToB3::compileObjectDefinePropertyFromFields는 Node::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과 겹치는 문제.
Background
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개의 정수 인자를 x0–x7에 전달하며, 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 상태로 표시합니다.
Analysis
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을 손상시킵니다.
Audit directions
maxFrameExtentForSlowPathCall이 0이기 때문에, C 시그니처가 아키텍처의 register 인자 한도를 초과하는 JIT slow-path helper는 조용히 spill slot을 손상시킵니다. 모든JSC_DECLARE_JIT_OPERATION및JSC_DECLARE_NOEXCEPT_JIT_OPERATION선언을 점검해야 합니다. x86_64에서는 6개, ARM64에서는 8개를 초과하는 매개변수를 가진 선언을 검색합니다.Source/JavaScriptCore/dfg/DFGOperations.h,Source/JavaScriptCore/ftl/FTLOperations.h,Source/JavaScriptCore/jit/JITOperations.h부터 시작하십시오. 한도를 초과하는 helper가 있으면, 해당 호출 지점이 모든 필드를 직접 값으로 전달하는 방식 대신 scratch buffer를 사용하는지 확인해야 합니다.- 점진적으로 추가된 variadic-shape helper. N개에서 N+k개로 인자가 늘어난 helper는 조용히 register 인자 한도를 넘어설 수 있습니다. 지난 2년간의 operation 시그니처 git history를 조사하고 현재 인자 개수를 재확인해야 합니다. 특히
operationDefineDataProperty,operationDefineAccessorProperty,operationPutByVal*WithThis, 그리고 property-descriptor 분해를 처리하는 모든 helper를 살펴봐야 합니다. maxFrameExtentForSlowPathCall의 아키텍처적 강제 적용.JSC_DECLARE_JIT_OPERATION매크로 확장에 빌드 시점 assertion(static_assert(helperArgCount <= registerArgCount)등)을 추가하여, 한도를 초과하는 helper를 추가하면 컴파일이 실패하도록 만들 수 있는지 검토해야 합니다. 적절한 강제 적용 지점을 찾기 위해Source/JavaScriptCore/jit/JITOperations.h와MaxFrameExtentForSlowPathCall.h를 살펴봐야 합니다.- 직접 작성된 stub 및 LLInt/thunk 경로.
Source/JavaScriptCore/llint/의 LLInt stub,Source/JavaScriptCore/jit/ThunkGenerators.cpp의 thunk generator,Source/JavaScriptCore/wasm/WasmSlowPaths.cpp의 Wasm slow-path 호출에서 플랫폼 register 한도를 초과하는 인자를 가진 직접적인 C-call이 있는지 점검해야 합니다.