Debugging and undefined behavior · about 50 min

valgrind and the sanitizers

Why this matters

The worst C bugs do not crash. Writing one element past an array, reading memory after freeing it, or using a variable that was never set usually works on your machine, on your input, today. Then it fails for a user with a slightly longer name, or after an unrelated change moves things around in memory, or it becomes a security hole that lets an attacker run code. You cannot find these bugs by testing outputs alone, because the output is often correct. You need tools that watch every memory access. This lesson covers the two families you will use for the rest of your C life: valgrind, which runs your unchanged program on a simulated CPU, and the sanitizers, which the compiler builds into your program.

valgrind: a CPU that checks everything

valgrind (its default tool is called memcheck) loads your program and runs it instruction by instruction on a synthetic CPU. Alongside every byte of your memory it keeps shadow information: is this byte addressable (part of a live allocation)? is it defined (has something been written to it)? It also replaces malloc and free with versions that remember every block. That lets it report:

No recompilation is needed, though -g makes reports show file names and lines. The cost is speed: programs run 20 to 50 times slower.

$ gcc -std=c17 -Wall -Wextra -g -O0 -o prog leak.c
$ valgrind --leak-check=full ./prog
==25== Memcheck, a memory error detector
==25== Invalid write of size 4
==25==    at 0x1091A2: main (leak.c:9)
==25==  Address 0x4a55050 is 0 bytes after a block of size 16 alloc'd
==25==    at 0x4844818: malloc (vg_replace_malloc.c:446)
==25==    by 0x10917A: main (leak.c:7)
==25==
9
Ada
==25== HEAP SUMMARY:
==25==     in use at exit: 6 bytes in 1 blocks
==25==   total heap usage: 3 allocs, 2 frees, 4,118 bytes allocated
==25==
==25== 6 bytes in 1 blocks are definitely lost in loss record 1 of 1
==25==    at 0x4844818: malloc (vg_replace_malloc.c:446)
==25==    by 0x1091D7: main (leak.c:11)
==25==
==25== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)

(The ==25== is the process id and differs every run.) The program printed the right answers, yet valgrind found two bugs. How to read a report:

  1. The first line is the kind of error and the size of the access ("size 4" is an int).
  2. The first stack ("at ... by ...") is where it happened. Read down until you reach your own file.
  3. The "Address ... is" paragraph relates the bad address to a known block: "0 bytes after a block of size 16" means just past the end; "inside a block of size 4 free'd" means use after free, followed by where it was freed and where it was allocated.
  4. The leak summary classifies unfreed memory. Definitely lost: no pointer to the block exists any more, a real leak. Indirectly lost: reachable only through a definitely lost block (the rest of a leaked linked list). Still reachable: a pointer still exists at exit, for example a global; usually harmless.

Useful options: --leak-check=full (details for each leak), --track-origins=yes (where an uninitialized value came from), --error-exitcode=1 (exit 1 if any error, for scripts and tests), -q (only print errors).

Uninitialized values look like this:

$ valgrind -q ./prog
==20== Conditional jump or move depends on uninitialised value(s)
==20==    at 0x109145: main (uninit.c:6)

valgrind does not complain when you merely copy garbage around; it reports when garbage affects what the program does, such as an if or a system call.

AddressSanitizer: checks compiled in

AddressSanitizer (ASan) takes the opposite approach: the compiler inserts a check before every memory access, and places poisoned red zones around every heap block, every stack array and every global. Touching a red zone or a freed block stops the program with a report.

$ gcc -std=c17 -g -fsanitize=address -o prog uaf.c
$ ./prog
=================================================================
==13==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010 ...
READ of size 4 at 0x502000000010 thread T0
    #0 0x564825cef252 in main /work/uaf.c:9
...
0x502000000010 is located 0 bytes inside of 4-byte region [0x502000000010,0x502000000014)
freed by thread T0 here:
    #0 0x7f920434c8f8 in free ...
    #1 0x564825cef21b in main /work/uaf.c:8

The program it ran:

int *n = malloc(sizeof *n);
*n = 42;
free(n);
printf("%d\n", *n);     /* line 9: reading freed memory */

Without ASan this prints 42, because freeing a block does not erase it. ASan programs run only about twice as slow as normal, so you can use them for your whole test suite. ASan also includes LeakSanitizer, which prints leaks when the program exits.

UndefinedBehaviorSanitizer

UBSan (-fsanitize=undefined) checks for operations the C standard calls undefined behavior (the subject of the next lesson): signed integer overflow, shifting by too many bits, out of range array indexes where the size is known, misaligned pointers, and more. It prints a runtime error line and, by default, keeps going:

$ gcc -std=c17 -g -fsanitize=undefined,address -o prog shift.c
$ ./prog
shift.c:8:22: runtime error: shift exponent 32 is too large for 32-bit type 'int'
shift.c:9:21: runtime error: index 3 out of bounds for type 'int [3]'
==26==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ff54ab0002c ...

The two sanitizers combine well: -fsanitize=address,undefined is a good default for every debug build. Add -fno-sanitize-recover=all to make UBSan stop at the first error.

Which tool when

