C Strings, Structs and Buffer Overflows Under ASan

C lecture 4 on Apple clang 21: the NUL terminator, strcpy/strncpy/snprintf traps, struct padding via offsetof, and one stack overflow caught three ways.

Series: C lecture 4/6 (intermediate). For developers who can write C functions and use pointers, and now handle text and records in real code. After this lecture you can predict where a C string ends, choose between strcpy, strncpy and snprintf on purpose, read a struct's memory layout, and read an AddressSanitizer report for a buffer overflow.

If you maintain a C library, parse a protocol, or review a pull request that copies a user name into a fixed-size field, this lecture is about the bug class that has caused more security advisories than any other in C. The examples are deliberately small: one missing byte, one unchecked copy. They are shown for defence only: the bug, what the tools report, and the fix.

Environment: Apple clang 21.0.0 (clang -std=c17 -Wall -Wextra), Apple Silicon Mac (M1 Pro), macOS. Every output below was copied from a real run; only paths were shortened. Sanitizer builds use -O0 -g -fsanitize=address. Back to the programming learning roadmap.

A C string is an array plus a promise

C has no string type. A "string" is a char array whose contents end with a zero byte, '\0' (called NUL). Every library function that takes a char *, such as strlen, printf("%s") or strcpy, walks forward until it finds that zero. Nothing records the length; nothing records the size of the array. That is the whole design, and almost every bug in this lecture follows from it.

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

int main(void) {
    char a[] = "hello";          /* 6 bytes: h e l l o \0 */
    char b[5] = "hello";         /* 5 bytes: no room for \0 (legal in C!) */
    const char *p = "hello";
    printf("sizeof a = %zu, strlen a = %zu\n", sizeof a, strlen(a));
    printf("sizeof p = %zu, strlen p = %zu\n", sizeof p, strlen(p));
    printf("strlen b = %zu\n", strlen(b));   /* reads past the end */
    return 0;
}

clang 21 notices the third line:

nul.c:6:17: warning: initializer-string for character array is too long, array size is 5 but
initializer has size 6 (including the null terminating character); did you mean to use the
'nonstring' attribute? [-Wunterminated-string-initialization]

It is only a warning because char b[5] = "hello"; is valid C: the standard explicitly allows dropping the terminator when the array is exactly one byte too short. The program runs and prints:

sizeof a = 6, strlen a = 5
sizeof p = 8, strlen p = 5
strlen b = 13

Three lessons in three lines. sizeof of an array counts the terminator; strlen does not. sizeof of a pointer is the pointer's size (8 bytes on a 64-bit Mac), whatever it points to. And strlen(b) returned 13 for a 5-letter word, because it kept reading the neighbouring stack bytes until it happened to hit a zero. The number 13 is not stable or meaningful; with a different layout it would be another number or a crash.

AddressSanitizer (ASan), a compiler option that surrounds every object with poisoned bytes and checks each access, turns the silent 13 into a report:

==73077==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x00016f6063c5 ...
READ of size 6 at 0x00016f6063c5 thread T0
    #0 0x0001012a6800 in strlen+0x22c (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x16800)
    #1 0x0001007f89e4 in main nul.c:10
  This frame has 2 object(s):
    [32, 38) 'a' (line 5)
    [64, 69) 'b' (line 6) <== Memory access at offset 69 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow nul.c:10 in main

Read it bottom-up: SUMMARY gives the kind and the line, the frame list shows b occupies bytes 64 to 68 of the frame, and the access at offset 69 is one past it. READ of size 6 is strlen trying to read the 5 letters plus the byte where the NUL should be.

Comparing and modifying strings: two mistakes clang half-catches

Common mistake 1: comparing with ==.

char answer[8] = "yes";
if (answer == "yes") puts("== says equal");
else                 puts("== says NOT equal");
if (strcmp(answer, "yes") == 0) puts("strcmp says equal");
mistakes.c:6:16: warning: result of comparison against a string literal is unspecified
(use an explicit string comparison function instead) [-Wstring-compare]

What actually happens: == compares two addresses, the array on the stack and the literal in read-only data. They are never the same, so the program printed == says NOT equal and then strcmp says equal. Fix: always strcmp(a, b) == 0 (or strncmp with a known bound).

Common mistake 2: writing into a string literal. char *lit = "hello"; lit[0] = 'J'; compiles with no warning under -Wall -Wextra in C (it is an error only in C++). On macOS the literal lives in a read-only page:

bash: line 1: 74295 Bus error: 10           ./mistakes
exit=138

Fix: if you need to modify it, make it an array, char s[] = "hello";, which copies the literal onto the stack. Declare pointers to literals as const char * so the compiler stops the write for you.

One more trap hid inside this run: when we redirected the output to a file, ./mistakes > out.txt left out.txt empty (0 bytes), although the two puts lines ran before the crash. stdout is line-buffered on a terminal but fully buffered into a file or pipe, and a crash discards the buffer. When a crashing program "prints nothing", add fflush(stdout) or print diagnostics to stderr, which is unbuffered.

strcpy, strncpy, snprintf: what each one really does

Here is the function from our pull request. It copies a name into an 8-byte field:

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

/* Copies a user name into a fixed 8-byte field. Bug: no length check. */
static void set_name(char *dst, const char *src) {
    strcpy(dst, src);
}

int main(int argc, char **argv) {
    char name[8];
    int  points = 100;
    const char *input = argc > 1 ? argv[1] : "alice";
    set_name(name, input);
    printf("name=%s points=%d\n", name, points);
    return 0;
}

set_name receives only a pointer, so neither it nor strcpy knows the destination holds 8 bytes. Note that 8 characters do not fit either: "abcdefgh" needs 9 bytes with its NUL.

strncpy(dst, src, n) looks like the bounded fix, but it was designed for fixed-width fields in old file formats, not for C strings. If src has n or more characters, it copies exactly n and does not terminate:

char b[8];
strncpy(b, "bartholomew", sizeof b);
printf("strncpy: last byte = %d (0 means terminated)\n", b[7]);
strncpy: last byte = 111 (0 means terminated)

111 is 'o'. The buffer is now a string with no end, and the next printf("%s", b) is the out-of-bounds read from the first section. clang 21 gave no warning for this line.

snprintf(dst, cap, "%s", src) always terminates (when cap > 0) and returns the length it wanted to write. That return value is the feature: compare it with the capacity and you know whether you truncated.

/* Returns 0 on success, -1 if src did not fit (dst still gets a truncated, terminated copy). */
static int copy_str(char *dst, size_t cap, const char *src) {
    int n = snprintf(dst, cap, "%s", src);
    return (n < 0 || (size_t)n >= cap) ? -1 : 0;
}
snprintf: "barthol" rc=-1
snprintf: "alice" rc=0
snprintf(NULL, 0, ...) says it needs 23 bytes + 1 for NUL

The last line shows a second use: snprintf(NULL, 0, fmt, ...) writes nothing and tells you how big to make a malloc'd buffer.

Function Knows the capacity? Always terminates? Tells you it truncated? Use it when
strcpy no yes, if it does not overflow no never with outside input
strncpy yes no (when src ≥ n) no fixed-width, non-string fields
strncat only the remaining count yes no rarely; the argument is easy to get wrong
snprintf(d, cap, "%s", s) yes yes (cap > 0) yes, via the return value default choice, portable C99
strlcpy yes yes yes, returns strlen(src) macOS/BSD; glibc 2.38+ (not run on Linux here)
track the length yourself + memcpy yes you write it yes hot loops and builders (see measurement)

One overflow, caught three different ways

Run the strcpy program with a 21-character name. What you see depends on how you built it, and that is the part a summary leaves out.

Plain -O0: the program prints, then dies.

name=bartholomew_the_third points=100
bash: line 1: 78502 Abort trap: 6           ./copy_O0 bartholomew_the_third
exit=134

(Captured in a terminal; when we piped the output instead, the name= line was lost in the stdout buffer, as above.) The overflow happened, the corrupted data was printed, and only on the way out of main did the stack protector notice. The compiler places a random value (the canary) next to the return address and checks it before returning. lldb confirms who pulled the trigger:

* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGABRT
    frame #2: 0x0000000188cf5a1c libsystem_c.dylib`__abort + 152
    frame #3: 0x0000000188cf5af4 libsystem_c.dylib`__stack_chk_fail + 96
    frame #4: 0x0000000100000518 copy_O0g`main(argc=2, argv=0x000000016fdfe848) at copy.c:15:5

Line 15 is return 0;. In a long-running server, the damage would have lived until the function returned, maybe much later.

Plain -O2: the program dies before the copy, with a different signal.

bash: line 1: 78507 Trace/BPT trap: 5       ./copy_O2 bartholomew_the_third
exit=133

