Three kinds of "it depends"

The C standard describes what a correct program does, but it deliberately leaves some things open so that C can run efficiently on very different hardware. It distinguishes three kinds of openness, and they are very different in how dangerous they are.

Implementation-defined behavior

Implementation-defined behavior is behavior the standard leaves to the compiler and platform, which must choose one option and document it. The size of long, whether plain char is signed, and what right-shifting a negative number does are examples. A program that depends on them is still correct, but only portable as far as its assumptions go:

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

int main(void)
{
    char c = (char)200;
    int negative = -8;
    printf("CHAR_MIN = %d, CHAR_MAX = %d\n", CHAR_MIN, CHAR_MAX);
    printf("(char)200 = %d\n", c);
    printf("sizeof(long) = %zu\n", sizeof(long));
    printf("-8 >> 1 = %d\n", negative >> 1);
    return 0;
}
CHAR_MIN = 0, CHAR_MAX = 255
(char)200 = 200
sizeof(long) = 8
-8 >> 1 = -4

On this 64-bit Linux machine (ARM64), char is unsigned; on most x86-64 systems the same program prints CHAR_MIN = -128 and (char)200 = -56. GCC documents that >> on a negative number shifts in copies of the sign bit. More such traps are collected in The Standard Library and Portability.

Unspecified behavior

Unspecified behavior means the standard allows several outcomes and the compiler need not document, or even consistently make, its choice. The classic case is the order in which the arguments of a function call are evaluated:

#include <stdio.h>

static int read_meter(const char *name, int kwh)
{
    printf("reading %s\n", name);
    return kwh;
}

static void report(int day, int night)
{
    printf("day %d kWh, night %d kWh\n", day, night);
}

int main(void)
{
    /* Which argument is evaluated first is unspecified. */
    report(read_meter("day register", 210), read_meter("night register", 140));
    return 0;
}
reading day register
reading night register
day 210 kWh, night 140 kWh

Both GCC and Clang happened to read the day register first on this machine, but another compiler, platform or optimization level may not. The result is still one of the allowed outcomes; the bug is only in code that relies on the order. If the order matters, read the values into variables first, in separate statements.

Undefined behavior

Undefined behavior (UB) is behavior for which the standard imposes no requirements at all. The program may crash, print a wrong answer, appear to work, or behave differently tomorrow; there is no "correct" outcome to fall back on. The most common sources in practice:

Undefined behavior Example Explained in
Signed integer overflow INT_MAX + 1 Variables, Types and Integers
Shift by the width of the type or more, or by a negative amount 1u << 32 for a 32-bit unsigned Operators, Expressions and Conversions
Access outside an array a[3] for int a[3] Arrays and Pointer Arithmetic
Writing past the end of a string buffer strcpy into a buffer that is too small Strings and Buffer Overflows
Dereferencing a null or dangling pointer *p with p == NULL Pointers
Use after free, double free free(p); p[0] = 1; Dynamic Memory
Reading an uninitialized variable int total; total += 5; Variables, Types and Integers
Modifying an object twice without sequencing i = i++ + 1; below
Modifying a string literal char *s = "abc"; s[0] = 'A'; Strings and Buffer Overflows

How optimizers exploit undefined behavior

Undefined behavior is not just "whatever the hardware does". Because a correct program never has it, compilers may assume it does not happen and optimize accordingly. Here is an overflow check that looks reasonable — if adding 1 makes the number smaller, it must have wrapped around:

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

/* Meant to detect overflow, but the check itself overflows. */
static int will_overflow(int x)
{
    return x + 1 < x;
}

int main(void)
{
    printf("will_overflow(INT_MAX) = %d\n", will_overflow(INT_MAX));
    return 0;
}
will_overflow(INT_MAX) = 0

GCC prints 0, even without optimization. Since signed overflow cannot happen in a correct program, x + 1 < x can never be true, and the compiler replaces the comparison with 0. The same source compiled with Clang (here in C23 mode, which changes nothing about overflow) prints the opposite:

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

/* Meant to detect overflow, but the check itself overflows. */
static int will_overflow(int x)
{
    return x + 1 < x;
}

int main(void)
{
    printf("will_overflow(INT_MAX) = %d\n", will_overflow(INT_MAX));
    return 0;
}
will_overflow(INT_MAX) = 1

Clang at -O0 performs the addition at run time, the processor wraps around, and the comparison is true. With optimization (-O2), Clang also folds it to 0. GCC's -fwrapv option defines signed overflow as wrapping around, which turns the answer back into 1:

cat > ub.c <<'C'
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>

static int will_overflow(int x) { return x + 1 < x; }

