Memory Safety Without Rust: Defensive C and C++ Patterns
I hear “just rewrite it in Rust” a lot these days, and while Rust’s ownership model genuinely does eliminate entire classes of memory safety bugs at compile time, that advice ignores reality. The vast majority of systems code – operating systems, embedded firmware, database engines, network stacks – is written in C and C++ and will remain so for decades. Rewriting is not always an option. So I wanted to dig into the defensive patterns, compiler features, and runtime tools that bring memory safety closer to C and C++ codebases without a language migration. What I found is that while none of these approaches match Rust’s compile-time guarantees, the combination of them makes a real difference.
The Memory Safety Landscape in C/C++
Memory safety bugs in C and C++ fall into a few well-defined categories, and once you know what to look for, you start seeing them everywhere:
- Buffer overflows (CWE-787): Writing past the end of an allocated buffer. Still the most common category in CVE databases.
- Use after free (CWE-416): Accessing memory after it has been freed. These are the hardest to track down manually.
- Null pointer dereference (CWE-476): Dereferencing a pointer that is NULL.
- Double free: Freeing the same memory twice, corrupting the heap allocator.
- Uninitialized memory: Reading memory that was allocated but never written to.
- Integer overflow leading to undersized allocation: An arithmetic overflow produces a small allocation size, and subsequent writes overflow the buffer.
Each category has defensive patterns that reduce (but honestly, cannot eliminate) the risk.
Defensive C Patterns
Bounded String Operations
The most common C vulnerability in public CVE data is strcpy or sprintf overflowing a fixed-size buffer. Replace every unbounded string function with its bounded counterpart:
// VULNERABLE: no bounds checking
char dest[64];
strcpy(dest, user_input); // overflow if input > 63 chars
sprintf(dest, "Hello, %s", name); // overflow if name is long
// DEFENSIVE: bounded alternatives
char dest[64];
strncpy(dest, user_input, sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0'; // strncpy does not guarantee null termination
snprintf(dest, sizeof(dest), "Hello, %s", name); // truncates, always null-terminates
snprintf is strictly better than sprintf. It always null-terminates and returns the number of characters that would have been written, allowing truncation detection:
int written = snprintf(dest, sizeof(dest), "prefix_%s_suffix", user_input);
if (written >= (int)sizeof(dest)) {
// Output was truncated, handle the error
log_error("Buffer too small for formatted string");
return -1;
}
Safe Integer Arithmetic
Integer overflow before a malloc call is a classic vulnerability pattern. Check for overflow before the operation:
// VULNERABLE: overflow in multiplication
size_t count = parse_count(input); // attacker-controlled
size_t alloc_size = count * sizeof(struct Entry); // can overflow to small value
struct Entry *entries = malloc(alloc_size);
// DEFENSIVE: overflow check
size_t count = parse_count(input);
if (count > SIZE_MAX / sizeof(struct Entry)) {
return -1; // would overflow
}
size_t alloc_size = count * sizeof(struct Entry);
struct Entry *entries = malloc(alloc_size);
if (entries == NULL) {
return -1;
}
GCC and Clang provide built-in overflow checking:
size_t alloc_size;
if (__builtin_mul_overflow(count, sizeof(struct Entry), &alloc_size)) {
return -1; // overflow detected
}
struct Entry *entries = malloc(alloc_size);
Nullify After Free
Use-after-free bugs are harder to prevent in C because there’s no ownership tracking. The simplest mitigation is to set pointers to NULL after freeing:
// DEFENSIVE: nullify after free
void cleanup_session(Session **session_ptr) {
if (session_ptr == NULL || *session_ptr == NULL) return;
free((*session_ptr)->token);
(*session_ptr)->token = NULL;
free(*session_ptr);
*session_ptr = NULL; // prevents use-after-free through this pointer
}
This doesn’t prevent use-after-free through aliased pointers (other pointers to the same memory), but it catches the most common pattern where the same pointer is used after free. It’s a simple discipline that prevents real bugs.
Consistent Allocation Patterns
// DEFENSIVE: wrapper that zeros memory and checks allocation
void *safe_alloc(size_t size) {
if (size == 0) return NULL;
void *ptr = calloc(1, size); // calloc zeros memory and checks overflow
if (ptr == NULL) {
abort(); // fail hard rather than return NULL
}
return ptr;
}
// DEFENSIVE: wrapper that zeros memory before freeing
void safe_free(void **ptr) {
if (ptr == NULL || *ptr == NULL) return;
// Zero the memory to prevent information leaks
// Note: compiler may optimize this out, use explicit_bzero if available
memset(*ptr, 0, malloc_usable_size(*ptr));
free(*ptr);
*ptr = NULL;
}
These wrapper functions consistently reduce the bug count when introduced into C projects. The abort() on allocation failure is controversial – some teams prefer returning NULL – but a hard crash is arguably better than silently propagating a NULL pointer that causes a harder-to-debug crash later.
Defensive C++ Patterns
C++ gives you much stronger tools for memory safety through RAII, smart pointers, and container classes.
Smart Pointers
// VULNERABLE: raw pointer ownership is ambiguous
Widget* createWidget(const Config& config) {
Widget* w = new Widget(config);
if (!w->isValid()) {
delete w;
return nullptr; // caller must check for null
}
return w; // caller must remember to delete
}
// DEFENSIVE: unique_ptr makes ownership explicit
std::unique_ptr<Widget> createWidget(const Config& config) {
auto w = std::make_unique<Widget>(config);
if (!w->isValid()) {
return nullptr; // unique_ptr automatically cleans up
}
return w; // ownership transfers to caller, no leak possible
}
Smart pointers eliminate most use-after-free and memory leak bugs, but they’re not a complete solution. There’s a common misconception that smart pointers make you immune to memory bugs:
// STILL VULNERABLE: dangling reference from smart pointer
std::unique_ptr<Widget> widget = std::make_unique<Widget>();
Widget& ref = *widget;
widget.reset(); // frees the Widget
ref.doSomething(); // use-after-free through the reference
This pattern shows up in production C++ code. Smart pointers manage lifetime, but references can still dangle.
Container Classes Instead of Raw Arrays
// VULNERABLE: manual buffer management
void processData(const char* input, size_t len) {
char* buffer = new char[len + 1];
memcpy(buffer, input, len);
buffer[len] = '\0';
// ... process ...
delete[] buffer; // must remember to free, even on exceptions
}
// DEFENSIVE: std::string handles allocation, bounds, and cleanup
void processData(const std::string& input) {
// No manual allocation, no bounds issues, exception-safe
std::string processed = transform(input);
// ...
} // automatic cleanup
// DEFENSIVE: std::vector for dynamic arrays
void processEntries(const std::vector<Entry>& entries) {
for (const auto& entry : entries) {
// bounds-checked access with .at()
// or unchecked but still memory-safe iteration
}
}
Every time new char[] shows up in a C++ codebase, it’s worth questioning. There’s almost never a good reason not to use std::string or std::vector instead.
span and string_view for Non-Owning References
// C++20: span provides bounds-checked access to contiguous memory
#include <span>
void processBuffer(std::span<const std::byte> data) {
if (data.size() < 4) return;
// Bounds-checked subspan
auto header = data.subspan(0, 4);
auto payload = data.subspan(4);
// data[data.size()] would throw in debug builds
}
std::span and std::string_view provide safe, non-owning views over memory without copying. They don’t prevent dangling references (the underlying memory can be freed), but they eliminate buffer overflow through bounds checking. They’re a significant improvement over raw pointer-and-length pairs.
Compiler and Toolchain Defenses
Compiler Flags
These are the flags worth enabling for every C/C++ project:
# GCC/Clang: enable all relevant warnings and hardening
gcc -Wall -Wextra -Werror \
-Wformat-security \
-Wstack-protector \
-fstack-protector-strong \
-D_FORTIFY_SOURCE=2 \
-fPIE -pie \
-Wl,-z,relro,-z,now \
-fsanitize=address,undefined \
-o program program.c
| Flag | Protection |
|---|---|
-fstack-protector-strong |
Stack canaries detect buffer overflows on return |
-D_FORTIFY_SOURCE=2 |
Replaces unsafe libc functions with bounds-checked versions |
-fPIE -pie |
Position-independent executable enables ASLR |
-Wl,-z,relro,-z,now |
Makes GOT read-only, preventing GOT overwrite attacks |
-fsanitize=address |
AddressSanitizer: detects buffer overflows, use-after-free at runtime |
-fsanitize=undefined |
UBSan: detects undefined behavior (integer overflow, null deref) |
Runtime Sanitizers
AddressSanitizer (ASan) and MemorySanitizer (MSan) catch memory bugs at runtime with ~2x slowdown. These should be non-negotiable for any C/C++ project’s CI pipeline:
// This code has a heap buffer overflow
int *arr = malloc(10 * sizeof(int));
arr[10] = 42; // one past the end
// With -fsanitize=address, ASan reports:
// ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
// WRITE of size 4 at 0x... thread T0
Run your test suite with sanitizers enabled in CI. The performance overhead is acceptable for testing, and the detection rate for memory bugs is excellent. ASan catches bugs that have been lurking in codebases for years – bugs that no amount of code review found.
Detection Strategies
Static Analysis
- cppcheck: Detects buffer overflows, null dereferences, memory leaks, and use-after-free in common patterns. Free and fast. Good baseline for any C/C++ project.
- clang-tidy: Deeper analysis using Clang’s AST. The
bugprone-*andcert-*checks catch many memory safety issues. Particularly strong for C++. - Coverity: Commercial tool with interprocedural analysis. Catches complex bugs that open-source tools miss. Worth the investment for large codebases.
Dynamic Analysis
- AddressSanitizer: Buffer overflows, use-after-free, double-free, memory leaks.
- MemorySanitizer: Uninitialized memory reads.
- ThreadSanitizer: Data races in multithreaded code. Finds race conditions that would take weeks to reproduce manually.
- Valgrind: Comprehensive memory error detection with higher overhead (~10-20x).
Fuzzing
# Compile with fuzzing instrumentation
clang -fsanitize=fuzzer,address -o fuzz_target fuzz_target.c
# Run the fuzzer
./fuzz_target corpus/
Fuzzing with sanitizers is the most effective technique documented in the research for finding memory safety bugs in C/C++ code. The fuzzer generates inputs that maximise code coverage, and the sanitizer detects memory errors triggered by those inputs. The results from fuzzing campaigns like OSS-Fuzz speak for themselves, thousands of bugs found in widely-used open source projects.
Remediation
Memory safety in C/C++ is not a single fix – it’s a layered defence:
- Language level: Use
std::string,std::vector,std::unique_ptr,std::spaninstead of raw pointers and arrays (C++). Usesnprintf,calloc, and overflow-checked arithmetic (C). - Compiler level: Enable
-fstack-protector-strong,-D_FORTIFY_SOURCE=2, and all relevant warnings with-Werror. - Testing level: Run all tests with AddressSanitizer and UndefinedBehaviorSanitizer. Fuzz all input parsers.
- Review level: Flag any use of
malloc/free, rawnew/delete,strcpy,sprintf,memcpywithout bounds checks.
No single layer catches everything. The combination of safe coding patterns, compiler hardening, runtime sanitizers, and fuzzing provides defence in depth that approaches (but honestly, never equals) the guarantees of a memory-safe language. It’s not perfect, but when applied consistently, it makes a real difference.