C — Debugging & Testing
C gives you raw access to memory, which means bugs can be catastrophic — segfaults, memory corruption, security vulnerabilities. Unlike managed languages, C won't save you from yourself. Mastering debugging tools and testing practices is not optional; it is a survival skill for any serious C developer.
This guide covers the full debugging toolkit: compiler warnings, GDB, Valgrind, AddressSanitizer, unit testing, and the common bug patterns you will encounter in production C code.
The first line of defense is always the compiler. Modern GCC and Clang can catch entire categories of bugs at compile time — but only if you let them. The default warning level is too lenient for production code.
| 1 | # The bare minimum — always use these three together |
| 2 | gcc -Wall -Wextra -pedantic -o program main.c |
| 3 | |
| 4 | # Promote warnings to errors so they cannot be ignored |
| 5 | gcc -Wall -Wextra -pedantic -Werror -o program main.c |
| 6 | |
| 7 | # Detect variable shadowing (a common source of subtle bugs) |
| 8 | gcc -Wall -Wextra -Wshadow -o program main.c |
| 9 | |
| 10 | # Catch implicit type conversions that may lose data |
| 11 | gcc -Wall -Wextra -Wconversion -o program main.c |
| 12 | |
| 13 | # Catch format string vulnerabilities |
| 14 | gcc -Wall -Wextra -Wformat-security -o program main.c |
| 15 | |
| 16 | # Enable all of the above at once |
| 17 | gcc -Wall -Wextra -pedantic -Werror -Wshadow -Wconversion -Wformat-security -o program main.c |
best practice
Beyond basic warnings, runtime sanitizers compile directly into your binary and catch bugs at execution time with precise diagnostics:
| 1 | # AddressSanitizer + UndefinedBehaviorSanitizer (the debug combo) |
| 2 | gcc -fsanitize=address,undefined -g -o program main.c |
| 3 | |
| 4 | # Run the program — sanitizer output will appear on stderr |
| 5 | ./program |
| 6 | |
| 7 | # If using clang, you can also add: |
| 8 | clang -fsanitize=address,undefined,leak -g -o program main.c |
| Flag | Catches | Overhead |
|---|---|---|
-Wall | Common mistakes: unused vars, missing returns, implicit declarations | Zero (compile-time only) |
-Wconversion | Implicit narrowing conversions, sign mismatches | Zero |
-Wshadow | Inner variable reuses outer variable name | Zero |
-fsanitize=address | Buffer overflow, use-after-free, double-free, memory leaks | ~2x slower, ~3x more memory |
-fsanitize=undefined | Signed overflow, null deref, shift overflow, alignment issues | ~2x slower |
GDB is the standard debugger on Linux and is available on macOS via LLDB (or Homebrew GDB). It lets you stop execution at any point, inspect variables, step through code line by line, and examine memory — all without modifying your source.
First, always compile with debug symbols using the -g flag. Without it, GDB cannot map addresses back to source lines:
| 1 | # Compile with debug symbols — always use -g in development |
| 2 | gcc -g -Wall -Wextra -o program main.c |
| 3 | |
| 4 | # Launch GDB on the compiled binary |
| 5 | gdb ./program |
Once inside GDB, these are the essential commands you need to know:
| 1 | (gdb) run # Start the program from the beginning |
| 2 | (gdb) run arg1 arg2 # Start with command-line arguments |
| 3 | |
| 4 | (gdb) break main # Breakpoint at the main function |
| 5 | (gdb) break main.c:42 # Breakpoint at line 42 of main.c |
| 6 | (gdb) break process_data # Breakpoint at function process_data |
| 7 | (gdb) info breakpoints # List all breakpoints |
| 8 | (gdb) delete 1 # Delete breakpoint number 1 |
| 9 | |
| 10 | (gdb) next # Step over (execute current line, don't enter functions) |
| 11 | (gdb) step # Step into (enter function calls) |
| 12 | (gdb) finish # Run until current function returns |
| 13 | (gdb) continue # Continue execution until next breakpoint |
| 14 | |
| 15 | (gdb) print x # Print variable x |
| 16 | (gdb) print *ptr # Dereference pointer and print value |
| 17 | (gdb) print sizeof(x) # Print size of variable |
| 18 | (gdb) print (int[5]){1,2,3,4,5} # Print compound literal |
| 19 | |
| 20 | (gdb) info locals # Show all local variables in current frame |
| 21 | (gdb) info args # Show function arguments |
| 22 | |
| 23 | (gdb) backtrace # Show call stack (also: bt) |
| 24 | (gdb) backtrace full # Show call stack with local variables |
| 25 | (gdb) frame 3 # Switch to stack frame 3 |
| 26 | |
| 27 | (gdb) watch x # Break when variable x changes value |
| 28 | (gdb) rwatch x # Break when variable x is read |
| 29 | |
| 30 | (gdb) x/16xb &variable # Examine 16 bytes in hex at address |
| 31 | (gdb) x/4dw array # Examine 4 words (ints) in decimal |
Conditional breakpoints are powerful for stopping only when a specific condition is true — essential for loops or recurring functions:
| 1 | /* Set a conditional breakpoint in GDB: |
| 2 | (gdb) break process_order if order_id == 1042 |
| 3 | |
| 4 | Or set one programmatically via GDB commands file: */ |
| 5 |
| 1 | # GDB batch mode — useful for automated debugging / CI |
| 2 | gdb -batch -ex "run" -ex "bt" -ex "info locals" ./program |
| 3 | |
| 4 | # Generate a core dump on crash for offline analysis |
| 5 | ulimit -c unlimited |
| 6 | ./program # crashes, produces core file |
| 7 | gdb ./program core |
| 8 | |
| 9 | # Inside GDB with core dump: |
| 10 | (gdb) bt # See where the crash happened |
| 11 | (gdb) info registers # Show CPU register values |
| 12 | (gdb) print errno # Check the error number |
| 13 | (gdb) thread apply all bt # Show backtrace for all threads (multithreaded) |
info
Valgrind is a dynamic analysis framework that instruments your binary at runtime without recompilation. Its Memcheck tool is the gold standard for detecting memory errors in C programs.
| 1 | # Basic memory check — the most common Valgrind invocation |
| 2 | valgrind --leak-check=full --show-leak-kinds=all ./program |
| 3 | |
| 4 | # With more verbose output and trace origins of leaks |
| 5 | valgrind --leak-check=full --show-leak-kinds=all \ |
| 6 | --track-origins=yes \ |
| 7 | --verbose \ |
| 8 | ./program |
| 9 | |
| 10 | # Save output to a file for later analysis |
| 11 | valgrind --leak-check=full --log-file=valgrind.log ./program |
| 12 | |
| 13 | # Generate call trace for every allocation |
| 14 | valgrind --leak-check=full --trace-children=yes ./program |
Understanding Valgrind's leak categories is critical:
| Category | Meaning | Action Required |
|---|---|---|
definitely lost | No pointers to the memory block exist anymore | Must fix — real leak |
indirectly lost | Only reachable via a definitely-lost pointer | Fix parent block |
possibly lost | Pointer exists but points somewhere into the block | Investigate |
still reachable | Pointers to block still exist at exit | Usually harmless |
Common Valgrind error patterns you will encounter:
| 1 | ==12345== Invalid read of size 4 |
| 2 | ==12345== at 0x4E2A: process_data (main.c:42) |
| 3 | ==12345== by 0x4A01: main (main.c:10) |
| 4 | ==12345== Address 0x0 is not stack'd, malloc'd or (recently) free'd |
| 5 | ==12345== |
| 6 | ==12345== Use of uninitialised value of size 8 |
| 7 | ==12345== at 0x4E30: compute_sum (main.c:55) |
| 8 | ==12345== |
| 9 | ==12345== Mismatched free() / delete / delete[] |
| 10 | ==12345== at 0x4C80: free (vg_replace_malloc.c:xxx) |
| 11 | ==12345== by 0x4B20: cleanup (main.c:67) |
| 12 | ==12345== Block was alloc'd at |
| 13 | ==12345== at 0x4C10: malloc (vg_replace_malloc.c:xxx) |
| 14 | ==12345== by 0x4A80: allocate_buffer (main.c:20) |
warning
Beyond Memcheck, Valgrind includes tools for profiling:
| 1 | # Cachegrind — cache hit/miss profiling |
| 2 | valgrind --tool=cachegrind ./program |
| 3 | cg_annotate cachegrind.out.12345 |
| 4 | |
| 5 | # Callgrind — call graph and instruction count profiling |
| 6 | valgrind --tool=callgrind ./program |
| 7 | callgrind_annotate callgrind.out.12345 |
| 8 | |
| 9 | # Suppressions — ignore known third-party leaks |
| 10 | valgrind --suppressions=my_suppressions.supp ./program |
AddressSanitizer is a compiler-based memory error detector that is faster than Valgrind and integrates directly into GCC and Clang. It instruments every memory access at compile time, catching bugs with minimal overhead.
| 1 | # Compile with AddressSanitizer — must include -g for symbols |
| 2 | gcc -fsanitize=address -g -o program main.c |
| 3 | |
| 4 | # Run — ASan output appears on stderr if errors are found |
| 5 | ./program |
| 6 | |
| 7 | # With leak detection (enabled by default on Linux, explicit elsewhere) |
| 8 | gcc -fsanitize=address -fsanitize=leak -g -o program main.c |
| 9 | |
| 10 | # ASan environment variables for runtime control |
| 11 | ASAN_OPTIONS=detect_leaks=1:halt_on_error=0 ./program |
What ASan detects with clear, actionable error messages:
| 1 | #include <stdlib.h> |
| 2 | #include <string.h> |
| 3 | |
| 4 | /* Example: buffer overflow — ASan catches this immediately */ |
| 5 | void overflow_example(void) { |
| 6 | char *buf = malloc(32); |
| 7 | strcpy(buf, "This string is way too long for a 32-byte buffer"); |
| 8 | free(buf); |
| 9 | } |
| 10 | |
| 11 | /* Example: use-after-free — ASan poisons freed memory */ |
| 12 | void use_after_free_example(void) { |
| 13 | int *arr = malloc(10 * sizeof(int)); |
| 14 | free(arr); |
| 15 | arr[5] = 42; /* BUG: accessing freed memory */ |
| 16 | } |
| 17 | |
| 18 | /* Example: stack buffer overflow */ |
| 19 | void stack_overflow(void) { |
| 20 | int small[4] = {1, 2, 3, 4}; |
| 21 | int big[10]; |
| 22 | memcpy(big, small, sizeof(big)); /* copies too much */ |
| 23 | } |
| 24 | |
| 25 | /* Example: double-free */ |
| 26 | void double_free_example(void) { |
| 27 | char *ptr = malloc(64); |
| 28 | free(ptr); |
| 29 | free(ptr); /* BUG: second free of same pointer */ |
| 30 | } |
| Error Type | ASan Output Tag | Description |
|---|---|---|
| Heap buffer overflow | heap-buffer-overflow | Access beyond allocated heap memory |
| Stack buffer overflow | stack-buffer-overflow | Access beyond local array bounds |
| Use-after-free | heap-use-after-free | Accessing memory after free() |
| Double-free | double-free | Calling free() on already-freed memory |
| Use-after-return | stack-use-after-return | Accessing local variable after function returns |
| Memory leak | memory-leak | Allocated memory never freed |
pro tip
UBSan detects undefined behavior at runtime. In C, undefined behavior means the standard imposes no requirements — the compiler can do anything, including seemingly impossible things. UBSan makes these violations visible.
| 1 | # Compile with UBSan — lightweight, fast, catches subtle bugs |
| 2 | gcc -fsanitize=undefined -g -o program main.c |
| 3 | |
| 4 | # Combine with ASan for comprehensive coverage |
| 5 | gcc -fsanitize=address,undefined -g -o program main.c |
| 6 | |
| 7 | # Runtime output example: |
| 8 | # main.c:15:15: runtime error: signed integer overflow |
| 9 | # main.c:22:5: runtime error: null pointer passed as argument 2 |
| 1 | #include <limits.h> |
| 2 | #include <stdio.h> |
| 3 | #include <stdlib.h> |
| 4 | |
| 5 | /* Signed integer overflow — undefined behavior */ |
| 6 | int overflow_example(void) { |
| 7 | int x = INT_MAX; |
| 8 | return x + 1; /* UB: signed overflow */ |
| 9 | } |
| 10 | |
| 11 | /* Null pointer dereference */ |
| 12 | void null_deref(void) { |
| 13 | int *p = NULL; |
| 14 | *p = 42; /* UB: null pointer dereference */ |
| 15 | } |
| 16 | |
| 17 | /* Shift by negative or too-large amount */ |
| 18 | int shift_example(int val) { |
| 19 | return val << 32; /* UB: shift exceeds type width */ |
| 20 | } |
| 21 | |
| 22 | /* Division by zero */ |
| 23 | int divide_by_zero(int a, int b) { |
| 24 | return a / b; /* UB: if b == 0 */ |
| 25 | } |
| 26 | |
| 27 | /* Misaligned pointer access */ |
| 28 | void misaligned_access(void) { |
| 29 | char buf[16]; |
| 30 | int *ip = (int *)(buf + 1); /* UB: misaligned cast */ |
| 31 | *ip = 42; |
| 32 | } |
C has no built-in test framework. You either write your own lightweight test harness or adopt one of the established frameworks. The key principle is the same as any language: isolate, exercise, and verify.
The simplest approach is an assert-based macro system:
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | /* Minimal test framework using macros */ |
| 6 | #define TEST(name) static void name(void) |
| 7 | #define ASSERT_EQ(a, b) do { \ |
| 8 | if ((a) != (b)) { \ |
| 9 | fprintf(stderr, "FAIL: %s:%d: %s != %s\n", \ |
| 10 | __FILE__, __LINE__, #a, #b); \ |
| 11 | exit(1); \ |
| 12 | } \ |
| 13 | } while (0) |
| 14 | |
| 15 | #define ASSERT_STR_EQ(a, b) do { \ |
| 16 | if (strcmp((a), (b)) != 0) { \ |
| 17 | fprintf(stderr, "FAIL: %s:%d: \"%s\" != \"%s\"\n", \ |
| 18 | __FILE__, __LINE__, (a), (b)); \ |
| 19 | exit(1); \ |
| 20 | } \ |
| 21 | } while (0) |
| 22 | |
| 23 | #define ASSERT_NOT_NULL(p) do { \ |
| 24 | if ((p) == NULL) { \ |
| 25 | fprintf(stderr, "FAIL: %s:%d: %s is NULL\n", \ |
| 26 | __FILE__, __LINE__, #p); \ |
| 27 | exit(1); \ |
| 28 | } \ |
| 29 | } while (0) |
| 30 | |
| 31 | #define RUN_TEST(test_func) do { \ |
| 32 | printf(" Running %s...\n", #test_func); \ |
| 33 | test_func(); \ |
| 34 | printf(" PASS\n"); \ |
| 35 | } while (0) |
| 36 | |
| 37 | /* Tests for a hypothetical string utility */ |
| 38 | int string_length(const char *s) { |
| 39 | int len = 0; |
| 40 | while (s[len]) len++; |
| 41 | return len; |
| 42 | } |
| 43 | |
| 44 | TEST(test_string_length_empty) { |
| 45 | ASSERT_EQ(string_length(""), 0); |
| 46 | } |
| 47 | |
| 48 | TEST(test_string_length_hello) { |
| 49 | ASSERT_EQ(string_length("hello"), 5); |
| 50 | } |
| 51 | |
| 52 | TEST(test_string_length_long) { |
| 53 | ASSERT_EQ(string_length("a long string with spaces"), 25); |
| 54 | } |
| 55 | |
| 56 | int main(void) { |
| 57 | printf("Running tests...\n"); |
| 58 | RUN_TEST(test_string_length_empty); |
| 59 | RUN_TEST(test_string_length_hello); |
| 60 | RUN_TEST(test_string_length_long); |
| 61 | printf("All tests passed.\n"); |
| 62 | return 0; |
| 63 | } |
For real projects, use an established framework like Unity (ThrowTheSwitch.org), which provides rich assertions, setup/teardown, and CI integration:
| 1 | #include "unity.h" |
| 2 | |
| 3 | /* Setup runs before each test */ |
| 4 | void setUp(void) { |
| 5 | /* allocate resources, reset state */ |
| 6 | } |
| 7 | |
| 8 | /* Teardown runs after each test */ |
| 9 | void tearDown(void) { |
| 10 | /* free resources, clean up */ |
| 11 | } |
| 12 | |
| 13 | void test_add_positive_numbers(void) { |
| 14 | TEST_ASSERT_EQUAL_INT(4, add(2, 2)); |
| 15 | } |
| 16 | |
| 17 | void test_add_zero(void) { |
| 18 | TEST_ASSERT_EQUAL_INT(5, add(5, 0)); |
| 19 | } |
| 20 | |
| 21 | void test_add_negative(void) { |
| 22 | TEST_ASSERT_EQUAL_INT(-3, add(-1, -2)); |
| 23 | } |
| 24 | |
| 25 | void test_divide_by_zero_returns_error(void) { |
| 26 | int result; |
| 27 | int status = safe_divide(10, 0, &result); |
| 28 | TEST_ASSERT_EQUAL_INT(-1, status); |
| 29 | } |
| 30 | |
| 31 | int main(void) { |
| 32 | UNITY_BEGIN(); |
| 33 | RUN_TEST(test_add_positive_numbers); |
| 34 | RUN_TEST(test_add_zero); |
| 35 | RUN_TEST(test_add_negative); |
| 36 | RUN_TEST(test_divide_by_zero_returns_error); |
| 37 | return UNITY_END(); |
| 38 | } |
Measuring test coverage with gcov and lcov:
| 1 | # Compile with coverage instrumentation |
| 2 | gcc -fprofile-arcs -ftest-coverage -o program main.c tests.c |
| 3 | |
| 4 | # Run tests to generate coverage data |
| 5 | ./program |
| 6 | |
| 7 | # Generate coverage report |
| 8 | gcov main.c |
| 9 | |
| 10 | # Generate HTML coverage report (requires lcov) |
| 11 | lcov --capture --directory . --output-file coverage.info |
| 12 | genhtml coverage.info --output-directory coverage_report/ |
| 13 | |
| 14 | # Open report |
| 15 | open coverage_report/index.html |
Mocking in C is typically done through function pointer substitution:
| 1 | #include <stdio.h> |
| 2 | |
| 3 | /* Production code uses a function pointer for I/O */ |
| 4 | typedef int (*read_fn_t)(char *buf, int max_len); |
| 5 | |
| 6 | int process_input(read_fn_t reader) { |
| 7 | char buf[256]; |
| 8 | int n = reader(buf, sizeof(buf)); |
| 9 | if (n <= 0) return -1; |
| 10 | /* process buf... */ |
| 11 | return n; |
| 12 | } |
| 13 | |
| 14 | /* Mock for testing */ |
| 15 | static int mock_read_return_42(char *buf, int max_len) { |
| 16 | (void)max_len; |
| 17 | snprintf(buf, 256, "test data"); |
| 18 | return 42; |
| 19 | } |
| 20 | |
| 21 | static int mock_read_return_error(char *buf, int max_len) { |
| 22 | (void)buf; |
| 23 | (void)max_len; |
| 24 | return -1; |
| 25 | } |
| 26 | |
| 27 | void test_process_input_success(void) { |
| 28 | int result = process_input(mock_read_return_42); |
| 29 | /* assert result == 42 */ |
| 30 | } |
| 31 | |
| 32 | void test_process_input_failure(void) { |
| 33 | int result = process_input(mock_read_return_error); |
| 34 | /* assert result == -1 */ |
| 35 | } |
When GDB or sanitizers are not available, or when you need quick answers, printf debugging is the oldest trick in the book. The key is strategic placement and flushing:
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | |
| 4 | /* Debug macro — compiles to nothing in release builds */ |
| 5 | #ifdef DEBUG |
| 6 | #define DBG_LOG(fmt, ...) fprintf(stderr, "[DEBUG %s:%d] " fmt "\n", \ |
| 7 | __FILE__, __LINE__, ##__VA_ARGS__) |
| 8 | #else |
| 9 | #define DBG_LOG(fmt, ...) ((void)0) |
| 10 | #endif |
| 11 | |
| 12 | void process_order(int order_id, double total) { |
| 13 | DBG_LOG("entering process_order: id=%d total=%.2f", order_id, total); |
| 14 | |
| 15 | if (order_id < 0) { |
| 16 | DBG_LOG("invalid order_id: %d", order_id); |
| 17 | return; |
| 18 | } |
| 19 | |
| 20 | /* Without fflush, output may be lost before a crash */ |
| 21 | fprintf(stderr, "about to call validate...\n"); |
| 22 | fflush(stderr); |
| 23 | |
| 24 | validate_order(order_id, total); |
| 25 | |
| 26 | DBG_LOG("order %d processed successfully", order_id); |
| 27 | } |
| 28 | |
| 29 | int main(void) { |
| 30 | DBG_LOG("program starting"); |
| 31 | process_order(42, 99.95); |
| 32 | process_order(-1, 0.0); |
| 33 | DBG_LOG("program finished"); |
| 34 | return 0; |
| 35 | } |
Core dumps and system tracing tools provide deeper insight when a crash occurs:
| 1 | # Enable core dumps |
| 2 | ulimit -c unlimited |
| 3 | echo "/tmp/core.%e.%p.%t" | sudo tee /proc/sys/kernel/core_pattern |
| 4 | |
| 5 | # Run the program — it will dump core on crash |
| 6 | ./program |
| 7 | |
| 8 | # Analyze the core dump |
| 9 | gdb ./program /tmp/core.program.12345.1234567890 |
| 10 | (gdb) bt full # Full backtrace with local variables |
| 11 | (gdb) info registers # CPU state at crash |
| 12 | |
| 13 | # strace — trace system calls (invaluable for I/O bugs) |
| 14 | strace -f ./program # trace all syscalls |
| 15 | strace -e trace=open,read,write ./program # trace file I/O |
| 16 | strace -e trace=network ./program # trace network ops |
| 17 | strace -c ./program # syscall summary/timing |
| 18 | |
| 19 | # ltrace — trace library calls (malloc, printf, etc.) |
| 20 | ltrace ./program |
| 21 | ltrace -c ./program # summary of library call costs |
Memory debugging patterns for production code:
| 1 | #include <stdlib.h> |
| 2 | #include <string.h> |
| 3 | |
| 4 | /* Canary words — detect buffer overflows in custom allocators */ |
| 5 | #define CANARY_VALUE 0xDEADBEEF |
| 6 | |
| 7 | typedef struct { |
| 8 | size_t size; |
| 9 | unsigned int canary; |
| 10 | char data[]; |
| 11 | } AllocHeader; |
| 12 | |
| 13 | void *safe_malloc(size_t size) { |
| 14 | AllocHeader *h = malloc(sizeof(AllocHeader) + size); |
| 15 | if (!h) return NULL; |
| 16 | h->size = size; |
| 17 | h->canary = CANARY_VALUE; |
| 18 | return h->data; |
| 19 | } |
| 20 | |
| 21 | int safe_free(void *ptr) { |
| 22 | if (!ptr) return 0; |
| 23 | AllocHeader *h = (AllocHeader *)ptr - 1; |
| 24 | if (h->canary != CANARY_VALUE) { |
| 25 | /* Buffer overflow detected! */ |
| 26 | return -1; |
| 27 | } |
| 28 | h->canary = 0; /* poison the canary */ |
| 29 | free(h); |
| 30 | return 0; |
| 31 | } |
These are the most frequent and dangerous bugs in C code. Recognizing them on sight will save you hours of debugging:
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | /* BUG 1: Off-by-one error */ |
| 6 | void off_by_one(void) { |
| 7 | int arr[10]; |
| 8 | for (int i = 0; i <= 10; i++) { /* should be < 10 */ |
| 9 | arr[i] = i; /* writes past end of array */ |
| 10 | } |
| 11 | } |
| 12 | |
| 13 | /* BUG 2: Null pointer dereference */ |
| 14 | void null_deref(char *str) { |
| 15 | /* str could be NULL — no check before use */ |
| 16 | printf("length: %zu\n", strlen(str)); /* crash if NULL */ |
| 17 | } |
| 18 | |
| 19 | /* BUG 3: Uninitialized variable */ |
| 20 | int uninitialized_use(int flag) { |
| 21 | int result; |
| 22 | if (flag) { |
| 23 | result = 42; |
| 24 | } |
| 25 | /* BUG: if flag is 0, result is uninitialized */ |
| 26 | return result; |
| 27 | } |
| 28 | |
| 29 | /* BUG 4: Format string vulnerability */ |
| 30 | void format_string(char *user_input) { |
| 31 | /* NEVER pass user input as format string directly */ |
| 32 | printf(user_input); /* user can inject %x %x %x to leak stack */ |
| 33 | /* CORRECT: printf("%s", user_input); */ |
| 34 | } |
| 35 | |
| 36 | /* BUG 5: Integer overflow leading to small allocation */ |
| 37 | void integer_overflow(size_t count) { |
| 38 | size_t total = count * sizeof(int); /* can overflow */ |
| 39 | int *arr = malloc(total); /* allocates tiny buffer */ |
| 40 | for (size_t i = 0; i < count; i++) { |
| 41 | arr[i] = 0; /* heap buffer overflow */ |
| 42 | } |
| 43 | free(arr); |
| 44 | } |
| 45 | |
| 46 | /* BUG 6: Use-after-free */ |
| 47 | void use_after_free(void) { |
| 48 | char *ptr = malloc(64); |
| 49 | strcpy(ptr, "hello"); |
| 50 | free(ptr); |
| 51 | printf("%s\n", ptr); /* accessing freed memory */ |
| 52 | } |
| 53 | |
| 54 | /* BUG 7: Dangling pointer */ |
| 55 | char *dangling_pointer(void) { |
| 56 | char local[] = "stack data"; |
| 57 | return local; /* returns pointer to stack (invalid) */ |
| 58 | } |
| 59 | |
| 60 | /* BUG 8: Memory leak in loop */ |
| 61 | void leak_in_loop(void) { |
| 62 | for (int i = 0; i < 100; i++) { |
| 63 | char *buf = malloc(1024); |
| 64 | /* forgot to free(buf) — leaks 100KB per call */ |
| 65 | process(buf); |
| 66 | } |
| 67 | } |
warning
Defensive patterns that prevent entire classes of bugs:
| 1 | #include <stdio.h> |
| 2 | #include <stdlib.h> |
| 3 | #include <string.h> |
| 4 | |
| 5 | /* Safe string copy with explicit size and null termination */ |
| 6 | void safe_strcpy(char *dst, size_t dst_size, const char *src) { |
| 7 | if (dst_size == 0) return; |
| 8 | size_t i; |
| 9 | for (i = 0; i < dst_size - 1 && src[i] != '\0'; i++) { |
| 10 | dst[i] = src[i]; |
| 11 | } |
| 12 | dst[i] = '\0'; |
| 13 | } |
| 14 | |
| 15 | /* Safe integer multiplication (check for overflow) */ |
| 16 | int safe_mul(size_t a, size_t b, size_t *result) { |
| 17 | if (a != 0 && b > SIZE_MAX / a) { |
| 18 | return -1; /* overflow would occur */ |
| 19 | } |
| 20 | *result = a * b; |
| 21 | return 0; |
| 22 | } |
| 23 | |
| 24 | /* NULL-checked allocation wrapper */ |
| 25 | void *checked_malloc(size_t size) { |
| 26 | void *ptr = malloc(size); |
| 27 | if (!ptr && size > 0) { |
| 28 | fprintf(stderr, "fatal: malloc(%zu) failed\n", size); |
| 29 | abort(); |
| 30 | } |
| 31 | return ptr; |
| 32 | } |
| 33 | |
| 34 | /* Safe free pattern — NULL the pointer after freeing */ |
| 35 | #define SAFE_FREE(ptr) do { \ |
| 36 | free(ptr); \ |
| 37 | (ptr) = NULL; \ |
| 38 | } while (0) |