int main(int argc, char *argv[])
{
    int shift = argc > 1 ? atoi(argv[1]) : 0;
    printf("will_overflow: %d   1u << %d: %u\n",
           will_overflow(INT_MAX), shift, 1u << shift);
    return 0;
}
C
for flags in "-O0" "-O2" "-O2 -fwrapv"; do
    gcc -std=c17 -Wall -Wextra $flags -o ub ub.c
    printf '%-12s ' "$flags"; ./ub 32
done
-O0          will_overflow: 0   1u << 32: 1
-O2          will_overflow: 0   1u << 32: 1
-O2 -fwrapv  will_overflow: 1   1u << 32: 1

The shift is undefined too. Mathematically, shifting a 32-bit value left by 32 positions would leave 0, but the ARM64 shift instruction only uses the shift count modulo 32, so it shifted by 0 and printed 1; in other surrounding code, the same machine printed 0 at -O2. Neither answer is "right", which is the point.

One source, three answers. The expression x + 1 < x with x equal to INT_MAX prints 0 with gcc at -O0 and -O2 and with clang at -O2, because the compiler assumes overflow cannot happen; prints 1 with clang at -O0 and with gcc -O2 -fwrapv, because the addition wraps around at run time; and produces a runtime error report with -fsanitize=undefined. x + 1 < x x = INT_MAX prints 0: overflow "cannot happen" gcc -O0, gcc -O2, clang -O2 prints 1: wrapped at run time clang -O0, gcc -O2 -fwrapv runtime error: reported -fsanitize=undefined (UBSan)
None of the three is a compiler bug. Only the sanitizer tells you that the source is.

This is why "it works on my machine" means little for C code with undefined behavior. Real security problems have come from the same mechanism: a check that the programmer wrote and the optimizer legally removed. Write overflow checks that cannot overflow themselves (x == INT_MAX, or the checked functions below).

Sequence points: modifying a variable twice

Within one expression, the order in which side effects happen is mostly unspecified. Older standards express this with sequence points — moments, such as the end of a full statement, a &&, || or comma operator, and a function call, by which all earlier side effects are complete; C11 and later speak of operations being "sequenced before" each other. Modifying the same variable twice without a sequence point in between, or reading it for another purpose, is undefined. GCC's -Wall recognizes the simple cases:

#include <stdio.h>

int main(void)
{
    int readings = 5;
    readings = readings++ + 1;
    printf("%d\n", readings);
    return 0;
}
example.c:6:14: error: operation on ‘readings’ may be undefined [-Werror=sequence-point]

Finding undefined behavior with tools

Because undefined behavior often looks like working code, people rarely find it by reading. Four kinds of tools do better, and they complement each other.

Compiler warnings

Warnings are free and immediate. GCC 12's -Wall already includes a use-after-free check for simple cases like this one; with -Werror, the program does not build:

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

int main(void)
{
    int *kwh = malloc(4 * sizeof *kwh);
    if (kwh == NULL) {
        return 1;
    }
    kwh[0] = 412;
    free(kwh);
    printf("%d\n", kwh[0]);
    return 0;
}
example.c:12:5: error: pointer ‘kwh’ used after ‘free’ [-Werror=use-after-free]

Turn on -Wall -Wextra (and consider -Wconversion and -Wformat-security), and make warnings errors in CI. Which bugs these flags do and do not catch is listed in Compiling and Running C.

Sanitizers in testing

Sanitizers add run-time checks to the program. With -fsanitize=undefined, the overflow check from the start of this section no longer silently gives an answer: the UndefinedBehaviorSanitizer reports the exact line and values, then lets the program continue (and, with the instrumentation in place, the answer is 1):

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

/* Meant to detect overflow, but the check itself overflows. */
static int will_overflow(int x)
{
    return x + 1 < x;
}

int main(void)
{
    printf("will_overflow(INT_MAX) = %d\n", will_overflow(INT_MAX));
    return 0;
}
example.c:7:14: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
will_overflow(INT_MAX) = 1

AddressSanitizer catches memory errors when they happen. Here the Kestrel Home dongle's serial number, KHP1-000231 (11 characters plus the terminator), is copied into an 8-byte buffer — a teaching example, not a bug in the real firmware. Without sanitizer, this program printed both lines and exited normally; with it, the program stops at the first byte written out of bounds:

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

static void show_serial(const char *serial)
{
    char id[8];                         /* room for 7 characters + '\0' */
    strcpy(id, serial);                 /* no length check */
    printf("dongle %s\n", id);
}

