← All reports

[2] [JSC] Check initial object structure in tryEnsureAbsence in DFG

HighJSC DFG JITTypeConfusion

The DFG certified a property absent from an object that owned it outright.

78c04ea

High. Compiler가 어떤 property를 object가 직접 갖고 있음에도 불구하고 부재하다고 증명하도록 만들 수 있습니다. 그 결과 DFG 코드는 실제와 모순되는 effect model을 전제로 실행됩니다. 첨부된 테스트는 이미 조작된 object를 이용한 PoC 형태로 구성되어 있으며, arbitrary read/write로의 확장이 통상적인 다음 단계입니다.

Speculation을 수행하는 JIT compiler는 object shape을 특정하기에 앞서 그에 대한 증명을 거쳐야 합니다. JSC에서 이런 증명 중 하나가 condition set입니다. Condition set은 특정 object들에 대한 일련의 assertion 묶음으로, watchpoint에 의해 뒷받침됩니다. 따라서 어떤 assertion을 무효화하는 structure transition이 발생하면 컴파일된 코드는 그대로 폐기됩니다. 이 assertion 중 하나는 부정형입니다. 즉 이 identifier는 이 object와 그것이 상속하는 모든 대상에 존재하지 않는다는 조건입니다. 이 조건은 단순한 property lookup을 넘어서는 중요한 역할을 하는데, 여러 JS operation이 특정 hook property가 존재할 때만 user code를 다시 호출하기 때문입니다. 여기서 지켜져야 할 invariant는, DFG가 컴파일 대상으로 삼는 condition set이 runtime 상의 실제 object shape에 대해 참이어야 한다는 점입니다. 다시 말해 compiler가 identifier P가 object O에 존재하지 않는다고 증명했다면, O에 대한 P의 runtime lookup은 어떤 것도 찾아낼 수 없어야 합니다.

관전 포인트: renderer script라면 조회 대상 property를 자기 자신에 직접 갖는 object를 만들고, compiler로 하여금 그 property의 부재를 증명하게 한 뒤, 컴파일된 코드가 "아무 일도 일어날 수 없다"고 전제한 바로 그 지점에서 user JavaScript를 실행시킬 수 있습니다.

커밋 메시지에는 다음과 같이 설명되어 있습니다:

DFG에서 tryEnsureAbsence는 현재, property가 계속 부재함을 보장하는 condition을 생성하려는 대상 object 자체의 structure는 검사하지 않습니다. Prototype chain 상에 있는 object들의 structure만 검사합니다. Object 자신이 부재를 확인하려는 바로 그 property를 갖고 있는 경우에는 이 방식이 정확하지 않습니다.

Source/JavaScriptCore/dfg/DFGGraph.cpp

if (!headStructure)
return ObjectPropertyConditionSet::invalid();
 
+ auto isAbsenceCacheable = [&](Structure* structure) {
+ if (structure->typeInfo().overridesGetOwnPropertySlot())
+ return false;
+ if (!structure->propertyAccessesAreCacheable())
+ return false;
+ if (!structure->propertyAccessesAreCacheableForAbsence())
+ return false;
+ unsigned attributes;
+ if (isValidOffset(structure->getConcurrently(identifier.uid(), attributes)))
+ return false;
+ if (structure->hasPolyProto())
+ return false;
+ return true;
+ };
+
+ // generateConditionsForPropertyMissConcurrently는 prototype chain만 순회하므로,
+ // headStructure를 먼저 validate합니다.
+ if (!isAbsenceCacheable(headStructure))
+ return ObjectPropertyConditionSet::invalid();
+
auto result = generateConditionsForPropertyMissConcurrently(globalObject->vm(), globalObject, headStructure, identifier.uid());
if (!result.isValid())
return result;
@@
if (!object)
return ObjectPropertyConditionSet::invalid();
 
- auto* structure = object->structure();
 
- if (structure->typeInfo().overridesGetOwnPropertySlot())
 
- return ObjectPropertyConditionSet::invalid();
 
- ...
 
- unsigned attributes;
 
- PropertyOffset offset = structure->getConcurrently(identifier.uid(), attributes);
 
- if (isValidOffset(offset))
 
- return ObjectPropertyConditionSet::invalid();
 
**Patch Details**
 
