[4] [JSC] ExpressionInfo::Encoder::adjustInstPC should take an index instead of a pointer
The one loop that never got the memo that its own vector reallocates.
Rated Medium — the diff establishes a genuine read/write through a dangling heap pointer, reachable from a short script with no JIT warm-up. It stops short of High because the written value is a fixed encoding constant with no attacker control, and reclaiming the freed buffer inside this synchronous, allocation-poor window would take real allocator grooming.
재할당을 통해 성장하는 C++ container는 이전 buffer를 가리키던 모든 raw pointer를 무효화합니다. 이는 language 차원의 기본 사실이며, growable buffer를 순회하면서 동시에 append하는 코드라면 주소를 캐싱하지 말고 매번 다시 계산해야 합니다. JSC는 각 bytecode의 instruction PC, divot, start/end offset, line, column 같은 source-position metadata를 고정 크기 레코드가 아니라 delta-compressed된 32-bit word 스트림 형태로 저장하는데, 이 데이터는 error message와 stack trace에서만 쓰이기 때문입니다. Generator 함수의 bytecode가 resume machinery를 삽입하기 위해 재작성되고 나면 해당 스트림에 기록된 모든 instruction PC를 다시 매핑해야 하는데, 이 remapper는 그 과정에서 스트림을 확장할 수 있도록 허용되어 있습니다.
관전 포인트: 적절한 형태를 갖춘 generator 함수를 포함하는 페이지 하나만으로도, 일반적인 script 컴파일 과정 중에 이미 해제된 heap word를 읽어 살아있는 bytecode metadata로 흘려보내고, 방금 해제된 buffer에 고정 상수를 기록하는 동작을 유발할 수 있습니다.
From the commit message:
adjustInstPC()previously took a rawEncodedInfo*pointer that aliasedm_expressionInfoEncodedInfo's backing buffer. This is fragile since the pointer can become invalid if the vector is resized.Change
adjustInstPC()to take an unsigned index instead. An integer index remains valid across reallocations since vector element access always recomputes the address from the current base pointer.
Source/JavaScriptCore/bytecode/ExpressionInfo.cpp
MultiWide relocation loop (pre-fix) ─────────────────────────────────── firstInfo ──► [ ... entry@infoIndex ... ] buffer B0 (capacity full) │ append({ firstValue }) ──────┴──► allocate B1, copy, FREE B0 firstInfo now dangles into B0 loop i = 1..N-1: read firstInfo[i] ──► UAF read (from freed B0) append(that value) ──► goes into live B1 write firstInfo[i] = no-op ──► UAF write (into freed B0) after loop: read firstInfo[N].value ──► UAF read write firstInfo[N] = no-op ──► UAF write ```
adjustInstPC()는 m_expressionInfoEncodedInfo 내부를 가리키는 raw EncodedInfo*를 인자로 받았습니다. m_expressionInfoEncodedInfo는 같은 Encoder 객체가 소유한 Vector<ExpressionInfo::EncodedInfo>이며, 이는 ExpressionInfo.h의 멤버 선언으로 확인됩니다. 그런데 이 함수는 aliasing pointer를 계속 역참조하면서 동시에 바로 그 vector에 append()를 호출합니다. 위 diagram에서 보듯, 첫 번째 append 시점에 기존 backing store가 해제될 수 있습니다. 그 이후로는 최대 여섯 개의 word가 해제된 메모리에서 읽히고, 최대 여섯 개의 고정 상수 word가 그 자리에 기록됩니다. 이때 오프셋(infoIndex, 즉 encoded stream 안에서 해당 entry가 위치한 word 위치)은 그 entry 앞에 얼마나 많은 expression metadata가 있는지에 따라 결정됩니다. 해제된 buffer에서 읽힌 word들은 MultiWide field 값으로 살아있는 vector에 append되며, 뒤이어 읽히는 firstValue = firstInfo[numberOfFields].value도 extension emitter로 전달됩니다.
이 문제가 이론상의 버그가 아니라 실제 버그인 이유는, 함수의 나머지 부분이 이미 index 기반으로 작성되어 있었기 때문입니다. 제거된 줄 unsigned infoIndex = info - &m_expressionInfoEncodedInfo[0];은 애초에 downstream extension emitter가 안전하게 re-index할 수 있도록 존재하던 코드였습니다. 오직 MultiWide relocation 루프만이 mutation이 일어나는 동안에도 raw pointer를 계속 사용하고 있었습니다. 호출자 쪽도 이 위험을 이미 인지하고 있었는데, adjustInstPC()를 호출할 때마다 decoder.recacheInfo(m_expressionInfoEncodedInfo)를 함께 호출하면서 "adjustInstPC() may have resized and reallocated m_expressionInfoEncodedInfo"라는 주석을 남겨두고 있었습니다. 즉 재할당은 이 함수의 문서화된, 예상된 동작이었던 셈입니다.
Reachability는 웹 콘텐츠에서 직접 확보됩니다. 추가된 테스트는 generator 함수를 eval()하는 것이 전부이므로, 적절한 형태의 generator를 담은 일반 <script>만으로도 이 코드에 도달할 수 있습니다. trigger 과정을 따라가 보면 다음과 같습니다.
function* gen(a)로 함수가 generator가 되면서,applyModification()이 generatorification을 수행하고Encoder::remap()이 비어있지 않은 adjustment label point list와 함께 호출됩니다. 이것이 애초에adjustInstPC()가 실행되기 위한 전제 조건입니다.- 앞쪽에 있는 다섯 개의
a.b;문과yield 1;은 label point 근처에 작은 delta를 가진 여러 ExpressionInfo entry를 만들어 둡니다. 그 결과 remap 경계 지점과 그 직후에 entry가 존재하게 됩니다. " ".repeat(9000000)은 마지막a.b;를 같은 줄에서 이전 expression보다 약 9×10^6 문자 뒤에 위치시킵니다. 그 결과 해당 entry의 divot delta와 column delta가 각각 약 9×10^6만큼 뛰어오르는데, 이는 23비트singleValueBits의 한계값인 8,388,608을 넘어서고 10비트duoValueBits도 훨씬 초과하는 크기입니다.startOffset/endOffset은 divot 기준 상대값으로 문서화되어 있으므로, divot이 아무리 멀리 움직여도 이 두 필드는 작게 유지됩니다. wide 인코딩으로 강제될 가능성이 있는 필드는 divot delta와 column delta, 그리고 조정 대상인 instPC이며, MultiWide는 세 개 이상의 필드가 wide일 것을 요구합니다. 이 padding의 목적은adjustInstPC()를 Single/Duo/Basic branch가 아니라 MultiWide relocation branch로 유도하는 데 있습니다. 나머지 branch들은 word 하나만 건드리고append()이전에 반환되기 때문입니다.- 이 branch 안에서는 첫 번째
m_expressionInfoEncodedInfo.append({ firstValue })가 vector를 growth시킵니다. 이 append가 capacity 경계를 넘으면 기존 buffer가 해제되고firstInfo는 stale 상태가 됩니다. 다만 이번 입력이 실제로 그 경계를 넘는지는 제공된 context만으로는 확인되지 않습니다. 해당 시점의 vector capacity 상태가 나타나 있지 않기 때문입니다. - 이어지는 루프는
firstInfo[i]를 해제된 메모리에서 읽고, 그 자리에encodeSingle(FieldID::InstPC, 0)을 다시 기록합니다.
ASan 환경에서는 즉시 heap-use-after-free read/write 리포트가 관찰됩니다. production build에서는 free-list에 들어간 buffer로 조용히 write가 이루어지는 결과가 가장 유력합니다. escalation 관점에서 보면, 기록되는 값은 attacker가 지정한 데이터가 아니라 고정된 encoding 상수이므로, corruption primitive의 강도는 그 값이 어디에 떨어지는지에 좌우됩니다. infoIndex는 encoded stream 안에서 대상 entry의 word offset이며, 이는 그 entry 앞에 얼마나 많은 expression metadata가 있는지에 따라 결정됩니다. 이 metadata 양은 attacker가 소스 코드로 자유롭게 조절할 수 있으므로, stale buffer 내부에서의 offset도 어느 정도 조작 가능할 것으로 보입니다. 다만 소스 텍스트와 word offset 사이의 구체적인 매핑은 제공된 context만으로는 확인되지 않습니다. 만약 stale store가 실행되기 전에 해제된 buffer가 살아있는 다른 객체로 재사용된다면, 그 객체의 해당 offset 필드에 고정 상수가 기록되는 상황으로 이어질 수 있습니다. 다만 까다로운 지점은, bytecode post-processing이 진행되는 동안 이 스레드에서 script가 동시에 실행되지 않는다는 점입니다. 따라서 재사용이 성립하려면 이 synchronous window 동안 같은 size class의 다른 allocation이 해제된 slot에 들어가도록 allocator를 grooming해야 합니다.
별개로, firstInfo[i]와 firstInfo[numberOfFields].value를 통해 읽힌 word들은 살아있는 encoded stream에 MultiWide field 값으로 append됩니다. 만약 이 stale word들이 이후 divot/line/column으로 decode된다면, 해제된 heap의 잔여 바이트가 예컨대 Error.prototype.stack을 통해 script에서 관찰 가능한 source position으로 노출될 가능성이 있습니다. 이는 좁은 범위의 info-leak channel에 해당할 수 있습니다. 다만 이 여부는 decoder가 해당 field 값을 어떻게 masking하고 누적하는지에 달려 있는데, 여기 제공된 ExpressionInfo.cpp의 일부는 decode 경로를 포함하고 있지 않아 stale word 중 얼마만큼이 관찰 가능한 숫자로 남는지는 확인되지 않습니다.
이 코드는 renderer에서 실행되는 JSC bytecode generation 경로입니다. IPC와 맞닿아 있지 않고 GPU process나 Networking process에서 실행되지도 않으므로, WebContent process 범위를 벗어나려면 별도의 escape가 필요합니다.
이 산출물과 가장 부합하는 발견 경로는 fuzzing 또는 ASan 계측 stress testing으로, 함수 내부에 아주 큰 source gap을 가진 generator를 만들어낸 경우로 보입니다. " ".repeat(9000000)은 축소된 fuzzer artifact의 모습을 하고 있는데, 9×10^6이라는 값이 singleValueBits의 2^23 경계를 살짝 넘어서면서 여러 필드를 wide encoding으로 밀어넣는 데 필요한 값이기 때문입니다. Pattern auditing 역시 가능성 있는 co-discovery 경로입니다. 해당 함수에는 이미 index 변환 코드가 있었고 호출자 쪽에도 이미 "may have resized and reallocated" 주석이 남아 있었으므로, 재할당이 문서화된 함수들에서 살아남은 raw alias를 점검하는 audit이라면 같은 루프에 도달했을 가능성이 있습니다. 이 commit은 원래 두 개의 서로 다른 radar와 함께 Safari release branch에 반영되었는데, upstream 이전에 crash report나 fuzzer finding을 triage한 흐름과 부합합니다.
이 vulnerability는 일반적인 script compilation만으로 도달 가능한 지점에서 WebContent process의 memory safety를 약화시키며, JIT tiering이나 warm-up이 전혀 필요하지 않습니다. 여기서 깨지는 invariant는 bytecode metadata encoding이 항상 자기 자신의 살아있는 allocation 안에서만 동작해야 한다는 것입니다. 패치 이전에는 MultiWide encoding이 필요한 expression metadata를 가진 generator가 bytecode post-processing 도중 방금 해제된 heap buffer에 read와 고정값 write를 유발할 수 있었습니다. 실질적인 한계는 이 synchronous하고 단일 스레드로 동작하는 code path 안에 attacker가 제어하는 allocation 활동을 얼마나 끼워넣을 수 있는가에 달려 있습니다.
Insight
주변 코드는 이미 이 위험을 알고 있었습니다. remap()에는 "adjustInstPC() may have resized and reallocated m_expressionInfoEncodedInfo"라는 주석이 있고, decoder가 캐싱해둔 pointer를 고치기 위해 decoder.recacheInfo()를 호출합니다. adjustInstPC() 자신도 함수 시작 부분에서 pointer 인자를 extension emitter를 위한 index로 변환하고 있었습니다. 즉 이 pointer를 사용하는 거의 모든 지점이 이미 hardening되어 있었고, 유일하게 read와 write를 모두 그 pointer로 수행하던 한 루프만 예외였습니다. 이것은 container-invalidation 버그에서 반복적으로 나타나는 패턴입니다. decoder의 cursor처럼 누가 봐도 오래 살아남는 pointer는 수정 대상이 되지만, mutating 함수 내부의 짧게 사는 local pointer는 mutation과 몇 줄 떨어져 있지 않다는 이유만으로 안전하다고 가정되곤 합니다. 참고로 fix의 auto fieldValue = ...; append(fieldValue);는 WTF::Vector의 self-referential append 문제를 우회하기 위한 코드가 아닙니다. 이 container의 slow-path append는 입력값의 주소를 expandCapacity()로 그대로 전달하도록 설계되어 있어서, v.append(v[i]) 형태도 원래부터 올바르게 동작합니다. local copy가 필요한 이유는 다른 데 있습니다. 원본 word를 읽어오는 read 연산은 매 iteration마다 현재 base pointer를 기준으로 다시 계산되어야 하는데, 이전 iteration의 append가 이미 buffer를 옮겨놓았을 수 있기 때문입니다.
Audit directions
-
자기 참조적 컨테이너 변경. 어떤 함수가 growable container 내부를 가리키는 raw pointer나 reference를 보유한 상태에서, 같은 container에 append를 수행하는 경우가 있습니다. 이 경우 mutation으로 인해 그 함수가 여전히 사용 중이던 alias가 무효화됩니다. 여기서 지켜야 할 invariant는, container의 내용물은 그 container를 키울 수 있는 어떤 operation을 거치더라도 raw pointer로 계속 주소를 참조해서는 안 된다는 것입니다. Narrow rung:
Source/JavaScriptCore/bytecode/와Source/JavaScriptCore/bytecompiler/에서 함수를 검색해 볼 필요가 있습니다. 대상은EncodedInfo*나Instruction*같은 element pointer를 파라미터로 받으면서, 동시에 멤버로Vector를 갖는 함수입니다.ExpressionInfo::Decoder의 rawEncodedInfo*cursor 멤버들이 대표적인 예로, 이 값들은 caller가recacheInfo()를 수동으로 호출해 줄 때만 유효성이 유지되는 cached-pointer 상태입니다. 따라서 이 호출을 빠뜨린 모든 mutation 지점을 점검해야 합니다. Wider rung: 같은 문제 class가 다른 형태로도 나타납니다. 이후의append/insert/shrink호출까지 살아남는auto& ref = vec[i]형태의 local 변수가 한 예입니다. body에서 자기 자신에게 append하는 container에 대한 range-for loop도 마찬가지입니다. growth 이전에 캡처된.begin()/.end()sentinel 역시 같은 범주에 속합니다. 같은 container에 대해 address 계산과 사용 사이에 growth 호출이 끼어 있는 지점을 찾아볼 필요가 있습니다. Widest rung: 이는 growable buffer를 다루는 모든 언어에서 나타나는 일반적인 iterator/reference-invalidation class에 해당합니다. C++의std::vector와absl::InlinedVector가 그 예입니다. borrow checker를 raw pointer나 index-then-unsafe패턴으로 우회하는 Rust 코드도 마찬가지입니다. reallocate를 유발하는append이후에도&s[i]를 계속 들고 있는 Go slice 역시 같은 범주에 속합니다. 모든 rung에서 공통되는 match tell은, 동일한 container identifier가 이후에 사용되는 pointer나 reference의 base로도 등장하고, 그 사이에서 growth operation의 receiver로도 등장한다는 점입니다. Carry-forward question: 이 address가 계산된 지점과 실제로 사용되는 지점 사이에서, 해당 container가 growth를 겪을 수 있는가? -
부분적으로만 적용된 hardening. 이미 알려진 invalidation hazard가 long-lived alias나 눈에 잘 띄는 alias에 대해서는 문서화되고 수정되지만, mutation을 수행하는 함수 내부의 short-lived local alias는 그대로 방치되는 경우가 있습니다. 여기서 지켜야 할 invariant는, 어떤 함수가 구조체를 reallocate한다고 문서화되어 있다면, caller가 볼 수 있는 alias뿐 아니라 함수 내부와 외부의 모든 alias가 다시 계산되어야 한다는 것입니다. 점검할 때는 주석을 이미 알려진 hazard의 index로 취급하는 방식이 유효합니다.
Source/JavaScriptCore/에서reallocat,may resize,invalidat,recache를 포함하는 주석을 검색해 볼 필요가 있습니다. 그리고 각 hit에 대해, call site뿐 아니라 주석이 달린 함수 자체의 body 안에서 해당 구조체를 가리키는 모든 alias를 나열해야 합니다. Wider rung: 이 패턴은 defensive helper가 존재하는 곳이면 어디서나 반복됩니다.recacheInfo()형태의 refresh 함수, ad-hoc safety net으로 추가된reserveCapacity호출, 그리고 산문 형태로만 표현된 "must be called after X" 계약이 모두 해당합니다. refresh가 모든 state를 커버하는지, 아니면 원래 bug report에서 문제가 되었던 state만 커버하는지를 점검해야 합니다. Widest rung: 이는 abstraction 자체가 아니라 call site를 patch하는 방식으로 bug가 수정된 모든 codebase에 일반화됩니다. 재사용 가능한 질문은, 이 hazard가 이전에 발견되었을 때 수정이 abstraction에 적용되었는지, 아니면 reporter의 reproducer 경로에만 적용되었는지입니다. Match tell은, hazard를 설명하는 주석이 있으면서도 같은 함수 안에서 그 아래에 위치한 코드가 여전히 그 hazard가 적용되지 않는다고 가정하고 있는 경우입니다. -
압축된 stream을 순회하면서 in place로 변경하는 metadata post-processing pass. 여기서 지켜야 할 invariant는, in-place rewriter가 mutation 이전 layout에서 파생된 cursor를 계속 들고 있어서는 안 된다는 것입니다. generatorification과 함께 동작하는 다른 post-generation bytecode rewriter들을 추적해 볼 필요가 있습니다.
UnlinkedCodeBlockGenerator::applyModification()과 이 함수가 구동하는 모든 것 — instruction stream rewriting, jump target fixup,m_expressionInfoChapters조정,RareDatatable — 을 시작점으로 삼을 필요가 있습니다. 각각에서 append나 erase 이전에 캡처된 cursor, end-sentinel, cached size가 있는지 확인해야 합니다. Wider rung: 같은 class는 encode 이후 patch를 수행하는 모든 two-phase 설계에서 나타나며, 이때 phase 2가 이미 emit된 record의 encoding width를 바꿀 수 있다는 조건이 핵심입니다. Wasm section rewriting, source-map/debug-info emitter, relocation fixup pass가 모두 이 형태를 가지고 있습니다. Tell은, 다른 code path가 해당 record를 더 넓거나 no-op인 encoding으로 교체했을 수 있는 상황에서, loop가 record의 width를 그 record 자체로부터 읽어오는 패턴입니다. Widest rung: 이는 emission 이후 in place로 patch되는 모든 variable-length record format에 적용됩니다. DWARF line program, protobuf wire-format patching, ELF relocation application이 예에 해당합니다. 여기서 이어지는 invariant는, patch가 record의 length나 buffer의 base를 바꿀 수 있다면, 모든 offset이 patch가 끝날 때마다 현재 base로부터 다시 계산되어야 한다는 것입니다. Match tell은, buffer를 통해 cursor를 전진시키면서 동시에 그 buffer에 append한다고 문서화된 함수를 호출하는 loop입니다.