int main(void)
{
    const char *serials[] = {"KHP1-23", "KHP1-000231"};
    for (int i = 0; i < 2; i++) {
        show_serial(serials[i]);
    }
    return 0;
}
dongle KHP1-23
SUMMARY: AddressSanitizer: stack-buffer-overflow ../../../../src/libsanitizer/asan/asan_interceptors.cpp:425 in __interceptor_strcpy

The summary names the library function that did the writing; the full report on standard error says where it was called from and which variable overflowed (addresses differ per run):

==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x… at pc 0x… bp 0x… sp 0x…
WRITE of size 12 at 0x… thread T0
    #0 0x… in __interceptor_strcpy ../../../../src/libsanitizer/asan/asan_interceptors.cpp:425
    #1 0x… in show_serial example.c:7
    #2 0x… in main example.c:15
    …
  This frame has 1 object(s):
    [32, 40) 'id' (line 6) <== Memory access at offset 40 overflows this variable

Sanitizers only see the code paths your tests run, and they slow the program down, so they belong in test builds and CI, not in production. How to read their reports, and how they compare with Valgrind, is covered in Debugging C.

Static analysis

Static analyzers reason about all paths through the code without running it. GCC has one built in, -fanalyzer (since GCC 10; its manual says it is only suitable for C code), and Clang has clang --analyze. They name the weakness by its Common Weakness Enumeration (CWE) number and print the path that leads to it — here for the use-after-free program above:

gcc -std=c17 -Wall -Wextra -fanalyzer -c readings.c
readings.c:12:5: warning: use after ‘free’ of ‘kwh’ [CWE-416] [-Wanalyzer-use-after-free]
   12 |     printf("%d\n", kwh[0]);
      |     ^~~~~~~~~~~~~~~~~~~~~~
  ‘main’: events 1-6
    …
    |   11 |     free(kwh);
    |      |     ~~~~~~~~~
    |      |     |
    |      |     (5) freed here
    |   12 |     printf("%d\n", kwh[0]);
    |      |     ~~~~~~~~~~~~~~~~~~~~~~
    |      |     |
    |      |     (6) use after ‘free’ of ‘kwh’; freed at (5)

Analyzers produce false positives and miss things (on this machine -fanalyzer did not report a leak at the end of main), but they are cheap to run on every commit as part of static application security testing.

Fuzzing

Fuzzing feeds a function huge numbers of generated inputs and watches for crashes; combined with AddressSanitizer, every out-of-bounds access becomes a crash. Parsers of untrusted input are the best candidates — such as the dongle's CRC field parser from Error Handling. With Clang's libFuzzer, the test is one function:

#include <stddef.h>
#include <stdint.h>
#include <string.h>

int parse_crc_field(const char *field, uint16_t *crc);   /* code under test */

/* libFuzzer calls this function with generated inputs. */
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
{
    char field[32];
    if (size >= sizeof field) {
        return 0;                           /* keep inputs short */
    }
    memcpy(field, data, size);
    field[size] = '\0';

    uint16_t crc;
    (void)parse_crc_field(field, &crc);   /* must never crash */
    return 0;
}
# illustrative
clang -g -O1 -fsanitize=fuzzer,address fuzz_crc.c crc_field.c -o fuzz_crc
./fuzz_crc -max_total_time=300 corpus/

On the machine used for this page, libFuzzer built and ran but got no coverage feedback, so its output is not shown. For firmware that Kestrel buys rather than writes, asking the manufacturer whether the telegram parser is fuzzed — and with which sanitizers — is a reasonable question next to the software bill of materials that IoT Security already asks for.

Checked integer arithmetic

Signed overflow is undefined, so the safe way to detect it is to ask the compiler to do the arithmetic and report whether the result fits. GCC and Clang have offered built-in functions for this for years. The Kestrel numbers make a good test: 400,000 meters times 8,760 hours in a year is 3,504,000,000, more than an int can hold.

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

int main(void)
{
    int meters = 400000;
    int hours = 8760;
    int product;

    /* GCC and Clang built-in: returns true if the result did not fit. */
    if (__builtin_mul_overflow(meters, hours, &product)) {
        printf("%d x %d does not fit in an int\n", meters, hours);
    } else {
        printf("%d\n", product);
    }

    long long wide = (long long)meters * hours;      /* widen first */
    printf("as long long: %lld\n", wide);
    return 0;
}
400000 x 8760 does not fit in an int
as long long: 3504000000

C23 standardizes the same idea in <stdckdint.h> as ckd_add, ckd_sub and ckd_mul, which return true when the mathematically correct result did not fit. This example is compiled with Clang in C23 mode (GCC supports the header from version 14):

