← All reports

[4] [JSC] ExpressionInfo::Encoder::adjustInstPC should take an index instead of a pointer

MediumJSC bytecodeUAF

The one loop that never got the memo that its own vector reallocates.

799e388

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 raw EncodedInfo* pointer that aliased m_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

-void ExpressionInfo::Encoder::adjustInstPC(EncodedInfo* info, unsigned instPCDelta)
+void ExpressionInfo::Encoder::adjustInstPC(unsigned infoIndex, unsigned instPCDelta)
{
 
- unsigned infoIndex = info - &m_expressionInfoEncodedInfo[0];
 
- auto* firstInfo = info;
 
- unsigned firstValue = firstInfo->value;
+ unsigned firstValue = m_expressionInfoEncodedInfo[infoIndex].value;
...
m_expressionInfoEncodedInfo.append({ firstValue }); // MultiWide header.
for (unsigned i = 1; i < numberOfFields; ++i) {
 
- m_expressionInfoEncodedInfo.append(firstInfo[i]);
 
- firstInfo[i] = encodeSingle(FieldID::InstPC, 0); // Replace with a no-op.
+ auto fieldValue = m_expressionInfoEncodedInfo[infoIndex + i];
+ m_expressionInfoEncodedInfo.append(fieldValue);
+ m_expressionInfoEncodedInfo[infoIndex + i] = encodeSingle(FieldID::InstPC, 0); // Replace with a no-op.
}
// Save the last field in firstValue, and let the extension emitter below append it.
 
- firstValue = firstInfo[numberOfFields].value;
 
`ExpressionInfo::Encoder::adjustInstPC()`는 encoder 자신의 `Vector<EncodedInfo> m_expressionInfoEncodedInfo`를 가리키는 raw `EncodedInfo*`를 받던 방식에서, `unsigned infoIndex`를 받는 방식으로 변경되었습니다. 이전에는 aliasing pointer를 통해 (`*firstInfo = encodeAbsInstPC(...)`, `= encodeSingle(...)`, `= encodeDuo(...)`, `= { replacement }`) 네 곳의 in-place write 지점이 값을 기록했는데, 이제는 모두 `m_expressionInfoEncodedInfo[infoIndex]`를 통해 기록됩니다.
 
핵심이 되는 변경은 MultiWide branch에 있습니다. MultiWide header와 N개의 value word를 extension island로 옮기는 루프는, 기존에는 `m_expressionInfoEncodedInfo.append(firstInfo[i]); firstInfo[i] = encodeSingle(FieldID::InstPC, 0);` 형태로 동작했습니다. 이때 `append()` 호출이 같은 vector를 growth시키며 재할당을 유발할 수 있는 상황이 그 사이에 끼어 있었습니다. 수정 후에는 각 word를 append하기 전에 local 변수로 먼저 복사하고 (`auto fieldValue = m_expressionInfoEncodedInfo[infoIndex + i];`), no-op overwrite 시에도 vector를 다시 인덱싱합니다. 그 결과 read와 write 모두 현재 base pointer를 기준으로 주소를 다시 계산하게 됩니다. 뒤이어 나오는 `firstValue = ...[infoIndex + numberOfFields].value` read와 그에 대응하는 no-op overwrite에도 동일한 방식이 적용되었습니다.
 
`ExpressionInfo.h`에서는 선언부가 갱신되었고, `ExpressionInfoInlines.h`의 `remap()`에서는 호출 지점에서 `decoder.currentInfo()`를 index로 변환합니다 (`decoder.currentInfo() - m_expressionInfoEncodedInfo.begin()`). 이 변환은 기존부터 존재하던 `decoder.recacheInfo()` 호출 바로 앞에 위치하는데, 해당 호출의 주석에는 이미 `adjustInstPC()`가 vector를 재할당할 수 있다는 사실이 기록되어 있었습니다. Regression test도 함께 추가되었는데, `yield` 뒤에 약 9,000,000개의 공백을 두고 마지막 expression이 이어지는 generator 함수입니다.
 
<mark>growable container에 append하는 연산이 진행되는 동안 그 container 내부를 가리키는 raw pointer를 계속 들고 있다가, 재할당이 일어나면 그 pointer가 이미 해제된 메모리를 가리키게 되는 패턴.</mark>
 
**Background**
 
**이 코드가 있는 위치.** `ExpressionInfo`는 JSC에서 bytecode 단위의 source-position 데이터(instPC, divot, start/end offset, line, column)를 압축해서 저장하는 구조로, unlinked code block에 붙어 있습니다. 이 데이터는 error message, stack trace, 디버깅에만 필요하기 때문에, 24바이트 레코드 대신 delta-compressed된 32비트 `EncodedInfo` word들의 스트림으로 저장됩니다. `ExpressionInfo.h`에는 `startOffset`/`endOffset`이 *divot 기준 상대값*이라고 명시되어 있습니다. 따라서 이 두 필드는 소스 상의 절대 위치가 아니라 expression 자체의 폭을 나타냅니다.
 
**Encoding width.** `Basic` word 하나에는 여섯 개 필드의 delta 값이 모두 32비트 안에 담깁니다. delta 값이 너무 크면 encoder는 먼저 Wide word를 내보내는데, 필드 하나에 23비트를 쓰는 `SingleWide`, 필드 두 개에 각각 10비트를 쓰는 `DuoWide`, 또는 최대 여섯 개까지 FieldID를 나열하는 header word 뒤에 그만큼의 32비트 value word가 이어지는 `MultiWide` 중 하나가 선택됩니다. decoder는 MultiWide의 header와 value word들이 반드시 연속해 있어야 한다고 요구합니다. 세 개 이상의 필드가 wide 값을 필요로 할 때 MultiWide가 선택됩니다.
 
**Generatorification.** 초기 bytecode 생성이 끝난 뒤, `UnlinkedCodeBlockGenerator::applyModification()`은 generator 함수의 bytecode를 다시 작성하여 resume switch와 save/restore sequence를 삽입합니다. 이 과정에서 instruction이 추가되거나 제거되므로, ExpressionInfo 스트림에 기록된 모든 instruction PC를 다시 remap해야 합니다.
 
**Extension island.** remap 과정에서 더 큰 InstPC encoding을 위한 공간을 마련할 때, encoder는 `EncodedInfo` vector 전체를 밀어내는 대신 해당 entry의 첫 word 자리를 `Extension` word로 바꿔치기합니다. 이 `Extension` word는 정상 스트림 끝 뒤에 추가되는 "extension island"를 가리키며, 원래 word(들)는 그쪽으로 복사됩니다. MultiWide entry의 경우 header와 그에 딸린 value word 전체가 함께 이동해야 하는데, decoder가 이들이 연속해 있을 것을 요구하기 때문입니다. 비워진 자리는 `SingleWide(InstPC, 0)` no-op으로 덮어씌워집니다.
 
**`WTF::Vector` growth.** capacity를 넘어서는 append가 일어나면 더 큰 backing buffer가 할당되고, 기존 원소들이 복사된 뒤 이전 buffer는 해제됩니다. 이 과정을 거쳐도 index는 여전히 유효한데, 원소 접근이 매번 현재 base pointer를 기준으로 주소를 다시 계산하기 때문입니다. 참고로 `WTF::Vector`의 slow-path append는 입력값의 주소를 `expandCapacity()`로 그대로 전달합니다. 그래서 `v.append(v[i])`처럼 자기 자신을 참조하는 인자도 container 자체적으로 올바르게 처리됩니다.
 
**`Encoder::remap()`.** `ExpressionInfo::Decoder`로 스트림을 순회하면서, 조정이 필요한 entry마다 `adjustInstPC()`를 호출합니다. 이어서 `decoder.recacheInfo()`를 호출해 decoder가 캐싱해둔 `EncodedInfo*` 범위를 vector의 현재 buffer 기준으로 갱신합니다.
 
**Analysis**
 
이 취약점은 use-after-free에 해당합니다. 재할당된 heap buffer 내부를 가리키던 pointer가 stale 상태가 되고, 그 pointer를 통해 read와 write가 모두 이루어집니다.
 

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 과정을 따라가 보면 다음과 같습니다.

  1. function* gen(a)로 함수가 generator가 되면서, applyModification()이 generatorification을 수행하고 Encoder::remap()이 비어있지 않은 adjustment label point list와 함께 호출됩니다. 이것이 애초에 adjustInstPC()가 실행되기 위한 전제 조건입니다.
  2. 앞쪽에 있는 다섯 개의 a.b; 문과 yield 1;은 label point 근처에 작은 delta를 가진 여러 ExpressionInfo entry를 만들어 둡니다. 그 결과 remap 경계 지점과 그 직후에 entry가 존재하게 됩니다.
  3. " ".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() 이전에 반환되기 때문입니다.
  4. 이 branch 안에서는 첫 번째 m_expressionInfoEncodedInfo.append({ firstValue })가 vector를 growth시킵니다. 이 append가 capacity 경계를 넘으면 기존 buffer가 해제되고 firstInfo는 stale 상태가 됩니다. 다만 이번 입력이 실제로 그 경계를 넘는지는 제공된 context만으로는 확인되지 않습니다. 해당 시점의 vector capacity 상태가 나타나 있지 않기 때문입니다.
  5. 이어지는 루프는 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 활동을 얼마나 끼워넣을 수 있는가에 달려 있습니다.

주변 코드는 이미 이 위험을 알고 있었습니다. 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를 옮겨놓았을 수 있기 때문입니다.