valgrindASanUBSan
needs recompilingnoyesyes
slowdown20-50xabout 2xsmall
heap overflow, use after freeyesyesno
stack and global array overflownoyespartly (known sizes)
uninitialized valuesyesnono
leaksyesyes (LeakSanitizer)no
integer overflow, bad shiftsnonoyes

You cannot run a sanitized program under valgrind; pick one per run. A good habit: develop with -fsanitize=address,undefined, and run valgrind before you call something finished, especially to catch uninitialized reads.

Common mistakes

Recap

Going further: run valgrind --track-origins=yes ./prog on a program with an uninitialized variable and see it point at the declaration. The ASan documentation is in the GCC manual under "Instrumentation Options" (gcc.gnu.org/onlinedocs, search for -fsanitize; man gcc is not installed in the sandbox).

Exercises

Exercise 1 · quiz

Read a valgrind report

valgrind reports:

==25== Invalid write of size 4
==25==    at 0x1091A2: main (leak.c:9)
==25==  Address 0x4a55050 is 0 bytes after a block of size 16 alloc'd
==25==    at 0x4844818: malloc (vg_replace_malloc.c:446)
==25==    by 0x10917A: main (leak.c:7)

What happened?

Answer
  • Line 9 wrote an int (4 bytes) immediately past the end of a 16-byte block that line 7 allocated: for example element [4] of a 4-int array

"Invalid write of size 4" is the access: 4 bytes, typically one int. "0 bytes after a block of size 16" places it right at the end of a 16-byte allocation, which is 4 ints with valid indices 0 to 3. The two stacks tell you where the bad access happened (leak.c:9) and where the block was allocated (leak.c:7). The classic cause is a loop written with <= instead of <.

Exercise 2 · quiz

Which tool catches it?

int a[3] is a local array and the program reads a[3]. Which of these will report it? Select all that apply.

Answer
  • A build with -fsanitize=address (AddressSanitizer)

  • A build with -fsanitize=undefined when the index is out of range for the array's declared type

valgrind only knows which heap blocks exist; a local array is part of the stack frame, which is valid memory as far as valgrind can tell, so reading one element too far is silent. ASan compiles red zones around every stack array, global and heap block, so it catches it. UBSan's bounds check catches indexing past the declared size of an array when the compiler can see that size. And no, it usually does not crash: it just reads the neighbouring bytes.

Exercise 3 · write code

Make valgrind happy

This program happens to print the right output, but valgrind reports an invalid write and a memory leak. Fix both bugs without changing what it prints:

9
Ada

The check runs your program under valgrind --error-exitcode=1 --leak-check=full --errors-for-leak-kinds=definite, so it passes only when valgrind finds no errors and no leaks.

File: main.c

Starting point

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    int *v = malloc(4 * sizeof *v);
    for (int i = 0; i <= 4; i++)
        v[i] = i * i;
    printf("%d\n", v[3]);

    char *name = malloc(6);
    strcpy(name, "Ada");
    printf("%s\n", name);

    free(v);
    return 0;
}

Example

expected output

9
Ada
Hint 1

The check output shows the valgrind report: read the line numbers it gives.

Hint 2

The loop writes v[4]. Valid indices for 4 elements are 0 to 3.

Hint 3

Every malloc needs a matching free: name is never freed.

Reference solution
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    int *v = malloc(4 * sizeof *v);
    for (int i = 0; i < 4; i++)
        v[i] = i * i;
    printf("%d\n", v[3]);

    char *name = malloc(6);
    strcpy(name, "Ada");
    printf("%s\n", name);

    free(name);
    free(v);
    return 0;
}

Exercise 4 · write code

One byte short

join_words(a, b) should return a newly allocated string containing a, one space, and b, for example join_words("hello", "world") returns "hello world". The caller frees it.

The starter's version works most of the time, which is exactly the problem: it allocates one byte too few, and the overflow usually lands in malloc's unused padding. The check builds your function with AddressSanitizer (-fsanitize=address,undefined), which reports even a single byte past the end. Fix the allocation size. Keep main as it is; it prints:

hello world

File: main.c

Starting point

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char *join_words(const char *a, const char *b)
{
    char *out = malloc(strlen(a) + strlen(b) + 1);
    if (out == NULL)
        return NULL;
    strcpy(out, a);
    strcat(out, " ");
    strcat(out, b);
    return out;
}

int main(void)
{
    char *s = join_words("hello", "world");
    printf("%s\n", s);
    free(s);
    return 0;
}

Example

expected output

hello world
Hint 1

Count every byte of the result: the letters of a, the space, the letters of b, and one more.

Hint 2

strlen does not count the terminating '\0', but strcpy and strcat write it.

Hint 3

malloc(strlen(a) + 1 + strlen(b) + 1)

Reference solution
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char *join_words(const char *a, const char *b)
{
    /* a, the space, b, and the terminating '\0' */
    char *out = malloc(strlen(a) + 1 + strlen(b) + 1);
    if (out == NULL)
        return NULL;
    strcpy(out, a);
    strcat(out, " ");
    strcat(out, b);
    return out;
}

int main(void)
{
    char *s = join_words("hello", "world");
    printf("%s\n", s);
    free(s);
    return 0;
}