Binary Exploitation: malloc and the heap
2025-03-26
Another fun challenge from Cyber Apocalypse CTF 2025 by HackTheBox under pwn section — blessing
In this challenge we explore the heap and how malloc works. We get to exploit a unique malloc behaviour to achieve arbitrary write access on the heap.
Looking at the challenge files , we have an x86 binary blessing, flag.txt and dynamically loaded libc library. Seems like another case of code redirection to force the binary to read the flag.
challenge/
├── blessing
├── flag.txt
└── glibc
├── ld-linux-x86-64.so.2
└── libc.so.6
After executing the binary we see this

An address is printed as a gift and quickly disappears. We can enter a song length and a song name. We can try some random values, but it’s much more effective to understand the program logic. Let’s look at decompiled code in Ghidra
undefined8 main(void)
{
long in_FS_OFFSET;
size_t local_30;
ulong local_28;
long *local_20;
void *local_18;
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
setup();
banner();
local_30 = 0;
local_20 = (long *)malloc(0x30000);
*local_20 = 1;
printstr(
"In the ancient realm of Eldoria, a roaming bard grants you good luck and offers you a gif t!\n\nPlease accept this: "
);
printf("%p",local_20);
sleep(1);
for (local_28 = 0; local_28 < 0xe; local_28 = local_28 + 1) {
printf("\b \b");
usleep(60000);
}
puts("\n");
printf("%s[%sBard%s]: Now, I want something in return...\n\nHow about a song?\n\nGive me the song\ 's length: "
,&DAT_00102063,&DAT_00102643,&DAT_00102063);
__isoc99_scanf(&DAT_001026b1,&local_30);
local_18 = malloc(local_30);
printf("\n%s[%sBard%s]: Excellent! Now tell me the song: ",&DAT_00102063,&DAT_00102643,
&DAT_00102063);
read(0,local_18,local_30);
*(undefined8 *)((long)local_18 + (local_30 - 1)) = 0;
write(1,local_18,local_30);
if (*local_20 == 0) {
read_flag();
}
else {
printf("\n%s[%sBard%s]: Your song was not as good as expected...\n\n",&DAT_001026e9,
&DAT_00102643,&DAT_001026e9);
}
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}
malloc(0x30000)allocates 196608 bytes on the heap memory and returned pointer is stored inlocal_20. Value of this pointer is set to 1. This address is then printed and quickly disappears as we see in the gif above.__isoc99_scanf(&DAT_001026b1,&local_30);reads the song length from user input and stores it inlocal_30. This value is then used inmalloc(local_30)which means we can control the amount of heap memory to request frommalloc. This is very interesting and most likely a vulnerability we can take advantage of.read(0,local_18,local_30)readslocal_30bytes (song length) from stdin intolocal_18, the pointer returned by malloc with user controlled size.*(undefined8 *)((long)local_18 + (local_30 — 1)) = 0;this looks slightly confusing but it’s basically setting the last entry oflocal_18buffer to 0. It’s much easier to understand in assembly.
0x000000000000171e <+348>: call 0x11f0 <read@plt>
0x0000000000001723 <+353>: mov -0x28(%rbp),%rdx
0x0000000000001727 <+357>: mov -0x10(%rbp),%rax
0x000000000000172b <+361>: add %rdx,%rax
0x000000000000172e <+364>: sub $0x1,%rax
0x0000000000001732 <+368>: movq $0x0,(%rax)
- Right after the
readcall, song lengthlocal_30(stored at-0x28(%rbp)) is moved tordx, pointer returned bymalloc(local_18stored at-0x10(%rbp)) is moved toraxand song length is added to the pointer to advance it. Then subtract0x1to get to the last element that‘s set to0x0in the next instruction. local_20pointer which was allocated at the start is compared to 0 andread_flagis called iftrue.- In order to solve the challenge, we have to trick
mallocinto returning a memory location such that code writes 0 exactly atlocal_20pointer. Remember that earlier in the code, value atlocal_20was set to 1 . So we possibly have to overwritelocal_20heap address.
The binary has what looks like an anti-debugging mechanism. If we stay at a breakpoint for some duration, binary exits with a message
Program terminated with signal SIGALRM, Alarm clock.
alarm is used to deliver a signal to the process after certain interval. We can easily patch this to avoid the alarm. setup function in the binary calls the alarm function. This can be set this to nop (0x90) to skip the alarm.
(gdb) disass setup
0x000055555555559c <+82>: call 0x555555555210 <setvbuf@plt>
0x00005555555555a1 <+87>: mov $0x7f,%edi
0x00005555555555a6 <+92>: call 0x5555555551c0 <alarm@plt>
Let’s patch it
set *(unsigned char*)0x00005555555555a6 = 0x90
Understanding the Heap
Heaps are memory areas allocated to each program. Memory allocated to heaps can be dynamically allocated, unlike memory allocated to stacks.
There are plenty of articles explaining heap memory so we skip to interesting parts relevant for this challenge.
Let’s first obtain the target address printed at the start of the program which we have to overwrite. 0x7ffff7f87010 we can simply copy it when it appears, or set a breakpoint in GDB when it’s printed.
Taking a naive approach, we can try to pass various sizes to malloc with the song length input to understand it’s behaviour and if we can get an address near the local_20 pointer that we must overwrite. Let’s send some values and set a breakpoint after the malloc call and check value of rax register where return value is stored after a function call.
(gdb) disass
0x00005555555556c7 <+261>: call 0x555555555240 <__isoc99_scanf@plt>
0x00005555555556cc <+266>: mov -0x28(%rbp),%rax
0x00005555555556d0 <+270>: mov %rax,%rdi
0x00005555555556d3 <+273>: call 0x555555555200 <malloc@plt>
0x00005555555556d8 <+278>: mov %rax,-0x10(%rbp)
(gdb) break *0x00005555555556d8
(gdb) c
Breakpoint 2 at 0x5555555556d8
(gdb) i r
rax 0x5555555592a0 93824992252576
rbx 0x0 0
rcx 0x21 33
rdx 0x0 0
rsi 0x5555555592b0 93824992252592
0x5555555592a0 is the returned heap address which is not at all similar to the target address printed at the start. Now, let’s repeat the process and request 196608 bytes. We get 0x7ffff7f56010, which looks much similar to the target address. What is happening?
When you call malloc(size), the behaviour changes depending on whether size is small or large. Small allocations happen from heap memory. The heap typically starts at 0x55555555xxxx in user-space programs. Since 20 bytes is small, it is allocated inside an already existing heap chunk. Large allocations bypass the usual heap bins (default >128 KB). Memory is mapped at a high address (0x7fff...), separate from the heap.
Since memory is managed by the kernel, it’s unlikely that it will allocate the target address in the same program unless it is free’ed.
Next, we can try to allocate zero, negative and huge sizes to see what happens.
malloc documentation says that
If size is 0, then malloc() returns a unique pointer value that can later be successfully passed to free()
…
On error, these functions return NULL and set errno.
Attempting to allocate more than PTRDIFF_MAX bytes is considered an error, as an object that large could cause later pointer subtraction to overflow.
Interesting, so we could trick malloc to return a null pointer by requesting negative or absurdly large size.
This is exactly what we can (ab)use to overwrite the target pointer. If you recall, returned pointer is advanced to the user controlled size to access the last element which is set to 0. So if we can send an absurdly large song length such that if it’s added to null pointer 0x0 , the resulting address would be the target address plus one 0x7ffff7f87010 + 1 (we add one, since it will be subtracted to access the last element which is set to 0).
Easy, we can just convert target address 0x7ffff7f87010 to int, add 1, fortunately, this gives us an absurdly large size to cause malloc to return a null pointer. We pass it as song length to exploit the vulnerability
