[2] [JSC] Check initial object structure in tryEnsureAbsence in DFG
The DFG certified a property absent from an object that owned it outright.
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
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가 놓치는 바로 그 구성입니다. 흐름을 따라가면 다음과 같습니다.
thenable을 ownthengetter와 함께 만들고,array = { 0: 1.1 }을 만듭니다. 이 array의 indexed storage를 컴파일러가 특화 대상으로 삼게 됩니다.opt안에서, 컴파일러가 effect-free라고 믿는 연산 이전에array[0]을 읽고, 이어서Promise.resolve(+tmp.toJSON === 1 ? thenable : promise)를 호출합니다.tmp는flags & 1에 따라object1(toJSON: 1)과object2(toJSON: {}) 사이를 오가며,+tmp.toJSON지점의 polymorphic profile을 형성합니다.flags를 바꿔가며opt를 200회 실행해, 각 지점이 의도된 profile을 가진 채 DFG 컴파일 대상이 되도록 합니다.trigger를 뒤집어, 마지막 호출에서thengetter가array[0] = {}를 실행하도록 만듭니다. 이는 컴파일된 코드가 "아무 일도 일어날 수 없다"고 믿었던 지점에서 객체의 element representation을 바꿔버리는 store입니다.- 이어지는
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에 해당합니다.
Insight
이 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로 통합해 처리하도록 한 것은 구조적으로 올바른 수정입니다. 두 경로가 다시 어긋날 여지 자체를 없앴기 때문입니다.
Audit directions
- 생성된 chain은 검증하면서 정작 그 root가 되는 seed는 건너뛰는 proof 패턴. 여기서 지켜야 할 invariant는 검증이 결론이 의존하는 모든 대상을 기준으로 이루어져야 하며, generator가 무엇을 출력했는지에 좌우되어서는 안 된다는 것입니다. 좁게 보면:
Source/JavaScriptCore안에서generateConditionsForPropertyMiss,generateConditionsForPropertyMissConcurrently,generateConditionsForPrototypePropertyHit, 그리고ObjectPropertyConditionSet.cpp의 다른generateConditionsFor*factory 호출부를 검색해, 각 caller가 전달한headStructure자체를 검증하는지, 아니면 반환된 set 안의 객체만 검증하는지 확인해야 합니다. 매치 패턴은headStructure를 계산해 generator에 넘긴 뒤for (auto& condition : result)로 structure 검사를 반복하면서,headStructure자체에는 loop 안팎 어디에도 검사를 적용하지 않는 형태입니다. 넓게 보면: iteration을 위해 seed를 넣고 그 iterate된 결과만 검증하는 동일한 패턴이 WebKit 다른 곳에도 있을 수 있습니다.InlineCacheCompiler.cpp의 inline-cache condition 생성,GetByStatus/InByStatus/PutByStatus의 chain 구성, 그리고structure->storedPrototype()에서 시작해 구조적으로 receiver를 건너뛰는 prototype-walk loop들이 해당합니다. 이때의 매치 패턴은 induction의 시작점이 검사 대상 객체보다 한 단계 지나쳐 있는 loop입니다. 가장 넓게 보면: 이것은 inductive proof의 head에서 발생하는 일반적인 off-by-one class에 해당하며, inheritance/delegation-chain을 최적화하는 어떤 엔진에서도(V8의 prototype validity cell, SpiderMonkey의 shape 및 megamorphic-miss guard) 동일하게 나타날 수 있고, 자신의 own entry는 다른 곳에서 처리되었다고 가정한 채 inherited ACL entry만 검사하는 policy engine에서도 나타날 수 있습니다. 이 관점은 다른 codebase에도 그대로 적용할 수 있습니다. "P가 X와 X가 위임하는 모든 대상에 대해 성립한다"는 형태로 proof가 서술되어 있다면, 실제 코드 경로가 X 자체를 evaluate하는지를 확인해야 합니다.
이 코드가 있는 위치가 아니라 번역 작업이므로 바로 진행하겠습니다.
-
부재 증명을 effect 증명으로 사용하는 패턴. 컴파일러는 "이 hook property가 존재하지 않는다"는 사실로부터 "여기서는 user code가 실행될 수 없다"는 결론을 내립니다. 이 방식이 위험한 이유는, 실패했을 때 나타나는 결과가 단순히 잘못된 값이 아니라 잘못된 side-effect 모델이라는 데 있습니다. 그래서 해당 연산 전체에 걸쳐 캐시된 abstract-interpreter의 모든 fact가 한꺼번에 의심스러워집니다. Narrow:
DFGByteCodeParser.cpp와DFGAbstractInterpreterInlines.h에서Graph::tryEnsureAbsence를 소비하는 모든 DFG consumer를 추적하고, 어떤 identifier(then,toJSON,Symbol.iterator,valueOf,toString)의 부재가 증명되는지, 그리고 각 증명이 어떤 clobber 동작을 억제하는지 나열해야 합니다. Match tell은 유효한ObjectPropertyConditionSet이 단순히GetById를undefined로 접는 데 그치지 않고,clobberWorld()/clobberStructures()자체를 건너뛰게 만드는 지점입니다. Wider: 같은 계열의 문제는 부정적 fact가 barrier 생략을 정당화하는 모든 optimization에서 나타납니다 — intrinsic 인식에 의존하는clobberize.h의 effect 선언, "chain 상에 getter/setter가 없다"는 inline-cache fast path, 그리고 prototype이 변경되지 않았다고 가정하는 builtin(ArrayPrototype,RegExpPrototype)의 watchpoint 기반 fast path가 이에 해당합니다. 이때 tell은 watchpoint 집합을 조건으로 하는 fast path인데, 그 실패 모드가 "잘못된 값"이 아니라 "user code가 실행된다"는 형태로 나타나는 경우입니다. Widest: negative capability 증명은 그 증명을 구성하는 가장 약한 enumeration 단계만큼만 강력하며, 이 증명이 실패했을 때의 결과는 임의의 re-entrancy로 이어집니다. Prototype check에 의존하는 Turbofan side-effect 모델링부터, "등록된 trigger가 없다"는 이유로 transaction을 생략하는 ORM lazy-loading 경로에 이르기까지, "등록된 handler가 없다"는 사실로 re-entrancy barrier를 생략하는 모든 지점을 점검할 필요가 있습니다. -
모두를 만족시켜야 하는 여러 객체에 structure-shape predicate가 비대칭적으로 적용되는 패턴. 여기서 지켜야 할 invariant는, 증명이 N개의 객체를 대상으로 한다면 모든 guard가 이상적으로는 하나의 공유 predicate를 통해 N개 전부에 동일하게 적용되어야 한다는 것입니다. Narrow: DFG와 FTL 전반에서
hasPolyProto(),overridesGetOwnPropertySlot(),propertyAccessesAreCacheable(),propertyAccessesAreCacheableForAbsence()사용처를 grep하고, 특정 structure에 대해 이 네 가지 중 일부만 호출하는 함수나, 같은 증명 안의 sibling 객체에 더 짧은 체크 목록만 적용하는 함수를 찾아야 합니다. Match tell은 같은 함수 안에 서로 다른 체크 집합을 가진 두 개의 structure-check 블록이 존재하는 경우입니다. 이 패치가 도입한isAbsenceCacheable같은 하나의 공유 helper나 lambda로 통합되어 있지 않다는 뜻입니다. Wider: 동일한 비대칭 문제는 validation predicate가 여러 call site에 각각 inline되고 하나로 factor out되지 않은 모든 곳에서 재현됩니다 — handler마다 반복되는 IPC message validation, encode/decode 경로마다 중복되는 sanitizer check가 그 예입니다. 코드 검색 결과에서의 tell은 동일한 3~5줄짜리 체크 시퀀스가 약간의 변형을 거친 채 여러 번 나타나는 패턴입니다. Widest: 중복된 validation은 서서히 어긋나지만, 공유된 validation은 그럴 수 없습니다. 이 대상이 안전한지 판단하는 함수가 정확히 하나만 존재하는지, 그리고 모든 경로가 그 함수를 거치는지 확인할 필요가 있습니다. -
새로 hoist된 head-structure check 자체의 concurrency-safety 검증.
isAbsenceCacheable은 이제 DFG compiler thread에서getConcurrently를 통해headStructure에 대해 호출되며, 이 시점은 watchpoint를 설치할 condition set이 아직 존재하지 않는 단계입니다. 이 함수가 읽는 모든 필드 —typeInfo(),getConcurrently를 통한 property table, poly-proto bit — 가 동시에 변형될 수 있는 mutator thread에 대해 안정적으로 보호되는지, 혹은 lock으로 보호되는지 살펴볼 필요가 있습니다. 또한ObjectPropertyConditionSet.cpp의 후속structuresEnsureValidity()/isStillValid(Concurrency::ConcurrentThread)재검증이 head structure까지 다시 커버하는지, 아니면 여전히 chain 객체만 커버하는지도 확인해야 합니다. 이 부분은 단순한 grep이 아니라 concurrent-compilation 메모리 모델에 대한 추론을 요구합니다. Match tell은 mutator-side transition이 무효화할 수 있는 대응 condition이나 watchpoint 없이, compile-thread에서의 structure read 결과가 곧바로 code generation을 정당화하는 모든 지점입니다.