The Holy Hole - Analysis of CVE-2023-2033

Introduction

I have been following v8 exploits related to hole for sometime now and I found CVE-2023-2033 really interesting. So I thought documenting whatever I learn while doing the analysis of this CVE will help me understand it even better.

Hole

Generally in JavaScript, A Hole to an empty slot in an array. These holes are different from undefined or null values, which are actual values that can be assigned to array elements.

For example, if you create an array like this:

var arr = [1, 2, 3];
arr.length = 5;

The array arr will have two holes at the end.

Now lets see how this looks like in v8, We can use %DebugPrint(arr) so get debug information of the array.

d8> %DebugPrint(arr)
DebugPrint: 0x38ae001c9415: [JSArray]
 - map: 0x38ae000cef71 <Map[16](HOLEY_SMI_ELEMENTS)> [FastProperties]
 - prototype: 0x38ae000ce925 <JSArray[0]>
 - elements: 0x38ae001cb0fd <FixedArray[20]> [HOLEY_SMI_ELEMENTS]
 - length: 5
 - properties: 0x38ae000006cd <FixedArray[0]>
 - All own properties (excluding elements): {
    0x38ae00000d41: [String] in ReadOnlySpace: #length: 0x38ae0030f6f9 <AccessorInfo name= 0x38ae00000d41 <String[6]: #length>, data= 0x38ae00000061 <undefined>> (const accessor descriptor), location: descriptor
 }
 - elements: 0x38ae001cb0fd <FixedArray[20]> {
           0: 1
           1: 2
           2: 3
        3-19: 0x38ae000006e9 <the_hole_value>
 }
0x38ae000cef71: [Map] in OldSpace
 - map: 0x38ae000c3c29 <MetaMap (0x38ae000c3c79 <NativeContext[285]>)>
 - type: JS_ARRAY_TYPE
 - instance size: 16
 - inobject properties: 0
 - unused property fields: 0
 - elements kind: HOLEY_SMI_ELEMENTS
 - enum length: invalid
 - back pointer: 0x38ae000ce6b1 <Map[16](PACKED_SMI_ELEMENTS)>
 - prototype_validity cell: 0x38ae00000a31 <Cell value= 1>
 - instance descriptors #1: 0x38ae000cef3d <DescriptorArray[1]>
 - transitions #1: 0x38ae000cef99 <TransitionArray[4]>Transition array #1:
     0x38ae00000e05 <Symbol: (elements_transition_symbol)>: (transition to PACKED_DOUBLE_ELEMENTS) -> 0x38ae000cefb1 <Map[16](PACKED_DOUBLE_ELEMENTS)>

 - prototype: 0x38ae000ce925 <JSArray[0]>
 - constructor: 0x38ae000ce61d <JSFunction Array (sfi = 0x38ae00335da5)>
 - dependent code: 0x38ae000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

[1, 2, 3, , ]

You can see that the elements kind for this array is HOLEY_SMI_ELEMENTS. SMI is used to represent small integers. and you can see that the elements after arr[2] are holes.

V8 uses a special object to represent these holes called TheHole. It is an internal implementation which never leaks(or should never) in the JavaScript code. since this is a special object it is handled differently in various places in v8. this special handling is what lead to a lot vulnerabilities in the past.

Setup

spektre@skream:~$ fetch v8
spektre@skream:~$ cd v8
spektre@skream:~/v8$ ./build/install-build-deps.sh # Assumes you're using apt
spektre@skream:~/v8$ git checkout f7a3499f6d7e50b227a17d2bbd96e4b59a261d3c
spektre@skream:~/v8$ ./tools/dev/v8gen.py x64.release
spektre@skream:~/v8$ ninja -C ./out.gn/x64.release # Release version
spektre@skream:~/v8$ ./tools/dev/v8gen.py x64.debug
spektre@skream:~/v8$ ninja -C ./out.gn/x64.debug # Debug version 

Bug Analysis

POC

var h0le = [Object];
function boom() {
  var h00le = h0le;
  function rGlobal() {
    h00le[0] = stack;
    return h00le;
  }
  Error.captureStackTrace(globalThis);
  Error.prepareStackTrace = function() {
    Reflect.deleteProperty(Error, 'prepareStackTrace');
    Reflect.deleteProperty(globalThis, 'stack');
    Reflect.defineProperty(
        globalThis, 'stack',
        {configurable: false, writable: true, enumerable: true, value: 1});
    stack = undefined;
    for (let i = 0; i < 0x5000; i++) {
      rGlobal();
    }
    return undefined;
  };
  Reflect.defineProperty(
      globalThis, 'stack',
      {configurable: true, writable: true, enumerable: true, value: undefined});
  delete globalThis.stack;
  rGlobal();
  %DebugPrint(h0le[0]);
}
boom();

