← All reports

[3] Array rematerialization should know how to have a bad time

HighJSC FTL JITTypeConfusion

The array that came back from an OSR exit disagreed with itself about its layout.

eba64ef

High. Exit-time reconstruction 경로는 컴파일 시점에 결정된 layout으로 butterfly를 직접 구성하면서, 동시에 global object에게 structure를 요청합니다. 그런데 이 두 값 모두 스크립트가 제어할 수 있는 대상입니다. 그 결과 JSC의 전형적인 structure/butterfly confusion이 발생하며, pointer 타입인 m_sparseMap 슬롯이 attacker가 작성한 double 값과 겹쳐지게 됩니다.

WebKit의 top-tier JavaScript optimizer는 객체가 컴파일된 영역을 벗어나지 않는 경우, 해당 객체의 allocation 자체를 제거하고 그 내용에 대한 descriptor만 기록할 수 있습니다. 이후 최적화된 코드가 하위 tier로 bail out하게 되면, exit 시점에 이 descriptor를 실제 heap object로 되돌려야 합니다. 이 경로에서는 두 요소가 만나게 됩니다. 하나는 JSArraystructure로, element가 어떻게 배치되는지 등을 기록하는 type descriptor입니다. 다른 하나는 butterfly로, 실제로 element를 담고 있는 heap block입니다. 여기서 지켜져야 할 invariant는, 재구성된 array에 설치되는 structure가 reconstruction 코드가 실제로 만든 butterfly를 정확히 기술해야 한다는 점입니다.

관전 포인트: 페이지는 엔진이 SlowPutArrayStorage 타입을 가진 JSArray를 생성하도록 유도할 수 있는데, 이때 실제 backing store는 contiguous 형태를 유지합니다. 결과적으로 이후 모든 indexed access는 고정된 displacement 위치에서 읽고 쓰게 되며, attacker가 선택한 double 값을 pointer field를 포함한 array metadata로 재해석하게 됩니다.

Commit message에는 이 구조가 정확히 명시되어 있습니다. Sunk array가 butterfly와 함께 rematerialize될 때는 항상 contiguous butterfly로 재구성되는데, 그 사이 VM이 bad time을 겪어 모든 array structure를 SlowPutArrayStorage로 전환했다면, rematerialization은 이제 ArrayStorage가 된 array를 여전히 contiguous butterfly인 것처럼 다루게 됩니다. 이 fix는 여전히 contiguous butterfly로 rematerialize하는 방식은 유지하되, VM이 bad time 상태라면 이후 SlowPutArrayStorage로 전환하도록 변경합니다.

Source/JavaScriptCore/ftl/FTLOperations.cpp

case PhantomNewArrayWithButterfly: {
 
- Structure* structure = globalObject->arrayStructureForIndexingTypeDuringAllocation(materialization->indexingType());
+ // Rematerialized butterflies are always non-ArrayStorage. However, isHavingABadTime could
+ // have become true between the FTL compilation and the rematerialization, which would have
+ // switched arrayStructureForIndexingTypeDuringAllocation to SlowPutArrayStorage for all
+ // indexing types. To avoid a layout mismatch, the original Array structure is used to
+ // rematerialize the Array initially. If we're having a bad time, the layout is switched to
+ // SlowPutArrayStorage below.
+ Structure* structure = globalObject->originalArrayStructureForIndexingType(materialization->indexingType());
 
Butterfly* butterfly = nullptr;
...
butterfly->contiguous().atUnsafe(index).setStartingValue(jsNumber(sentinel));
}
 
+ if (globalObject->isHavingABadTime()) [[unlikely]] {
+#if ASSERT_ENABLED
+ Structure* originalStructure = globalObject->arrayStructureForIndexingTypeDuringAllocation(materialization->indexingType());
+ ASSERT(!originalStructure || hasSlowPutArrayStorage(originalStructure->indexingType()));
+#endif
+ result->switchToSlowPutArrayStorage(vm);
+ }
+
return result;

Source/JavaScriptCore/ftl/FTLOperations.cpp

if (hasDouble(array->indexingType()) && value.isNumber() && std::isnan(value.asNumber())) [[unlikely]]
array->butterfly()->contiguousDouble().atUnsafe(index) = PNaN;
else if ((hasInt32(array->indexingType()) || hasContiguous(array->indexingType())) && !value) [[unlikely]]
array->butterfly()->contiguous().atUnsafe(index).setStartingValue(JSValue());
+ else if (hasAnyArrayStorage(array->indexingType()) && !value) [[unlikely]]
+ array->butterfly()->arrayStorage()->m_vector[index].clear();
else
array->putDirectIndex(globalObject, index, value);

