← All reports

[2] ObjectDefinePropertyFromFields overran the ABI's argument-register budget

HighJSC DFG and FTL JITOOB

Nine arguments, six registers, and zero bytes reserved for the overflow

59a20dd

High로 평가된 이유는 다음과 같습니다. 9개의 machine-word 인자를 6개 레지스터 budget에 맞춰 넣는 과정에서, JavaScript에서 공급된 값이 실행 중인 프레임의 live spill slot 위에 그대로 올라가게 됩니다. 이를 type confusion으로 발전시키려면 type-proven 값을 해당 aliased slot으로 유도해야 하는데, 이는 예상되는 방향일 뿐 이번 변경 자체가 그 경로를 성립시키지는 않습니다.

C call frame을 직접 구성하는 code generator는 플랫폼 ABI가 레지스터로 전달할 수 있는 인자 개수에 정해둔 한도를 지켜야 합니다. 이 한도를 넘어서는 인자는 stack 공간으로 넘어가며, 이 공간을 예약하는 책임은 generator에 있습니다. WebKit의 DFG와 FTL은 value type을 speculate하는 최적화 JavaScript tier로, 복잡한 연산을 C++ runtime 함수 호출로 구현하며 이때 callOperation이 인자를 marshalling합니다. ARM64와 x86_64에서는 컴파일러가 이 목적을 위한 공간을 전혀 예약하지 않는데, runtime-call의 모든 인자가 레지스터에 들어간다는 전제 위에서 전체 설계가 이루어졌기 때문입니다. 대신 call site에서 stack pointer 바로 아래 메모리는 해당 프레임 자체의 spill region으로, 컴파일러가 이미 type을 증명해둔 값들을 담고 있습니다.

관전 포인트: Object.defineProperty를 inline descriptor와 함께 반복 실행해 tier up될 만큼 hot한 페이지라면, 현재 실행 중인 optimized 프레임의 live spill slot 위에 원하는 JSValue를 직접 써넣을 수 있습니다.

ObjectDefinePropertyFromFields는 9개의 파라미터를 갖는 operation을 호출합니다. 하지만 현재 ARM64 / x64는 최대 6개의 레지스터 파라미터만 지원합니다. 그리고 현재 DFG JIT는 ARM64 / x64가 파라미터 전달에 stack을 전혀 사용하지 않는다고 가정하고 있습니다. 이 패치는 필드를 위한 scratch buffer를 사용하는 방식으로 이 문제를 해결합니다.

Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp

SpeculateCellOperand target(this, m_graph.varArgChild(node, 0));
JSValueOperand key(this, m_graph.varArgChild(node, 1));
 
- JSValueOperand enumerable(this, m_graph.varArgChild(node, 2));
 
- JSValueOperand configurable(this, m_graph.varArgChild(node, 3));
 
- JSValueOperand value(this, m_graph.varArgChild(node, 4));
 
- JSValueOperand writable(this, m_graph.varArgChild(node, 5));
 
- JSValueOperand getter(this, m_graph.varArgChild(node, 6));
 
- 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();
+ }
+
+ target.use();
+ key.use();
 
flushRegisters();
 
- callOperation(operationObjectDefinePropertyFromFields, LinkableConstant::globalObject(*this, node), targetGPR, keyRegs, enumerableRegs, configurableRegs, valueRegs, writableRegs, getterRegs, setterRegs);
 
- noResult(node);
+ callOperation(operationObjectDefinePropertyFromFields, LinkableConstant::globalObject(*this, node), targetGPR, keyRegs, bufferGPR);
+ noResult(node, UseChildrenCalledExplicitly);

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

