C · 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
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
- Bound the loop. Change
fillin the stack-canary example so it never writes more thansizeof bufbytes, clampingnand printing a warning to stderr. Build it with-fstack-protector-strongand confirm it reaches the end ofmain.
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.
- Make the ASan example pass. Rewrite
copy_namefrom the first example withsnprintf, report truncation on stderr, and run it under the same-fsanitize=address,undefinedbuild.
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.
- Checked addition. Write
int checked_add(int a, int b, int *out)that returns0and stores the sum, or returns-1ifa + bwould overflow. Test it with40 + 2andINT_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.
- Reject over-long lines. Change the
fgetsexample so a line too long for the buffer is discarded entirely, printingrejected: ..., 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.