Skip to main content
C · lesson 24 of 25
C advanced Lesson 24 of 25

Also part of: Web Security (OWASP Top 10, fix-side)

C Secure Coding: Buffer Overflows, Sanitizers, and Safe Functions

Catch C memory bugs before they ship: AddressSanitizer, _FORTIFY_SOURCE, stack canaries, -Wformat-security, and safe snprintf, fgets and overflow checks.

By SkillByExample · Updated

c security addresssanitizer secure-coding

Most C security bugs are memory bugs: a write past the end of a buffer, a read through a freed pointer, an arithmetic result that silently wrapped. This lesson shows the compiler and runtime catching each kind, then the code that avoids it in the first place. Every example was built with gcc:14 (GCC 14.4.0) on Linux. AddressSanitizer is not available in MinGW on Windows, so run these in Linux, WSL or Docker.

AddressSanitizer catches an out-of-bounds write

strcpy copies until it finds a '\0' and has no idea how big the destination is. Building with -fsanitize=address adds a check to every memory access, so the first bad write stops the program.

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

void copy_name(const char *input) {
    char buf[16];
    strcpy(buf, input);          /* bug: no length check */
    printf("buf: %s\n", buf);
}

int main(void) {
    copy_name("short");
    copy_name("this name is far too long for sixteen bytes");
    return 0;
}
$ gcc -g -fsanitize=address,undefined overflow.c -o overflow && ./overflow
buf: short
=================================================================
==13==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7f68eda00070 at pc 0x7f68f04474c1 bp 0x7ffd80fa42e0 sp 0x7ffd80fa3aa0
WRITE of size 44 at 0x7f68eda00070 thread T0
    #0 0x7f68f04474c0  (/usr/local/lib64/libasan.so.8+0xa24c0)
    #1 0x40122b in copy_name /src/overflow.c:6
    #2 0x40129a in main /src/overflow.c:12
...
Address 0x7f68eda00070 is located in stack of thread T0 at offset 48 in frame
    #0 0x4011a5 in copy_name /src/overflow.c:4

  This frame has 1 object(s):
    [32, 48) 'buf' (line 5) <== Memory access at offset 48 overflows this variable
...
SUMMARY: AddressSanitizer: stack-buffer-overflow (/usr/local/lib64/libasan.so.8+0xa24c0) 
...
==13==ABORTING

The first call fits and prints normally. The second call tries to write 44 bytes (43 characters plus the terminator) into a 16-byte array. Without the sanitizer, that write would quietly overwrite whatever sits next to buf on the stack. The program might crash later or might carry on with corrupted data. ASan stops it at the write and gives you three facts: the line (overflow.c:6), the caller (overflow.c:12), and the variable ('buf' (line 5), valid range [32, 48), access at offset 48). The ... lines mark where the libc frames and shadow-memory map were trimmed from this listing. -fsanitize=undefined adds UBSan, which catches the signed-overflow bug later in this lesson.

The fix: snprintf with a truncation check

snprintf takes the destination size, always writes a terminator, and returns the length it would have written. Compare that return value with the size to detect truncation.

#include <stdio.h>

int copy_name(char *dst, size_t size, const char *input) {
    int n = snprintf(dst, size, "%s", input);
    if (n < 0 || (size_t)n >= size)
        return -1;               /* truncated: caller decides what to do */
    return 0;
}

int main(void) {
    char buf[16];
    const char *names[] = { "short", "this name is far too long for sixteen bytes" };
    for (int i = 0; i < 2; i++) {
        int rc = copy_name(buf, sizeof buf, names[i]);
        printf("rc=%d buf=\"%s\"\n", rc, buf);
    }
    return 0;
}
$ gcc -Wall safecopy.c -o safecopy && ./safecopy
rc=0 buf="short"
rc=-1 buf="this name is fa"

The long name is cut to 15 characters plus '\0', so memory is never corrupted. The -1 return value still matters, though. Silently truncating a file path or a username can be its own bug, for example when it opens the wrong file or matches the wrong account. strncpy is often suggested here, but it doesn’t add a terminator when the source is too long and gives no signal that it truncated anything. snprintf does both.

_FORTIFY_SOURCE aborts an overflowing strcpy

With optimisation on, -D_FORTIFY_SOURCE=2 replaces strcpy, memcpy, sprintf and similar calls with checked versions whenever gcc knows the destination size. Unlike ASan, it is cheap enough for release builds.

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