+// DFG/FTL frame layout 버그에 대한 regression test입니다. operationObjectDefinePropertyFromFields는
+// 원래 GPR 인자를 9개 받았는데, 이는 ARM64의 8-레지스터 인자 예산(및 x86_64의 6개)을 초과합니다.
+// 9번째 인자는 [sp + 0]에 poke되었지만, 해당 타겟에서는 maxFrameExtentForSlowPathCall이 0이라
+// [sp + 0]이 가장 낮은 spill slot과 겹치면서 그 자리에 저장돼 있던 값을 손상시켰습니다.
+// 여기서는 손상된 값이 나중에 ValueAdd에 사용되는 함수 인자였고, 손상된 slot을 읽는 과정에서
+// operationValueAddNotNumber에서 crash가 발생했습니다.
+
+function opt(input) {
+ Object.defineProperty((function (t, x) { t.y = x; }), 'reject', { get: (({ valueOf: (/(?<!x)y/.test(input)), c: -5.3049894784e-314, this: 1_000_000 }).prototype &&= "ab") });
+ a2 = ["ab"];
+ try {
+ let combined = a2 + input;
+ ...
+ } catch (x) { }
+}
+for (let i = 0; i < testLoopCount; i++) {
+ try { opt(-5.3049894784e-314); } catch (e) { }
+}

이번 변경은 operation의 C 시그니처를 재구성합니다. 또한 두 tier의 lowering 코드를 모두 수정해, 여섯 개의 descriptor 필드를 레지스터 대신 VM scratch buffer를 통해 전달하도록 했습니다. 그리고 runtime 측에서 이 필드들을 decode하는 부분을 GC-visible scope 안으로 옮겨, 첫 allocation을 유발하는 호출 이전에 scope가 닫히도록 했습니다.

시그니처 측면에서는, DFGOperations.h의 선언이 (JSGlobalObject*, JSObject*, EncodedJSValue, EncodedJSValue x6)에서 (JSGlobalObject*, JSObject*, EncodedJSValue, EncodedJSValue* descriptorBuffer)로 변경되었습니다. 이로써 GPR 인자가 9개에서 4개로 줄었습니다.

Lowering 측면에서는, DFGSpeculativeJIT::compileObjectDefinePropertyFromFields가 더 이상 여섯 개의 JSValueOperand를 동시에 만들지 않습니다. 대신 하나의 GPRTemporary buffervm().scratchBufferForSize(sizeof(EncodedJSValue) * Node::numberOfDescriptorSlots)로 얻은 포인터를 갖습니다. 여섯 개의 descriptor child는 루프 안에서 하나씩 lowering되며, 각각 storeValue를 통해 Address(bufferGPR, sizeof(EncodedJSValue) * slot)에 저장된 뒤 use()됩니다. target.use()key.use()는 명시적으로 호출되고, 노드는 noResult(node, UseChildrenCalledExplicitly)로 종료됩니다. FTL에서는 LowerDFGToB3::compileObjectDefinePropertyFromFields가 동일한 작업을 수행하는데, vmCall 이전에 여섯 번의 m_out.store64(lowJSValue(...), m_out.absolute(buffer + slot)) store가 이루어집니다.

Runtime 측에서는, operation 본문이 ActiveScratchBufferScope(ScratchBuffer::fromData(descriptorBuffer), Node::numberOfDescriptorSlots)로 scope된 nested block 안에서 descriptorBuffer[Node::EnumerableSlot/...]로부터 여섯 개의 필드를 decode합니다. 이 decode 과정은 allocation과 throw가 가능한 toPropertyKey(globalObject) 호출보다 으로 옮겨졌습니다. 또한 DFGOperations.cpp에는 기존에 존재하던 Node::*Slot 상수들을 참조할 수 있도록 DFGNode.h가 새로 include되었습니다.

  Before (9 GPR args, ARM64)          After (4 GPR args)
  ─────────────────────────           ──────────────────
  x0..x7 <- globalObject, target,     x0..x3 <- globalObject, target,
            key, 6 descriptor fields             key, scratchBufferPtr
  9th arg -> poke [sp + 0]            6 descriptors -> scratch buffer,
       |                                 stored one at a time
       v                              [sp + 0] untouched
  [sp + 0] == lowest spill slot
       (frame extent reserved: 0)

Runtime-call의 인자 개수가 target ABI의 레지스터 인자 예산을 조용히 초과하면서, 남는 인자들이 code generator가 예약해두지 않은 stack 영역으로 spill됩니다.

이 코드가 있는 위치. DFG와 FTL JIT는 복잡한 semantics를 구현할 때, JIT 코드에서 JSC_DECLARE_JIT_OPERATION으로 선언된 C++ 함수로 C calling convention 호출을 내보내는 방식을 사용합니다. callOperation(DFG)과 vmCall(FTL)이 이 인자들을 플랫폼의 인자 레지스터에 맞춰 배치하는 역할을 담당합니다.