JSTests/stress/ftl-osr-exit-phantom-new-array-with-butterfly-having-a-bad-time.js

+//@ runDefault("--jitPolicyScale=0.1")
+function cb() {
+ if (trigger) {
+ Object.defineProperty(Array.prototype, 0, {
+ get() { return 42; }, configurable: true
+ });
+ }
+}
+function opt() {
+ let a = new Array(5);
+ a[0] = 1.1; a[1] = 2.2; a[2] = 3.3; a[3] = 4.4; a[4] = 5.5;
+ cb();
+ collect();
+ return a[0] + a[1] + a[2] + a[3] + a[4];
+}
+for (let i = 0; i < 1000; i++) opt();
+trigger = true;
+opt();
+gc();

두 production 변경 모두 FTLOperations.cpp의 FTL OSR-exit object rematerialization 중 PhantomNewArrayWithButterfly 경로에 위치합니다.

operationMaterializeObjectInOSR에서는 structure lookup이 globalObject->arrayStructureForIndexingTypeDuringAllocation(materialization->indexingType())에서 globalObject->originalArrayStructureForIndexingType(materialization->indexingType())로 변경됩니다. Rematerialization 코드는 항상 non-ArrayStorage 형태(contiguous, double, 또는 int32 형태)의 butterfly를 직접 만들기 때문에, structure 역시 global object가 현재 allocation 시점에 내주는 값이 아니라 해당 indexing type의 원본 structure여야 합니다. Butterfly가 sentinel 값으로 채워진 뒤에는 새로운 tail block이 추가됩니다. globalObject->isHavingABadTime()이 참이면 result->switchToSlowPutArrayStorage(vm)을 통해 array를 정상적으로 변환하며, ASSERT_ENABLED 빌드에서만 동작하는 cross-check로 arrayStructureForIndexingTypeDuringAllocation이 실제로 그 시점에 hasSlowPutArrayStorage structure를 반환했을지를 검증합니다.

operationPopulateObjectInOSR에서는 hole-writing fast path에 세 번째 분기가 추가됩니다. else if (hasAnyArrayStorage(array->indexingType()) && !value) array->butterfly()->arrayStorage()->m_vector[index].clear();인데, 이는 array가 이미 SlowPutArrayStorage로 전환된 경우를 처리하기 위한 것입니다. 이 경우 빈 JSValue hole은 contiguous().atUnsafe(index)를 통해 쓰는 대신 ArrayStorage vector에서 clear되어야 합니다. 두 함수의 comment block이 확장되었고, 나머지 파일 변경 사항은 새로 추가된 regression test입니다.

Mutable한 global 상태로부터 객체의 type descriptor를 도출하면서, 정작 메모리 layout은 컴파일 시점의 낡은 가정을 기반으로 구성하여 descriptor와 backing store가 서로 어긋나는 패턴.

Butterfly. JSC는 객체의 indexed element와 out-of-line named property를 butterfly라 불리는 하나의 heap block에 저장합니다. 이 pointer는 block 중간을 가리키며, 아래쪽에는 named property가, 위쪽에는 indexed element가 위치합니다. 그리고 그 앞에는 public length와 vector length를 담은 IndexingHeader가 존재합니다.

Indexing types. Array의 structure는 element 영역이 어떻게 배치되는지를 기록합니다. ArrayWithInt32, ArrayWithDouble, ArrayWithContiguous는 butterfly pointer 자체에서 element를 시작하는 반면, ArrayWithArrayStorageArrayWithSlowPutArrayStorage는 butterfly pointer 위치에 ArrayStorage header(m_sparseMap, write-barrier가 걸린 pointer; m_indexBias; m_numValuesInVector)를 먼저 배치하고, 그 뒤에 element vector m_vector[]를 둡니다.

Having a bad time. 스크립트가 모든 array에 대해 fast indexed access를 더 이상 안전하지 않게 만드는 동작을 수행하면 — 가장 흔한 경우가 Array.prototype이나 Object.prototype에 indexed accessor를 설치하는 것입니다 — global object는 haveABadTime()을 호출합니다. 이 호출은 기존 array들을 SlowPutArrayStorage로 전환시키고, 이후로는 arrayStructureForIndexingTypeDuringAllocation()이 모든 indexing type에 대해 SlowPutArrayStorage structure를 반환하도록 만듭니다. originalArrayStructureForIndexingType()은 주어진 indexing type에 대해 bad-time 이전의 structure를 반환합니다. 실제 변환은 JSObject::switchToSlowPutArrayStorage()가 수행하는데, butterfly를 ArrayStorage 형태로 재할당하고 element들을 이관합니다.

