← All reports

[3] [JSC] Move DataView null vector check in IC outside of register save/restore

HighJSC inline cachesOOB

An IC exit undid a stack adjustment that had never happened.

34669c8

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 before restoreReusedRegistersByPopping. 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

if (isResizableOrGrowableSharedTypedArrayIncludingDataView(accessCase.structure()->classInfoForCells())) {
+ // The null-vector guard above was emitted before the push, so route it
+ // directly to m_failAndIgnore to avoid the post-push restore path.
+ m_failAndIgnore.append(failAndIgnore);
+
auto allocator = makeDefaultScratchAllocator(m_scratchGPR);
GPRReg scratch2GPR = allocator.allocateScratchGPR();
 
ScratchRegisterAllocator::PreservedState preservedState = allocator.preserveReusedRegistersByPushing(jit, ScratchRegisterAllocator::ExtraStackSpace::NoExtraSpace);
 
+ CCallHelpers::JumpList postPushFailAndIgnore;
if (isDataView) {
auto [outOfBounds, doneCases] = jit.loadDataViewByteLength(baseGPR, valueGPR, m_scratchGPR, scratch2GPR, type);
 
- failAndIgnore.append(outOfBounds);
+ postPushFailAndIgnore.append(outOfBounds);
doneCases.link(&jit);
} else
...
allocator.restoreReusedRegistersByPopping(jit, preservedState);
succeed();
 
- if (allocator.didReuseRegisters() && !failAndIgnore.empty()) {
 
- failAndIgnore.link(&jit);
+ if (allocator.didReuseRegisters() && !postPushFailAndIgnore.empty()) {
+ postPushFailAndIgnore.link(&jit);
allocator.restoreReusedRegistersByPopping(jit, preservedState);
m_failAndIgnore.append(jit.jump());
} else
 
diff
- m_failAndIgnore.append(failAndIgnore);
+ m_failAndIgnore.append(postPushFailAndIgnore);
return;
}

JSTests/stress/dataview-bytelength-ic-stub-stack-desync.js

+let ab = new ArrayBuffer(64, { maxByteLength: 1024 });
+let dv = new DataView(ab);
+let decoy = { byteLength: 7 };
+let decoy2 = { byteLength: 7, x: 1 };
+let decoy3 = { byteLength: 7, y: 1 };
+let objs = [];
+for (let i = 0; i < 64; i++) objs.push({marker: 0x1337 + i});
+function hot(o, a, b) {
+ let p0=b[0], p1=b[1], /* ... 32 live values ... */ p31=b[31];
+ let len;
+ try { len = o.byteLength; } catch (e) { len = -1; }
+ return [len, p0.marker, /* ... */ p31.marker];
+}
+noInline(hot);
+for (let i = 0; i < 200000; i++) {
+ hot(decoy, A, objs); hot(decoy2, A, objs); hot(decoy3, A, objs); hot(dv, A, objs);
+}
+ab.transfer();
+let r = hot(dv, A, objs);

이번 변경은 InlineCacheCompiler::emitIntrinsicGetterUSE(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 postPushFailAndIgnoreoutOfBounds 분기만을 수집합니다. 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를 실행하게 되는 패턴입니다.

Inline cache. JSC는 property 연산을 특정 object structure에 특화된 작은 machine-code 스텁 형태로 캐싱합니다. InlineCacheCompiler::emitIntrinsicGetterDataView.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인 경우 둘 다 아무 동작도 하지 않습니다.

JumpListlink. 대기 중인 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를 갖게 됩니다.

이 문제는 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 자체가 동작하는 하나의 예시입니다. 순서대로 살펴보면 다음과 같습니다.

  1. 루프가 decoy, decoy2, decoy3, dvhot()을 호출하므로, o.byteLength call site의 IC는 네 가지 structure에 걸쳐 polymorphic해지고 emitIntrinsicGetter에서의 스텁 컴파일에 도달합니다.
  2. 동시에 live 상태인 32개의 p0..p31 로컬 변수는 거의 모든 GPR을 점유하도록 설계되어 있습니다. 이로 인해 scratch2GPR에 대한 allocateScratchGPR()가 live 레지스터를 가져가야 할 가능성이 높아지고, 그렇게 되면 didReuseRegisters()가 true가 되어 prologue가 실제 stack 조정 코드를 방출하게 됩니다. 다만 제공된 ScratchRegisterAllocator 소스는 이 메커니즘 자체는 보여주지만, 이 테스트가 실제로 reuse case에 도달한다는 것까지 확정해주지는 않습니다. 따라서 이 단계는 테스트 구성으로부터의 추론입니다.
  3. dv의 structure는 resizable-buffer 분기를 선택하며, 이 스텁에는 push 이전의 guard와 push 이후의 outOfBounds 분기가 함께 포함되어 있습니다.
  4. ab.transfer()가 버퍼를 detach시켜, DataView의 vector는 null이 됩니다.
  5. 마지막 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로 이어질 가능성이 있습니다.

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 기능이라 할 수 있습니다.