[3] [JSC] Move DataView null vector check in IC outside of register save/restore
An IC exit undid a stack adjustment that had never happened.
High. Script로 유발 가능한 cold exit path 하나가, live frame data보다 위로 올라간 stack pointer와 caller 자신의 frame에서 다시 채워진 register 상태를 그대로 안은 채 컴파일된 JavaScript로 복귀합니다. 이때 확장 가능성은, 잘못 채워진 slot에 들어 있는 64비트 word들을 script가 얼마나 통제할 수 있는지에 달려 있습니다.
JIT가 만들어내는 machine-code stub은 무엇보다 하나의 규약을 지켜야 합니다. 모든 exit path는 진입했을 때와 정확히 동일한 stack pointer와 register 내용을 유지한 채 caller로 복귀해야 한다는 규약입니다. JSC의 inline cache가 바로 이런 stub에 해당합니다. 특정 object shape에 대한 property 연산을 처리하는 작고 특화된 코드 조각입니다. Stub이 call site에서 비어 있는 register보다 더 많은 scratch register를 필요로 하면, 현재 사용 중인 register를 빌려 prologue에서 스택에 spill하고 epilogue에서 다시 채워 넣습니다. 이때 prologue와 epilogue는 한 쌍으로 짝을 이룹니다. Prologue가 stack pointer를 낮추고 방금 만든 공간에 값을 저장하면, epilogue는 그 공간에서 값을 다시 읽어 들이고 pointer를 원래대로 올립니다.
관전 포인트: 어떤 페이지든 hot한 polymorphic byteLength read 뒤에서 resizable ArrayBuffer를 detach시켜, JIT로 컴파일된 JavaScript가 동기화가 깨진 stack pointer와 자기 자신의 live frame에서 뽑아낸 word로 채워진 register를 그대로 안은 채 계속 실행되도록 유도할 수 있습니다.
Commit message는 이 구조를 정확히 짚고 있습니다.
In the DataView byteLength getter IC, there is a null vector check which executes before a
preserveReusedRegistersByPushing, but jumps to a point beforerestoreReusedRegistersByPopping. In other words, causing a misbalanced push and pop of register state.This PR fixes by making the null vector check jump to after the popping of saved registers.
Source/JavaScriptCore/bytecode/InlineCacheCompiler.cpp
JSTests/stress/dataview-bytelength-ic-stub-stack-desync.js
Patch Details
이번 변경은 InlineCacheCompiler::emitIntrinsicGetter의 USE(JSVALUE64) 분기 중에서도, isResizableOrGrowableSharedTypedArrayIncludingDataView(accessCase.structure()->classInfoForCells())가 성립하는 경로에 국한됩니다. 기존에는 하나의 failAndIgnore JumpList가 스텁의 stack-depth 타임라인 상 서로 다른 두 시점에서 생성된 side-exit 분기들을 함께 모으고 있었습니다. 하나는 allocator.preserveReusedRegistersByPushing(...) 이전, 즉 함수 앞쪽에서 생성되는 guard로, 패치의 주석 자체가 이를 null-vector guard로 명시합니다. 다른 하나는 push 이후에 jit.loadDataViewByteLength(...)가 만들어내는 outOfBounds 분기입니다. 두 분기 모두 allocator.restoreReusedRegistersByPopping(jit, preservedState)를 실행한 뒤 m_failAndIgnore로 점프하는 공통 label에 연결되어 있었습니다.
패치는 이 두 시점을 분리합니다. push 이전의 failAndIgnore는 restore 과정 없이 곧바로 m_failAndIgnore에 append되고, 새로 추가된 로컬 CCallHelpers::JumpList postPushFailAndIgnore가 outOfBounds 분기만을 수집합니다. pop 후 jump하는 epilogue에는 이 postPushFailAndIgnore만 연결됩니다. 마지막의 if (allocator.didReuseRegisters() && !failAndIgnore.empty()) / else m_failAndIgnore.append(failAndIgnore) 쌍도 postPushFailAndIgnore를 대상으로 동작하도록 재작성되었습니다. 아울러 register pressure가 높은 상황에서 polymorphic byteLength IC를 구성한 뒤 ArrayBuffer.prototype.transfer()로 버퍼를 detach하는 regression test가 추가되었습니다.
서로 다른 두 stack-depth 시점에서 생성된 branch target들이 하나의 jump list로 합쳐지면서, prologue의 stack 조정이 실행되기 전에 취해진 side exit이 그 조정을 되돌리는 epilogue를 실행하게 되는 패턴입니다.
Background
Inline cache. JSC는 property 연산을 특정 object structure에 특화된 작은 machine-code 스텁 형태로 캐싱합니다. InlineCacheCompiler::emitIntrinsicGetter는 DataView.prototype.byteLength처럼 intrinsic으로 구현된 getter의 스텁 본문을 생성합니다.
Polymorphic IC. 하나의 call site가 여러 object shape을 관찰하게 되면, IC는 여러 AccessCase를 체인 형태로 보유하게 되고, 스텁 생성 시점에 caller의 레지스터가 더 많이 live 상태로 표시됩니다.
ScratchRegisterAllocator. 스텁 내부에서 쓰이는 로컬 register allocator입니다. allocateScratchGPR()는 먼저 lock되지 않고 call site에서 live하지도 않은 레지스터를 우선 배정하며, 남은 게 없으면 live 레지스터를 배정하고 m_numberOfReusedRegisters를 증가시킵니다. 이때 didReuseRegisters()는 true를 반환하게 됩니다.
preserveReusedRegistersByPushing / restoreReusedRegistersByPopping. 빌려온 레지스터를 위한 한 쌍의 prologue/epilogue입니다. prologue는 stack pointer에서 반올림된 byte 수를 빼고, 낮아진 stack pointer 기준 양수 offset 위치에 재사용 레지스터들을 저장하며, numberOfBytesPreserved를 담은 PreservedState를 반환합니다. epilogue는 동일한 stack-pointer 상대 offset에서 해당 레지스터들을 다시 로드하고 byte 수만큼 stack pointer를 되돌립니다. didReuseRegisters()가 false인 경우 둘 다 아무 동작도 하지 않습니다.
JumpList와 link. 대기 중인 forward branch들을 모아두는 assembler 레벨의 리스트입니다. list.link(&jit)는 리스트 내 모든 branch를 assembler의 현재 코드 위치에 바인딩하므로, 한 리스트에 속한 모든 branch는 하나의 목적지를 공유하게 됩니다. 중요한 점은, JumpList는 각 branch가 명령어 스트림 상 어디서 생성되었는지만 기록할 뿐, 그 branch가 생성된 시점의 stack depth 정보는 전혀 담고 있지 않다는 것입니다.
failAndIgnore vs succeed(). 스텁은 성공 continuation을 통해 빠져나가거나, 실패 경로를 통해 빠져나갑니다. m_failAndIgnore는 이 스텁을 포기하고 generic slow path로 fallback하는 branch들을 compiler 레벨에서 모아둔 컬렉션입니다.
Resizable ArrayBuffer와 transfer(). new ArrayBuffer(n, { maxByteLength: m })는 resizable buffer를 생성하며, 이를 대상으로 하는 view는 isResizableOrGrowableSharedTypedArrayIncludingDataView 코드 경로를 타게 됩니다. 이 경로에서는 길이를 loadDataViewByteLength로 런타임에 계산해야 합니다. ArrayBuffer.prototype.transfer()는 버퍼를 detach하며, 이후 해당 버퍼를 대상으로 하는 view들은 null backing vector를 갖게 됩니다.
Analysis
이 문제는 JIT stack 및 register state의 desynchronization에 해당합니다. push/pop이 균형을 이루지 못하면서 stack pointer가 오염되고, live 상태인 caller 레지스터가 clobber되는 형태입니다.
Stub timeline (didReuseRegisters() == true)
──────────────────────────────────────────
entry sp = S
null-vector guard emitted here ──────────┐ (epoch A: sp == S)
preserveReusedRegistersByPushing: │
subPtr N, sp -> sp = S - N │
store regs at [sp + off] │
loadDataViewByteLength -> outOfBounds ─┐ │ (epoch B: sp == S - N)
restoreReusedRegistersByPopping (success)│ │
succeed() │ │
▼ ▼
shared label: load regs from [sp + off] <-- with sp == S, reads
addPtr N, sp CALLER'S LIVE FRAME
jump m_failAndIgnore and then sp = S + N
null-vector guard와 outOfBounds 분기는 위 다이어그램에서 서로 다른 epoch에 속해 있음에도, 둘 다 같은 공유 label로 이어졌습니다. IC가 polymorphic 상태이고 call site의 register pressure가 충분히 높아 allocateScratchGPR()가 live 레지스터를 재사용해야 하는 상황이 되면, m_numberOfReusedRegisters는 0이 아니게 되고 didReuseRegisters()는 true가 됩니다. 이 경우 push/pop 쌍 양쪽 모두 실제 코드를 방출합니다. 즉 subPtr와 spill 코드, 그리고 fill 코드와 이에 대응하는 addPtr가 생성됩니다.
버퍼가 detach되어 DataView의 backing vector가 null인 경우, epoch-A guard는 곧바로 epilogue label로 branch합니다. 그러면 대응하는 subPtr가 실행된 적이 없음에도 restore 시퀀스가 실행됩니다. spill slot의 주소는 낮아진 stack pointer 기준 양수 offset([sp + extraBytesAtTopOfStack + offset])으로 지정되어 있으므로, 선행하는 subPtr 없이 fill만 실행하면 entry stack pointer 위쪽 워드를 읽게 됩니다. 이는 원래 저장된 값을 담고 있어야 할 spill 영역이 아니라, caller의 현재 live 상태인 frame 영역 안의 메모리입니다. 결과적으로 재사용된 live 레지스터들은 관련 없는 live-frame 워드로 덮어써지고, 이후 stack pointer는 preservedState.numberOfBytesPreserved만큼 올라가면서 live frame 데이터를 넘어서게 됩니다. 제어 흐름은 m_failAndIgnore를 거쳐 IC의 slow path로, 그리고 다시 컴파일된 JS 함수로 돌아가게 되는데, 이 시점에는 stack pointer가 desynchronized된 상태이면서 동시에 register allocator가 넣지 않은 값을 레지스터가 들고 있는 상태가 됩니다.
트리거를 구성하는 모든 요소는 순수한 JavaScript이며, 추가된 regression test 자체가 동작하는 하나의 예시입니다. 순서대로 살펴보면 다음과 같습니다.
- 루프가
decoy,decoy2,decoy3,dv로hot()을 호출하므로,o.byteLengthcall site의 IC는 네 가지 structure에 걸쳐 polymorphic해지고emitIntrinsicGetter에서의 스텁 컴파일에 도달합니다. - 동시에 live 상태인 32개의
p0..p31로컬 변수는 거의 모든 GPR을 점유하도록 설계되어 있습니다. 이로 인해scratch2GPR에 대한allocateScratchGPR()가 live 레지스터를 가져가야 할 가능성이 높아지고, 그렇게 되면didReuseRegisters()가 true가 되어 prologue가 실제 stack 조정 코드를 방출하게 됩니다. 다만 제공된ScratchRegisterAllocator소스는 이 메커니즘 자체는 보여주지만, 이 테스트가 실제로 reuse case에 도달한다는 것까지 확정해주지는 않습니다. 따라서 이 단계는 테스트 구성으로부터의 추론입니다. dv의 structure는 resizable-buffer 분기를 선택하며, 이 스텁에는 push 이전의 guard와 push 이후의outOfBounds분기가 함께 포함되어 있습니다.ab.transfer()가 버퍼를 detach시켜, DataView의 vector는 null이 됩니다.- 마지막
hot(dv, A, objs)호출이 스텁에 진입하면, push가 일어나기 전에 pre-push guard가 fire되고, 제어 흐름은restoreReusedRegistersByPopping을 실행하는 epilogue로 이어집니다.
즉시 관찰되는 효과는 다음과 같습니다. 재사용된 레지스터들이 entry stack pointer 기준 양수 offset의 워드로부터 다시 채워지는데, 이는 애초에 할당된 적 없는 spill 영역이 아니라 caller의 live frame 영역 내부입니다. 이후 stack pointer가 올라가고, m_failAndIgnore가 컴파일된 함수로 제어를 되돌립니다. 테스트의 p0.marker ... p31.marker 읽기는 바로 이어지는 clobbering을 관찰하기 위한 장치이며, 일반적인 결과는 crash이거나 garbage 값입니다.
이를 더 확장하려면 순서대로 다음 조건들이 필요합니다. (a) fill 시퀀스가 재사용된 레지스터의 offset에서 되읽는 live-frame 워드가, 컴파일된 함수 자신이 해당 frame slot에 보관하는 JS 값을 통해 공격자에 의해 제어될 수 있어야 합니다. 제공된 context에는 preserveRegistersToStackForCall/restoreRegistersFromStackForCall은 포함되어 있지만, 이 offset들이 어느 slot에 대응하는지를 결정하는 frame-layout 모델은 포함되어 있지 않으므로, 제어 가능한 정도는 하나의 projection에 해당합니다. (b) clobber된 레지스터 중 최소 하나는 컴파일된 함수가 live JSValue를 담고 있다고 믿는 레지스터여야 하며, 이는 테스트의 pN 로컬 변수들이 만들어내는 형태와 일치합니다. (c) 주입된 bit pattern이 이를 cell로 역참조하는 use site까지 살아남아야 합니다. 이 세 조건이 모두 성립한다면, 공격자는 fake-JSValue를 주입할 수 있게 되고, 여기서부터 arbitrary read/write를 구축하기 위한 type-confusion primitive로 이어질 가능성이 있습니다. 별도로, 상승한 stack pointer는 같은 함수가 이후에 수행하는 호출에서 callee frame이 caller의 live frame slot과 겹치게 만들 가능성이 있으며, 이는 독립적인 두 번째 corruption 경로가 될 수도 있습니다.
이 버그는 전적으로 WebContent process 내부에 국한됩니다. sandbox 경계는 넘지 않으며, renderer code execution을 확보한 이후에도 별도의 escape가 필요합니다.
발견 시그니처는 무작위 fuzzing보다는 resizable-ArrayBuffer IC 경로에 대한 targeted pattern auditing을 가리킵니다. 스택 조정 epilogue를 건너뛰거나 중복 실행하게 만드는 side exit은, 스텁 emission 코드를 push/pop 균형 관점에서 읽어야 발견할 수 있습니다. 결함이 있는 경로는 polymorphic IC, didReuseRegisters()를 true로 만들 만큼의 register pressure, resizable-buffer 기반 DataView, 그리고 detach된 버퍼라는 조건들이 동시에 성립해야 하기 때문입니다. 테스트는 fuzzer로 축소된 형태가 아니라 신뢰성을 위해 손으로 구성된 형태에 가깝습니다. 64개짜리 decoy 배열 구성, 정확히 32개인 live 로컬 변수, polymorphism을 priming하는 루프 순서, getter를 감싼 try/catch 모두 의도적으로 설계된 것으로 보입니다. resizable ArrayBuffer와 transfer()를 hot polymorphic property read 안에서 함께 방출하는 fuzzer라면 애초에 이 audit을 촉발한 crash를 만들어냈을 가능성이 있습니다.
이 취약점은 핵심적인 JIT invariant를 깨뜨림으로써 WebContent process 내부의 memory-safety 경계를 약화시킵니다. 패치 이전에는 detached buffer로 인한 side exit이 발생하면, 컴파일된 함수는 live frame 데이터를 넘어서까지 상승한 stack pointer 위에서 계속 실행되었습니다. 동시에 JSC의 register allocator가 live JSValue를 담고 있다고 믿는 레지스터들은, 실제로는 caller 자신의 live frame 영역에서 읽어온 워드를 담고 있게 됩니다. 이 잘못된 fill이 읽어오는 frame slot에 원하는 64비트 값을 배치할 수 있는 공격자라면, live JS 변수 자리에 공격자가 제어하는 bit pattern을 대신 채워 넣을 수 있게 됩니다. 이는 fake-object/type-confusion primitive로 이어지는 전형적인 경로이며, 여기서부터 renderer 내에서 arbitrary read/write로 이어질 가능성이 있습니다.
Insight
JumpList는 구조적으로 stack-height를 알지 못합니다. branch는 명령어 스트림 상의 origin만 기록할 뿐, 생성 당시의 stack depth는 기록하지 않습니다. 이 때문에 "하나의 리스트에 두 epoch"라는 실수는 저지르기 쉬우면서도 review 과정에서는 눈에 띄지 않는 종류의 실수가 됩니다. 두 append 호출 지점 사이에 preserveReusedRegistersByPushing이 끼어 있는 채로 코드상 수십 줄 떨어져 있을 수 있기 때문입니다. 손상의 방향성도 주목할 부분입니다. spill slot이 낮아진 stack pointer 기준 양수 offset으로 주소 지정되기 때문에, subPtr를 건너뛰면 stack 아래쪽의 버려진 메모리가 아니라 caller 자신의 live frame을 읽게 됩니다. 즉 두 영역 중 공격자에게 더 가까운 쪽을 읽게 되는 셈입니다. 이번 패치의 실질적인 내용은 변수 이름에 epoch를 인코딩하는 네이밍 컨벤션(postPushFailAndIgnore)에 가깝습니다. 더 강력한 완화책이라면 ScratchRegisterAllocator::PreservedState가 debug-mode용 stack-height 토큰을 함께 들고 다니게 하고, restoreReusedRegistersByPopping이 연결되는 각 predecessor에 대해 이 토큰을 assert하도록 만드는 방식이 될 것입니다.애초에 이런 스텁에 런타임 length 계산을 강제한 것은 resizable/growable ArrayBuffer 지원이며, preserve/restore 블록 전체가 이 경로에서만 존재합니다. 즉 이 함수에서 epoch가 갈라지게 된 원인 자체가 resizable-buffer 기능이라 할 수 있습니다.
Audit directions
-
서로 다른 stack depth에서 생성된 jump가, 다른 depth를 전제로 하는 코드를 가진 label로 함께 연결되는 패턴. 여기서 지켜져야 할 invariant는 하나의 link target으로 합쳐지는 모든 branch는 동일한 stack height에서 생성되어야 한다는 것이며, 이를 지키기 어려운 이유는 assembler
JumpList가 depth 정보를 전혀 담지 않고, 두 append 지점이 코드상 멀리 떨어져 있을 수 있기 때문입니다. 좁은 범위:Source/JavaScriptCore전체에서preserveReusedRegistersByPushing을 검색하고, 각 지점마다 대응하는restoreReusedRegistersByPopping이후에 link되는JumpList가 push보다 위쪽 코드에서도append(...)호출을 받는지 확인해야 합니다.InlineCacheCompiler.cpp내 다른 scratch-allocator 블록들과DFGSpeculativeJIT/FTLLower의ScratchRegisterAllocator사용처가 우선 점검 대상입니다. 넓은 범위: 동일한 유형의 문제는 다른 어떤 stack-adjusting primitive든 공유 exit label과 짝을 이룰 때 나타날 수 있습니다.pushToSave/popToRestore,ScratchBuffersave/restore 시퀀스, snippet emission 주변에서 stack pointer에 직접 가하는 수동subPtr/addPtr, 그리고 frame-shuffle을 가로지르는 OSR-exitJumpList등이 해당됩니다. 좁은 범위에서의 match tell은 push 이전에 선언된 로컬JumpList가 대응하는 pop 이후에link()되는 패턴입니다. 넓은 범위에서의 match tell은, stack-pointer를 복원하는 시퀀스로 시작하는 label이면서 그 predecessor 중 일부가 대응하는 조정 이전에 방출된 경우입니다. 가장 넓은 범위: 이는 "control-flow edge는 target 지점의 abstract stack/frame state와 반드시 일치해야 한다"는 일반적인 invariant이며, basic block 단위로 stack height를 추적하는 모든 codegen 시스템에서 성립합니다. V8의 Liftoff와 SpiderMonkey의 Wasm baseline compiler 모두 branch target에 stack height를 부여하며, LLVM의 stackmap/CFI 메커니즘도 동일한 제약을 인코딩합니다. 이어지는 원칙: branch와 그 target이 stack pointer 아래에 몇 바이트가 live한지에 대해 서로 어긋나 있다면, 그 버그는 드문 경로가 실제로 실행되기 전까지는 조용히 숨어 있게 됩니다. -
JIT fast path의 rare-exit branch 중, stub compilation 이후 foreign JS API 호출로 조건이 뒤집힐 수 있는 지점을 점검할 필요가 있습니다. ArrayBuffer의 detachment, resizing, shrinking이 이런 조건 반전을 유발하는 대표적인 요인입니다. 이 exit들은 cold path에 해당하기 때문에 일반적인 테스트나 대부분의 fuzzing corpus에서는 잘 실행되지 않습니다. 바로 이 지점 때문에 잘못된 epilogue가 review를 통과한 채 남아있을 수 있습니다. 우선
InlineCacheCompiler.cpp에서loadDataViewByteLength와loadTypedArrayByteLength의 호출부, 그리고isResizableOrGrowableSharedTypedArrayIncludingDataView와forResizableTypedArray의 모든 사용처를 살펴보는 것이 출발점입니다. 이후toTypedArrayType에 나열된IndexedResizableTypedArray*Load/Store/Inaccess case로 범위를 넓혀 확인해야 합니다. 다른 지점에서 emit된 guard와 목적지를 공유하는 guard branch, 혹은 success path가 수행하는 cleanup을 건너뛰는 exit가 있다면 이것이 match tell에 해당합니다. -
push/pop balance를 naming convention이 아니라 mechanical하게 검증할 수 있는지 살펴볼 필요가 있습니다.
ScratchRegisterAllocator::PreservedState는 이미numberOfBytesPreserved와extraStackSpaceRequirement를 갖고 있습니다. 따라서 allocator에 debug-only로 monotonically increasing epoch counter를 두고, 생성 시점마다 각Jump에 이 값을 stamp하는 방식을 생각해볼 수 있습니다. 이렇게 하면restoreReusedRegistersByPopping이 자신의 label로 향하는 모든 predecessor가 동일한 epoch를 공유하는지 assert할 수 있게 됩니다.Source/JavaScriptCore/bytecode와Source/JavaScriptCore/jit전체에서ScratchRegisterAllocatorinstantiation 개수를 세어보면, 몇 개의 기존 stub에 annotation이 필요한지 가늠할 수 있습니다. 다만 여기서의 검증은 간단하지 않습니다. 해당 assertion을 포함해 빌드한 뒤 높은 register pressure와 polymorphic IC 조건에서JSTests/stresssuite 전체를 실행해야 하는데, mismatch 자체가 거의 실행되지 않는 rare path에서만 관찰되기 때문입니다. -
Register pressure는 JIT 버그를 증폭시키는 일반적인 요인으로 작동합니다.
didReuseRegisters()가 문제의 prologue/epilogue가 아예 emit되는지 여부를 gate하기 때문에, 동일한 stub이 낮은 register pressure에서는 정상 동작하다가 높은 pressure에서는 corrupt될 수 있습니다.didReuseRegisters()와numberOfReusedRegisters()를 grep한 뒤, 각 guard block에서 guarded pair 중 한쪽만 우회하는 path가 있는지 확인해야 합니다.if (allocator.didReuseRegisters() && ...)의elsearm이 push의 반대편에서 emit된 jump를 처리하고 있다면 이것이 match tell입니다. 가장 넓게 보면, emit되는 코드 형태가 resource-pressure predicate에 의존하는 모든 optimization은 그 predicate의 두 설정 모두에서 rare-exit path가 테스트되어야 합니다 — 이는 JSC뿐 아니라 V8 Turbofan의 spill slot, SpiderMonkey Ion의LStackSlot처리 등 모든 컴파일러의 register allocator에 동일하게 적용되는 원칙입니다. 이어서 살펴볼 지점은, spilling이 발생할 때만 존재하는 path가 무엇이고 지금까지 그 path가 한 번이라도 실행된 적이 있는가입니다.