Allocation sinking. 컴파일된 영역을 벗어나지 않는 객체의 allocation을 제거하는 FTL 최적화입니다. 제거된 allocation은 phantom materialization descriptor로 기록되는데, 여기서는 PhantomNewArrayWithButterfly가 그 역할을 하며 register나 stack에 보관된 property와 element 값들을 나열합니다.

OSR exit and rematerialization. 최적화된 코드가 하위 tier로 bail out할 때, observable해지는 sunk object는 heap 상에 재구성되어야 합니다. operationMaterializeObjectInOSR이 object와 butterfly를 allocate하고, 이어서 operationPopulateObjectInOSR이 기록된 element 값들을 그 안에 기록합니다.

Hole sentinels. 아직 쓰이지 않은 element는 int32/contiguous array에서는 empty JSValue, double array에서는 PNaN으로 표현됩니다. Populate 경로는 이 값들을 putDirectIndex를 거치지 않고 butterfly에 직접 기록하는데, 그렇지 않으면 indexing-type 변환이 강제로 발생하기 때문입니다.

Watchpoints. VM이 bad time 상태가 아니라는 가정 하에 컴파일된 FTL 코드는, 이 가정이 깨지는 순간 invalidate됩니다. 이는 OSR exit이 강제되는 경로 중 하나입니다.

이것은 structure와 butterfly가 서로 다른 시점에 이루어진 두 개의 lookup에 의해 결정되는 type confusion입니다.

  Compile time                     Exit time (bad time now on)
  ────────────                     ───────────────────────────
  sink new Array(5)                structure := arrayStructureFor...
    indexingType = Double            ──► SlowPutArrayStorage
                                   butterfly := hand-built contiguous
                                     elem[0] at butterfly + 0

  ArrayStorage view of that block:
    butterfly + 0  ─► m_sparseMap      ◄── holds 1.1 (attacker double)
    butterfly + 8  ─► m_indexBias/m_numValuesInVector
    butterfly + 16 ─► m_vector[0]      ◄── reads elem[2]

Bad-time 전환은 FTL 컴파일 시점과 sunk array를 실제로 materialize하는 OSR exit 시점 사이에 발생할 수 있습니다. 정확히 이 window를 regression test가 열고 있는데, opt() 실행 도중 noInline으로 지정된 callee 안에서 Object.defineProperty(Array.prototype, 0, {get(){}})를 호출하는 방식입니다. Exit 시점에 array는 indexing type이 ArrayStorage인 structure를 부여받지만, butterfly는 여전히 contiguous하게 배치되어 있습니다. 이후의 모든 access는 실제로 값이 저장된 위치에서 고정된 양(positive)의 displacement만큼 떨어진 곳을 읽거나 쓰게 되고, element data의 첫 슬롯들은 ArrayStorage header — pointer field인 m_sparseMap을 포함해서 — 로 재해석됩니다.

operationPopulateObjectInOSR은 이 문제를 한층 더 악화시켰습니다. Array가 이제 ArrayStorage 타입으로 취급되면서 hasDouble / hasInt32 / hasContiguous 검사가 모두 실패하게 되고, 그 결과 element 값들이 putDirectIndex를 거쳐 기록됩니다. 이는 contiguous storage 크기로 만들어진 butterfly 위의 m_vector[index]에 쓰기를 수행하는 셈입니다.

테스트가 사용하는 shell helper (gc(), noInline, --jitPolicyScale)를 제외한 나머지 동작은 모두 평범한 JS 코드로도 재현 가능합니다. FTL이 array allocation을 sink한 채로 함수를 컴파일할 때까지 warm-up시킨 다음, 그 함수 중간의 호출 안에서 haveABadTime()을 유발하면 됩니다. 코드 흐름을 따라가 보면, opt()new Array(5)를 할당하고 double 값들로 채워 phantom materialization이 ArrayWithDouble을 갖도록 만듭니다. 그리고 최종 합산 이전까지 array를 escape시키지 않기 때문에, allocation sinking이 이를 제거합니다. 1001번째 호출에서 trigger가 true가 되면서 cb()가 indexed accessor를 설치하게 되고, 이에 따라 global object는 bad-time 상태로 진입하며 FTL 코드의 가정이 invalidate되어 sunk array를 반드시 rematerialize해야 하는 exit이 강제됩니다. Fix 이전에는 arrayStructureForIndexingTypeDuringAllocation(ArrayWithDouble)SlowPutArrayStorage structure를 응답하는 동안에도, 이어지는 코드는 여전히 butterfly->contiguous().atUnsafe(index)를 통해 butterfly를 구성하고 sentinel 값으로 채웁니다. 이후 기록된 다섯 개의 값은 putDirectIndex를 거치게 되고, 마지막의 gc()가 이 불일치된 view로 array를 순회하게 됩니다. 이 write가 ArrayStorage의 fast path(vector 내부)를 타는지 slow path를 타는지는 canSetIndexQuicklyForPutDirect에 달려 있는데, 이 함수는 vector length와 m_numValuesInVector를 참조하며 이번에 제공된 context에는 포함되어 있지 않습니다.