int main(int argc, char **argv) {
    char buf[8];
    const char *src = argc > 1 ? argv[1] : "fine";
    strcpy(buf, src);            /* gcc knows sizeof buf at compile time */
    printf("copied: %s\n", buf);
    return 0;
}
$ gcc -O2 -D_FORTIFY_SOURCE=2 fortify.c -o fortify && ./fortify && ./fortify 'much too long'
copied: fine
*** buffer overflow detected ***: terminated
Aborted (core dumped)

The second run passes 13 characters for an 8-byte buffer, and glibc’s __strcpy_chk aborts before copying anything. The check compares the source length against sizeof buf at run time, so it catches input that only arrives at run time, such as a command-line argument. It needs -O1 or higher, because the size information comes from the optimiser. Without -O, the macro has no effect. It also can’t help when the destination is a bare pointer whose size gcc can’t see, which is why ASan still belongs in your test builds. -D_FORTIFY_SOURCE=3 (GCC 12+ with glibc 2.34+) narrows that gap: it uses __builtin_dynamic_object_size, so it also checks buffers whose size is only known at run time, such as malloc(n).

Stack canaries catch the overflow at function return

A hand-written loop doesn’t go through strcpy, so FORTIFY never sees it. -fstack-protector-strong puts a random “canary” value between a function’s arrays and its saved return information. If the canary has changed when the function returns, the program aborts.

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

void fill(size_t n) {
    char buf[16];
    for (size_t i = 0; i < n; i++)   /* bug: trusts n instead of sizeof buf */
        buf[i] = 'A';
    printf("filled %zu bytes\n", n);
}

int main(void) {
    fill(16);
    fill(64);
    puts("unreachable if the canary fires");
    return 0;
}
$ gcc -fstack-protector-strong canary.c -o canary && ./canary
filled 16 bytes
filled 64 bytes
*** stack smashing detected ***: terminated
Aborted (core dumped)

Notice that filled 64 bytes does print on a terminal (pipe the output and it can vanish, because abort() discards buffered stdout), because the canary is checked when fill returns, not when the bad write happens. The canary is a last line of defence, not a bug finder. It turns silent stack corruption into an immediate, visible crash, but it can’t tell you which line did the damage. Run the same program under -fsanitize=address and you get the exact line. The real fix is a loop bounded by sizeof buf, which is the first practice task below.

-Wformat-security flags a user-controlled format string

If a string from outside the program becomes the format argument of printf, any % sequences in it are interpreted as conversions. gcc can warn about this pattern at compile time.

#include <stdio.h>

void log_bad(const char *msg) {
    printf(msg);                 /* msg is used as the format */
}

void log_good(const char *msg) {
    printf("%s", msg);           /* msg is only data */
}

int main(void) {
    const char *from_user = "progress: 100% done\n";
    log_good(from_user);
    return 0;
}
$ gcc -Wall -Wextra -Wformat-security fmt.c -o fmt && ./fmt
fmt.c: In function 'log_bad':
fmt.c:4:5: warning: format not a string literal and no format arguments [-Wformat-security]
    4 |     printf(msg);                 /* msg is used as the format */
      |     ^~~~~~
progress: 100% done

log_good prints the % literally, because "%s" is the format and the message is just data. Passed to log_bad instead, "% d" in that string would be parsed as a conversion (a space flag followed by d), and printf would read an int argument that was never passed. That is undefined behavior. gcc catches the unsafe form even though log_bad is never called. Add -Werror=format-security to make it a build failure. The same rule applies to fprintf, sprintf, snprintf and syslog.

UBSan reports signed integer overflow

In C, signed overflow is undefined behavior, but unsigned arithmetic wraps by definition. Neither one reports anything unless you ask, and UBSan is how you ask.

#include <stdio.h>
#include <limits.h>

int main(void) {
    int total = INT_MAX;
    total = total + 1;           /* signed overflow: undefined behavior */
    printf("total = %d\n", total);

    unsigned int u = UINT_MAX;
    printf("u + 1 = %u\n", u + 1);   /* unsigned wraps: defined */
    return 0;
}
$ gcc -O2 -fsanitize=undefined intover.c -o intover && ./intover
intover.c:6:11: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
total = -2147483648
u + 1 = 0