이 patch는 `JSC::DFG::Graph::tryEnsureAbsence`를 수정합니다. 기존에는 `headStructure`를 계산한 뒤 곧바로 `generateConditionsForPropertyMissConcurrently()`에 넘기고, 반환된 condition set에 등장하는 객체들 — 즉 prototype chain에 속한 객체들 — 에 대해서만 cacheability를 검증했습니다. 이번 patch는 구조체 단위 검증 로직을 `isAbsenceCacheable(Structure*)`라는 local lambda로 분리했습니다. 이 lambda는 `typeInfo().overridesGetOwnPropertySlot()`, `propertyAccessesAreCacheable()`, `propertyAccessesAreCacheableForAbsence()`, `isValidOffset(structure->getConcurrently(identifier.uid(), attributes))`, `hasPolyProto()`를 확인합니다. 이 lambda는 generator 호출 *이전에* `headStructure`에 먼저 적용되며, 실패 시 `ObjectPropertyConditionSet::invalid()`를 반환하고 빠져나갑니다. 반환된 condition들을 순회하는 loop 역시 동일한 lambda를 `object->structure()`에 대해 호출하도록 재작성되어, 이제 head와 prototype이 완전히 동일한 검증 과정을 거치게 됩니다. 함께 추가된 comment는 그 이유를 다음과 같이 밝히고 있습니다: `generateConditionsForPropertyMissConcurrently only walks the prototype chain, so validate headStructure first.` 이 commit에는 회귀 테스트 `JSTests/stress/dfg-ensure-absence-own-then-property.js`도 함께 추가되었습니다.
 
<mark>Delegation chain의 모든 링크를 검증하면서, 정작 그 증명이 근거로 삼는 root만 빠뜨려서, root 자신이 가진 property가 absent로 증명되는 패턴입니다.</mark>
 
**Background**
 
**이 코드가 있는 위치.** DFG는 JSC의 mid-tier optimizing JIT입니다. 함수는 interpreter와 baseline JIT에서 실행되며 type profile을 수집하고, 충분히 실행되면(테스트의 200회 반복 loop처럼) 그 profile을 바탕으로 DFG가 최적화된 버전을 컴파일합니다. DFG 컴파일은 mutator가 계속 실행 중일 수 있는 background thread에서 진행되며, 이 때문에 여기서 사용하는 여러 API 이름에 `Concurrently`라는 접미사가 붙어 있습니다.
 
**Structure.** JSC에서 모든 객체는 property 배치(이름과 offset)와 prototype 정보를 담은 `Structure`를 가리킵니다. 형태가 같은 객체는 structure를 공유하기 때문에, structure identity 비교는 전체 shape 비교를 대신하는 저비용 방법으로 쓰입니다.
 
**Condition set.** `ObjectPropertyCondition`은 특정 객체에 대한 단정입니다. `Presence`(주어진 offset에 property가 존재), `Absence`(property가 존재하지 않음), `Equivalence` 등이 있습니다. `ObjectPropertyConditionSet`은 이런 condition들을 묶은 것입니다. 컴파일러는 컴파일 시점에 이 set을 검증하고 watchpoint를 설치하므로, condition을 무효화하는 structure transition이 발생하면 컴파일된 코드는 폐기됩니다. 이 condition 관련 로직은 `Source/JavaScriptCore/bytecode/ObjectPropertyConditionSet.cpp`에 있으며, inline-cache 컴파일러와도 공유됩니다.
 
**`generateConditionsForPropertyMissConcurrently`.** `(vm, globalObject, headStructure, uid)`를 인자로 받아, `headStructure`에서 시작한 `uid` 조회가 miss함을 증명하는 condition set을 만듭니다. 이때 prototype chain을 순회하며 각 prototype 객체마다 `Absence` condition을 생성합니다.
 
**Lambda가 확인하는 structure predicate들.** `Structure::getConcurrently(uid, attributes)`는 thread-safe한 조회 함수로, 해당 structure에서 `uid`가 위치한 `PropertyOffset`을 반환합니다. 이 결과에 `isValidOffset()`을 적용하면 그 structure가 own property로 해당 property를 갖고 있는지 알 수 있습니다. `propertyAccessesAreCacheable()`와 `propertyAccessesAreCacheableForAbsence()`는 각각 positive/negative property lookup을 이 shape에서 안전하게 캐싱할 수 있는지를 나타내며, dictionary structure 같은 특수한 shape는 이를 허용하지 않습니다. `overridesGetOwnPropertySlot()`은 `TypeInfo` flag로, 해당 class가 C++에서 custom property lookup을 구현하고 있어 structure의 property table만으로는 전체 그림이 되지 않는다는 것을 나타냅니다. *Poly-proto*는 prototype이 structure에 인코딩되지 않고 instance에 저장되는 객체 할당 방식이며, `Structure::hasPolyProto()`로 이를 확인할 수 있습니다.
 
**Effect 증명 수단으로서의 property absence.** 여러 JS 연산은 특정 hook property가 존재할 때만 user code를 호출합니다. `Promise.resolve(v)`는 `v.then`을 확인하고, `JSON.stringify`는 `toJSON`을 확인하며, iteration은 `Symbol.iterator`를, coercion은 `valueOf`를 확인합니다. 이런 hook이 absent임을 증명하면, 컴파일러는 주변 연산에 user-visible callback이 없다고 간주할 수 있게 됩니다.
 
