[2] DFG/FTL `Spread(SetObjectUse)` realm confusion in watchpoint coverage
Instruction selection sank an atomic past memory.grow, onto memory already freed.
High. Optimizer는 watchpoint를 근거로 iterator protocol의 정식 check를 생략합니다. 그런데 cross-realm 상황에서는 이 watchpoint가 엉뚱한 global에 걸립니다. 그 결과 Set.prototype[Symbol.iterator]가 덮어써진 뒤에도 compiled code가 그대로 남아 있을 수 있습니다. Exploit 가능성이 높습니다. 다만 addrof/fakeobj로 확장되려면 clobber model 간의 결합 관계와 structure grooming이 뒷받침되어야 합니다.
DFG/FTL은 WebKit의 speculative JavaScript optimizer입니다. 값의 type을 정적으로 증명한 뒤 fast path를 생성하며, 각 fast path는 watchpoint로 보호됩니다. 전제가 깨지는 순간 watchpoint가 발동해 compiled code를 jettison합니다. Spread(SetObjectUse) fold는 upstream CheckStructure가 원래의 Set structure를 이미 증명한 경우, 실제 isIteratorProtocolFastAndNonObservable check를 건너뜁니다. 문제는 DFG가 realm을 넘나들며 inlining을 수행한다는 점입니다. 그래서 하나의 compiled graph가 여러 JSGlobalObject에 걸칠 수 있습니다. 이때 지켜져야 하는 invariant는 분명합니다. Check 생략을 보호하는 watchpoint는, 생략 여부를 판단할 때 기준이 된 realm과 같은 realm에 속해야 합니다.
관전 포인트: cross-realm으로 inlining된 script는 realm X를 기준으로 판단된 Set spread fast path를 탈 수 있습니다. 그런데 invalidation 구독은 realm Y의 watchpoint에 걸려 있습니다. 이 상태에서 X의 Set.prototype[Symbol.iterator]를 덮어쓰면, abstract interpreter가 effect-free로 모델링한 지점에서 attacker JavaScript가 실행될 수 있습니다.
Commit message: FixupPhase는 Set iterator-protocol watchpoint를 node->child1(), 즉 Set operand에서 resolve한 global에 겁니다. 반면 세 곳의 consumer는 spread 연산 자체의 realm인 node->origin.semantic에서 global을 resolve했습니다. Fix는 세 지점 모두 child1()의 global을 사용하도록 변경했습니다.
Source/JavaScriptCore/dfg/DFGAbstractInterpreterInlines.h
Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp
Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp
Patch Details
이번 diff는 fold 결정을 사용하는 세 consumer를 모두 수정했습니다. 대상은 abstract interpreter(executeEffects), mid-tier(SpeculativeJIT::compileSpread), top tier(LowerDFGToB3::compileSpread)입니다. 이제 각 consumer는 iterator-protocol global을 spread node가 아니라 Set operand node의 semantic origin에서 resolve합니다. 결과적으로 각 backend가 setStructureConcurrently()를 확인하는 기준 realm이, FixupPhase가 실제로 watchpoint를 건 realm과 다시 일치하게 됩니다. 세 지점 모두에 operand realm과 operation realm의 차이를 설명하는 주석도 추가되었습니다.
Watchpoint 적용 범위의 realm/scope confusion: check 생략은 한 global을 기준으로 판단되는데, 이를 무효화하는 watchpoint는 다른 global을 구독하는 패턴.
Background
JIT tiering과 speculation. JSC는 Baseline, DFG(mid-tier), FTL(top tier) JIT를 운용합니다. 상위 tier는 profile 기반 type 정보를 바탕으로 speculation을 수행하고, speculation이 실패하면 Baseline으로 OSR exit합니다. 한편 watchpoint를 사용하면 compiled code는 명시적으로 무효화되기 전까지 특정 조건이 유지된다고 가정할 수 있습니다.
Abstract interpreter. DFG IR node graph를 순회하며 edge를 따라 type과 structure set을 전파하는 pass입니다. "Edge에서 type을 증명한다"는 표현은 정적 분석의 결과를 의미합니다. 덕분에 이후 pass는 runtime check 없이도 값의 형태를 가정할 수 있습니다.
Realm과 CodeOrigin. 각 JSGlobalObject가 하나의 realm에 해당합니다. globalObjectFor(CodeOrigin)은 해당 bytecode 위치에 있는 code의 realm을 반환합니다. 다만 inlining은 다른 realm의 callee를 하나의 graph로 이어 붙입니다. 그래서 node->origin.semantic(연산의 위치)과 node->child1()->origin.semantic(operand를 생성한 위치)이 서로 다른 realm을 가리킬 수 있습니다.
Set iterator-protocol watchpoint. FixupPhase는 Set.prototype[Symbol.iterator]나 관련 protocol 요소가 변경될 때 발동하는 watchpoint를 겁니다. 이 watchpoint가 발동하면 빠른 Set spread 경로에 의존하던 code가 jettison됩니다.
Analysis
Root cause는 realm(scope) confusion입니다. 그 여파로 re-entrant call을 거치는 동안 abstract interpreter의 상태가 stale해지는 문제가 이어집니다.
Fold decided against: realm X (node->origin.semantic — the spread)
Watchpoint armed on: realm Y (child1()'s global — the Set operand)
overwrite Set.prototype[Symbol.iterator] in realm Y ──► no jettison
Spread node의 realm과 operand node의 realm이 다르면 어긋남이 생깁니다. Fold는 realm X의 setStructureConcurrently()를 기준으로 판단되지만, invalidation은 realm Y의 watchpoint를 구독합니다. 이 상태에서는 operand의 realm에서 protocol을 덮어써도 compiled code가 무효화되지 않습니다.
Regression test는 이 상황을 구체적으로 재현합니다. 먼저 operand structure가 realm B의 원래 Set structure와 일치하므로 fold가 적용됩니다. 반면 발동해야 할 watchpoint는 Set을 생성한 node의 realm에 속합니다. 따라서 realm B에서 Set.prototype[Symbol.iterator]를 덮어써도 무효화는 발생하지 않습니다.
이어서 add/add/delete로 Set의 deleted-entry count를 0이 아닌 값으로 만듭니다. 그러면 inline fast path가 operationSpreadSet으로 bail하고, 이 함수는 generic iterator 경로를 실행하면서 attacker의 generator를 호출합니다. 결과적으로 abstract interpreter가 effect-free로 모델링한 지점에서 임의의 JavaScript가 실행됩니다.
Generator는 object o에 accessor를 설치합니다. 바깥 함수는 spread 이전에 o를 load하고 이후에 다시 load합니다. 그래서 두 번째 load 시점에는 o에 대해 증명된 structure/type 정보가 모두 stale 상태입니다. 또한 test는 {a} → accessor transition을 미리 만들어 둡니다. 이렇게 하면 warmup structure가 watchable leaf가 아니게 되므로, Object.defineProperty로 인해 structure-transition watchpoint가 발동하는 일도 막을 수 있습니다.
Abstract interpreter의 가정과 실제 실행이 어긋나므로, JIT soundness가 깨진 상태에 해당합니다. 확장 경로는 o에 대한 stale type 정보를 이용해 type confusion을 구성하는 방향으로 이어집니다. 즉 field slot을 잘못된 type으로 읽게 만드는 방식이며, addrof/fakeobj의 출발점으로 exploit될 가능성이 높습니다. 다만 이 확장은 두 가지 조건에 의존합니다. 하나는 re-entrant call과 그 주변 load 사이의 clobber model 결합 관계이고, 다른 하나는 o에 대한 structure grooming입니다. 어디까지나 예상되는 방향(projected direction)이며, 이번 commit 범위에서 입증된 사항은 아닙니다.
Audit directions
- Watchpoint arming 시 operation realm과 operand realm의 불일치. Watchpoint를 근거로 protocol/structure check를 생략하는 fold라면, watchpoint의 global은 check 대상 값을 가진 바로 그 node에서 resolve해야 합니다.
DFGAbstractInterpreterInlines.h,DFGSpeculativeJIT.cpp,FTLLowerDFGToB3.cpp에 있는 다른SetObjectUse/MapObjectUse/array iterator fold도 점검할 필요가 있습니다. 특히 보호 대상 값은child1()에서 오는데globalObjectFor(node->origin.semantic)을 사용하는 지점이 대상입니다. Code review 단계에서도, iterator-protocol watchpoint 근처의globalObjectFor(...origin.semantic)에는 의도한 realm을 명시하는 주석이 필요합니다. - Upstream
CheckStructure를 근거로 한 check 생략. Structure check가 observability check를 대신하는 지점을 살펴볼 필요가 있습니다. 이때 cross-realm inlining 이후에도, 생략을 뒷받침하는 watchpoint가 다른 realm에 걸릴 수 없는지 확인해야 합니다.