← All reports

[2] Stale OSR recovery rebuilds `arguments` from a second varargs call's stack slots

HighJSC DFG/FTL OSR machineryTypeConfusion

DFG liveness is a union over CFG successors. A catch handler isn't one.

26aa84f

High. 서로 독립적인 두 개의 gap이 맞물립니다. 하나는 heap availability를 kill하지 않는 누락이고, 다른 하나는 catch handler를 전혀 보지 못하는 liveness 집합입니다. 그 결과 exit 시점에 materialize된 arguments 객체가, 이후 호출이 이미 덮어쓴 stack slot에서 값을 채우게 됩니다. value/length confusion을 넘어 uninitialized memory read까지 확장되는 경로는 예상되는 방향일 뿐이며, 이번 변경 자체로 확립되는 내용은 아닙니다.

Deoptimization metadata는 JIT가 계속 유지하는 약속에 해당합니다. 최적화된 코드가 생략한 interpreter state를 어느 exit 지점에서든 다시 복원할 수 있다는 약속입니다. 다만 값이 어디에 있는지 기록해 두는 모든 자료구조가 그렇듯, slot이 재사용되었는데도 기록이 무효화되지 않으면 이 약속은 깨집니다. DFGOSRAvailabilityAnalysisPhase가 그 기록을 관리하는 forward flow analysis입니다. 이 phase는 stack operand별 availability를 m_locals에 담고, 그와 나란히 m_heap도 함께 추적합니다. m_heap은 promoted heap location들의 map으로, compiler가 삭제해 버린 객체의 필드를 exit 시점에 복원하기 위한 정보를 담고 있습니다. 한편 DFGArgumentsEliminationPhasearguments allocation을 아예 삭제하고 그 자리에 phantom marker만 남깁니다. 그래서 이런 객체가 실제로 생성되는 시점은 exit뿐이며, 객체의 내용을 설명하는 정보 역시 recovery entry가 전부입니다.

관전 포인트: inline된 varargs 호출 두 개를 중첩시키고 catch handler에서 exit를 유발하면, length와 element가 다른 호출의 stack slot에서 채워진 arguments 객체를 script가 그대로 사용할 수 있게 됩니다.

서로 맞물린 두 가지 변경이 적용되었습니다. 먼저 LocalOSRAvailabilityCalculatorLoadVarargs / ForwardVarargs 처리에서, 새로 정의할 operand들에 대해 killHeaps()를 호출하도록 변경되었습니다. 대상은 data->count와 각 data->start + i slot이며, 이들의 새 m_locals availability를 기록하기 전에 호출됩니다. killHeaps()의 본문은 제공된 context에 포함되어 있지 않습니다. 다만 이름과 patch가 삽입된 위치로 볼 때 역할은 분명합니다. virtual register를 재정의하면 그 register를 통해 복원되는 m_heap entry를 모두 무효화해야 하는데, 바로 그 의무를 이행하는 지점입니다.

두 번째로, ArgumentsEliminationPhase::eliminateCandidatesThatInterfere()에 catch handler 안에서만 bytecode-live인 값을 처리하는 로직이 추가되었습니다. 이런 값들이 왜 누락되고 있었는지를 기록한 주석도 함께 들어갔습니다. DFG가 exceptional control flow를 CFG edge로 모델링하지 않기 때문입니다.

이후 노드가 재정의하는 stack slot을 promoted heap location recovery가 계속 가리키고 있었고, liveness는 exceptional edge를 모델링하지 않던 패턴.

이 코드가 있는 위치. OSR exit는 speculation이 실패했을 때 최적화된 DFG/FTL 코드가 baseline tier로 되돌아가는 장치입니다. 최적화 과정에서 생략했던 모든 것이 그 시점에 복원 가능해야 합니다.

Availability, locals, heap. AvailabilityMap은 서로 연결된 두 개의 map을 갖습니다. m_locals는 stack operand별로 그 값을 어디서 복원할 수 있는지 기록합니다. m_heap은 같은 일을 PromotedHeapLocation 단위로 수행하는데, 여기서 말하는 대상은 compiler가 sink하거나 제거한 객체의 개별 필드입니다. promoted location의 recovery가 "virtual register R에서 flush됨"으로 기록되면, 해당 entry가 살아 있는 동안 analysis는 그 기록을 지킬 의무를 지게 됩니다.

Arguments elimination. 이 phase는 arguments allocation을 PhantomClonedArguments 노드로 대체하고, 객체의 내용은 ArgumentCountPLocArgumentPLoc promoted location으로 기술합니다. 또한 collectAvailability()를 호출해 calculator.m_availability를 phase 내부의 m_candidates map으로 snapshot합니다. 다만 이 snapshot이 쓰이는 곳은 phase 자체의 판단이며, exit recovery 자체는 아닙니다.

