[JSC] Implement `String#split` in C++
Component: JSC | 1a01603
Source/JavaScriptCore/builtins/BuiltinNames.h
Source/JavaScriptCore/builtins/StringPrototype.js
Source/JavaScriptCore/runtime/RegExpObjectInlines.h
JSC는 builtin을 두 가지 트랙으로 최적화합니다. 하나는 bytecode로 컴파일되어 범용적으로 JIT되는 self-hosted JS builtin이고, 다른 하나는 type-specialized IR node로 변환되어 C++ 호출을 직접 방출하는 DFG intrinsic입니다. 이번 commit은 String.prototype.split을 첫 번째 트랙에서 두 번째 트랙으로 옮기는 작업으로, 기존 JS builtin을 삭제하고 StringSplit이라는 새로운 DFG node를 도입합니다. 이 node는 separator가 문자열인 경우 operationStringSplit으로, primordial RegExp인 경우 operationStringSplitRegExp로 분기됩니다.
RegExp.prototype[@@split] 호출을 완전히 건너뛰는 최적화는 그 호출이 observable하지 않은 경우에만 안전합니다. 이 때문에 fast path를 보호하는 watchpoint set이 세 개로 늘어났습니다. m_stringSymbolSplitWatchpointSet, m_regExpSpeciesWatchpointSet, 그리고 regExpPrimordialPropertiesWatchpointSet에 추가된 splitSymbol입니다. watchpoint는 일종의 invalidation hook으로, 해당 property에 값이 할당되면 트리거되어 JIT가 deoptimize하거나 slow path로 전환하도록 합니다.
String#split(sep) call
│
[DFG StringSplit node]
│
watchpoints valid?
┌─────┴──────────────────────────┐
yes no
│ │
sep type check slow path
│ (observable @@split)
├─ String ──────────────► operationStringSplit
│
└─ RegExp?
│
isSymbolSplitFastAndNonObservable?
├─ yes ──► operationStringSplitRegExp (C++ fast)
└─ no ──► RegExp.prototype[@@split] (JS, observable)
Significance
split이 많은 workload에서 11-23%의 속도 향상이 있었지만, 보안 관점에서 중요한 부분은 수치가 아니라 fast path의 게이트 로직입니다. 새로 추가된 isSymbolSplitFastAndNonObservable 검사, 세 개의 새로운 watchpoint set, 그리고 새로운 DFG node 경계 — 이 모든 것이 edge case를 만들어내는데, 이는 과거 String#replace의 유사한 C++ 이식 작업에서 나타났던 버그 표면과 겹치는 부분입니다.
Audit directions
세밀하게 점검할 만한 영역은 세 곳입니다.
첫째는 watchpoint invalidation race입니다. m_stringSymbolSplitWatchpointSet과 m_regExpSpeciesWatchpointSet은 이번에 새로 추가되었습니다. fast-path guard를 통과한 이후, 그러나 C++ operation이 완료되기 이전에 둘 중 하나가 트리거될 수 있다면 문제가 됩니다. 혹은 JIT code patching과 이 watchpoint들의 invalidation 순서가 어긋나 있어도 마찬가지입니다. 이 경우 attacker는 recompilation을 트리거하지 않은 채로 @@split이 교체된 regex에 대해 JIT가 동작하도록 만들 수 있습니다. 이는 과거 @@replace의 watchpoint race와 같은 종류의 버그이므로, replace의 이력부터 패턴을 대조해보는 것이 출발점이 됩니다.
둘째는 isSymbolSplitFastAndNonObservable의 structure check입니다. instance별 @@split override는 structure check로 걸러지는데, structure ID는 GC 이후 재사용됩니다. 정교하게 타이밍을 맞춘 allocation 시퀀스로 structure ID 재사용을 유발할 수 있다면, 수정된 RegExp가 이 검사를 통과할 가능성이 있습니다. replace의 구현체와 diff를 떠서 차이가 나는 부분을 우선적으로 살펴볼 필요가 있습니다.
셋째는 separator type dispatch 경계입니다. DFG node는 type speculation을 기준으로 string 경로와 RegExp 경로 중 하나를 선택합니다. separator 객체가 toString과 Symbol.split을 동시에 가지고 있으면 어떻게 되는지가 관건입니다. 또한 separator가 primitive가 아니라 String 객체인 경우도 마찬가지입니다. C++ fast path가 이런 케이스를 예전 JS builtin과 다르게 처리할 가능성이 있는데, 기존 builtin은 spec의 명시적인 typeof 분기를 그대로 따랐습니다. 이 패턴은 self-hosted JS에서 C++ host function으로 이식된 다른 모든 builtin에 동일하게 적용될 수 있습니다. spec의 coercion 순서는 이식 과정에서 놓치기 쉬운 부분이기 때문입니다.