여기서 한 단계 더 나아가 생각해 보면, ArrayStorage header가 attacker가 선택한 double 값으로 채워진 슬롯과 겹치기 때문에 m_sparseMapWriteBarrier field가 완전히 attacker 통제 하의 bit pattern을 갖게 될 가능성이 있고, m_numValuesInVector / m_indexBias 역시 임의의 32-bit 값으로 설정될 가능성이 있습니다. 이후 slow-put이나 sparse-map lookup이 이 field를 dereference한다면 attacker는 fake-object read primitive를 얻게 될 가능성이 있고, m_indexBias 기반 연산이 vector base를 재계산하는 데 사용된다면 access 위치가 추가로 displacement될 가능성도 있습니다. 다만 이 두 확장 시나리오는 예상되는 방향일 뿐 확립된 동작은 아닙니다. 이를 실제로 달성하려면 out-of-bounds write가 m_vector[] 상단에서 원하는 인접 객체 위에 정확히 놓이도록 하는 heap grooming과, confused array를 JS에서 처음 소비하는 연산을 통제하는 능력이 모두 필요한데, 이번 변경 사항만으로는 이 두 조건 중 어느 것도 성립하지 않습니다.

이 vulnerability는 WebContent process 내부에서 JSC의 type-safety boundary를 약화시킵니다. 여기서 지켜져야 할 invariant는 JSObject의 structure가 butterfly layout을 정확히 반영해야 한다는 것입니다. Fix 이전에는 평범한 스크립트만으로도 structure는 SlowPutArrayStorage라고 말하지만 실제 backing store는 contiguous인 JSArray를 만들어낼 수 있었습니다. 그 결과 이후의 모든 indexed access — JS 코드에서든, inline cache에서든, GC의 visit routine에서든 — 는 attacker가 작성한 element data를 ArrayStorage metadata로 해석하게 됩니다. 이 상태를 안정적으로 유발할 수 있는 attacker라면 butterfly에 인접한 out-of-bounds access와, controlled double로부터 채워진 pointer field를 함께 확보할 수 있습니다. 이는 renderer 내에서 arbitrary read/write를 구축하는 전형적인 출발점에 해당합니다. 다만 그 이상으로 나아가려면 별도의 sandbox escape가 여전히 필요합니다.

주목할 만한 점은, 이 버그가 allocation 경로가 아니라 reconstruction 경로에 존재한다는 사실입니다. operationMaterializeObjectInOSR은 exit 시점에, 컴파일 시점에 결정된 shape를 가진 객체를 재현해야 하는 구조상 그 사이에 발생하는 어떠한 global VM 상태 변화에도 노출됩니다. arrayStructureForIndexingTypeDuringAllocation()이 일반적인 allocation site에서는 정확한 선택인 이유는, 이 함수가 현재의 bad-time 상태를 따라가기 때문입니다. 반대로 caller가 이미 구체적인 메모리 layout에 commit한 지점에서는 이 함수 호출이 잘못된 선택이 됩니다. 이번 fix의 핵심 방향은 일반적인 해법이기도 합니다. Layout 변환은 실제로 메모리를 재작성하는 routine을 통해서만 이루어져야 하며, type descriptor를 단순히 바꿔치기해서는 안 됩니다. 한 가지 더 눈여겨볼 부분은 이 fix가 만든 파급 효과입니다. Materialize 쪽을 수정하자 populate 쪽에도 대응하는 분기가 필요해졌는데, 이는 그쪽의 hole-writing fast path 역시 "rematerialize된 array는 절대 ArrayStorage가 아니다"라는 동일한 가정 위에 작성되어 있었기 때문입니다. "이 경로는 오직 layout X만 볼 수 있다"는 형태의 invariant는 대체로 여러 함수에 중복되어 존재하는 경향이 있으며, 그 함수들은 한꺼번에 함께 깨지곤 합니다.