UBSan flags line 6 and keeps running by default. Pass -fno-sanitize-recover=undefined to make it abort, which is what you want in CI. The -2147483648 you see is just what this build happened to produce. The standard says nothing about the result, and the optimiser is allowed to assume the overflow never happens, which can delete bounds checks written as if (x + 1 < x). The unsigned line raises no report because wrapping to 0 is defined behavior. That makes unsigned wrap the more dangerous bug in size calculations, since gcc’s UBSan never flags it (clang offers -fsanitize=unsigned-integer-overflow, off by default).

Overflow-checked size arithmetic before malloc

malloc(count * sizeof(int)) is a common way to get a heap overflow. If the multiplication wraps, you get a small buffer and then write count elements into it. __builtin_mul_overflow does the multiplication and tells you if it wrapped.

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

int *alloc_ints(size_t count) {
    size_t bytes;
    if (__builtin_mul_overflow(count, sizeof(int), &bytes)) {
        fprintf(stderr, "alloc_ints: %zu ints overflows size_t\n", count);
        return NULL;
    }
    return malloc(bytes);
}

int main(void) {
    int *a = alloc_ints(1000);
    int *b = alloc_ints(SIZE_MAX / 2);
    printf("a %s, b %s\n", a ? "ok" : "NULL", b ? "ok" : "NULL");
    free(a);
    return 0;
}
$ gcc -Wall mulcheck.c -o mulcheck && ./mulcheck
alloc_ints: 9223372036854775807 ints overflows size_t
a ok, b NULL

SIZE_MAX / 2 times 4 doesn’t fit in a 64-bit size_t. An unchecked multiply would wrap to a much smaller number, and malloc could succeed with a buffer far too small for what the caller then writes. The builtin returns true and the function refuses the allocation. calloc(count, size) does the same check internally, which is another reason to prefer it for arrays. __builtin_add_overflow and __builtin_sub_overflow work the same way, and C23 standardises all three as ckd_add, ckd_sub and ckd_mul in <stdckdint.h>.

AddressSanitizer catches use-after-free

After free, the pointer still holds the old address. Reading through it is undefined, and without tooling it usually “works” because the bytes are still there.

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

int main(void) {
    int *p = malloc(sizeof *p);
    *p = 42;
    free(p);
    printf("%d\n", *p);          /* bug: read after free */
    return 0;
}
$ gcc -g -fsanitize=address uaf.c -o uaf && ./uaf
=================================================================
==55==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010 at pc 0x000000401240 bp 0x7ffcb3a40510 sp 0x7ffcb3a40508
READ of size 4 at 0x502000000010 thread T0
    #0 0x40123f in main /src/uaf.c:8
...
0x502000000010 is located 0 bytes inside of 4-byte region [0x502000000010,0x502000000014)
freed by thread T0 here:
    #0 0x7f9d21d51738  (/usr/local/lib64/libasan.so.8+0xf3738)
    #1 0x401208 in main /src/uaf.c:7
...
previously allocated by thread T0 here:
    #0 0x7f9d21d52a97 in malloc (/usr/local/lib64/libasan.so.8+0xf4a97)
    #1 0x4011b7 in main /src/uaf.c:5
...
SUMMARY: AddressSanitizer: heap-use-after-free /src/uaf.c:8 in main
...
==55==ABORTING

The report gives three locations: where the bad read happened (line 8), where the memory was freed (line 7), and where it was allocated (line 5). In a real program those are often in three different files, and the “freed by” stack is usually the part you need. ASan finds these by keeping freed memory in quarantine for a while instead of reusing it at once, so a stale pointer hits poisoned memory and doesn’t read some unrelated new object.

AddressSanitizer catches double-free

Freeing the same block twice corrupts the allocator’s bookkeeping. glibc sometimes catches it and sometimes doesn’t, depending on the block size and the allocator’s state. ASan always catches it.

#include <stdlib.h>

int main(void) {
    char *p = malloc(32);
    free(p);
    free(p);                     /* bug: second free of the same block */
    return 0;
}
$ gcc -g -fsanitize=address dfree.c -o dfree && ./dfree
=================================================================
==62==ERROR: AddressSanitizer: attempting double-free on 0x503000000040 in thread T0:
    #0 0x7f0e929ef738  (/usr/local/lib64/libasan.so.8+0xf3738)
    #1 0x401183 in main /src/dfree.c:6
...
0x503000000040 is located 0 bytes inside of 32-byte region [0x503000000040,0x503000000060)
freed by thread T0 here:
    #0 0x7f0e929ef738  (/usr/local/lib64/libasan.so.8+0xf3738)
    #1 0x401177 in main /src/dfree.c:5
