[1] OSR exit scratch buffers were never published to the collector
The collector was never told where the exit stub parked its pointers
High. Exit stub는 곧 덮어쓸 stack에서 살아있는 cell pointer들을 옮겨, collector가 요청이 있을 때만 스캔하는 buffer로 옮겨 담습니다. 문제는 그 요청 자체가 이루어지지 않았다는 점입니다. 이 window 안에 collection이 정확히 들어맞아야 확장이 가능하며, 이는 테스트에 사용된 세 가지 GC-stress 옵션 없이는 성립하기 어렵습니다.
런타임이 살아있는 객체를 가리키는 마지막 pointer를 garbage collector가 들여다보지 않는 곳에 남겨두면 premature collection이 발생합니다. JavaScriptCore에서 이 위험은 주로 OSR exit에 집중되어 있습니다. OSR exit는 speculation이 실패하거나 exception이 unwind될 때, 최적화된 stack frame을 baseline interpreter의 layout으로 다시 써주는 메커니즘입니다. 이동 중인 값들이 임시로 머무는 곳은 ScratchBuffer입니다. 이는 VM이 소유하는 scratch 영역으로, collector가 conservative하게 스캔하기는 하지만 activeLength라는 동반 필드가 살아있다고 선언한 byte 범위에 한해서만 스캔합니다. 이 설계 전체가 기대는 invariant는, JS cell을 유일하게 붙잡고 있을 수 있는 모든 위치가 그 역할을 맡고 있는 동안에는 반드시 collector에 의해 열거 가능해야 한다는 것입니다.
관전 포인트: 함수를 최적화시킨 뒤 강제로 exit를 유발할 수 있는 페이지라면, 살아있는 JS 객체가 그 유일한 참조가 이동 중인 사이에 수거될 수 있습니다. 그 결과 일반 script가 해제된 heap 메모리를 가리키는 pointer를 들고 있게 됩니다.
DFG와 FTL의 OSR exit는 exit 자체에서 stack을 재배치할 때 ScratchBuffer를 사용합니다. Stack이 덮어써지면 ScratchBuffer가 이전에 stack에 있던 pointer들의 유일한 보유자가 될 수 있습니다. 이 buffer들은
activeLength에 따라 GC의 conservative root로 취급되는데, OSR exit는 이 값을 설정하지 않고 있었습니다. 이 PR은activeLength를 설정하여 이를 수정합니다. 원래는305413.448@rapid/safari-7624.2.5.110-branch에 반영되었습니다.
Source/JavaScriptCore/dfg/DFGOSRExit.cpp
Source/JavaScriptCore/ftl/FTLOSRExitCompiler.cpp
JSTests/stress/osr-exit-scratch-buffer-gc.js
Patch Details
이제 두 stub 생성기 모두 자신이 사용하는 scratch buffer의 크기를 명시적으로 게시하도록 수정되었습니다. OSRExit::compileExit에서는 vm.scratchBufferForSize 호출에 있던 크기 계산이 별도의 로컬 변수 scratchBufferSize로 분리되었고, 새로운 기계어 코드 시퀀스 두 개가 추가되었습니다. 하나는 값 저장/복구 구간 이후, stack 재배치 이전 위치에 삽입되어 jit.move(TrustedImmPtr(scratchBuffer->addressOfActiveLength()), regT0)에 이어 jit.storePtr(TrustedImm32(scratchBufferSize), Address(regT0))를 실행합니다. 다른 하나는 spooler.finalizeGPR()가 재구성된 stack에 모든 값을 복원한 뒤, emitRestoreArguments 이전 위치에서 동일한 방식으로 0을 저장하는 대칭 시퀀스입니다. FTL의 compileStub에도 동일한 처리가 적용되었습니다. 다만 크기 계산식이 여러 항으로 이루어져 있어 (values, materializations, materialization 인자 수, requiredScratchMemorySizeInBytes(), callee-save 영역까지) scratchBufferSize로 분리되었고, 게시용 store는 register 보존 stack 조정 이전에, 초기화용 store는 마지막 spooler.finalizeGPR() 이후 handleExitCounts / reifyInlinedCallFrames / adjustAndJumpToTarget 직전에 위치합니다. 두 store 모두 기존에 있던 null 허용 삼항 연산자와 마찬가지로 if (scratchBuffer) 조건 안에 배치되었습니다. 회귀 테스트는 --useConcurrentJIT=0 --useZombieMode=1 --slowPathAllocsBetweenGCs=16 옵션 하에서 실행됩니다.
Stack을 다시 쓰는 도중 값들을 opt-in 스캔 대상인 side buffer로 옮기면서, 그 buffer의 살아있는 범위를 garbage collector에 게시하지 않은 패턴.
Background
이 코드가 있는 위치. JSC는 LLInt, baseline, DFG, FTL 등 여러 tier를 거쳐 JavaScript를 실행하며, 상위 tier들은 profile data를 기반으로 speculative하게 compile을 수행합니다. Speculation이 틀리거나 exception이 최적화된 frame을 통과해 unwind되면, 실행은 하위 tier로 되돌아가야 합니다.
OSR exit.
이 복귀는 단순한 jump가 아닙니다. 물리적인 stack frame이 최적화 tier의 layout으로 되어 있기 때문에 baseline/LLInt layout으로 다시 써야 합니다. 이 재작성은 생성된 기계어 코드가 수행하며, DFG에서는 OSRExit::compileExit, FTL에서는 compileStub가 그 역할을 담당합니다. 재생되는 복구 절차는 ValueRecovery/Operands에서 가져옵니다. 이는 각 bytecode-visible 변수가 최적화된 frame 안 어디에 있는지 — register, stack slot, constant, boxed 또는 unboxed 형식 여부 — 를 기록한 compiler의 자료입니다.
ScratchBuffer와 activeLength.
ScratchBuffer는 vm.scratchBufferForSize(n)을 통해 얻는 VM 레벨의 scratch 영역으로, JIT가 생성한 코드에서 spill 영역으로 사용됩니다. 호출 지점의 scratchBuffer ? ... : nullptr 삼항 연산에서 알 수 있듯, 반환되는 pointer는 null일 수 있습니다. 각 buffer는 activeLength라는 word 하나를 가지고 있으며, JIT 코드에서 addressOfActiveLength()를 통해 접근할 수 있습니다. 이 값은 buffer 앞부분 중 현재 살아있는 데이터를 담고 있는 byte 수를 기록합니다.
Conservative root.
JSC의 collector는 machine register, machine stack, 그리고 각 scratch buffer의 active 영역을 타입 없는 word 배열로 취급하여, heap pointer처럼 보이는 것은 모두 살려둡니다. 이 방식 덕분에 stack 위의 JS 값들은 명시적인 handle 없이도 살아남을 수 있습니다. ActiveScratchBufferScope는 buffer에 값을 담는 코드 주변에서 activeLength를 설정하고 해제하는, C++ 측 RAII helper입니다.
Spooler와 callee save.
emitSaveCalleeSavesFor는 callee-saved register의 내용을 구성 중인 frame에 기록합니다. AssemblyHelpersSpoolers의 finalizeGPR()는 일괄로 모아둔 store를 실행하여 복구된 값들을 재구성된 frame에 다시 써넣습니다.
테스트 옵션과 rope.
--useZombieMode=1은 회수된 cell을 회수 이후에도 식별 가능하게 만들고, --slowPathAllocsBetweenGCs=N은 slow-path allocation N회마다 collection을 강제로 유발합니다. JSC에서 s + s는 보통 lazy한 JSRopeString을 생성하며, 결합된 길이가 최대 문자열 길이를 초과하는 concatenation은 slow path에서 error를 발생시킵니다.
Analysis
이 버그는 GC root 하나가 누락된 형태이며, 그 결과로 나타나는 것은 일반 script가 여전히 참조하고 있는 객체에 대한 use-after-free입니다.
OSR exit stub Collector's view of cell `o`
───────────── ────────────────────────────
save operands -> scratch buffer stack slot: reachable
emitSaveCalleeSavesFor / reshuffle stack slot: OVERWRITTEN
[ window ] scratch buffer: activeLength == 0
-> looks unreachable -> swept
spooler.finalizeGPR() writes back baseline slot: dangling pointer
(post-fix: activeLength = size before (post-fix: buffer scanned across
the window, 0 after) the whole window)
위 그림에 표시된 window 구간에서는, stack slot이 이미 덮어써진 상태이기 때문에 conservative stack scan은 아무것도 찾아내지 못합니다. 동시에 activeLength가 0이므로 scratch-buffer scan 역시 기여하는 바가 없습니다. 그 결과 여전히 살아있는 cell이 garbage로 취급되어 수거되며, 이후 exit stub는 이미 dangling 상태가 된 pointer를 그대로 재구성된 baseline frame에 기록합니다. 실행은 그 상태에서 재개되고, 해제된 cell이 평범한 JS 변수 안에 들어 있게 됩니다. 이번 commit에 새로 추가된 주석이 이 상황을 직접 설명하고 있습니다. buffer가 "아래의 emitSaveCalleeSavesFor에 의해 stack이 덮어써지면 저장된 on-stack 값들의 유일한 보유자가 될 수 있다"는 것입니다.
초기화 store의 위치는 의도적으로 선택된 것으로 보입니다. DFG stub에서는 이 store가 emitRestoreArguments 이전에 위치하는데, 이 함수는 operationCreateDirectArgumentsDuringExit와 같은 allocation을 수행하는 helper를 호출합니다. 이 시점에는 재구성된 stack이 이미 다시 유효한 conservative root이므로, activeLength를 그대로 설정된 채 두면 오히려 과도한 보존만 낳게 됩니다. 새로 커버되는 구간 안에서 실제로 collection을 유발할 수 있는 구체적인 allocation 호출 지점이 어디인지는 제공된 context만으로는 확인되지 않습니다(두 파일 모두 일부만 발췌되어 있습니다). 다만 그 구간에 GC trigger가 존재한다는 점은 fix에 달린 주석에서 알 수 있습니다. FTL 쪽이 좀 더 유력한 후보로 보이는데, 이 파일의 scratch buffer 크기 계산식에는 객체 materialization을 위한 numMaterializations와 maxMaterializationNumArguments 슬롯이 명시적으로 포함되어 있기 때문입니다.
회귀 테스트는 간결한 형태의 trigger로 구성되어 있습니다. opt()는 100회 warm-up되어 compile되고, const o = {}는 같은 frame 안에서 allocate되어 그 유일한 참조가 해당 frame의 operand slot 하나뿐입니다. 이후 opt가 약 1GB 크기의 문자열로 호출되면, s + s가 최대 문자열 길이를 초과하여 예외를 던지고, 이는 exception 종류의 exit로 이어집니다. Exit stub는 register에 있던 operand들(그 중에는 o를 가리키는 cell pointer도 포함됩니다)을 scratch buffer에 저장하고, 이어지는 callee-save 및 재배치 구간이 원래 slot들을 덮어씁니다. spooler.finalizeGPR()가 완료되기 전까지는 buffer가 해당 pointer의 유일한 보유자가 되는 셈입니다. catch 블록은 o를 배열 a에 다시 돌려주고, zombie mode 하에서 지연 실행되는 a.toString()이 회수된 cell을 실제로 관찰 가능한 assertion 지점으로 드러내는 역할을 합니다.
도달 가능성 측면에서는 별다른 제약이 없습니다. 함수를 최적화시킨 뒤 exit를 유발할 수 있는 web-content JavaScript라면 어디서든 이 코드 경로에 도달할 수 있습니다. 다만 --slowPathAllocsBetweenGCs 없이 실제 환경에서 이 window를 재현하려면, 반복되는 exit와 타이밍이 맞는 지속적인 allocation 압력이 필요할 것으로 보입니다. Dangling reference 이후의 확장 가능성은 두 가지 추가 조건에 달려 있습니다. 하나는 해제된 cell의 size class가 공격자가 선택한 allocation으로 재사용되는 것으로, sized array나 typed object를 이용한 통상적인 JSC heap grooming으로 충족될 가능성을 고려할 수 있습니다. 다른 하나는 재사용된 cell의 타입이 script가 여전히 믿고 있는 타입과 달라지는 것입니다. 두 조건이 모두 성립한다면, stale reference를 통해 완전히 script가 통제하는 객체에 대한 type-confusion primitive로 이어질 가능성이 있습니다. 두 조건이 성립하지 않는다면, 관찰되는 영향은 회수되었거나 오염된 cell로 인한 crash 수준에 그칩니다.
이 vulnerability는 reachable한 객체는 절대 회수되지 않는다는 GC의 근본적인 invariant를 깨뜨려, WebContent process 내부의 메모리 안전성을 약화시킵니다. Collection이 해당 window 안에 정확히 들어맞도록 유도할 수 있는 공격자라면, 자신이 원하는 타입과 크기를 가진 객체에 대한 dangling reference를 얻을 수 있습니다. 이는 renderer 안에서 type-confusion이나 arbitrary-read/write primitive로 이어지는 전형적인 출발점에 해당합니다.
특히 눈여겨볼 부분은 같은 파일 안에 존재하는 비대칭성입니다. operationCompileOSRExit는 시작 부분에서 ActiveScratchBufferScope activeScratchBufferScope(ScratchBuffer::fromData(bufferToPreserve), ...)를 사용하고 있어, exit 경로의 C++ 쪽 절반은 항상 자신의 buffer가 가진 살아있는 범위를 게시해왔습니다. 반면 같은 exit 경로의 JIT가 직접 생성하는 쪽 절반은 그렇게 하지 않고 있었습니다. 어떤 subsystem이 동일한 invariant에 대해 C++ RAII helper와 손으로 작성한 기계어 코드 등가물을 동시에 가지고 있을 때, 소리 없이 어긋나는 쪽은 대체로 기계어 코드 경로입니다. Destructor가 실행되지 않을 뿐 아니라, compiler 역시 그 짝을 강제할 방법이 없기 때문입니다.
Audit directions
-
JIT가 생성한 코드가 GC-managed pointer를 opt-in scan 대상 메모리에 임시로 보관하는 패턴. 이 코드에서 지켜야 하는 invariant는, 어떤 cell을 유일하게 붙잡고 있는 영역이라면 그 역할을 맡고 있는 정확한 구간 동안 collector가 반드시 그 영역을 순회할 수 있어야 한다는 점입니다. Spilling 코드와 scanning 코드가 서로 다른 파일에 위치하고 compile-time 연결고리가 없기 때문에, 이 invariant는 깨지기 쉽습니다. 좁은 범위로는,
Source/JavaScriptCore에서scratchBufferForSize(를 검색한 뒤 각 결과에 대응하는addressOfActiveLength()store나ActiveScratchBufferScope가 있는지 확인해야 합니다.dfg/DFGThunks.cpp의 thunk generator들과jit/의 callOperation spill 경로부터 살펴보는 것이 좋습니다. 조금 더 넓은 범위로는, machine stack이 아닌 모든 JIT spill 영역이 같은 클래스에 속합니다.ProbeContext,copyCalleeSavesToEntryFrameCalleeSavesBuffer가 기록하는 entry-frame callee-saves buffer, 그리고 Wasm의 임시 value buffer가 여기에 해당합니다. 코드 검색에서 눈여겨봐야 할 형태는, boxed된EncodedJSValue를TrustedImmPtr로 주소를 지정한 absolute buffer에 기록하면서 인접한 bookkeeping store가 존재하지 않는 패턴입니다. 가장 넓은 범위로 보면, 이는 "off-stack root region with opt-in scanning"이라는 일반적인 클래스에 속하며, V8의 handle-scope와 stack-scanning 경계, HotSpot의 oop-map coverage, Go의 spill slot용 stack map에도 동일하게 적용됩니다. 이 패턴은 코드베이스를 넘나들며 적용할 수 있는데, runtime이 collector가 확실히 스캔하는 영역 밖으로 reference를 복사해내는 모든 지점을 찾은 뒤, 어떤 코드가 그 목적지를 root로 표시하고 언제 그 표시를 해제하는지 물어보면 됩니다. -
GC-visibility marker를 set/clear하는 쌍이 straight-line machine code로 emit되는 패턴. Clear 동작이 destructor가 아니라 store로 구현되어 있기 때문에, emit된 영역에서 조기에 빠져나가는 branch가 하나라도 있으면
activeLength가 stale 상태로 남습니다. Stale-large 상태에서는 buffer에 남아 있는 임의의 word들을 과도하게 붙잡아 conservative하게 pin해 버리고, stale-zero 상태에서는 이 버그가 다시 열리게 됩니다. 좁은 범위로는,OSRExit::compileExit와 FTLcompileStub에서 새로 추가된 두 store 사이의 모든 exit edge를 추적해야 합니다.GenericUnwind경로,handleExitCounts의 jettison branch,adjustAndJumpToTarget이 각각 zeroing store를 반드시 거치는지 확인해야 합니다. 조금 더 넓은 범위로는, JIT 코드가 branching하는 emit 영역 전반에 걸쳐 enter/leave state mutation을 쌍으로 emit하는 곳이라면 어디든 같은 형태가 나타날 수 있습니다.validateDFGDoesGC의 expectation store, VM entry/exit frame push, exception-handler state store 등이 그 예입니다. 코드 리뷰에서는, 짝을 이루는 store와 dominator/post-dominator 관계가 성립하지 않는 constant flag store가 시각적인 단서가 됩니다. 가장 넓은 범위로 보면, 이는 "manually-paired state transition with no scope guard"라는 일반적인 클래스에 속하며, exceptional한 경로를 포함해 그 영역을 벗어나는 모든 경로에서 marker를 반드시 복원해야 합니다. -
새로 삽입된 JIT bookkeeping 코드에서 scratch register clobbering 여부를 점검해야 합니다. 새로 추가된 두 hunk 모두 조건 없이
jit.move(..., GPRInfo::regT0)를 호출하는데, 이는 해당 두 삽입 지점에서regT0가 dead 상태일 때만 안전합니다. 좁은 범위로는, 두 파일 모두에서 삽입된 코드 시퀀스 전체에 걸쳐regT0에 살아남는 값이 없는지 직접 확인해야 합니다. 단서는, emit된 코드 안에서 삽입 지점을 dominate하면서 이후에 use가 존재하는regT0def입니다. 조금 더 넓은 범위로는, 어떤 fix가 기존 stub에 machine-code emission을 추가할 때마다 register-restore 시퀀스와 spooler 영역 안의 모든GPRInfo::regT*/nonArgGPR*사용에 대해서도 같은 질문을 던져야 합니다. 리뷰에서 멈춰야 할 형태는, spooler의 batching과finalize*()호출 사이에 새로 삽입된jit.move/jit.storePtr입니다. 이 패턴은 register allocation이 수작업으로 이루어지고 삽입된 코드 전반의 liveness를 검증하는 verifier가 없는, hand-written MacroAssembler stub 코드에 특히 해당됩니다. -
다른 deoptimization 경로나 unwinding 경로에도 동일한 coverage gap이 있는지 점검해야 합니다. baseline-to-LLInt unwinding 경로,
genericUnwind, 혹은 checkpoint OSR 메커니즘(CheckpointOSRExitSideState, 두 변경 파일 모두에서 include됨)이 stack 바깥에서 어떤 cell의 마지막 reference를 붙잡고 있는 경우가 있는지 확인해야 합니다. 먼저 frame rewrite 전반에 걸쳐 값을 유지하는 구조들을 나열하는 것부터 시작할 수 있습니다.CheckpointOSRExitSideState에 저장된 temporary 값들과vm.callFrameForCatch인접 state가 여기에 해당하며, 각각에 대해 어떤 GC visitor가 이를 방문하는지 확인해야 합니다. 일치하는 단서는, JIT 코드에서 기록되면서WriteBarrier도 아니고 machine stack 위에도 있지 않으며 heap의 root-gathering 루틴에도 이름이 등록되어 있지 않은JSValue/EncodedJSValuecontainer입니다.