[2] DFG Object.defineProperty descriptor cell confusion
A hot defineProperty fold kept a shape but dropped the check it was an object.
Severity를 High로 평가한 이유는, fold 과정에서 생략된 check가 바로 "이 구조를 가진 객체"와 "구조 집합을 우연히 공유하는 undefined immediate"를 구분해주는 check이기 때문입니다. 일반 renderer script에서 도달 가능하며, diff만으로도 안정적인 invalid-cell dereference가 성립함을 확인할 수 있습니다. 다만 controlled read로의 확장은 undefined가 아니라 attacker가 영향을 줄 수 있는 immediate가 병합되어야 한다는 조건에 달려 있습니다.
WebKit의 mid-tier JavaScript optimizer는 값의 type을 speculate하고, type을 증명했다고 판단하면 연산을 strength-reduce합니다. 이때 그 rewrite가 speculation을 증명해준 바로 그 check를 함께 없애버리면 특정 유형의 버그가 발생합니다. Hot function의 shape이 알려져 있으면, DFG는 Object.defineProperty를 ObjectDefinePropertyFromFields로 fold합니다. 이는 general runtime을 거치지 않고 descriptor 객체의 필드를 메모리 offset으로 직접 읽는 특수화된 형태입니다. 이 fold는 abstract interpreter에 의존합니다. Abstract interpreter는 각 값을 AbstractValue로 추적하는데, 여기에는 m_type(예를 들어 SpecFinalObject | SpecOther 같은 집합)과 m_structure 집합이 포함됩니다. m_structure는 값의 cell 부분만을 기술하며, direct-offset load는 해당 값이 cell임이 증명된 경우에만 emit된다는 invariant를 전제로 합니다.
관전 포인트: 어떤 renderer script든 때로는 object이고 때로는 undefined인 descriptor 값을 hot한 Object.defineProperty 호출에 흘려 넣을 수 있고, 이 경우 JIT은 그 undefined immediate를 object pointer인 것처럼 dereference하게 됩니다. 이는 안정적으로 재현되는 renderer crash이며, non-object branch에 attacker가 영향을 줄 수 있는 immediate가 실릴 수 있다면 controlled read primitive로도 이어질 가능성이 있습니다.
ObjectDefineProperty를 constant-fold할 때는, 이런 filter들이 계속 동작하도록 node의 edge에 붙어 있는 check들을 함께 emit해야 합니다. 이번 케이스에서는 descriptor의 ObjectUse edge filter가 누락되어 있었습니다. Fix는 node를 rewrite하기 직전에 한 줄을 삽입합니다.
Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp
JSTests/stress/object-define-property-fields-merged-non-object-descriptor.js
Patch Details
ObjectDefineProperty node를 처리하는 ConstantFoldingPhase::foldConstants 케이스에 한 줄이 추가되었습니다. Fold는 원래 AbstractValue::m_structure에서 descriptor의 구조를 읽어와 node를 ObjectDefinePropertyFromFields로 rewrite하고, descriptor의 slot들에 대해 KnownCellUse GetByOffset load를 생성합니다. 이 rewrite 직전에 m_insertionSet.insertCheck(m_graph, indexInBlock, node)가 삽입되었습니다. insertCheck는 node의 edge에 붙어 있는 type-check speculation, 특히 descriptor edge의 ObjectUse filter를 명시적인 Check node로 구체화합니다. 그 결과 원본 node가 교체되더라도 이 check가 함께 사라지지 않고 유지됩니다. 이 commit에는 FinalObject 리터럴과 undefined를 병합하는 Phi 형태의 descriptor 값을 검증하는 regression test도 함께 추가되었습니다.
Compiler fold가 node를 rewrite하면서 edge에 붙어 있던 type speculation을 함께 누락시키고, cell 부분에 대해서만 증명된 structure를 값 전체가 cell이라는 증거로 취급한 패턴입니다.
Background
DFG speculation.
DFG JIT은 speculative type check를 이용해 hot JavaScript function을 컴파일합니다. DFG node 사이의 edge는 useKind를 가지는데, ObjectUse edge는 값이 object가 아니면 OSR-exit(하위 tier로 되돌아감)하는 runtime speculation을 emit합니다. 반면 KnownCellUse edge는 값이 이미 cell임이 증명되었다고 단정하며 아무런 check도 emit하지 않습니다.
The abstract interpreter.
Abstract interpreter는 DFG node graph에 대한 정적 분석입니다. 각 값을 AbstractValue로 추적하는데, 여기에는 SpecFinalObject, SpecOther 같은 SpeculatedType 집합인 m_type과 m_structure 집합이 포함됩니다. 중요한 점은 m_structure가 값의 cell 부분만을 기술한다는 것입니다. SpecOther는 null/undefined immediate를 포괄하는데, 이들은 cell이 아니라 non-pointer boxed 값입니다.
Folding define-property.
ObjectDefineProperty는 Object.defineProperty에 대응하는 DFG node입니다. Descriptor의 shape이 알려져 있으면, 컴파일러는 이를 ObjectDefinePropertyFromFields로 fold하여 descriptor의 value/writable/enumerable/configurable 필드를 offset으로 직접 읽습니다.
insertCheck.
InsertionSet의 insertCheck는 node의 edge에 붙어 있던 speculation을 독립적인 Check node로 다시 구체화합니다. 그 결과 node 자체가 변환되거나 제거되더라도 해당 speculation은 보존됩니다.
Analysis
Root cause는 JIT speculation-check 누락으로 인한 type confusion입니다. Fix 이전에는, fold가 direct KnownCellUse GetByOffset load를 생성하기 위해 descriptor의 m_structure를 참조하면서도 원본 node의 edge에 실려 있던 type-check speculation을 다시 emit하지 않았습니다. 함정은 m_structure가 값의 cell 부분만을 제약한다는 데 있습니다.
Before fix (fold, no insertCheck) After fix (fold, insertCheck)
descriptor AbstractValue: descriptor AbstractValue:
m_structure = {FinalObject} m_structure = {FinalObject}
m_type = SpecFinalObject|SpecOther m_type = SpecFinalObject|SpecOther
-> "has a concrete structure" -> Check(ObjectUse) re-emitted
-> emit KnownCellUse GetByOffset -> non-object path OSR-exits,
(NO runtime speculation) runtime throws TypeError
Descriptor 값이 한쪽 경로에서는 object 리터럴(구체적인 FinalObject structure)이고 다른 경로에서는 undefined인 Phi로 병합되면, 병합된 abstract value는 FinalObject structure를 그대로 유지하면서 m_type은 SpecFinalObject | SpecOther로 넓어집니다. 즉 object임이 증명된 상태가 아닙니다. Fold는 "구체적인 structure를 가진다"는 사실을 "cell/object이다"로 취급하여 KnownCellUse edge를 emit했고, 이 edge는 아무런 runtime speculation도 수행하지 않습니다. 실행 시점에 값이 non-object 경로를 타면, 생성된 코드는 raw undefined immediate를 마치 유효한 cell pointer인 것처럼 취급해 GetByOffset으로 descriptor slot을 읽어들입니다.
Test는 정확히 이 상황을 재현합니다. defineWithMaybeDescriptor는 서로 다른 branch에서 object 리터럴 또는 undefined를 반환하며, hot하게 반복 실행되어 DFG가 이를 컴파일하게 됩니다. Descriptor의 abstract value는 FinalObject structure를 유지하면서 type은 SpecFinalObject | SpecOther가 됩니다. undefined branch에서는 fix 이전 코드가 그 immediate에 대해 직접 KnownCellUse GetByOffset load를 수행하게 됩니다. 이렇게 branch에 따라 병합되는 descriptor shape은 object 값과 non-object 값이 control flow를 넘나들며 병합되는 define-property 호출을 생성하는 Fuzzilli 스타일 경로와도 맞아떨어집니다.
직접 관찰되는 영향은 invalid cell dereference입니다. Web content에서 유발되는 JIT 코드 상의 near-null read로, 안정적으로 재현되는 renderer crash에 해당합니다. Memory-disclosure primitive로의 확장은 예상되는 방향입니다. Non-object branch에 undefined가 아니라 attacker가 영향을 줄 수 있는 immediate(즉 FinalObject structure와 병합된 boxed double이나 int32)가 들어올 수 있다면, 그 값에 대한 GetByOffset load는 attacker가 영향을 준 bit pattern을 cell pointer로 취급하게 되며, 이는 descriptor field offset 기준의 controlled relative/absolute read로 이어질 가능성이 있습니다. 이 모든 동작은 WebContent process 안에서 이루어지며, R/W로의 확장이 성립하더라도 별도의 sandbox escape가 여전히 필요합니다.
이 vulnerability는 renderer 내부의 memory-type safety를 약화시킵니다. KnownCellUse edge는 값이 cell임이 증명된 경우에만 emit된다는 DFG의 계약이 깨졌고, 그 결과 JIT-compiled 코드가 non-object JSValue를 object pointer로 dereference할 수 있는 상태가 되었습니다.
Audit directions
- Structure/shape 제약을 읽은 뒤, 값의 type 집합이 요구되는 concrete type의 부분집합인지 확인하지 않은 채 unchecked(
Known*Use/no-speculation) access를 emit하는 compiler fold. 여기서 성립해야 할 invariant는, 값의 cell 부분에 대한 structure 지식이 그 값 자체가 cell이라는 증거가 되지 않는다는 것입니다. 범위를 좁히면:DFGConstantFoldingPhase.cpp와DFGFixupPhase.cpp에서AbstractValue::m_structure/m_futurePossibleStructure를 읽은 뒤KnownCellUse/KnownObjectUse를 설정하거나 대응하는insertCheck또는speculateObject없이GetByOffset/PutByOffset을 구성하는 지점을 검색해야 합니다. 확인해야 할 패턴은, structure 정보에 접근하면서도 node가 교체되기 전에 그 edge filter를 다시 emit하지 않는 fold입니다. - Node를 교체할 때 edge에 실려 있던 speculation을 함께 누락시키는 node-transforming pass. 여기서 성립해야 할 invariant는, 교체되는 node의 edge에 실려 있던 모든 check가 교체 이전에 반드시 다시 구체화되어야 한다는 것입니다. 범위를 좁히면:
foldConstants안에서 node를 특수화된*FromFields/direct-offset 형태로 변환하는 다른 케이스들을 감사하여, edge를 사용하기 전에 각각m_insertionSet.insertCheck(m_graph, indexInBlock, node)를 호출하는지 확인해야 합니다. 범위를 넓히면: speculate된 node를 lower하거나 strength-reduce하는 다른 DFG/FTL phase도 대상이 됩니다.FTLLowerDFGToB3.cpp와DFGStrengthReductionPhase.cpp에서 upstream edge kind를 재확인 없이 가정하는 lowering이 있는지 확인해야 합니다. 가장 넓게 보면, "optimizer가 연산을 특수화하면서 guard를 버리는" 유형 전반에 해당하며, V8 Turbofan의 node reducer, IonMonkey의 MIR lowering,assume/range fact를 누락시키는 LLVM peephole에도 동일하게 적용 가능합니다. 확인해야 할 패턴은, 값의 일부에 대한 abstract fact를 근거로 값 전체에 대해 no-speculation access를 emit하는 rewrite입니다. - 병합된 type을 통한 확장이 실제로 가능한지 조사해야 합니다.
m_structure가 여전히 구체적인FinalObjectstructure를 유지한 채로 descriptor Phi에 도달할 수 있는,undefined이외의 값(double이나 int32 경로)을 조사해야 합니다. Attacker가 영향을 줄 수 있는 immediate가 병합될 수 있다면, 그 값에 대한GetByOffset은 이번에 확인된 crash보다 더 강력한 primitive에 해당합니다.AbstractValue의SpeculatedType병합 규칙과ObjectDefinePropertyFromFieldslowering부터 살펴보아야 합니다.