Make C Safe Again !
Introduction
This post is the continuation of the C static analyzer post. If the first part we only deal with one particularly nasty code (null pointer dereference), on this one we will deal with a lot more case studies.
After we are done with this post you'll see that C can be safe and hard to crack IF we utilize modern C practices: tools (compiler flags, sanitizers, static analyzers) and safer functions (strncpy, snprintf, fgets, strncat, etc).
GCC vs Clang
I will be utilizing GCC and Clang as my main compilers and source of static analyzers. GCC is more mature and old-style while Clang is more modern and especially useful to compile programs to modern architecture.
I have updated Clang to the latest version 19 to make sure I get the latest and greatest features. GCC remain on version 14 (latest).
A bit of spoiler, since we are focusing on safety and strictness in this post, we shall see there are a lot of caveats when using Clang (if A then need to do 123, if B then need 789, if C...you get the gist). On the other hand GCC is more straightforward and behave as expected.
Note: the _safest alias I'm using with the C compilers:
alias clang-19_safest='clang-19 -Wall -Wextra -Werror -Warray-bounds -fsanitize=address -fsanitize=undefined -fno-omit-frame-pointer -std=c2x'
alias gcc-14_safest='gcc-14 -ggdb -Wall -Werror -Wextra -Wpedantic -Wconversion -fsanitize=address -fsanitize=undefined -fno-omit-frame-pointer -std=c23'
Otherwise, clang-19
and gcc-14
are plain vanilla compiler command without any flags.
Case 1: Incorrect printf format.
Lets start with something simple.
#include <stdio.h>
int main(void)
{
// ---Wrong printf format---
printf("%s %lb %d\n", "unix", 10, 20);
return 0;
}
Result
Both compilers did their job well, but notice the caveat/notes for clang. That is the first caveat of the many Clang caveats we are going to experience!
Case 2: Divide by Zero
#include <stdio.h>
int function(int b)
{
int a, c;
switch (b) {
case 1: a = b / 0; break;
case 2: c = b - 4;
a = b / c; break;
}
return a;
}
int main(void)
{
return 0;
}
Result
No caveat. Notice without using static analyzer, the compilers managed to caught the bugs at compile time.
Below is the version using static analyzers. GCC output on the left, Clang output on the right.
Case 3: Array out-of-bounds
void out_of_bounds(int x)
{
int a[4]; // initialize array of 4 elements
if (x == 5) { // true
if (a[x] == 123) {} // set the 6th element a[5] --> array out of bounds
}
}
int main(void)
{
int x = 5;
out_of_bounds(x);
return 0;
}
Result
The image above we can see only using the compilers is not enough: Clang did not even produce any error, GCC produced the wrong error.
Below using static analyzers, array out-of-bounds bug got squashed!
Case 4: Memory leak
One of the most common memory errors: leaking memory.
#include <stdlib.h>
int process(void *ptr, int cond)
{
if (cond != 0) { // cond == 0 means false
free(ptr); // this never ran
}
return 0; // jump directly to the end. ptr never freed --> memory leak!
}
int entry(size_t size, int cond)
{
void *ptr = malloc(size);
if (ptr) {
process(ptr, cond);
}
return 0;
}
int main(void)
{
entry(5, 0);
return 0;
}
Result:
Another caveat for Clang, even using the _safest alias (which includes address sanitizer), it failed to detect the memory leak! GCC got no issue detecting memory leak at runtime.
Below using static analyzers: both GCC and Clang detected memory leak at compile time. Awesome!
Case 5: Buffer overflow
This memory related bug is ranked #1 in terms of frequency.
#include <stddef.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
void buffer_overflow(char *str)
{
size_t size = 16;
char *buffer = (char*)malloc(size);
size_t len = strlen(str);
strncpy(buffer, str, len);
printf("Buffer contents: %s\n", buffer);
// free(buffer); // deliberately comment-out this line to cause memory leak!
}
int main(void)
{
char *input_str = "A very long string to cause a buffer overflow!";
buffer_overflow(input_str);
return 0;
}
- Using compiler flags: both GCC and Clang compiles but well detected buffer-overflow at runtime.
Result using static analyzers:
Another caveat from Clang, it could not detect the obvious buffer overflow! How many has Clang got these caveats..three? Yeah 3 caveats for Clang which is not ideal.
Case 6: Use after free
One of the memory errors in the Top 5 of C bugs.
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
int use_after_free(int a, int b)
{
printf("This is the use after free bug");
int *buff_a = malloc(sizeof(int));
int *buff_b = malloc(sizeof(int));
if (buff_a == NULL || buff_b == NULL) {
free(buff_a);
free(buff_b);
printf("Malloc failed.\n");
return -1; }
*buff_a = a;
*buff_b = b;
free(buff_a);
free(buff_b);
return *buff_a + *buff_b;
}
int main(void)
{
int a = 69;
int b = 420;
int result = use_after_free(a, b);
printf("Result = %d\n", result);
return 0;
}
Result:
- With comp flags:
- Clang: compiles but detected UAF at runtime.
- GCC: detected UAF at compile time
Static Analyzers:
Both managed to detect Use After Free bug at compile time. Sweet!
Case 7: Double Free
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
int double_free(int a, int b)
{
printf("This is the use double free bug");
int *buff_a = malloc(sizeof(int));
int *buff_b = malloc(sizeof(int));
{
if (buff_a == NULL || buff_b == NULL) {
free(buff_a);
free(buff_b);
printf("Malloc failed.\n");
return -1;
}
*buff_a = a;
*buff_b = b;
free(buff_a);
free(buff_b);
}
free(buff_a);
free(buff_b);
return *buff_a + *buff_b;
}
int main(void)
{
int a = 69;
int b = 420;
int result = double_free(a, b);
printf("Result = %d\n", result);
return 0;
}
Result
- With comp flags:
- GCC: detected UAF at compile time, but not Double Free.
- Clang: does not detect anything at compile time.
- Both detects double free at runtime (sanitizer working well).
Static analyzers:
Double Free sucessfully detected!
Conclusion
As you can see, all the bugs and memory errors tested in this post can be detected at compile time by GCC. Unfortunately Clang is sometimes a bit hit and miss, but it managed to detect the bugs more often than not.
However, if you ask me which compiler / static analyzer is better: the answer is obvious = GCC is superior in terms of safety / strictness. What's more I've seen plenty of benchmarks, GCC also has the upper hand in terms of speed..so it's a no-brainer option for me.
So what do you think? Are you still thinking C is riddled with memory bugs and there's nothing we can do about it? Coz this post proves just the opposite of that: all memory bugs are caught at COMPILE TIME. Is it surprising? Do you think only Rust and Zig are able to do that?
If you do, maybe it's time to take a look at modern C practices..you might start from this post I wrote on the importance of modern C. The C ecosystem is massive, plus with the heightened awareness towards memory safety in the past decade, tools are being developed for C/C++ to tackle this issue.
And as you can see, it works quite well!