EncodedJSValue와 레지스터 예산. EncodedJSValue는 JavaScript 값을 64비트로 NaN-boxing한 raw 표현입니다. 64비트 타겟에서는 정확히 하나의 general-purpose register를 차지하므로, 이런 파라미터 하나가 인자 레지스터 slot 하나를 소비합니다. System V x86_64 ABI는 처음 여섯 개의 integer/pointer 인자를 레지스터(rdi, rsi, rdx, rcx, r8, r9)로 전달하고, ARM64 AAPCS는 처음 여덟 개(x0–x7)를 레지스터로 전달합니다. 이 예산을 넘어서는 인자는 caller frame 하단에 caller가 할당한 outgoing-argument 영역으로 들어갑니다.

pokemaxFrameExtentForSlowPathCall. poke는 값을 [sp + offset]에 기록하는 MacroAssembler idiom으로, JIT call helper가 stack으로 전달되는 인자를 배치할 때 사용합니다. maxFrameExtentForSlowPathCall은 code generator가 runtime-call 인자를 위해 frame 아래쪽에 얼마나 stack을 예약해두는지를 나타내는 컴파일 타임 상수이며, DFG는 이 상수를 기준으로 frame 크기를 결정합니다.

Spill slot. DFG는 레지스터에 다 담기지 않는 값들을 위해 stack frame의 고정된 영역을 할당합니다. flushRegisters()는 runtime call 이전에 살아있는 모든 레지스터 상주 값을 각자의 spill slot에 기록하며, 호출 이후에는 그 slot에서 값을 다시 읽어옵니다. 이때 컴파일러가 증명해둔 타입이 그대로 유지된다는 전제가 깔려 있습니다.

Scratch buffer와 conservative scanning. vm().scratchBufferForSize(n)은 VM 단위로 존재하는, frame 밖의 scratch 영역을 반환합니다. JIT 코드는 이 영역을 이용해 대량의 데이터를 runtime operation에 넘길 수 있습니다. ActiveScratchBufferScope는 scratch buffer와 그중 몇 개의 slot이 live 상태인지를 표시하는 역할을 하며, 그 scope가 유지되는 동안 collector가 이를 root로 scan하도록 합니다. 한편 JSC의 collector는 native stack을 conservative하게 scan하므로, C++ local 변수에 담긴 JSValue는 별도의 등록 없이도 root로 취급됩니다.

varArgChild 노드. 가변 개수의 operand를 갖는 DFG 노드는 자식들을 side table에 저장하며, m_graph.varArgChild(node, i)로 조회합니다. ObjectDefinePropertyFromFields는 target, key, 그리고 여섯 개의 descriptor slot(enumerable, configurable, value, writable, get, set)까지 총 여덟 개의 자식을 갖습니다. 값이 없는 필드는 JSConstant(empty)로 표현됩니다. 각 slot의 인덱스는 기존에 존재하던 Node::EnumerableSlot/ConfigurableSlot/ValueSlot/WritableSlot/GetSlot/SetSlot 상수로 이름 붙여지고, 개수는 Node::numberOfDescriptorSlots로 세어집니다.

JSValueOperand / use(). DFG 자식 노드를 레지스터로 materialize하는 RAII helper입니다. use()는 operand가 처리되고 나면 레지스터를 해제하고, noResult(node, UseChildrenCalledExplicitly)는 register allocator에게 해당 노드가 이미 자식들의 use를 직접 처리했다는 사실을 알려줍니다.

이 operation은 JSGlobalObject*, JSObject*, 그리고 일곱 개의 EncodedJSValue(key와 여섯 개의 descriptor 필드)까지, 총 아홉 개의 machine-word 인자로 선언되어 있었습니다. 이 시그니처는 컴파일 자체에는 아무 문제가 없었습니다. "모든 runtime-call 인자가 레지스터에 들어간다"는 규칙은 컴파일러가 강제하는 것이 아니라 관례로만 지켜지고 있었기 때문입니다. callOperation이 남는 인자를 [sp + 0]poke했을 때, 이 주소는 예약된 outgoing-argument 영역을 가리키지 않았습니다. maxFrameExtentForSlowPathCall == 0인 상황에서는 그런 영역 자체가 존재하지 않기 때문입니다. 대신 이 주소는 DFG frame 자신의 spill 영역을 가리키고 있었습니다.