First, we call Error.captureStackTrace():

  • This function captures the current stack trace and stores it in a private symbol property.
  • It creates a “v8 api/native accessor” (AccessorInfo) property for stack.
  • In V8, AccessorInfo objects are used to store information about custom getter and setter functions.

Overriding Error.prepareStackTrace:

  • The prepareStackTrace function is redefined by the user.
  • This function is called whenever the stack property of an Error object is accessed.

Call Object.defineProperty on globalThis, “stack”, with the specified properties {...}:

  • To define a property descriptor, it’s necessary to first get the current property descriptor. This action invokes JSReceiver::GetOwnPropertyDescriptor.
  • Since stack is an AccessorInfo (and not a standard “JavaScript accessor” represented by AccessorPair), the process involves calling Object::GetProperty.
  • This leads to the invocation of the user-defined Error.prepareStackTrace function, which formats the stack trace.

Invoke the user-defined Error.prepareStackTrace function during the execution of Object.defineProperty:

  • Delete the Old ‘stack’ Property: Remove the existing stack property from globalThis, using its configurable attribute set to true.

  • Define a New ‘stack’ Data Property: Create a new stack data property on globalThis. This property is made writable but non-configurable.

  • Transition PropertyCell Type: Modify the stack property to change its PropertyCell type to kMutable.

  • Trigger TurboFan Optimization: Optimize the function loading stack with TurboFan (V8’s optimizing compiler), due to the new status of stack as a non-configurable and mutable property.

    1. Optimize LoadGlobal Operation:
      • Optimize the LoadGlobal operation to load from a HeapConstant PropertyCell.
    2. Bypass De-optimization Checks:
      • No lazy de-optimization compilation dependencies are installed during this process.
  • After the user-defined Error.prepareStackTrace function completes, Object.defineProperty() continues with the descriptor returned earlier (which does not reflect the changes made inside Error.prepareStackTrace).

  • The stack property is updated to {value: 1, configurable: true}, contradicting the non-configurable state assumed by the JIT-optimized code.

  • Upon deletion of the stack property, the PropertyCell is cleared and transitions into a state with a value known as “The Hole”.

  • The TurboFan-optimized function then tries to access the stack property, expecting a non-configurable, mutable property. Instead, it retrieves the cleared PropertyCell, which now contains “The Hole”.

Patch Analysis

There were two fixes for this bug.

Patch 1

diff --git [a/src/builtins/builtins-error.cc](https://chromium.googlesource.com/v8/v8/+/f7a3499f6d7e50b227a17d2bbd96e4b59a261d3c/src/builtins/builtins-error.cc) [b/src/builtins/builtins-error.cc](https://chromium.googlesource.com/v8/v8/+/fa81078cca6964def7a3833704e0dba7b05065d8/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);

This introduced a simple check to return undefined value If object_obj is a JSGlobalProxy I think this patch was a temporary fix as they introduced another patch after few days for a better fix

Patch 2 :

The patch notes itself is pretty much self explanatory here:

[error] Define Error.stack property as a JavaScript accessor

... instead of the native data property.
This fixes the JS spec violation when reading a data property does not
expect any observable side effects. For example, OrdinaryGetOwnProperty
(see [https://tc39.es/ecma262/#sec-ordinarygetownproperty](https://tc39.es/ecma262/#sec-ordinarygetownproperty), step 4a).

Differences to previous behaviour:
- Error.stack is defined as a JavaScript accessor property.
- all Error objects get a private "error_stack_symbol" field where
  the captured stack trace is stored (previously it was added only when
  the error was actually thrown which caused unnecessary transitions).
- Error.captureStackTrace(obj) adds public "stack" accessor property
  and private "error_stack_symbol" property to given "obj".
- calling "stack" getter/setter is a no-op in case receiver is not an
  "Error-like" object, i.e. it doesn't have a "error_stack_symbol"
  property and it doesn't have a prototype with such a property (the
  lookup stops at JSProxy or interceptor).
- the "stack" getter walks the prototype chain from receiver until it
  finds a holder with the "error_stack_symbol" property which is then
  used for computing the result.
  This is slightly different from the previous behaviour in case
  receiver's prototype chain contains multiple error objects.
- the "stack" setter walks the prototype chain from receiver until it
  finds a holder with the "error_stack_symbol" property and stores the
  value there.

Conclusion

I have learnt a lot while doing analysis for this CVE, and the next blog post will be about how this vulnerability was exploited. If you find any mistakes or have any doubts/suggestions feel free to contact me :)

© 2025 spektre. All rights reserved.
Built with Hugo
Theme Stack designed by Jimmy