#include <stdckdint.h>
#include <stdio.h>

int main(void)
{
    int meters = 400000;
    int hours = 8760;
    int product;

    if (ckd_mul(&product, meters, hours)) {
        printf("%d x %d does not fit in an int\n", meters, hours);
    } else {
        printf("%d\n", product);
    }
    return 0;
}
400000 x 8760 does not fit in an int

More C23 features are on Modern C and Where C Is Used.

Memory safety

Memory safety is the property that a program can only access memory it is entitled to: within the bounds of an object, while that object is alive, through a pointer of the right type. In C, memory safety is entirely the programmer's job, and the bugs that break it — buffer overflows, out-of-bounds reads, use after free, double free, null pointer dereferences — are exactly the undefined behavior from the table above. They are also the most valuable bugs for attackers, because writing outside an object can change data or control flow elsewhere in the program.

The MITRE Common Weakness Enumeration (CWE) Top 25 of 2025 (released December 2025) ranks the most dangerous software weaknesses; seven of them are memory-safety weaknesses that apply to C:

Rank (2025) Weakness
5 CWE-787 Out-of-bounds Write
7 CWE-416 Use After Free
8 CWE-125 Out-of-bounds Read
11 CWE-120 Classic Buffer Overflow
13 CWE-476 NULL Pointer Dereference
14 CWE-121 Stack-based Buffer Overflow
16 CWE-122 Heap-based Buffer Overflow

Integer overflow (CWE-190), often listed in older material, is not in the 2025 Top 25. Large vendors report similar proportions: Microsoft wrote in 2019 that about 70% of the vulnerabilities it assigns a CVE each year are memory-safety issues, and the Chromium project reports that around 70% of its high-severity security bugs are memory unsafety problems, half of them use-after-free bugs.

Heartbleed

The best-known example is Heartbleed, CVE-2014-0160, published on 7 April 2014. OpenSSL, the C library behind a large share of the world's TLS connections, trusted a length field in a "heartbeat" message from the other side and copied that many bytes back, reading past the end of the actual message — an out-of-bounds read (CWE-125) that could leak private keys and passwords from server memory. It affected OpenSSL 1.0.1 before 1.0.1g. The fix was a bounds check: compare the claimed length with the length actually received. The lesson is the same as for BUG-1060's parser: input from the other side of a connection is untrusted, including the numbers that describe it.

The push toward memory-safe languages

A memory-safe language prevents memory-safety bugs by design, through bounds checks, automatic memory management or compile-time ownership rules — for example Python, Java, C#, Go, Swift and Rust. Since 2022, security agencies have recommended them for new software, and C and C++ are the languages these documents name:

Date Publication Main message
Nov 2022 NSA, Software Memory Safety use memory-safe languages where possible; harden the rest
Dec 2023 CISA, NSA, FBI and agencies of four other countries (Australia, Canada, the United Kingdom and New Zealand), The Case for Memory Safe Roadmaps software makers should publish a plan to move to memory-safe languages
Feb 2024 US White House Office of the National Cyber Director, Back to the Building Blocks (now in the archive) "in most cases" a memory-safe language is the most efficient way to improve software security
Jan 2025 CISA and FBI, Product Security Bad Practices v2.0 new product lines in C or C++ where alternatives exist are a bad practice; existing products should have a memory-safety roadmap by the end of 2025 (voluntary guidance)
Jun 2025 CISA and NSA, Memory Safe Languages: Reducing Vulnerabilities in Modern Software Development adoption takes language protections, libraries, tooling and training; non-memory-safe languages remain a necessity in some cases

The approach works where it is applied: Google reported in November 2025 that memory-safety vulnerabilities had fallen below 20% of all Android vulnerabilities for the first time, down from 76% in 2019, after writing new code in memory-safe languages. In the EU, the Cyber Resilience Act requires secure products but does not mention memory safety or prescribe languages.

None of this makes C disappear. Operating system kernels, firmware such as the P1 dongle's, language runtimes and decades of libraries are written in C, and rewriting working code introduces new bugs of its own. The balanced reading of the guidance, as of October 2026: choose a memory-safe language for new components where that is practical, and for the C that remains, apply everything on this page — warnings, sanitizers, analysis, fuzzing, hardening and a coding standard. How Rust achieves memory safety without a garbage collector is outlined in Memory Management.

Compiler hardening

Compiler hardening means building a program with options that make memory-safety bugs harder to exploit: the compiler adds checks that stop the program when memory is corrupted, and the linker arranges the executable so that an attacker cannot predict or modify key addresses. It does not fix bugs; it turns silent corruption into a crash, which is part of defense in depth. The same 8-byte buffer and 11-character serial, built three ways:

cat > serial.c <<'C'
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[])
{
    char id[8];
    if (argc != 2) {
        return 2;
    }
    strcpy(id, argv[1]);                /* no length check */
    printf("dongle %s\n", id);
    return 0;
}
C
run() { ./serial "$1" 2>&1 | cat; echo "exit status: ${PIPESTATUS[0]}"; }

gcc -std=c17 -Wall -Wextra -O0 -o serial serial.c
echo "--- no protection"
run KHP1-000231

gcc -std=c17 -Wall -Wextra -O0 -fstack-protector-strong -o serial serial.c
echo "--- -fstack-protector-strong"
run KHP1-000231

gcc -std=c17 -Wall -Wextra -O2 -D_FORTIFY_SOURCE=2 -o serial serial.c
echo "--- -O2 -D_FORTIFY_SOURCE=2"
run KHP1-23
run KHP1-000231
--- no protection
dongle KHP1-000231
exit status: 0
--- -fstack-protector-strong
*** stack smashing detected ***: terminated
exit status: 134
--- -O2 -D_FORTIFY_SOURCE=2
dongle KHP1-23
exit status: 0
*** buffer overflow detected ***: terminated
exit status: 134

Without protection, the program overwrote four bytes of its own stack, printed the serial and exited with status 0 — the worst outcome, because nobody notices. The stack protector places a canary value next to local buffers and checks it before the function returns. _FORTIFY_SOURCE makes glibc replace calls such as strcpy with versions that know the destination size where the compiler can work it out; it needs optimization (-O1 or higher). Exit status 134 means the program was stopped with SIGABRT.

Option What it does
-fstack-protector-strong canary check for functions with local arrays or address-taken locals
-O2 -D_FORTIFY_SOURCE=2 (or =3) size-checked versions of string and memory functions
-fPIE -pie position-independent executable, so address space layout randomization (ASLR) can move the program itself
-Wl,-z,relro,-z,now makes the table of library function addresses read-only after startup
-fstack-clash-protection prevents a large stack allocation from jumping over the guard page

Defaults differ per distribution and compiler build. With Debian 12's GCC on this machine, executables are PIE by default, but the stack protector, stack clash protection and _FORTIFY_SOURCE are off unless you ask (Debian's own package builds add hardening flags through their build tooling). Check your own toolchain rather than assuming, and set the flags explicitly in the Makefile or CMake configuration of release builds (see The Preprocessor and Build Tools).

Coding standards

Coding standards turn the lessons of decades of C bugs into rules that reviewers and analysis tools can check.

The SEI CERT C Coding Standard, from the Software Engineering Institute at Carnegie Mellon University, is a free set of rules and recommendations for secure C, each with non-compliant and compliant examples. The current edition is from 2016; the rules are published on the SEI's website. Rules relevant to this topic:

Rule Title
INT32-C Ensure that operations on signed integers do not result in overflow
ARR30-C Do not form or use out-of-bounds pointers or array subscripts
STR31-C Guarantee that storage for strings has sufficient space for character data and the null terminator
MEM30-C Do not access freed memory
MEM31-C Free dynamically allocated memory when no longer needed
EXP33-C Do not read uninitialized memory
ERR33-C Detect and handle standard library errors
FIO47-C Use valid format strings

MISRA C is a set of guidelines for C in safety-critical and embedded systems, from the MISRA consortium, which started in the automotive industry. It restricts the language to a subset that is easier to analyze — for example, its required rules forbid the standard library's dynamic memory functions and recursion unless a formal deviation is recorded — and its rules are checked by commercial static analysis tools. The current version, as of October 2026, is MISRA C:2025, published in March 2025. Unlike SEI CERT C, the MISRA documents are sold, not free.

A safer-C checklist

  • Compile with -Wall -Wextra and treat warnings as errors in CI; add -Wconversion where you can.
  • Run the test suite with -fsanitize=address,undefined; a sanitizer report is a failed test.
  • Run a static analyzer on every change, and fuzz every parser of files, network messages or device data.
  • Build releases with hardening flags (stack protector, _FORTIFY_SOURCE, PIE, RELRO) and check what your toolchain enables by default.
  • Use bounded functions and explicit checks: snprintf instead of sprintf, strtol with error checks instead of atoi, checked arithmetic for sizes and counts.
  • Follow a coding standard (SEI CERT C, or MISRA C for embedded and safety-critical code) and use it in code review.
  • For new components, consider a memory-safe language; for the C you keep, keep a list of where untrusted input enters.