이 문제가 단순한 stack 관리 실수가 아니라 memory-safety 버그가 되는 이유는 flushRegisters()와 poke 사이의 순서 때문입니다. flushRegisters()는 살아있는 모든 DFG 값을 compiled frame의 spill slot에 기록합니다. 그 직후 callOperation이 남는 인자를 [sp + 0]에 poke하면서, 방금 flush된 바로 그 frame의 가장 낮은 slot과 겹치게 됩니다. Poke되는 값은 JavaScript 소스의 property-descriptor 객체 리터럴에서 그대로 가져온 raw EncodedJSValue입니다. 이 값은 operation이 실행되기도 전에 기록됩니다. 즉 get/set/writable 필드에 무엇이 들어 있는지에 대한 어떤 검증도 거치기 전입니다. 호출이 반환된 뒤 compiled 코드는 aliasing된 spill slot을 다시 읽으면서도, 그 자리에 원래 spill해두었던 값에 대해 증명했던 타입을 여전히 신뢰합니다.

FTL 경로에서도 동일한 방식으로 spill slot이 aliasing되었는지는 제공된 context만으로는 확인되지 않습니다. B3/Air는 call을 lowering할 때 자체적으로 outgoing-argument 영역을 할당하기 때문에, 예산을 초과한 시그니처는 DFG SpeculativeJIT 경로에서만 실제로 발현되는 결함이었을 가능성이 있습니다. 테스트 주석의 "DFG/FTL frame layout bug"라는 표현은 두 tier를 다소 느슨하게 함께 지칭한 것으로 보입니다.

Regression test를 따라가 보면 다음과 같습니다.

  1. opt(input)testLoopCount만큼 반복 실행되며, DFG/FTL로 tier up됩니다.
  2. Object.defineProperty(<function expression>, 'reject', { get: ... })ObjectDefinePropertyFromFields로 lowering되며, target과 key 'reject', 그리고 여섯 개의 descriptor slot까지 총 여덟 개의 자식을 갖습니다. 값이 없는 slot은 JSConstant(empty)로 채워집니다.
  3. 호출 지점에서 flushRegisters()는 이후 a2 + input에서 필요한 input 인자를 포함해, 살아있는 값들을 spill합니다.
  4. callOperation은 ARM64에서 처음 여덟 개의 인자를 x0–x7에 배치하고, 아홉 번째 인자(encodedSetter)를 [sp + 0]에 poke합니다. 이 주소는 가장 낮은 spill slot, 즉 input이 들어 있는 slot과 겹칩니다. 이 한 word짜리 ARM64 케이스가 바로 commit 자체의 테스트 주석이 기록하고 있는 상황입니다.
  5. operation이 반환된 뒤 a2 + input은 손상된 slot을 다시 읽어 ValueAdd에 전달합니다. 그 자리에 타입이 잘못된 word가 들어 있었기 때문에, 실행은 결국 operationValueAddNotNumber에서 crash로 이어졌습니다.

SysV의 integer 예산이 여섯 개인 x86_64에서는, 인자 하나가 아니라 세 개(encodedWritable, encodedGetter, encodedSetter)가 stack으로 넘어가게 되므로, aliasing되는 영역도 그만큼 넓어질 것으로 추정됩니다. 다만 이 시그니처에 대해 JSC가 실제로 사용하는 정확한 poke offset은 제공된 context에 포함되어 있지 않아, 이 부분은 ABI 수준에서의 추론에 해당합니다. 테스트에 등장하는 이상한 fuzzer 산물들 — .prototype에 대한 &&=, lookbehind 정규식, numeric separator — 은 호출 전후로 어떤 값이 살아있는지, 그래서 어떤 slot이 aliasing되는지를 조정하기 위한 장치일 뿐, 결함 자체에 본질적인 요소는 아닙니다.

조건부로 정리하면, weaponization의 방향은 다음과 같습니다.