...
previously allocated by thread T0 here:
    #0 0x7f0e929f0a97 in malloc (/usr/local/lib64/libasan.so.8+0xf4a97)
    #1 0x401167 in main /src/dfree.c:4
...
SUMMARY: AddressSanitizer: double-free (/usr/local/lib64/libasan.so.8+0xf3738) 
==62==ABORTING

This report has the same shape as the use-after-free one: the second free (line 6), the first free (line 5), and the allocation (line 4). Double frees usually come from two owners of one pointer, such as an error path that frees a buffer and then a cleanup block that frees it again. The report points you at both.

Prevention: clear the pointer as you free it

Both bugs above need a stale pointer. If every free also sets the pointer to NULL, a second free becomes free(NULL), which the standard defines as a no-op, and a stale dereference becomes an obvious null-pointer crash instead of a quiet read.

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

#define FREE(p) do { free(p); (p) = NULL; } while (0)

int main(void) {
    int *p = malloc(sizeof *p);
    *p = 42;
    FREE(p);
    FREE(p);                     /* free(NULL) is a no-op */
    if (p == NULL)
        puts("p is NULL: no dangling pointer left to misuse");
    return 0;
}
$ gcc -Wall nullfree.c -o nullfree && ./nullfree
p is NULL: no dangling pointer left to misuse

The macro only clears the one variable you pass it. Any copies of the pointer elsewhere, such as a struct field or a second alias, still dangle. So this pattern supports clear ownership but doesn’t replace it: decide which piece of code owns each allocation, and let only that code free it.

Bounded input with fgets

gets was removed from C11 because it can’t be used safely. fgets takes the buffer size and stops there, leaving the rest of a long line for the next call.

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

int main(void) {
    char line[8];
    while (fgets(line, sizeof line, stdin)) {
        size_t len = strcspn(line, "\n");
        int complete = line[len] == '\n';
        line[len] = '\0';
        printf("[%s]%s\n", line, complete ? "" : " (cut: more follows)");
    }
    return 0;
}
$ gcc -Wall readline.c -o readline && printf 'hi\nhello world\n' | ./readline
[hi]
[hello w] (cut: more follows)
[orld]

fgets reads at most sizeof line - 1 = 7 characters and always adds a terminator, so "hello world" arrives in two pieces. There’s no overflow, but the program now treats "orld" as a separate line, which can be its own bug if each line is a command or a record. strcspn(line, "\n") finds the newline or the end of the string, so the code can tell whether the line ended. That’s how the program knows to print the (cut: ...) note. For scanf, the equivalent is a field width: scanf("%31s", word) for a char word[32].

A hardened build, in one line

For development and CI, build with -Wall -Wextra -Werror=format-security -g -fsanitize=address,undefined and run your whole test suite under it. For release builds, where sanitizers cost too much, -O2 -D_FORTIFY_SOURCE=2 -fstack-protector-strong -Wformat-security keeps the cheap run-time checks you saw above. Static analysers such as gcc -fanalyzer, clang --analyze and cppcheck find some of the same bugs without running the code. They complement the run-time tools rather than replacing them.

Practice

  1. Bound the loop. Change fill in the stack-canary example so it never writes more than sizeof buf bytes, clamping n and printing a warning to stderr. Build it with -fstack-protector-strong and confirm it reaches the end of main.
Solution
#include <stdio.h>

void fill(size_t n) {
    char buf[16];
    if (n > sizeof buf) {
        fprintf(stderr, "fill: %zu exceeds %zu, clamping\n", n, sizeof buf);
        n = sizeof buf;
    }
    for (size_t i = 0; i < n; i++)
        buf[i] = 'A';
    printf("filled %zu bytes\n", n);
}

int main(void) {
    fill(16);
    fill(64);
    puts("reached the end safely");
    return 0;
}
$ gcc -fstack-protector-strong canary_fixed.c -o canary_fixed && ./canary_fixed
filled 16 bytes
fill: 64 exceeds 16, clamping
filled 16 bytes
reached the end safely

The canary never fires because the loop can’t reach it. Whether to clamp or reject depends on the caller. For security-relevant data, refusing the input is usually safer than quietly shortening it.

  1. Make the ASan example pass. Rewrite copy_name from the first example with snprintf, report truncation on stderr, and run it under the same -fsanitize=address,undefined build.
Solution
#include <stdio.h>

