C Security: Manual Memory Management and Its Consequences
C gives you direct control over memory allocation, pointer arithmetic, and hardware interaction. I respect that. But that control comes with absolutely no safety net: no bounds checking, no garbage collection, no type safety beyond what you enforce manually. Every buffer overflow, use-after-free, double-free, format string vulnerability, and null pointer dereference in C is a direct consequence of this design. C remains the language of operating systems, embedded systems, and performance-critical libraries, so its security pitfalls affect every layer of the software stack. When I started digging into the patterns behind C vulnerabilities, the same shapes kept appearing, from the textbook strcpy overflow to the subtle integer promotion that bypasses a bounds check. Let me walk through them.
Buffer Overflows: The Foundational Vulnerability
C arrays have no runtime bounds checking. Writing past the end of a buffer overwrites adjacent memory, stack variables, return addresses, heap metadata, or other program data. It’s remarkable how many CVEs trace back to this single fact.
The Easy-to-Spot Version: Stack Buffer Overflow
#include <stdio.h>
#include <string.h>
void greet(const char *name) {
char buffer[64];
strcpy(buffer, name); // No length check
printf("Hello, %s!\n", buffer);
}
int main(int argc, char *argv[]) {
if (argc > 1) {
greet(argv[1]);
}
return 0;
}
strcpy copies until it finds a null terminator. If name is longer than 63 bytes, it overflows buffer and overwrites the stack frame, including the return address. The attacker controls the return address and redirects execution to shellcode or a ROP chain. Every SAST tool and compiler warning flags strcpy. If you’re still using it, please stop.
The Hard-to-Spot Version: Off-by-One in strncpy
This one is interesting because developers think they’re doing the right thing by switching to strncpy.
#include <stdio.h>
#include <string.h>
void process_username(const char *input) {
char username[32];
strncpy(username, input, sizeof(username));
// strncpy does NOT null-terminate if input >= 32 bytes
printf("User: %s\n", username); // Reads past buffer until it finds a null byte
}
strncpy copies at most n bytes but does not null-terminate the destination if the source is longer than n. The printf reads past the buffer looking for a null terminator, leaking adjacent stack memory. I’ve run into this in code reviews, code that was specifically written to “fix” a strcpy vulnerability. The fix is username[sizeof(username) - 1] = '\0'; after strncpy, or better yet, using snprintf:
void process_username(const char *input) {
char username[32];
snprintf(username, sizeof(username), "%s", input); // Always null-terminates
printf("User: %s\n", username);
}
Comparison: Rust’s String Handling
Rust’s String and &str types are always valid UTF-8 and carry their length. There is no null terminator, no unchecked copy, and no way to read past the end:
fn process_username(input: &str) {
let username: String = if input.len() > 31 {
input[..31].to_string()
} else {
input.to_string()
};
println!("User: {}", username);
// No buffer overflow possible, String manages its own memory
}
Use-After-Free: Dangling Pointers
C has no garbage collector. When memory is freed with free(), the pointer becomes dangling, it still holds the address of the freed memory, but that memory may be reallocated for a different purpose. What makes use-after-free bugs so tricky is that they often don’t crash immediately. They just silently corrupt data until something eventually falls over.
The Easy-to-Spot Version
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
char *create_message(const char *text) {
char *msg = malloc(strlen(text) + 1);
if (!msg) return NULL;
strcpy(msg, text);
return msg;
}
int main() {
char *msg = create_message("Hello");
free(msg);
printf("Message: %s\n", msg); // Use-after-free
return 0;
}
The Hard-to-Spot Version: Stale Pointer in a Struct
This pattern is particularly dangerous in real codebases because the bug is separated from the symptom by potentially thousands of lines of code.
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
typedef struct {
char *name;
char *email;
} User;
typedef struct {
User **users;
int count;
int capacity;
} UserList;
void remove_user(UserList *list, int index) {
if (index < 0 || index >= list->count) return;
free(list->users[index]->name);
free(list->users[index]->email);
free(list->users[index]);
// Shift remaining users
for (int i = index; i < list->count - 1; i++) {
list->users[i] = list->users[i + 1];
}
list->count--;
// Bug: list->users[list->count] still points to the freed User
// If another part of the code iterates to capacity instead of count,
// it accesses freed memory
}
The removed user’s pointer remains in the array at position list->count (the old last position). If any code iterates to capacity instead of count, or if the array is later resized and the stale pointer is copied, the freed memory is accessed. Setting list->users[list->count] = NULL; after the shift mitigates this, but the root cause is C’s lack of ownership tracking. Here’s what clicked for me when studying these patterns: in C, you are the garbage collector, and you’re not as good at it as you think.
Comparison: Go’s Garbage Collection
Go’s garbage collector prevents use-after-free entirely. There is no free(), memory is reclaimed when no references exist:
type User struct {
Name string
Email string
}
func removeUser(users []*User, index int) []*User {
users = append(users[:index], users[index+1:]...)
// The removed User is garbage collected when no references remain
return users
}
Format String Vulnerabilities
This is one of my favourite vulnerability classes to study, because it’s so simple yet so devastating. C’s printf family interprets format specifiers (%s, %x, %n) in the format string. If the format string comes from user input, the attacker can read stack memory (%x), crash the program (%s with no corresponding argument), or write to arbitrary memory (%n).
The Vulnerable Pattern
#include <stdio.h>
void log_message(const char *msg) {
printf(msg); // Format string vulnerability
}
int main(int argc, char *argv[]) {
if (argc > 1) {
log_message(argv[1]);
}
return 0;
}
The attacker sends %x.%x.%x.%x to read stack values, or %n to write the number of bytes printed so far to an address on the stack. The fix is trivial: printf("%s", msg);. And yet this still shows up in code reviews.
The Subtle Version: Format String in syslog
#include <syslog.h>
void log_auth_failure(const char *username) {
openlog("auth", LOG_PID, LOG_AUTH);
syslog(LOG_WARNING, username); // Format string vulnerability
closelog();
}
syslog uses the same format string mechanism as printf. If username contains %s or %n, the vulnerability is exploitable. What I find interesting is how easy it is to miss this, syslog doesn’t look like printf. It looks like a logging function that takes a message. The fix: syslog(LOG_WARNING, "%s", username);.
Comparison: Java’s String.format
Java’s String.format interprets format specifiers but cannot read or write arbitrary memory:
// Java: Format string cannot access memory
String msg = String.format(userInput); // May throw IllegalFormatException
// But cannot read stack memory or write to arbitrary addresses
Java’s format string bugs cause exceptions or incorrect output, not memory corruption. The %n specifier in Java writes to a provided Appendable argument, not to an address on the stack. This is one of those areas where managed languages just win outright.
Integer Promotion and Implicit Conversion
C’s integer promotion rules silently convert smaller types to int in expressions. Combined with signed/unsigned conversions, this creates subtle bugs that bypass bounds checks. These are the bugs that tend to survive the longest in production because they require understanding the C standard’s type promotion rules to spot.
The Vulnerable Pattern
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void copy_data(const char *src, size_t src_len, char *dst, unsigned short dst_size) {
if (src_len > dst_size) {
printf("Source too large\n");
return;
}
memcpy(dst, src, src_len);
}
int main() {
char dst[256];
char src[70000];
memset(src, 'A', sizeof(src));
copy_data(src, sizeof(src), dst, sizeof(dst));
return 0;
}
dst_size is unsigned short (max 65,535). sizeof(dst) is 256, which fits. But src_len is size_t (64-bit on most systems). The comparison src_len > dst_size promotes dst_size to size_t, so the comparison works correctly here. The real danger is when dst_size is computed from arithmetic that overflows unsigned short:
void allocate_and_copy(const char *src, unsigned short count, unsigned short item_size) {
unsigned short total = count * item_size; // Overflow in unsigned short
char *dst = malloc(total);
if (!dst) return;
memcpy(dst, src, count * item_size); // This uses size_t arithmetic, no overflow
free(dst);
}
count * item_size in the unsigned short assignment overflows to a small value. malloc allocates a small buffer. But memcpy’s third argument count * item_size is computed in size_t arithmetic (because memcpy takes size_t), producing the correct large value. The result is a heap buffer overflow. What I found particularly eye-opening when researching this is that the two multiplications look identical but produce different results because of implicit type promotion. This is the kind of thing that makes C both powerful and terrifying.
Comparison: Rust’s Explicit Casting
Rust requires explicit casts between integer types and warns about lossy conversions:
let count: u16 = 1000;
let item_size: u16 = 100;
// let total: u16 = count * item_size; // Panics in debug (overflow)
let total: usize = (count as usize) * (item_size as usize); // Explicit widening
Rust forces the programmer to choose the arithmetic width explicitly. There are no implicit promotions or narrowing conversions. It’s a design choice I really appreciate.
Signal Handler Vulnerabilities
This is a topic that doesn’t get discussed enough. C programs use signal handlers for asynchronous event processing. Signal handlers run in a restricted context, only async-signal-safe functions may be called. Calling non-safe functions (like malloc, printf, or free) from a signal handler causes undefined behaviour.
The Vulnerable Pattern
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *global_log = NULL;
void handle_signal(int sig) {
// UNSAFE: malloc, sprintf, and free are not async-signal-safe
global_log = malloc(256);
if (global_log) {
sprintf(global_log, "Caught signal %d\n", sig);
printf("%s", global_log);
free(global_log);
}
}
int main() {
signal(SIGINT, handle_signal);
while (1) {
char *data = malloc(1024); // If signal fires during this malloc...
if (data) {
memset(data, 0, 1024);
free(data);
}
}
return 0;
}
If the signal fires while main is inside malloc, the signal handler calls malloc again. Most malloc implementations use global locks, the signal handler deadlocks on the lock that main already holds, or corrupts the heap metadata. This is exploitable: an attacker who can trigger signals (e.g., by sending network data that causes SIGPIPE) can corrupt the heap. The more I researched this pattern, the more I realised how common it is, and how rarely it gets flagged in reviews. The deadlock only manifests under specific timing conditions, which makes it easy to miss in testing.
The Safe Pattern
#include <signal.h>
#include <unistd.h>
volatile sig_atomic_t signal_received = 0;
void handle_signal(int sig) {
signal_received = sig; // Only set a flag, async-signal-safe
}
int main() {
signal(SIGINT, handle_signal);
while (!signal_received) {
// Normal processing
}
// Handle signal outside the handler
char msg[] = "Signal received, shutting down\n";
write(STDOUT_FILENO, msg, sizeof(msg) - 1); // write() is async-signal-safe
return 0;
}
Detection Strategies
| Tool | What It Catches | Limitations |
|---|---|---|
GCC/Clang -Wall -Wextra -Werror |
Format string warnings, implicit conversions, unused variables | Does not catch runtime bugs |
AddressSanitizer (-fsanitize=address) |
Buffer overflow, use-after-free, double-free, stack overflow | Runtime only; requires triggering inputs |
MemorySanitizer (-fsanitize=memory) |
Uninitialized memory reads | Runtime only; high overhead |
UBSan (-fsanitize=undefined) |
Signed overflow, null dereference, shift overflow | Runtime only |
| Valgrind | Memory leaks, use-after-free, uninitialized reads | Slow (10-50x overhead); does not catch stack overflows |
| cppcheck | Buffer overflows, null dereferences, memory leaks | Static analysis; limited interprocedural analysis |
| clang-tidy | Modernization, bug-prone patterns, some security checks | Requires compilation database |
| Semgrep | Pattern-based detection of dangerous function calls | Cannot reason about pointer arithmetic |
Manual Review Checklist
- Search for
strcpy,strcat,sprintf,gets, replace withstrncpy,strncat,snprintf,fgets. - Search for
printf(variable), verify the first argument is a string literal, not user input. - Search for
mallocfollowed bymemcpy, verify the allocation size accounts for the copy size. - Search for
free(), verify the pointer is set toNULLafter freeing and not used again. - Search for
signal()and signal handler functions, verify only async-signal-safe functions are called. - Search for casts between signed and unsigned types, verify the conversion does not change the value’s meaning.
- Search for arithmetic on
unsigned shortorunsigned char, verify the result does not overflow before being used in allocation or copy sizes.
Remediation Patterns
Use Safe String Functions
#include <stdio.h>
#include <string.h>
void greet(const char *name) {
char buffer[64];
int written = snprintf(buffer, sizeof(buffer), "Hello, %s!", name);
if (written < 0 || (size_t)written >= sizeof(buffer)) {
fprintf(stderr, "Name too long, truncated\n");
}
printf("%s\n", buffer);
}
snprintf always null-terminates and returns the number of characters that would have been written, allowing truncation detection. It’s the right default for any string formatting in C.
Use Checked Allocation
#include <stdlib.h>
#include <stdint.h>
#include <limits.h>
void *safe_calloc(size_t count, size_t size) {
if (size != 0 && count > SIZE_MAX / size) {
return NULL; // Overflow
}
return calloc(count, size);
}
Check for multiplication overflow before allocating. calloc performs this check internally on most implementations, but malloc(count * size) does not. Using calloc when possible is a good habit, both for the overflow check and the zero-initialization.
Nullify Freed Pointers
#define SAFE_FREE(ptr) do { free(ptr); (ptr) = NULL; } while(0)
void cleanup(User *user) {
SAFE_FREE(user->name);
SAFE_FREE(user->email);
SAFE_FREE(user);
}
Setting the pointer to NULL after free converts use-after-free into a null pointer dereference, which is easier to detect and less exploitable. It’s not a perfect solution, but it’s a cheap defence-in-depth measure worth adopting in any C codebase.
Key Takeaways
- Every C buffer operation is a potential overflow. Use
snprintf,strncat, andmemcpywith explicit size limits. Never usestrcpy,strcat,sprintf, orgets. strncpydoes not null-terminate. Always null-terminate manually or usesnprintfinstead.- Format string vulnerabilities hide in logging functions. Any function that passes a user-controlled string as a format argument is vulnerable. Always use
"%s"as the format. - Integer promotion creates invisible type widening. Arithmetic on
unsigned shortstays in 16-bit until promoted. Widen explicitly before security-critical calculations. - Signal handlers must only set flags. Calling
malloc,printf, orfreefrom a signal handler is undefined behaviour. - Compile with sanitizers during development. AddressSanitizer, MemorySanitizer, and UBSan catch bugs that static analysis misses, but they require test inputs that trigger the vulnerable paths.