Strings and Buffer Overflows
Strings as null-terminated character arrays, the string.h functions and their traps, safe copying and formatting with snprintf, parsing numbers with strtol, and how buffer overflows happen and are prevented.
What a C string is
C has no string type. A
null-terminated string
is a sequence of char values in memory, usually in an
array, followed by a byte with the value zero. That zero byte is the
null terminator, written
'\0'. It is not a printable character; it only marks
where the text ends. A string of seven letters therefore needs eight
bytes.
The example prints every byte of the array as a number, so the terminator becomes visible:
#include <stdio.h>
#include <string.h>
int main(void)
{
char name[] = "Kestrel"; /* the compiler counts: 8 bytes */
const char *text = "Kestrel"; /* a pointer to the same text */
printf("sizeof name = %zu, strlen(name) = %zu\n", sizeof name, strlen(name));
printf("sizeof text = %zu (the size of a pointer)\n", sizeof text);
for (size_t i = 0; i < sizeof name; i++) {
printf("%d ", name[i]);
}
printf("\n");
return 0;
}
sizeof name = 8, strlen(name) = 7 sizeof text = 8 (the size of a pointer) 75 101 115 116 114 101 108 0
Because there is no stored length, strlen must look at
every character until it finds the zero: its cost grows with the
length of the text. And if the terminator is missing, every string
function keeps reading the bytes that happen to follow in memory.
The array and pointer basics behind this (why
name turns into a pointer when you pass it to a
function, and why a function cannot know the size of the array it
received) are on
Arrays and Pointer Arithmetic.
String literals are read-only
A
string literal is text between
double quotes in the source code, such as "Kestrel".
The compiler stores it, with its terminator, in memory that exists
for the whole run of the program. Two uses look alike but behave
very differently:
-
char name[] = "Kestrel";creates an array of 8 bytes and copies the literal into it. The array is yours to change. -
char *text = "Kestrel";makes a pointer to the literal itself. Changing it is undefined behavior; on Linux, literals live in a read-only part of the program, so the write crashes.
#include <stdio.h>
int main(void)
{
char copy[] = "kestrel";
copy[0] = 'K'; /* fine: the array is a copy */
printf("%s\n", copy);
char *literal = "kestrel"; /* compiles without a warning in C */
literal[0] = 'K'; /* writes into read-only memory */
printf("%s\n", literal);
return 0;
}
Kestrel Segmentation fault
Neither -Wall nor -Wextra warns about
this, because in C a string literal has the type
char[], not const char[] (C++ changed
that). Make the habit explicit instead: declare pointers to literals
as const char *; then an accidental write is a compile
error.
The string.h functions
The header <string.h> declares the classic string
functions. All of them take pointers and rely on the terminator;
none of them knows how big the arrays are.
| Function | What it does | Trap |
|---|---|---|
strlen(s) |
Number of bytes before the terminator |
Returns size_t (unsigned); reads until a zero
byte
|
strcmp(a, b) |
Negative, zero or positive, like a dictionary order by byte value |
Returns 0 for equal strings:
if (strcmp(a, b)) means "different"
|
strchr(s, c), strstr(s, t) |
Pointer to the first matching character or substring, or
NULL
|
Check for NULL before using the result |
strcpy(dst, src), strcat(dst, src)
|
Copy or append src, including the terminator
|
No size check at all: the main source of overflows |
strncpy(dst, src, n) |
Copy at most n bytes |
Leaves dst without a terminator when
src is too long
|
strtok(s, delims) |
Split into tokens |
Overwrites s and keeps hidden state between
calls
|
memcpy, memset,
memchr
|
Work on a given number of bytes, terminator or not | You pass the size, so you can still pass the wrong one |
#include <stdio.h>
#include <string.h>
int main(void)
{
const char *meter = "E-001";
const char *topic = "kestrel/home/h0017/p1/power";
printf("strlen: %zu\n", strlen(meter));
printf("strcmp: %d %d %d\n",
strcmp("E-001", "E-002") < 0, strcmp("kWh", "kW") > 0,
strcmp(meter, "E-001"));
const char *dash = strchr(meter, '-');
if (dash != NULL) {
printf("after the dash: %s\n", dash + 1);
}
const char *p1 = strstr(topic, "/p1/");
if (p1 != NULL) {
printf("offset of /p1/: %td\n", p1 - topic);
}
char unit[8] = "kW"; /* room for 7 characters + '\0' */
strcat(unit, "h"); /* fits: "kWh" needs 4 bytes */
printf("unit: %s\n", unit);
return 0;
}
strlen: 5 strcmp: 1 1 0 after the dash: 001 offset of /p1/: 18 unit: kWh
Only the sign of strcmp's result is defined, so the
example compares it with zero instead of printing it. The difference
of two pointers into the same string, p1 - topic, is
the offset of the match and has the type
ptrdiff_t (printed with %td).
Comparing strings: never with ==
== on two strings compares two addresses, not the text.
With the warnings this site uses, gcc refuses the most common form
of the mistake:
#include <stdio.h>
#include <string.h>
int main(void)
{
char status[16];
strcpy(status, "OPEN");
if (status == "OPEN") { /* compares addresses */
puts("ticket is open");
}
return 0;
}
example.c:8:16: error: comparison with string literal results in unspecified behavior [-Werror=address]
The fix is strcmp(status, "OPEN") == 0. Without
-Wall the program compiles and simply never prints the
line, which is how this bug survives in old code.
Splitting with strtok
strtok splits a string at any of the delimiter
characters you give it. The first call takes the string, every next
call passes NULL to continue where the previous one
stopped:
#include <stdio.h>
#include <string.h>
int main(void)
{
char line[] = "E-001;2026-10-07;350"; /* an array: strtok writes into it */
for (char *field = strtok(line, ";"); field != NULL; field = strtok(NULL, ";")) {
printf("[%s]", field);
}
printf("\n");
printf("line is now: %s\n", line);
return 0;
}
[E-001][2026-10-07][350] line is now: E-001
Afterward line reads only E-001:
strtok replaced every delimiter it found with
'\0'. It also remembers its position in a hidden static
variable, so it cannot split two strings at the same time and is
unsafe in threads. POSIX offers strtok_r, which keeps
that position in a variable you pass; it needs
#define _POSIX_C_SOURCE 200809L when you compile with
-std=c17. Never pass a string literal to
strtok: it would write into read-only memory.
How buffer overflows happen
A buffer overflow is a write (or read) past the end of a buffer: the program puts more bytes into a block of memory than it has room for, and the extra bytes land in whatever lies next to it. C never checks, so the write simply happens. What breaks depends on where the buffer is: other variables change value, the program crashes later in an unrelated place, or, in the worst case, an attacker who controls the input can change what the program does. The C standard calls it undefined behavior.
A
stack buffer overflow
is the case where the buffer is a local array in a function's
stack frame. Do not confuse it with a
stack overflow, which
means running out of stack space, typically through recursion that
is too deep. Overflows of memory from malloc are heap
overflows, covered on
Dynamic Memory.
A teaching example with the Kestrel Home P1 dongle's serial number,
KHP1-000231: eleven characters, so twelve bytes with
the terminator. A function that keeps a short device ID in an 8-byte
array and copies with strcpy works for short IDs and
overflows for this one.
When the compiler can see it
When both sizes are known at compile time, gcc warns, and with
-Werror it refuses to build:
#include <stdio.h>
#include <string.h>
int main(void)
{
char id[8];
strcpy(id, "KHP1-000231");
printf("device %s\n", id);
return 0;
}
example.c:7:5: error: ‘__builtin_memcpy’ writing 12 bytes into a region of size 8 overflows the destination [-Werror=stringop-overflow=]
(gcc reports __builtin_memcpy because it turned the
copy of a known string into a plain memory copy.) In real code the
serial number comes from a label, a configuration file or the
network, and the compiler cannot know its length. Then the program
compiles cleanly:
gcc -std=c17 -Wall -Wextra -Werror -O0 -o device device.c
./device
device KHP1-07 device KHP1-000231
The program printed the full serial number and exited with status 0. Nothing looked wrong, yet four bytes of the stack were overwritten. That is what makes overflows dangerous: on another machine, with other compiler options or with a longer input, the same code crashes or misbehaves, and a quick test shows nothing. Do not rely on this output; it is undefined behavior.
Detecting overflows at run time
AddressSanitizer (ASan), switched on with
-fsanitize=address, surrounds every buffer with guard
bytes and stops the program at the first byte written out of bounds.
The same program, with the serial number passed in as a parameter so
the compiler cannot see it:
#include <stdio.h>
#include <string.h>
static void show_device(const char *serial)
{
char id[8]; /* room for 7 characters + '\0' */
strcpy(id, serial); /* no size check */
printf("device %s\n", id);
}
int main(void)
{
show_device("KHP1-07");
show_device("KHP1-000231");
return 0;
}
device KHP1-07 SUMMARY: AddressSanitizer: stack-buffer-overflow ../../../../src/libsanitizer/asan/asan_interceptors.cpp:425 in __interceptor_strcpy
The summary line names strcpy, because ASan checks the
copy inside its own version of that function. The full report shows
where it was called from and which variable overflowed (trimmed;
addresses and the process ID differ per run):
==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffff16f86c8 at pc 0x7fffa2c9e888 bp 0x7ffff16f7df0 sp 0x7ffff16f7e48
WRITE of size 12 at 0x7ffff16f86c8 thread T0
#0 0x7fffa2c9e884 in __interceptor_strcpy ../../../../src/libsanitizer/asan/asan_interceptors.cpp:425
#1 0x555564c70d84 in show_device example.c:7
…
This frame has 1 object(s):
[32, 40) 'id' (line 6) <== Memory access at offset 40 overflows this variable
…
SUMMARY: AddressSanitizer: stack-buffer-overflow ../../../../src/libsanitizer/asan/asan_interceptors.cpp:425 in __interceptor_strcpy
Line 7, the strcpy into id (declared on
line 6): exactly the bug. Two protections that are cheap enough for
production builds catch the same overflow without a sanitizer, by
stopping the program instead of letting it continue with corrupted
memory. Both were recorded with the program above:
device KHP1-07 device KHP1-000231 *** stack smashing detected ***: terminated Aborted
device KHP1-07 *** buffer overflow detected ***: terminated Aborted
The stack protector places a check value (a "canary") between the
local arrays and the return address and checks it when the function
returns. _FORTIFY_SOURCE makes glibc replace calls such
as strcpy with versions that know the size of the
destination where the compiler can work it out; it needs
optimization (-O1 or higher). On this machine's gcc
(Debian 12) neither is on by default; many distributions enable them
in their own package builds. Hardening flags and sanitizers are
covered in depth on
Undefined Behavior and Safer C
and Debugging C.
gets is gone
The old function gets read a line from standard input
into a buffer without any way to say how big the buffer was. Every
call was an overflow waiting for a long line, so C11 removed it from
the language. Under -std=c17 it is not even declared
any more:
#include <stdio.h>
int main(void)
{
char name[16];
gets(name);
printf("%s\n", name);
return 0;
}
example.c:6:5: error: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Werror=implicit-function-declaration]
gcc already suggests the replacement: fgets, which
takes the buffer size. Reading input safely is covered on
Input, Output and Files.
The same applies to scanf("%s", name) without a width:
write %15s for a 16-byte buffer.
Safe copying and formatting
snprintf() is the
printf variant that writes into a buffer of a given
size. It never writes more than that size, always ends the result
with a terminator (for a size of at least 1), and returns the number
of characters the complete result would have needed. If
that number is equal to or larger than the buffer size, the text was
cut off. That makes it the standard tool for both formatting and
copying strings safely:
#include <stdbool.h>
#include <stdio.h>
/* Formats a ticket ID such as "INC-33004" into buf.
Returns false if the result did not fit. */
static bool format_ticket(char *buf, size_t size, const char *prefix, int number)
{
int needed = snprintf(buf, size, "%s-%05d", prefix, number);
return needed >= 0 && (size_t)needed < size;
}
int main(void)
{
char small[8];
char large[16];
bool ok = format_ticket(large, sizeof large, "INC", 33004);
printf("%-5s \"%s\"\n", ok ? "ok" : "CUT", large);
ok = format_ticket(small, sizeof small, "INC", 33004);
printf("%-5s \"%s\"\n", ok ? "ok" : "CUT", small);
return 0;
}
ok "INC-33004" CUT "INC-330"
The 8-byte buffer received INC-330 and a terminator, no
overflow, and the function reported that the ID was cut off. Whether
a cut-off value is acceptable is your decision: for a log line it
may be, for an ID it is not, because a truncated ID can point to the
wrong record. Always check the return value.
The same function copies a string safely, with
"%s" as the format:
snprintf(dst, sizeof dst, "%s", src). Never pass the
text itself as the format (snprintf(dst, size, src)): a
% in the text would be read as a conversion. That
mistake is a
format-string bug.
strncpy is not a safe strcpy
Many tutorials recommend strncpy as the safe
strcpy. It was designed for fixed-width fields in old
Unix directory entries, not for strings: when the source is too
long, it copies exactly n bytes and adds
no terminator.
#include <stdio.h>
#include <string.h>
static void copy_and_check(const char *src)
{
char dst[4];
strncpy(dst, src, sizeof dst);
printf("%-8s terminated: %s\n", src,
memchr(dst, '\0', sizeof dst) != NULL ? "yes" : "no");
}
int main(void)
{
copy_and_check("kWh");
copy_and_check("E-001");
return 0;
}
kWh terminated: yes E-001 terminated: no
The second copy left four bytes without a terminator; printing
dst would read past its end. Prefer
snprintf. You may also meet strcpy_s and
the other "secure" functions of the C standard's Annex K: that annex
is optional, and glibc (like most C libraries outside Microsoft's)
does not implement it, so code that uses them is not portable.
Parsing numbers from text
Numbers arrive as text: from a configuration file, a command-line
argument, a P1 telegram. atoi looks like the obvious
choice and has no way to report an error:
atoi("12x4") is 12 and atoi("abc") is 0,
the same as a real zero. Use strtol (or
strtoul, strtoll, strtod)
instead. It reports three things:
- Through its second argument, a pointer to the first character it did not use. If that is the start of the text, there was no number at all; if it is not the terminator, there was something after the number.
-
Through
errno:ERANGEwhen the number does not fit in along(the result is thenLONG_MAXorLONG_MIN). - The value itself, which you still check against your own limits.
#include <errno.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
/* A meter reading in kWh: digits only, 0 to 100000. */
static bool parse_kwh(const char *text, long *out)
{
char *end;
errno = 0;
long value = strtol(text, &end, 10);
if (end == text || *end != '\0') {
return false; /* no digits, or text after them */
}
if (errno == ERANGE || value < 0 || value > 100000) {
return false; /* out of range */
}
*out = value;
return true;
}
int main(void)
{
const char *inputs[] = {"350", "12x4", "", "-5", " 42", "99999999999999999999"};
printf("atoi(\"12x4\") = %d, atoi(\"abc\") = %d\n", atoi("12x4"), atoi("abc"));
for (size_t i = 0; i < sizeof inputs / sizeof inputs[0]; i++) {
long kwh;
if (parse_kwh(inputs[i], &kwh)) {
printf("\"%s\" -> %ld kWh\n", inputs[i], kwh);
} else {
printf("\"%s\" -> rejected\n", inputs[i]);
}
}
return 0;
}
atoi("12x4") = 12, atoi("abc") = 0
"350" -> 350 kWh
"12x4" -> rejected
"" -> rejected
"-5" -> rejected
" 42" -> 42 kWh
"99999999999999999999" -> rejected
Note " 42": strtol skips leading white
space. Whether that is acceptable depends on the data; for a strict
format, check the first character yourself. Setting
errno to 0 before the call matters, because
strtol only sets it on an error and never clears it.
The errno mechanism is explained on
Error Handling.
Too lenient: the P1 CRC field
Kestrel's BUG-1060, described on
Smart Meters and the P1 Port, was about the last line of a P1 telegram: the four hexadecimal
digits of the CRC after the !. The pilot dongle's
parser accepted exactly four digits, and one meter sent three,
B9F. The dongle's firmware is the outside
manufacturer's C code. A tempting quick fix in C is to hand the
field to strtoul(field, &end, 16), which reads any
number of hexadecimal digits. The example shows why that is far too
lenient for device input, next to a strict parser that accepts one
to four hexadecimal digits followed by exactly \r\n:
#include <ctype.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Strict: one to four hex digits, then CR LF, nothing else. */
static bool parse_crc(const char *field, uint16_t *out)
{
size_t n = 0;
uint16_t value = 0;
while (n < 4 && isxdigit((unsigned char)field[n])) {
int c = toupper((unsigned char)field[n]);
value = (uint16_t)(value * 16 + (isdigit(c) ? c - '0' : c - 'A' + 10));
n++;
}
if (n == 0 || strcmp(field + n, "\r\n") != 0) {
return false;
}
*out = value;
return true;
}
int main(void)
{
const char *fields[] = {"F1F4\r\n", "B9F\r\n", " 0xB9F\r\n", "-1\r\n", "F1F4F\r\n", "\r\n"};
for (size_t i = 0; i < sizeof fields / sizeof fields[0]; i++) {
const char *field = fields[i];
int shown = (int)strcspn(field, "\r\n"); /* print without CR LF */
char *end;
unsigned long lenient = strtoul(field, &end, 16);
uint16_t crc;
printf("%-8.*s strtoul: %-16lX strict: ", shown, field, lenient);
if (parse_crc(field, &crc)) {
printf("%04X\n", crc);
} else {
printf("rejected\n");
}
}
return 0;
}
F1F4 strtoul: F1F4 strict: F1F4
B9F strtoul: B9F strict: 0B9F
0xB9F strtoul: B9F strict: rejected
-1 strtoul: FFFFFFFFFFFFFFFF strict: rejected
F1F4F strtoul: F1F4F strict: rejected
strtoul: 0 strict: rejected
strtoul happily accepted a leading space and a
0x prefix, turned "-1" into the largest
unsigned long (it negates the value in unsigned
arithmetic), and read five digits as one number. Each of those would
let a corrupted telegram through as long as some CRC happened to
match. The strict parser accepts the harmless quirk (three digits,
printed as 0B9F) and rejects the rest. Note also the
(unsigned char) casts: the
<ctype.h> functions are only defined for values
of unsigned char and EOF, and on systems
where char is signed a byte above 127 would be passed
as a negative number. (On a 64-bit Linux machine (ARM64)
char is unsigned; on most x86-64 systems it is signed.
The cast makes the code correct on both.)
Preventing buffer overflows
No single trick prevents all overflows. In practice it is a set of habits and tools, each catching what the others miss. The SEI CERT C Coding Standard sums up the rule as STR31-C: "Guarantee that storage for strings has sufficient space for character data and the null terminator".
| Habit or tool | What it does |
|---|---|
| Pass sizes with buffers |
Every function that writes into a buffer takes its size
(char *buf, size_t size), and callers pass
sizeof buf of the real array
|
| Bounded functions |
snprintf instead of sprintf,
strcpy and strcat;
fgets instead of gets; a width in
every scanf %s
|
| Check every result |
Truncation from snprintf, the end pointer and
errno from strtol, lengths before
memcpy
|
| Validate input early | Reject input that is too long or has the wrong format at the edge of the program (input validation) |
| Compiler warnings |
-Wall -Wextra (and -Werror) catch
overflows with known sizes, as shown above
|
| Sanitizers and fuzzing in testing |
-fsanitize=address,undefined in test builds,
fed by unit tests and a fuzzer
|
| Hardening in release builds |
-fstack-protector-strong and
-D_FORTIFY_SOURCE=2 (or 3) with
-O2, so that an overflow that slips through
stops the program instead of continuing silently
|
For new code where none of this is acceptable, the alternative is a language with bounds-checked strings. That debate, and the government guidance behind it, is on Undefined Behavior and Safer C.