macOS headers turn strcpy into __builtin___strcpy_chk(dst, src, size) (this is _FORTIFY_SOURCE). At -O2 the compiler inlines set_name into main, can see name is 8 bytes, and passes 8; the libc checks the length and stops in __chk_fail_overflow. At -O0 the same call received "size unknown", so the check could not help. Both binaries import ___strcpy_chk; only the optimized one knew the size.

-fsanitize=address: the program stops at the exact write.

==73154==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x00016dade388 ...
WRITE of size 22 at 0x00016dade388 thread T0
    #0 0x000102d82af0 in strcpy+0x458 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x3aaf0)
    #1 0x000102320a84 in set_name copy.c:6
    #2 0x0001023209a4 in main copy.c:13
  This frame has 1 object(s):
    [32, 40) 'name' (line 10) <== Memory access at offset 40 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow copy.c:6 in set_name

WRITE of size 22: 21 characters plus NUL into 8 bytes, at copy.c:6 called from line 13. Exit status 134 (ASan aborts on macOS). This is the report to aim for during development and in CI.

Flow diagram. Start: strcpy of 21 characters into an 8-byte stack buffer. Three branches by build. Plain -O0: the copy happens, the corrupted output is printed, then the stack protector aborts at return (Abort trap 6, exit 134). Plain -O2: the fortified __strcpy_chk knows the size is 8 and traps before copying (Trace/BPT trap 5, exit 133). -fsanitize=address: AddressSanitizer reports stack-buffer-overflow, WRITE of size 22 at copy.c line 6, and aborts (exit 134).
The same strcpy overflow (21 characters into char[8]) under three builds, Apple clang 21 on macOS. Only ASan stops at the faulty write and names the line.

The fix is to pass the capacity to the function that copies, and to decide what truncation means:

static int set_name(char *dst, size_t cap, const char *src) {
    int n = snprintf(dst, cap, "%s", src);
    return (n < 0 || (size_t)n >= cap) ? -1 : 0;
}
/* in main: */
if (set_name(name, sizeof name, input) != 0)
    fprintf(stderr, "warning: name truncated to \"%s\"\n", name);

Built with -fsanitize=address,undefined and run with the same input:

warning: name truncated to "barthol"
name=barthol points=100
exit=0

The rule behind it: a function that writes into a buffer must receive the buffer's size. sizeof name works only where name is still an array; inside set_name it would be 8, the size of a pointer (lecture 2 showed this decay).

Structs: padding you can measure

A struct groups fields, and the compiler lays them out in declaration order, inserting padding so each field starts at a multiple of its alignment (8 for double, 4 for int32_t, 1 for char). offsetof from <stddef.h> shows the result:

#include <stdio.h>
#include <stddef.h>
#include <stdint.h>
#include <stdbool.h>

struct Bad  { char flag; double price; char kind; int32_t qty; bool active; };
struct Good { double price; int32_t qty; char flag; char kind; bool active; };

#define SHOW(T, f) printf("  %-7s offset %2zu size %zu\n", #f, offsetof(struct T, f), sizeof(((struct T*)0)->f))

int main(void) {
    printf("struct Bad: sizeof %zu, alignof %zu\n", sizeof(struct Bad), _Alignof(struct Bad));
    SHOW(Bad, flag); SHOW(Bad, price); SHOW(Bad, kind); SHOW(Bad, qty); SHOW(Bad, active);
    printf("struct Good: sizeof %zu, alignof %zu\n", sizeof(struct Good), _Alignof(struct Good));
    SHOW(Good, price); SHOW(Good, qty); SHOW(Good, flag); SHOW(Good, kind); SHOW(Good, active);
    return 0;
}
struct Bad: sizeof 32, alignof 8
  flag    offset  0 size 1
  price   offset  8 size 8
  kind    offset 16 size 1
  qty     offset 20 size 4
  active  offset 24 size 1
struct Good: sizeof 16, alignof 8
  price   offset  0 size 8
  qty     offset  8 size 4
  flag    offset 12 size 1
  kind    offset 13 size 1
  active  offset 14 size 1

Same 15 bytes of data; 32 bytes versus 16. Bad has 7 empty bytes after flag, 3 after kind, and 7 at the end so that the next element of an array is aligned too. You do not have to work this out by hand; -Wpadded lists every hole:

layout.c:6:33: warning: padding struct 'struct Bad' with 7 bytes to align 'price' [-Wpadded]
layout.c:6:59: warning: padding struct 'struct Bad' with 3 bytes to align 'qty' [-Wpadded]
layout.c:6:8: warning: padding size of 'struct Bad' with 7 bytes to alignment boundary [-Wpadded]
layout.c:7:8: warning: padding size of 'struct Good' with 1 byte to alignment boundary [-Wpadded]

Do not leave -Wpadded on permanently (almost every struct triggers it); use it to inspect one header. Two practical consequences: never fwrite a struct to a file or socket and expect another compiler or CPU to read it back (padding and byte order differ), and never compare structs with memcmp (the padding bytes hold whatever was there before).

Measured: strcat in a loop, and padded vs packed structs

Two costs that are invisible in a code review. Both measured with clock_gettime(CLOCK_MONOTONIC), best of 5 runs, clang -O2, Apple Silicon Mac (M1 Pro). The full program is at the end of this section.

Joining n words with strcat versus remembering where the end is:

n words strcat each time keep an end pointer
10,000 5.30 ms 0.003 ms
20,000 20.1 ms 0.006 ms
40,000 80.1 ms 0.013 ms

Doubling n multiplies the strcat time by 4: each call runs strlen over everything joined so far, so the total work grows with n². At 40,000 words it is about 6,000 times slower than tracking the length. This is the same reason snprintf returning the length is valuable: keep the number and you never rescan.

Bar chart in milliseconds. strcat with n = 10,000: 5.30. n = 20,000: 20.1. n = 40,000: 80.1. End pointer with n = 40,000: 0.013. The strcat time grows with the square of n because each call rescans the whole buffer.
Joining n five-byte words: strcat on every iteration vs keeping an end pointer. Apple clang 21 -O2, Apple Silicon Mac (M1 Pro), best of 5. Doubling n quadruples the strcat time.

Summing one field over 10,000,000 structs: struct Bad (32 bytes) took 5.41 ms, struct Good (16 bytes) 2.71 ms, for the same qty values. The loop reads one int per element either way, but memory arrives in whole cache lines (128 bytes on this M1 Pro, per sysctl hw.cachelinesize): a line holds 4 Bad structs or 8 Good ones. Half the bytes moved, half the time. Lecture 6 takes this idea further.

/* bench4.c: strcat in a loop vs remembering the end; padded vs packed structs.
   clang -std=c17 -O2 bench4.c -o bench4 && ./bench4 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stdbool.h>
#include <time.h>

static double now_ms(void) {
    struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t);
    return t.tv_sec * 1e3 + t.tv_nsec / 1e6;
}
static size_t join_strcat(char *buf, int n) {      /* rescans buf on every call */
    buf[0] = '\0';
    for (int i = 0; i < n; i++) strcat(buf, "word,");
    return strlen(buf);
}
static size_t join_end(char *buf, int n) {         /* remembers where the end is */
    char *end = buf;
    for (int i = 0; i < n; i++) { memcpy(end, "word,", 5); end += 5; }
    *end = '\0';
    return (size_t)(end - buf);
}
struct Bad  { char flag; double price; char kind; int32_t qty; bool active; };
struct Good { double price; int32_t qty; char flag; char kind; bool active; };
static struct Bad  *gb;
static struct Good *gg;
enum { N = 10000000 };
static size_t sum_bad(void)  { size_t t = 0; for (int i = 0; i < N; i++) t += gb[i].qty; return t; }
static size_t sum_good(void) { size_t t = 0; for (int i = 0; i < N; i++) t += gg[i].qty; return t; }

#define BEST(label, expr) do { double best = 1e30; volatile size_t r = 0;           \
    for (int k = 0; k < 5; k++) { double t0 = now_ms(); r = (expr);              \
        double dt = now_ms() - t0; if (dt < best) best = dt; }                   \
    printf("%-26s %9.3f ms  (result %zu)\n", label, best, (size_t)r); } while (0)

int main(void) {
    int sizes[] = {10000, 20000, 40000};
    char *buf = malloc(40000 * 5 + 1);
    for (int s = 0; s < 3; s++) {
        char l1[40], l2[40];
        snprintf(l1, sizeof l1, "strcat   n=%d", sizes[s]);
        snprintf(l2, sizeof l2, "end ptr  n=%d", sizes[s]);
        BEST(l1, join_strcat(buf, sizes[s]));
        BEST(l2, join_end(buf, sizes[s]));
    }
    free(buf);
    struct Bad  *b = gb = malloc(N * sizeof *b);
    struct Good *g = gg = malloc(N * sizeof *g);
    srand(42);
    for (int i = 0; i < N; i++) {
        int q = rand() % 100;
        b[i] = (struct Bad){ .qty = q, .price = q * 0.5 };
        g[i] = (struct Good){ .qty = q, .price = q * 0.5 };
    }
    BEST("sum qty, Bad (32 B)",  sum_bad());
    BEST("sum qty, Good (16 B)", sum_good());
    free(b); free(g);
    return 0;
}