void copy_name(const char *input) {
    char buf[16];
    int n = snprintf(buf, sizeof buf, "%s", input);
    if (n < 0 || (size_t)n >= sizeof buf)
        fprintf(stderr, "copy_name: input truncated (%d chars)\n", n);
    printf("buf: %s\n", buf);
}

int main(void) {
    copy_name("short");
    copy_name("this name is far too long for sixteen bytes");
    return 0;
}
$ gcc -g -fsanitize=address,undefined overflow_fixed.c -o overflow_fixed && ./overflow_fixed
buf: short
copy_name: input truncated (43 chars)
buf: this name is fa

ASan prints nothing because nothing is out of bounds. The return value 43 is the full length snprintf wanted to write, which is how you know exactly how much was lost.

  1. Checked addition. Write int checked_add(int a, int b, int *out) that returns 0 and stores the sum, or returns -1 if a + b would overflow. Test it with 40 + 2 and INT_MAX + 1.
Solution
#include <stdio.h>
#include <limits.h>

int checked_add(int a, int b, int *out) {
    return __builtin_add_overflow(a, b, out) ? -1 : 0;
}

int main(void) {
    int r;
    if (checked_add(40, 2, &r) == 0)
        printf("40 + 2 = %d\n", r);
    if (checked_add(INT_MAX, 1, &r) != 0)
        printf("INT_MAX + 1 rejected\n");
    return 0;
}
$ gcc -Wall addcheck.c -o addcheck && ./addcheck
40 + 2 = 42
INT_MAX + 1 rejected

The builtin does the addition as if with unlimited precision and reports whether the result fits, so there is no undefined behavior and nothing for UBSan to flag. Without the builtin, the portable test is (b > 0 && a > INT_MAX - b) || (b < 0 && a < INT_MIN - b), which checks before adding.

  1. Reject over-long lines. Change the fgets example so a line too long for the buffer is discarded entirely, printing rejected: ..., instead of being split into pieces.
Solution
#include <stdio.h>
#include <string.h>

int main(void) {
    char line[8];
    while (fgets(line, sizeof line, stdin)) {
        size_t len = strcspn(line, "\n");
        if (line[len] != '\n' && !feof(stdin)) {
            int c;
            while ((c = getchar()) != '\n' && c != EOF)
                ;                    /* discard the rest of the long line */
            printf("rejected: line longer than %zu chars\n", sizeof line - 2);
            continue;
        }
        line[len] = '\0';
        printf("[%s]\n", line);
    }
    return 0;
}
$ gcc -Wall readline_drop.c -o readline_drop && printf 'hi\nhello world\nok\n' | ./readline_drop
[hi]
rejected: line longer than 6 chars
[ok]

The inner getchar loop drains the rest of the over-long line, so "orld" never appears as a fake line of its own. The !feof(stdin) test accepts a final line that has no trailing newline. The limit is 6 characters because the buffer also needs room for the newline and the terminator.

Frequently Asked Questions

How do I find buffer overflows in a C program?
Compile with gcc -g -fsanitize=address,undefined and run your tests. AddressSanitizer stops the program at the first out-of-bounds read or write and prints the file, line and variable involved. The ASan documentation cites a typical slowdown of about 2x, so use it in development and CI, not in release builds.
What is the difference between AddressSanitizer, _FORTIFY_SOURCE and -fstack-protector-strong?
AddressSanitizer is a debugging tool that instruments every memory access and reports the exact bad access. _FORTIFY_SOURCE swaps calls like strcpy and memcpy for checked versions when the destination size is known at compile time. -fstack-protector-strong places a canary value in stack frames and aborts if it changes before the function returns. The last two are cheap enough to leave on in production.
Which C string functions should I avoid?
Avoid gets (removed in C11), strcpy, strcat, sprintf and scanf with a bare %s, because none of them know how big the destination buffer is. Use fgets, snprintf, and scanf with a field width such as %31s instead, and check the return value of snprintf to detect truncation.
Why is printf(user_input) dangerous in C?
When user input is passed as the format string, any % sequences in it are treated as conversion specifiers, so printf reads arguments that were never passed. That is undefined behavior. Always write printf("%s", user_input), and compile with -Wformat-security so gcc warns about the unsafe form.
How do I check for integer overflow before calling malloc in C?
Use GCC and Clang's __builtin_mul_overflow(count, size, &result), which returns true if the multiplication overflowed. If it did, refuse the allocation instead of calling malloc with a wrapped, too-small size. Unsigned arithmetic wraps silently, so without this check a huge count can produce a tiny buffer.