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 in local_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 in local_30. This value is then used in malloc(local_30) which means we can control the amount of heap memory to request from malloc. This is very interesting and most likely a vulnerability we can take advantage of.
  • read(0,local_18,local_30) reads local_30 bytes (song length) from stdin into local_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 of local_18 buffer 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 read call, song length local_30 (stored at -0x28(%rbp)) is moved to rdx , pointer returned by malloc (local_18 stored at -0x10(%rbp)) is moved to rax and song length is added to the pointer to advance it. Then subtract 0x1 to get to the last element that‘s set to 0x0 in the next instruction.
  • local_20 pointer which was allocated at the start is compared to 0 and read_flag is called if true.
  • In order to solve the challenge, we have to trick malloc into returning a memory location such that code writes 0 exactly at local_20 pointer. Remember that earlier in the code, value at local_20 was set to 1 . So we possibly have to overwrite local_20 heap 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