**Indexing type.** 정수 키를 가진 property를 저장하는 객체는 butterfly에 이를 담으며, element representation은 indexing type에 인코딩됩니다. 예를 들어 unboxed double인지 boxed `JSValue`인지가 여기 해당하고, JIT는 이 representation을 기준으로 element load/store를 특화합니다.
 
**Analysis**
 
이것은 sound하지 않은 compiler assumption에 해당합니다. 검증 누락으로 인해 DFG가 잘못된 invariant를 증명하게 되고, 그 결과가 이후 misspeculation과 type confusion으로 나타나는 구조입니다.
 

tryEnsureAbsence(headStructure, "then") ─────────────────────────────────────── BEFORE AFTER head ──(never checked)──┐ head ──► isAbsenceCacheable? ──► bail │ own "then" getter │ │ (own "then" found) ▼ │ ▼ Object.prototype ──► checked Object.prototype ──► isAbsenceCacheable? │ │ ▼ │ null └──► set VALID ("then" is absent") └──► compiler: Promise.resolve cannot call user JS ```

다이어그램에 나타나듯이, generateConditionsForPropertyMissConcurrently는 prototype 객체에 대해서만 condition을 생성합니다. Query의 root가 되는 head 객체 자체는 반환된 set 안에 condition으로 등장하지 않습니다. 수정 전 검증 loop는 이 generator의 출력을 순회하는 구조였기 때문에, 가장 중요한 검사인 isValidOffset(structure->getConcurrently(identifier.uid(), attributes)) — "이 structure가 실제로 own property로 해당 property를 갖고 있는가" — 가 head에는 한 번도 적용되지 않았습니다. Base 객체가 조회 대상 identifier를 own property로 가지고 있는 경우, 수정 전 코드는 prototype chain에서는 아무것도 찾지 못한 채 겉보기에 유효한 condition set을 만들어 반환하게 됩니다.

이후 DFG는 "이 identifier는 이 객체에 absent하다"는 전제 하에 컴파일을 진행하며, 이 사실은 컴파일러의 effect model과 fast-path 선택 양쪽 모두를 결정하는 데 사용됩니다. 그러나 실제 runtime에는 해당 property가 존재하고 accessor가 실행되므로, DFG가 생성한 코드는 "아무 effect도 발생하지 않았다"고 가정한 지점에서 user JavaScript가 실행되는 상황을 맞게 됩니다. 그 결과 해당 지점을 가로질러 caching된 abstract-interpreter 상태(structure, array mode, constant 등)가 stale해질 수 있습니다.

hasPolyProto() 누락은 별개의 독립적인 unsoundness입니다. Poly-proto head 객체는 structure가 prototype을 고정하지 않으므로, 해당 structure를 root로 삼은 prototype-chain 증명은 runtime에 실제로 순회되는 chain을 제약하지 못합니다.

회귀 테스트는 정확히 이 own-property 케이스를 겨냥해 구성되어 있습니다. thenable{ x: 1 }__defineGetter__로 own accessor then을 설치한 형태이므로, then은 head 객체의 own property이면서 Object.prototype에는 존재하지 않습니다. 이는 prototype-only walk가 놓치는 바로 그 구성입니다. 흐름을 따라가면 다음과 같습니다.

  1. thenable을 own then getter와 함께 만들고, array = { 0: 1.1 }을 만듭니다. 이 array의 indexed storage를 컴파일러가 특화 대상으로 삼게 됩니다.
  2. opt 안에서, 컴파일러가 effect-free라고 믿는 연산 이전에 array[0]을 읽고, 이어서 Promise.resolve(+tmp.toJSON === 1 ? thenable : promise)를 호출합니다. tmpflags & 1에 따라 object1(toJSON: 1)과 object2(toJSON: {}) 사이를 오가며, +tmp.toJSON 지점의 polymorphic profile을 형성합니다.
  3. flags를 바꿔가며 opt를 200회 실행해, 각 지점이 의도된 profile을 가진 채 DFG 컴파일 대상이 되도록 합니다.
  4. trigger를 뒤집어, 마지막 호출에서 then getter가 array[0] = {}를 실행하도록 만듭니다. 이는 컴파일된 코드가 "아무 일도 일어날 수 없다"고 믿었던 지점에서 객체의 element representation을 바꿔버리는 store입니다.
  5. 이어지는 array[0] = 2.3023e-320이 이전 호출 시점의 specialization이 여전히 유효한 상태로 실행되도록 하고, main의 마지막 array[0].x가 그 결과를 사용하도록 합니다.

Stale specialization이 2.3023e-320의 raw bit를 그 사이 boxed value를 담도록 retype된 butterfly에 그대로 저장하게 되면, 결과 slot에는 attacker가 원하는 bit pattern이 들어가게 됩니다. 그리고 엔진은 이후 이를 cell reference로 취급하게 되는데, 이는 fabricated-object 형태의 전형적인 패턴이며, 이로부터 관례적으로 arbitrary read/write가 구축되는 controlled-address dereference에 해당합니다. Stale abstract state가 다른 어떤 정보가 아니라 array의 element representation이라는 정확한 중간 단계는, 테스트에 사용된 상수와 접근 순서({ 0: 1.1 }, subnormal double, getter의 array[0] = {})로부터 추론한 것입니다. Condition set을 소비하는 DFG node-level fold 자체는 DFGByteCodeParser.cpp에 있는데, 제공된 context는 tryEnsureAbsence 호출 지점 이전에서 잘려 있어 이 부분까지는 확인되지 않습니다.

Reachability는 명확합니다. tryEnsureAbsence는 일반 script의 DFG 컴파일 과정에서 실행되며, 테스트는 noDFG(main)이라는 harness용 hint 외에 특별한 flag 없이 순수 JavaScript만으로 전체 경로를 구동합니다. 이 hint는 버그의 precondition이 아닙니다. Corruption은 WebContent process에 국한됩니다. Renderer 측 메모리 손상에 그치며 privileged 진입점이나 IPC 경로는 관여하지 않으므로, 별도의 sandbox escape가 있어야 한다는 점은 변함없습니다.

발견 경위는 JIT-aware JavaScript fuzzer(Fuzzilli류) 이후 minimization 과정을 거친 것으로 보입니다. 이 테스트에는 fuzzer 산출물을 손으로 축소한 결과물의 특징이 여럿 드러납니다. 특정 code path를 만들기 위해서만 남겨둔 dead 0[0] 표현식, branch polymorphism을 유도하는 flags bitmask, 결과를 쓰지 않고 반복되는 +tmp.toJSON, structure transition을 강제하기 위해서만 존재하는 일회성 Object.create(tmp) 호출, boxed value로 재해석될 때만 의미를 갖는 magic subnormal 상수가 그것입니다. bugs.webkit.org id, 두 개의 rdar 참조, 그리고 out-of-band rapid-response landing을 나타내는 Originally-landed-as ... rapid/safari-...-branch 라인의 조합은 외부에서 제보된 exploit-shaped PoC라는 정황과 부합합니다. 다만 variant analysis 역시 배제할 수 없는 두 번째 가능성입니다. Property-absence와 prototype-chain condition의 soundness는 DFG에서 반복적으로 나타나는 버그 family이며, tryEnsureAbsence에서 head-vs-chain 비대칭을 점검하는 작업은 전형적인 variant hunt의 형태와 일치합니다.

이 vulnerability는 JIT의 soundness guarantee를 약화시키며, 이는 WebContent renderer 내부의 memory-type safety를 떠받치는 기반에 해당합니다. Fix 이전에는, script가 조회 대상 property를 자기 자신 위에 직접 갖는 객체를 만들어도 컴파일러가 그 absence를 인증해버릴 수 있었습니다. 그 결과 DFG가 생성한 코드는 실제와 맞지 않는 effect model과 speculation 집합을 전제로 실행되었습니다. 공격자가 이 불일치를 구체적인 misspeculation으로 전환할 수 있다면 — 이번에 추가된 회귀 테스트가 겨냥하는 바로 그 형태입니다 — renderer 안에서 memory-corruption primitive를 얻을 가능성이 있으며, 이는 arbitrary read/write와 이어지는 code execution으로 나아가는 통상적인 building block에 해당합니다.

이 patch가 추가한 comment 한 줄이 이번 사안의 핵심을 그대로 담고 있습니다: generateConditionsForPropertyMissConcurrently only walks the prototype chain. 이 helper의 계약은 정작 자신이 root로 삼는 객체를 조용히 제외하고 있었고, caller의 검증 loop는 "증명이 의존하는 모든 객체"가 아니라 "helper가 출력한 결과"를 순회하는 구조였습니다. 그 결과 출력에 한 번도 등장하지 않는 바로 그 객체만이 검증에서 빠지게 된 것입니다. 이번 fix가 head structure에 대해 한 번에 네 가지 검사를 되살렸다는 점도 눈여겨볼 부분입니다. Own-property 검사 하나만이 아니라, overridesGetOwnPropertySlot, propertyAccessesAreCacheable, propertyAccessesAreCacheableForAbsence, hasPolyProto 모두 head에는 빠져 있었으므로, 이 중 어느 것이라도 다른 variant에서는 결정적인 gap이 될 수 있었던 셈입니다. Head와 chain을 하나의 공유 isAbsenceCacheable lambda로 통합해 처리하도록 한 것은 구조적으로 올바른 수정입니다. 두 경로가 다시 어긋날 여지 자체를 없앴기 때문입니다.

이 코드가 있는 위치가 아니라 번역 작업이므로 바로 진행하겠습니다.