Combined liveness. DFGCombinedLivenessliveAtTail[block]을 오직 CFG successor들의 liveAtHead 합집합으로만 계산합니다. bytecode liveness를 직접 채워 넣는 경우는 successor가 없는 block뿐입니다. 그런데 DFG에서 catch handler는 별도의 catch entrypoint이지 CFG successor가 아닙니다.

ExitValueInJSStack. "exit 시점에 기록된 virtual register에서 이 값을 로드하라"는 의미를 갖는 exit value 형태입니다.

이번 사안의 본질은 stale해진 deoptimization metadata입니다. lifetime bug라기보다는 객체를 materialize하는 시점의 state/value confusion에 가깝습니다. 해제되는 객체는 없습니다. 잘못된 stack storage를 읽는 것이 문제입니다.

  inlined varargs call #1          inlined varargs call #2
  -----------------------          -----------------------
  LoadVarargs
    m_locals[count]   = R_c
    m_locals[start+i] = R_i
    m_heap[ArgumentCountPLoc] -> R_c
    m_heap[ArgumentPLoc(i)]   -> R_i
  PhantomClonedArguments
    (allocation removed)
                                   LoadVarargs (overlaps R_c, R_i)
                                     m_locals rewritten
                                     m_heap NOT killed  <-- gap 1
  clobber of source-frame slots
    liveAtTail misses catch-only
    values -> removeViaKill()
    never runs                                          <-- gap 2
  OSR exit inside catch handler
    ExitValueInJSStack(R_c), (R_i)
      -> call #1's arguments object
         materialized from call #2's
         count and element values

각 gap은 하나만 놓고 보면 그 자체로는 치명적이지 않습니다. 먼저 gap 1은 promoted heap recovery가 두 번째 LoadVarargs가 차지해 버린 operand를 계속 가리키게 만듭니다. 두 번째 LoadVarargs가 같은 operand에 배치되도록 만드는 stack slot 할당 규칙은 제공된 context에 포함되어 있지 않습니다. 따라서 이 overlap은 확정된 사실이 아니라 예상되는 조건에 해당합니다. 다만 fix가 겨냥하는 것이 정확히 이 상황입니다. killHeaps()가 삽입된 대상이 data->count와 각 data->start + i, 즉 첫 번째 호출의 recovery가 가리키던 바로 그 operand이기 때문입니다. gap 2는 interference analysis가 이 상황을 잡아내지 못하는 이유입니다. stack을 clobber하는 노드에서 removeViaKill()combinedLiveness.liveAtTail[block]에 들어 있는 모든 노드에 대해 호출됩니다. 그런데 catch handler 안에서만 bytecode-live인 값은 어떤 CFG successor의 liveAtHead에도 등장하지 않습니다. 결국 어떤 block의 liveAtTail에도 들어가지 못하고, removeViaKill()에 전달되는 일도 없습니다. clobber 구간을 넘어 여전히 OSR-live인 상태인데도, 후보는 제거 대상 자격을 그대로 유지하게 됩니다.

구체적인 trigger 형태를 정리하면 다음과 같습니다. inline된 varargs 호출 안에서 strict-mode arguments allocation이 생성되고, 이것이 PhantomClonedArguments로 전환됩니다. 이어서 두 번째 inline varargs 호출의 LoadVarargs가 첫 번째 호출의 argument slot과 겹칩니다. 그리고 해당 객체가 bytecode-live인 catch handler에서 exit가 발생합니다. exit 시점에는 각 ArgumentPLoc recovery가 기록된 virtual register를 읽는 ExitValueInJSStack load로 컴파일됩니다. 그래서 재구성된 객체의 length와 element는 두 번째 호출에서 오게 됩니다.

exploit 가능성은 exit 시점에 두 번째 호출의 frame에 무엇이 들어 있는지에 달려 있습니다. 다른 호출에서 가져온 count는 곧 length confusion이며, 두 호출의 arity를 고르는 것만으로 script가 직접 제어할 수 있습니다. 여기서 예상되는 uninitialized memory read 요소는, 두 번째 호출이 한 번도 값을 기록한 적 없는 stack storage에서 element slot을 읽게 되는 상황입니다. 같은 recovery에서 파생되는 결과이지만, 겹친 호출이 실제로 설치한 것보다 더 큰 count를 만들어 두어야 성립합니다. 이 조건 구성은 예상되는 방향일 뿐, 이번 변경으로 확립되는 내용은 아닙니다.

이 vulnerability는 deoptimization을 보이지 않게 만들어 주는 correctness 보장을 약화시킵니다. exit 시점에 materialize된 객체가 baseline tier가 할당했을 객체와 구분되지 않아야 한다는 약속입니다.