Integer Overflows and Format-String Bugs, Explained
How integer overflow, truncation and signedness errors turn into memory corruption, why user-controlled format strings are dangerous, and the checks and warnings that prevent both.
Integer bugs and format-string bugs are rarely dangerous on their own. They become dangerous when their result feeds a memory operation: a length passed to memcpy, a size passed to malloc, an index into an array, or a string passed to printf as the format. This guide covers both classes from the defender's side: the vulnerable patterns, the compiler features that catch them, and the coding rules that make them impossible. It complements stack buffer overflows and heap corruption, which are frequently the second half of an integer bug.
Integer bugs
C integers have fixed widths, mixed signedness and implicit conversions. Four failure modes account for most real-world bugs:
| Failure mode | Example | Result |
|---|---|---|
| Overflow / wraparound | count * sizeof(struct item) exceeds SIZE_MAX | A tiny allocation for a huge count |
| Truncation | uint16_t len = strlen(s); | Length silently reduced modulo 65536 |
| Signedness confusion | int len from input is negative, passed to memcpy as size_t | A negative value becomes an enormous size |
| Underflow | size_t remaining = total - used; with used > total | Wraps to a value near SIZE_MAX |
The allocation-size overflow
This is the canonical integer-to-heap bug (CWE-190 leading to CWE-122):
/* Vulnerable: the multiplication can wrap on 32-bit size_t or with huge counts. */
struct item *load_items(uint32_t count, const uint8_t *src) {
struct item *items = malloc(count * sizeof(struct item));
if (!items) return NULL;
for (uint32_t i = 0; i < count; i++)
parse_item(&items[i], src, i); /* writes far past a small buffer */
return items;
}
If count * sizeof(struct item) wraps, malloc succeeds with a small buffer and the loop writes count full items into it. The fix is to check the arithmetic before trusting it:
#include <stdckdint.h> /* C23; GCC 14+, Clang 18+ */
struct item *load_items(uint32_t count, const uint8_t *src) {
size_t bytes;
if (count > MAX_ITEMS) return NULL; /* domain limit first */
if (ckd_mul(&bytes, (size_t)count, sizeof(struct item))) /* true on overflow */
return NULL;
struct item *items = malloc(bytes);
...
}
On older toolchains, __builtin_mul_overflow(a, b, &res) in GCC and Clang does the same job. Simplest of all, calloc(count, sizeof(struct item)) performs the overflow check internally and zeroes the memory.
Signedness and the negative length
/* Vulnerable: len is signed; a negative value passes the check. */
int copy_field(char *dst, size_t dst_size, const char *src, int len) {
if (len > (int)dst_size) return -1; /* -1 is not > dst_size */
memcpy(dst, src, len); /* -1 converts to SIZE_MAX */
return 0;
}
Fix it by making lengths unsigned from the moment they enter the program, and validating both ends:
int copy_field(char *dst, size_t dst_size, const char *src, size_t len) {
if (len > dst_size) return -1;
memcpy(dst, src, len);
return 0;
}
GCC and Clang warn about many of these conversions with -Wconversion and -Wsign-conversion. They are noisy on legacy code, so a common strategy is to enable them for new modules and for the parsing layer first.
Undefined behaviour makes checks disappear
Signed overflow is undefined behaviour, so the compiler is allowed to assume it never happens. A check written in terms of the overflow itself can be optimised away:
/* Broken check: the compiler may delete it, since a + b "cannot" overflow. */
if (a + b < a) return -1;
/* Correct: check before doing the arithmetic. */
if (b > 0 && a > INT_MAX - b) return -1;
The rule is simple: never detect overflow by performing it. Use the checked-arithmetic builtins or compare against limits beforehand.
Tooling for integer bugs
| Tool or flag | Catches |
|---|---|
-fsanitize=signed-integer-overflow (UBSan) | Signed overflow at runtime |
-fsanitize=unsigned-integer-overflow (Clang only) | Unsigned wraparound (not UB, but often a bug) |
-fsanitize=implicit-conversion (Clang) | Truncating and sign-changing implicit conversions |
-ftrapv | Traps on signed overflow (older, less precise than UBSan) |
-Wconversion -Wsign-conversion | Risky conversions at compile time |
| CodeQL / Coverity | Tainted values reaching allocation sizes and indices |
The sanitizers guide shows how to combine UBSan with ASan in a test build.
Format-string bugs
printf-family functions interpret their first argument as a small program: conversion specifiers such as %s, %x and %p tell the function how many further arguments to read from registers and the stack, and how to interpret them. If an attacker controls that first argument, they control how the function walks the argument area.
The vulnerable pattern
/* Vulnerable: user input is used as the format string. */
void log_user(const char *msg) {
printf(msg);
}
/* Fixed: the format is a constant; user input is data. */
void log_user(const char *msg) {
printf("%s", msg);
}
With the vulnerable version, input containing %x or %p makes printf print values it was never given, which leaks stack contents and, often, addresses that defeat ASLR. The %n specifier is worse: instead of printing, it writes the number of characters output so far to a pointer taken from the argument list. That is why this bug class was historically rated as severe as a buffer overflow. See the glossary entry on format-string vulnerabilities for a short definition.
The same bug appears with fprintf, sprintf, snprintf, syslog, err/warn, and, most often in modern code, custom logging wrappers that forward a caller-supplied string as the format.
Defences that make it hard to write
- Warnings.
-Wformat -Wformat-securityflags calls where the format is not a string literal and there are no other arguments;-Wformat=2adds-Wformat-nonliteraland more. Debian, Ubuntu and Fedora enable-Wformat -Wformat-securityin their default package build flags, and several turn it into an error with-Werror=format-security. - Annotate your wrappers. Tell the compiler which argument is the format so the same checks apply to your own logging functions:
void log_msg(int level, const char *fmt, ...)
__attribute__((format(printf, 2, 3)));
- FORTIFY_SOURCE. With
-D_FORTIFY_SOURCE=2or higher, glibc's checkedprintfvariants abort if%nappears in a format string located in writable memory, which neutralises the classic write primitive. - Positional and width checks. Fortified builds also verify that positional arguments (
%1$s) are used consistently. - Code review rule. A format argument must be a string literal or come from a trusted, constant table. There are no exceptions worth the risk.
How these bugs show up in triage
Integer bugs usually present as something else: an ASan heap-buffer-overflow whose allocation size looks absurdly small, or a memcpy with a size near 0xffffffffffffffff. When you see that, walk back to the arithmetic that produced the size. UBSan builds pinpoint the exact expression:
parser.c:41:25: runtime error: unsigned integer overflow:
4294967295 * 24 cannot be represented in type 'unsigned int'
Format-string bugs present as garbled or hexadecimal-looking log output, crashes inside vfprintf, or *** %n in writable segment detected *** from a fortified binary. Each of those is a code fix, not a configuration issue. The crash report guide covers the triage workflow.
Checklist
- Represent sizes and lengths as
size_tfrom the point they enter the program, and validate them against documented maxima. - Never detect overflow by performing it. Use
ckd_add/ckd_mul,__builtin_*_overflowor pre-checks against limits. - Use
callocor checked multiplication for everycount * sizeallocation. - Build with
-Wall -Wextra -Wformat=2 -Werror=format-security, and-Wconversionon parsing code. - Annotate logging wrappers with
__attribute__((format(printf, ...))). - Run tests with
-fsanitize=address,undefinedand fuzz the parsers that compute sizes.