It compiles cleanly even with -Wpedantic. Both struct loops printed the same checksum (494840983), so the faster one is not skipping work.

Honest limits and version notes

  • Everything here ran on Apple clang 21.0.0 on arm64 macOS. The padding numbers hold for the common 64-bit ABIs (arm64 and x86-64 Linux/macOS/Windows give the same 32/16 for these structs), but the standard only says "implementation-defined"; we measured only this machine.
  • We saw -Wunterminated-string-initialization and its nonstring hint on clang 21 with plain -Wall -Wextra; we did not check which earlier versions emit it.
  • The Abort trap: 6 (stack protector) and Trace/BPT trap: 5 (_FORTIFY_SOURCE check) results are macOS libc behaviour. Linux with glibc reports *** stack smashing detected *** or *** buffer overflow detected *** instead (not run here). Neither is a substitute for ASan: they only see some overflows, and only after or just before the damage.
  • Standards: strncpy, strcat and offsetof are C89; snprintf is C99; _Alignof is C11 (alignof became a keyword in C23). memccpy and strdup joined ISO C in C23. strlcpy is not in ISO C; it is in POSIX.1-2024, on macOS/BSD, and in glibc from 2.38.
  • Timings are best-of-5 on one idle machine. The ratios (n², half the bytes ≈ half the time) are the point; your absolute numbers will differ.

Exercises

  1. Without running it, what do sizeof(struct A) and sizeof(struct B) print on a 64-bit Mac? struct A { char a; int b; char c; }; and struct B { int b; char a; char c; };
  2. Write int append(char *dst, size_t cap, size_t *len, const char *src) that appends src, keeps *len up to date, and returns -1 without changing dst if the result would not fit. Test it with a 12-byte buffer and "GET ", "/index", ".html".

Answers

  1. A=12 B=8. In A, b must start at offset 4, so 3 bytes follow a, then c at 8 and 3 bytes of tail padding to reach a multiple of 4. In B, the two chars sit at 4 and 5, and 2 bytes of tail padding make 8.
  2. One version, checked under -fsanitize=address,undefined:
static int append(char *dst, size_t cap, size_t *len, const char *src) {
    size_t n = strlen(src);
    if (*len + n + 1 > cap) return -1;
    memcpy(dst + *len, src, n + 1);      /* copies the NUL too */
    *len += n;
    return 0;
}
append "GET " -> rc=0 buf="GET " len=4
append "/index" -> rc=0 buf="GET /index" len=10
append ".html" -> rc=-1 buf="GET /index" len=10

"GET /index.html" needs 16 bytes, so the third call refuses and the buffer stays a valid string.

For more practice with structs and pointers in a real data structure, see the linked list and stack posts, which are also checked with ASan.


Previous: Lecture 3: pointers, memory and malloc · Next: Lecture 5: undefined behavior and the optimizer

FAQ

Is strncpy a safe replacement for strcpy?

No. When the source is as long as the buffer or longer, strncpy fills every byte and writes no terminating NUL. In our run the last byte of an 8-byte buffer was 111 ('o'), not 0, so the next strlen or printf reads past the end. Use snprintf(dst, sizeof dst, "%s", src) and check its return value, or strlcpy where your platform has it.

Why is sizeof my struct bigger than the sum of its fields?

The compiler inserts padding so each field sits at an address that is a multiple of its alignment. A struct of char, double, char, int32_t and bool needs 15 bytes of data but measured 32 bytes on arm64; ordering the same fields from largest to smallest gave 16. Print offsetof for each field, or compile with -Wpadded, to see where the holes are.

Why did my program print the right output and then crash with Abort trap: 6?

On macOS, a stack buffer overflow at -O0 is often detected by the stack protector only when the function returns. The overflowing strcpy already ran and the printf already printed, then __stack_chk_fail called abort (exit status 134). Build with -fsanitize=address to stop at the exact write instead.

  • #c
  • #intermediate
  • #strings
  • #structs
  • #buffer-overflow
  • #addresssanitizer
  • #clang

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