Why this matters
Memory bugs are the defining hazard of C. Around 70 percent of serious security vulnerabilities in large C and C++ code bases (Microsoft and Google have both published this figure) are memory safety bugs: writing past the end of a buffer, using memory after freeing it, and their relatives. What makes them dangerous is that they usually do not crash where they happen. The program keeps running with corrupted memory and fails later, somewhere else, or never visibly, until an attacker finds the input that makes it do something useful for them. You cannot find these bugs by looking at output. You need a tool that watches memory, and the classic one is valgrind.
The bug catalog
There are five kinds of heap bugs to know by name. Here is a single program with one of each, selected by a command-line argument:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[])
{
int which = argc > 1 ? atoi(argv[1]) : 0;
if (which == 1) { /* off by one */
int *a = malloc(5 * sizeof *a);
for (int i = 0; i <= 5; i++)
a[i] = i;
free(a);
} else if (which == 2) { /* use after free */
char *name = malloc(16);
strcpy(name, "Ada");
free(name);
printf("%c\n", name[0]);
} else if (which == 3) { /* uninitialized */
int *count = malloc(sizeof *count);
if (*count > 10)
printf("big\n");
free(count);
} else if (which == 4) { /* leak */
char *buf = malloc(100);
buf = malloc(200);
free(buf);
}
return 0;
}
Run normally, every one of these runs to completion with exit status 0. Case 2 even prints A, the "right" answer. The bugs are invisible.
- Heap buffer overflow (case 1): reading or writing outside the block. The loop runs to
i <= 5, writing a sixth int into a five-int block. The write lands in padding or in the next chunk's header. - Use after free (case 2): accessing a block after
free. The memory may still hold the old bytes, or may already belong to another allocation. - Uninitialized read (case 3): using the value of memory nobody wrote.
mallocdoes not clear memory, so the branch depends on leftover bytes. - Memory leak (case 4): losing the last pointer to a block. The first
malloc(100)can never be freed. - Invalid or double free: calling
freeon something that is not a livemalloced block. glibc often catches this one itself and aborts withfree(): double free detected in tcache 2orfree(): invalid pointer.
How valgrind works
valgrind (the tool is called Memcheck; it is the default) does not need a special build. It loads your program and translates its machine code, on the fly, into instrumented code that runs on a simulated CPU. For every byte of memory it tracks two extra facts:
- addressable? Is this byte part of a live allocation, a stack frame, or a global? Heap blocks get unaddressable "red zones" around them, and freed blocks are marked unaddressable and kept out of circulation for a while.
- defined? Has this byte been written since it was allocated? (It tracks this per bit.)
It also replaces malloc and free with its own versions that record where every block was allocated. Every load and store is checked. The price is speed: programs run 10 to 50 times slower. Compile with -g so reports can show file names and line numbers, and -O0 so the lines match your source.
Reading the reports
Case 1, the off-by-one:
$ valgrind ./bugs 1
==10== Invalid write of size 4
==10== at 0x1091DF: main (bugs.c:11)
==10== Address 0x4a55054 is 0 bytes after a block of size 20 alloc'd
==10== at 0x4844818: malloc (vg_replace_malloc.c:446)
==10== by 0x1091BA: main (bugs.c:9)
Every report has the same structure. (The ==10== prefix is the process id, and the hex addresses differ from run to run; ignore them.)
- What:
Invalid write of size 4, a 4-byte store (anint) to a bad address. - Where: a stack trace, innermost first.
main (bugs.c:11)is the linea[i] = i;. - Which memory:
0 bytes after a block of size 20. The address is just past the end of a 20-byte block. "N bytes before" means an underflow; "inside a block ... free'd" means use-after-free. - Its history: where the block was allocated (
bugs.c:9) and, for freed blocks, where it was freed.
Case 2, use after free:
==12== Invalid read of size 1
==12== at 0x10922A: main (bugs.c:17)
==12== Address 0x4a55040 is 0 bytes inside a block of size 16 free'd
==12== at 0x484787F: free (vg_replace_malloc.c:989)
==12== by 0x109225: main (bugs.c:16)
==12== Block was alloc'd at
==12== at 0x4844818: malloc (vg_replace_malloc.c:446)
==12== by 0x10920B: main (bugs.c:14)
Three stack traces: the bad read, the free, and the original malloc. That is usually enough to see the whole story.
Case 3, uninitialized value:
==14== Conditional jump or move depends on uninitialised value(s)
==14== at 0x109265: main (bugs.c:20)
==14==
==14== Use --track-origins=yes to see where uninitialised values come from
valgrind does not complain when you copy uninitialized bytes around (programs do that legitimately all the time, for example copying a struct with padding). It complains when the value is used to make a decision or passed to a system call. Rerun with --track-origins=yes and it adds where the memory came from.
Case 4, the leak. Leaks are reported at exit, and you need --leak-check=full to see where each leaked block was allocated:
==16== HEAP SUMMARY:
==16== in use at exit: 100 bytes in 1 blocks
==16== total heap usage: 2 allocs, 1 frees, 300 bytes allocated
==16==
==16== 100 bytes in 1 blocks are definitely lost in loss record 1 of 1
==16== at 0x4844818: malloc (vg_replace_malloc.c:446)
==16== by 0x109293: main (bugs.c:24)
==16==
==16== LEAK SUMMARY:
==16== definitely lost: 100 bytes in 1 blocks
==16== indirectly lost: 0 bytes in 0 blocks
==16== possibly lost: 0 bytes in 0 blocks
==16== still reachable: 0 bytes in 0 blocks
The trace shows where the leaked block was allocated, not where the pointer was lost. You then have to work out which path forgot to free it.
Using valgrind in scripts and tests
These options make valgrind useful as an automatic check, which is exactly how this track checks your exercises:
$ valgrind -q --error-exitcode=1 --leak-check=full ./prog < input.txt
-qprints only errors, not the banner and summaries.--error-exitcode=1makes valgrind exit with status 1 if it found any error, so scripts andmakecan detect it.--leak-check=fullreports each leak with its allocation trace, and counts definite leaks as errors.
Help from the compiler
gcc can spot some of these at compile time, when the bug is visible within one function. Building the catalog program with -Wall -Wextra gives:
bugs.c:20:13: warning: '*count' may be used uninitialized [-Wmaybe-uninitialized]
bugs.c:17:28: warning: pointer 'name' used after 'free' [-Wuse-after-free]
It caught two of the four, and only because each bug sat in one short block. Spread the same bugs over a few functions and the compiler sees nothing. The AddressSanitizer (gcc -fsanitize=address) is a second runtime tool that instruments code at compile time and runs much faster than valgrind; the debugging module covers it alongside gdb.
Common mistakes
- Trusting a clean run. "It printed the right thing" says nothing about memory errors. Run valgrind on anything that allocates.
- Fixing the reported line instead of the cause. The invalid write is where memory broke; the bug may be the size computation in the
malloccall or a loop bound one line up. - Ignoring the first error. Like compiler errors, later valgrind reports can be consequences of the first one. Fix from the top.
- Testing only small inputs.
malloc(n)usually rounds up to a multiple of 16 bytes, so being one byte short often lands in harmless padding. valgrind catches it anyway; normal runs only fail with unlucky sizes. - Forgetting
-g. Without debug info the traces show only addresses, such asat 0x1091DF: main (in /work/prog).
Recap
- The five heap bugs: overflow, use after free, uninitialized read, leak, invalid or double free. Most do not crash where they happen.
- valgrind runs your unmodified program on a simulated CPU, tracking which bytes are addressable and defined, and reports bad accesses with stack traces for the access, the free and the allocation.
- Read reports as: what (read/write, size), where (trace), which memory (bytes before/after/inside a block), and its history.
valgrind -q --error-exitcode=1 --leak-check=fullturns it into an automatic test.
Going further: valgrind --track-origins=yes ./prog for uninitialized values, and the Memcheck manual at man valgrind (search for "leak-check"). Try writing one more bug yourself, reading one int before the start of a block (a[-1]), and see how the report phrases it.