Poke되는 word들은 descriptor의 set/get/writable 필드가 평범한 JSValueOperand로 lowering된 결과이며, 이 poke는 operation이 해당 필드들을 검증하기 전에 일어납니다. 따라서 공격자는 aliasing되는 slot에 어떤 JSValue가 들어갈지 선택할 수 있습니다. Double 리터럴, 객체 참조, undefined 등을 넣을 수 있어 64비트 word에 대한 영향력이 상당히 넓습니다. 다만 완전히 임의는 아니며, JSC의 NaN-boxing 인코딩이 허용하는 범위로 제한됩니다.

이때 공격자가 주변 함수를 조작한다고 가정할 수 있습니다. 목표는 가장 낮은 spill slot에 컴파일러가 타입을 증명해둔 값을 넣는 것입니다. 예를 들어 호출 전에 structure가 확인된 cell, 혹은 unboxed Double/Int32 값이 해당됩니다. 그러면 호출 이후의 reload는 증명된 타입 하에서 공격자의 word를 그대로 사용하게 되며, 이는 최적화된 코드에서의 type confusion에 해당합니다.

이 confusion이 controlled integer와 cell 타입 slot을 짝짓는 방향(또는 그 반대 방향)으로 성립한다면, 전형적인 addrof/fakeobj 쌍을 얻을 가능성이 있습니다. Heap grooming과 결합하면, renderer 안에서 arbitrary read/write로까지 발전할 가능성도 있습니다.

다만 이를 실현하려면 몇 가지 조건이 충족되어야 합니다. 먼저 compiled frame에서 어떤 spill slot이 [sp + 0]에 대응하는지 알아야 합니다. 소스 함수의 형태를 반복적으로 바꿔가며 이를 추론하는 것이 이론적으로는 가능해 보이지만, 이 매핑이 컴파일마다 얼마나 안정적으로 유지되는지는 제공된 context로는 확인되지 않습니다. 또한 Object.defineProperty 호출 전 구간에서 타입이 증명된 값을 해당 slot에 밀어 넣어야 하고, poke 이후 이어지는 descriptor 검증(도중에 throw가 발생할 수 있음)을 통과해야 합니다. 이런 조건들이 충족되지 않는 경우에는, 테스트가 재현하는 것과 같은 corruption 기반 crash로만 관찰됩니다.

이 vulnerability는 WebContent renderer 내부의 memory-type safety를 약화시킵니다. Runtime call 이후 reload되는 spill 값이 컴파일러가 증명한 타입을 계속 유지한다는 DFG/FTL의 계약이 깨진 셈입니다. Web-content JavaScript가 실행 중인 optimized frame의 살아있는 spill slot 위에 원하는 EncodedJSValue를 아무 검증 없이 올려놓을 수 있기 때문입니다. Corrupting word에 대한 제어력은 넓지만 완전히 임의는 아닙니다. 도달 가능한 bit pattern은 NaN-boxing이 허용하는 범위, 즉 DoubleEncodeOffset을 갖는 double이나 tagged pointer 형태의 cell로 한정됩니다. 최소한으로 보더라도, 이는 JIT에서 매번 재현 가능하고 원격에서 유발할 수 있는 memory-corruption crash에 해당합니다.

특히 눈에 띄는 부분은 이 실패 양상이 매우 조용하다는 점입니다. 인자 개수가 많은 새 DFG 노드는 컴파일 단계에서는 아무 문제도 드러내지 않았고, runtime에서, 그것도 문제를 일으킨 호출과는 거리가 먼 frame에서만 오작동했습니다. 주요 64비트 타겟 두 곳에서 maxFrameExtentForSlowPathCall == 0이라는 점은, 다른 상황이었다면 실수를 흡수해주었을 안전 여유분이 이 경우에는 애초에 존재하지 않았다는 뜻입니다. 이는 callOperation/vmCall 템플릿 단계에서, operation 시그니처의 machine-word 인자 개수가 플랫폼의 레지스터 예산 안에 들어오는지 컴파일 타임에 assert해야 한다는 근거가 됩니다. 이때 32비트에서는 EncodedJSValue를 두 slot으로 계산해야 합니다. 또한 이 crash가 전혀 관련 없는 operation인 operationValueAddNotNumber에서 드러났다는 점도 짚어둘 만합니다. JIT의 frame-layout 버그는 문제가 발생한 지점이 아니라, 손상된 값이 실제로 소비되는 지점에서 triage 근거를 남기는 경향이 있습니다.

항목별 audit-direction 부분을 한국어로 번역하겠습니다.