Undefined Behavior and Safer C
What undefined, unspecified and implementation-defined behavior mean, how optimizers exploit undefined behavior, memory safety and the push toward memory-safe languages, and the tools and coding standards that make C safer.
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.
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 -Wextraand treat warnings as errors in CI; add-Wconversionwhere 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:
snprintfinstead ofsprintf,strtolwith error checks instead ofatoi, 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.