Why this matters
Writing every string loop by hand gets old fast, so C provides a library of string functions in <string.h>. They are fast and everywhere, and several of them are also responsible for decades of security holes, because they write into your arrays without knowing how big those arrays are. The Morris worm of 1988, one of the first internet worms, spread through a buffer overflow in a program that used the function gets. This lesson covers the functions you will use every day, and the habits that keep them from writing past the end of a buffer.
The everyday functions
#include <stdio.h>
#include <string.h>
int main(void)
{
char name[32];
char greeting[64];
strcpy(name, "Ada");
strcat(name, " Lovelace");
printf("name = \"%s\", strlen = %zu, sizeof = %zu\n", name, strlen(name), sizeof name);
printf("strcmp(\"apple\", \"banana\") = %d\n", strcmp("apple", "banana") < 0 ? -1 : 1);
printf("strcmp(\"cat\", \"cat\") = %d\n", strcmp("cat", "cat"));
printf("strcmp(\"Zebra\", \"apple\") < 0: %s\n", strcmp("Zebra", "apple") < 0 ? "yes" : "no");
int n = snprintf(greeting, sizeof greeting, "Hello, %s! You are %d.", name, 36);
printf("%s (%d chars)\n", greeting, n);
return 0;
}
name = "Ada Lovelace", strlen = 12, sizeof = 32
strcmp("apple", "banana") = -1
strcmp("cat", "cat") = 0
strcmp("Zebra", "apple") < 0: yes
Hello, Ada Lovelace! You are 36. (32 chars)
| function | what it does | danger |
|---|---|---|
strlen(s) | length, not counting '\0'; returns size_t (print with %zu) | reads past the end if s is unterminated |
strcpy(dst, src) | copies src including its terminator into dst | writes past dst if src is too long |
strcat(dst, src) | appends src after the text in dst | same, and dst must already be a string |
strcmp(a, b) | compares: 0 if equal, negative if a sorts first, positive otherwise | the sign is all that is guaranteed, not -1/1 |
strncmp(a, b, n) | compares at most n chars (useful for prefixes) | |
strcspn(s, set) | index of the first char of s that is in set (or the length if none) | |
memcpy(dst, src, n) | copies exactly n bytes | you pick n, so you must get it right |
memset(dst, byte, n) | sets n bytes to a value, e.g. zeroing a buffer | |
snprintf(dst, size, fmt, ...) | printf into a buffer, never more than size bytes, always terminated | none, when size is right |
size_t is the unsigned integer type C uses for sizes and lengths; sizeof and strlen produce it. Several of these functions also return addresses (for example strchr, which finds a character); those make sense after the pointers module.
What a buffer overflow looks like
strcpy, strcat and scanf("%s") all copy until they reach the end of the source. They never look at the size of the destination, because, as you learned two lessons ago, a function receiving an array gets only its starting address. If the source is longer, they keep writing into whatever memory follows:
#include <stdio.h>
#include <string.h>
int main(void)
{
int secret = 1234;
char buf[8];
char input[100];
if (scanf("%99s", input) != 1) {
return 1;
}
strcpy(buf, input); /* no check that input fits in buf! */
printf("buf = %s, secret = %d\n", buf, secret);
return 0;
}
$ echo short | ./prog
buf = short, secret = 1234
$ echo abcdefghijk | ./prog
buf = abcdefghijk, secret = 7039593
$ echo abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstu | ./prog
Segmentation fault (core dumped)
Eleven characters plus a terminator is 12 bytes, 4 more than buf holds. In the sandbox's stack layout, secret sat right after buf, so the extra bytes i, j, k and '\0' overwrote it: 7039593 is 0x006b6a69, which is the codes of k, j, i read back as an int (the bytes come out reversed for reasons covered in the bit manipulation module). Nothing reported an error; a variable that the program never touched simply changed. With even longer input, the overflow reached the function's saved return address, and when main tried to return, it jumped into garbage and crashed.
stack, low addresses on the left (layout as observed in the sandbox):
before: buf: [ ? ? ? ? ? ? ? ? ] secret: [ 1234 ] ... saved return address ...
after: buf: [ a b c d e f g h ] secret: [ i j k \0 ] ...
^ overwritten
An attacker who controls the input controls those bytes. Overwriting a return address with a chosen value is the textbook way to hijack a program, which is why modern systems add defenses (stack canaries, non-executable stacks, address randomization) and why careful code never relies on them.
Detecting overflows
AddressSanitizer pinpoints the line:
$ gcc -g -fsanitize=address -o prog main.c && echo abcdefghijk | ./prog
==19==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7f8afa300038 ...
WRITE of size 12 at 0x7f8afa300038 thread T0
#0 0x7f8afc712680 in strcpy ...
#1 0x562768f76290 in main /work/main.c:13
And glibc's "fortify" checks, enabled with optimization plus -D_FORTIFY_SOURCE=2 (which many Linux distributions turn on for their packages), stop the program at the moment of overflow when the buffer size is known at compile time:
*** buffer overflow detected ***: terminated
Aborted (core dumped)
Safe habits
1. Know the size of every buffer you write into, and pass it. Functions that take a size are the safe ones. sizeof buf gives it when buf is an array in scope; inside a function that received the array, the size must be a parameter.
2. Build strings with snprintf. It replaces most uses of strcpy and strcat:
snprintf(dst, sizeof dst, "%s", src); /* safe copy */
snprintf(path, sizeof path, "%s/%s", dir, file); /* safe concatenation */
It writes at most size bytes including the terminator, and returns the number of characters the full result would have had. If that is >= size, the output was truncated:
#include <stdio.h>
#include <string.h>
int main(void)
{
char small[8];
int n = snprintf(small, sizeof small, "%s", "a rather long string");
printf("stored \"%s\", wanted %d chars\n", small, n);
if (n >= (int)sizeof small) {
printf("output was truncated\n");
}
return 0;
}
stored "a rathe", wanted 20 chars
output was truncated
gcc even saw this one coming at compile time: warning: '%s' directive output truncated writing 20 bytes into a region of size 8 [-Wformat-truncation=]. Truncation is not an overflow, but it can still be a bug (a truncated file path names a different file), so check the return value when it matters.
3. Check before strcpy/strcat. They are fine when you have proved the result fits: if (strlen(dst) + strlen(src) + 1 <= size) strcat(dst, src);
4. Be wary of strncpy. Its name suggests "safe strcpy", but if the source is at least n characters long it fills the buffer and writes no terminator. It was designed for fixed-width record fields, not strings.
5. Never use gets. It reads a line with no size limit at all. It was so dangerous that it was removed from the C language in C11; modern compilers refuse it or warn loudly.
Reading lines with fgets
fgets(buf, size, stdin) reads one line, including its '\n', but never more than size - 1 characters, and always terminates buf. It returns NULL at end of input. (NULL means "no address"; for now just compare against it.)
#include <stdio.h>
#include <string.h>
int main(void)
{
char line[100];
int number = 0;
while (fgets(line, sizeof line, stdin) != NULL) {
line[strcspn(line, "\n")] = '\0';
number++;
printf("%2d: [%s] (%zu chars)\n", number, line, strlen(line));
}
return 0;
}
$ printf 'first line\n\nthird, longer line\n' | ./prog
1: [first line] (10 chars)
2: [] (0 chars)
3: [third, longer line] (18 chars)
strcspn(line, "\n") is the index of the first newline, or the string's length if there is none (the last line of a file may lack one). Writing '\0' there removes the newline in both cases. If a line is longer than the buffer, fgets returns the first part and the rest on the next call; the missing '\n' is how you can tell.
fgets is the recommended way to read text input. Unlike scanf("%s") it does not stop at spaces, and it can never overflow.
Common mistakes
if (strcmp(a, b))to test equality. That is true when they differ. Write== 0.sizeofversusstrlen:sizeof bufis the buffer's capacity,strlen(buf)the text's length. Pass capacity tosnprintfandfgets; use length to know how much text there is.strcaton an uninitialized buffer:char out[50]; strcat(out, "x");searches garbage for a terminator. Start withchar out[50] = "";.- Forgetting the
+ 1for the terminator when checking whether something fits. - Printing
strlenwith%d:size_tneeds%zu. gcc warnsformat '%d' expects argument of type 'int', but argument 2 has type 'size_t'. - Leaving the newline from
fgetsin the string, sostrcmp(line, "quit") == 0never matches"quit\n".
Recap
<string.h>givesstrlen,strcpy,strcat,strcmp,strcspn,memcpy,memset.strcpy,strcatandscanf("%s")do not know the destination size; overflowing a buffer silently corrupts nearby memory and is a classic security hole.- Prefer size-aware functions:
snprintffor building strings,fgetsfor reading lines. Checksnprintf's return value for truncation. strcmpreturns 0 for equal.strncpymay not terminate.getsis gone; never use it.- Use
-fsanitize=addressto find overflows during development.
Going further: man 3 string lists the whole family. Read man 3 snprintf, section RETURN VALUE, and man 3 fgets.