Security review for generated C beyond memory safety
The memory bugs have their own catalogue. This is the rest of the threat model: input you did not validate, privilege you did not drop, and races you did not know were there.
The failure-mode catalogue covers memory safety, which is most of C's security story and is largely addressable with sanitizers. This page is the rest — the parts a sanitizer will never flag because the program is doing exactly what it was written to do.
Trust boundaries first#
Before reading any code, answer one question: where does data from outside this process enter it?
argv / environ command line, env vars
stdin / files including files whose path came from outside
sockets obviously
IPC pipes, shared memory, signals
deserialisation any parser you wrote or vendoredEvery one of those is a boundary, and everything crossing it is hostile until validated. Generated C is usually correct about the boundary it was told about and unaware of the others — a function written to parse a config file is fine until someone points it at an upload.
Input validation#
1. Length and bounds before use#
/* generated: reads a length from the wire and trusts it */
uint32_t len;
read_exact(fd, &len, sizeof len);
len = ntohl(len);
char *buf = malloc(len); /* attacker picks the allocation size */
read_exact(fd, buf, len);Three bugs: no upper bound, no allocation check, and len == 0 produces an implementation-defined malloc(0).
if (len == 0 || len > MAX_FRAME) return -EBADMSG; /* bound FIRST */
char *buf = malloc(len);
if (!buf) return -ENOMEM;Every length that arrives from outside needs an explicit maximum, chosen deliberately and stated as a constant. This is the single most common protocol-parsing vulnerability and it is not a memory bug in the sanitizer's sense — the allocation succeeds and the read is in bounds.
2. Integer conversion at the boundary#
int n = atoi(argv[1]); /* no error reporting; UB on overflow */
size_t size = n; /* negative becomes enormous */atoi cannot distinguish 0 from a parse failure and has undefined behaviour on overflow. Use strtol and check everything:
errno = 0;
char *end;
long v = strtol(argv[1], &end, 10);
if (errno == ERANGE || end == argv[1] || *end != '\0' || v < 0 || v > MAX_N)
return usage("count out of range");
size_t n = (size_t)v;Catch it with: -Wconversion, which is noisy on existing code and finds real bugs on new code. Worth enabling for new files even if you cannot fix the whole tree.
3. Format strings#
printf(user_input); /* %n writes to memory */
syslog(LOG_INFO, message_from_client); /* same, and less obvious */Never pass non-literal data as a format string. Catch it with: -Wformat=2 -Wformat-security, which turns this into a compile error. Turn it on today; there is no downside.
4. Path traversal#
snprintf(path, sizeof path, "%s/%s", base_dir, user_name); /* ../../etc/passwd */Generated file-serving code concatenates and hopes. Resolve, then verify:
char resolved[PATH_MAX];
if (!realpath(path, resolved)) return -ENOENT;
size_t base_len = strlen(base_real);
if (strncmp(resolved, base_real, base_len) != 0 || resolved[base_len] != '/')
return -EACCES; /* escaped the sandbox */Note the check on resolved[base_len] — without it, /var/data-evil passes a prefix check against /var/data.
Races and the filesystem#
5. TOCTOU#
if (access(path, W_OK) == 0) { /* check */
int fd = open(path, O_WRONLY); /* use — path may have changed */
}The classic. Between the two calls the path can become a symlink to something else. Correct: open first and check the resulting descriptor with fstat, or use openat with O_NOFOLLOW. access() for a security decision is almost always a bug — it also checks the real uid rather than the effective one, which is rarely what you want.
6. Predictable temporary files#
char *tmp = tmpnam(buf); /* race between name and creation */
FILE *f = fopen(tmp, "w");Correct: mkstemp(), which creates and opens atomically with mode 0600. Also mkdtemp() for directories. Generated code reaches for tmpnam and mktemp because they read more simply.
7. Umask and permissions#
int fd = open(path, O_CREAT | O_WRONLY, 0666); /* & ~umask, so possibly 0666 */For anything containing secrets, pass 0600 and set the umask explicitly at startup rather than inheriting whatever the caller had.
Privilege#
8. Dropping privileges in the wrong order#
setuid(uid); /* groups still privileged */
setgid(gid); /* too late — no longer permitted to */Order matters and the return values matter:
if (setgroups(0, NULL) != 0) die("setgroups"); /* drop supplementary first */
if (setgid(gid) != 0) die("setgid"); /* then group */
if (setuid(uid) != 0) die("setuid"); /* then user, last */
if (setuid(0) == 0) die("privileges not actually dropped");That last line is the check nobody writes: attempt to regain root and abort if it succeeds. Generated privilege-dropping code omits the return-value checks almost every time, which means a failed drop looks identical to a successful one.
9. Environment inherited into a child#
execve with the current environ passes LD_PRELOAD, LD_LIBRARY_PATH, IFS and PATH straight through. For a privileged program, build a clean environment explicitly.
And never system() on anything containing outside data — that is a shell, with all the quoting problems that implies. posix_spawn or fork/execve with an argument array.
Secrets#
10. Secrets in argv#
./tool --api-key sk-live-abc123argv is visible in /proc/*/cmdline to any user on the box, and it lands in shell history and process listings. Read secrets from a file descriptor, an environment variable, or a file with restrictive permissions — never a command-line argument.
11. Memory not cleared#
char key[32];
load_key(key);
use(key);
memset(key, 0, sizeof key); /* the compiler may delete this */A memset on memory that is never read again is dead code, and optimisers remove it. Use memset_s, explicit_bzero, or SecureZeroMemory depending on platform — and remember that mlock is what stops the page reaching swap.
12. Comparison that leaks timing#
if (memcmp(mac, expected, 32) == 0) /* returns early on first difference */Use a constant-time comparison for any secret:
static int ct_eq(const void *a, const void *b, size_t n) {
const unsigned char *x = a, *y = b;
unsigned char d = 0;
for (size_t i = 0; i < n; i++) d |= x[i] ^ y[i];
return d == 0;
}Randomness#
rand() is not a CSPRNG and neither is time(NULL) as a seed. For keys, tokens, nonces or session identifiers: getrandom() on Linux, arc4random_buf() on BSD and macOS, or your crypto library's own function.
Generated code uses rand() because it is what appears in every tutorial.
Turn on the free mitigations#
HARDEN := -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fstack-clash-protection \
-fcf-protection=full -fPIE -Wl,-z,relro,-z,now,-z,noexecstack
WARN := -Wall -Wextra -Wformat=2 -Wformat-security -Wconversion -Wshadow \
-Wstrict-prototypes -Wnull-dereferenceThese apply to your vendored code too, which is the point — they mitigate bugs in libraries nobody on your team has read. Combined with the sanitizers in your debug build, this is the highest security return per line of configuration available in the language.
The review#
# the greppable half
grep -rnE 'system\(|popen\(|exec[lv]p|tmpnam|mktemp\(|access\(|rand\(\)|srand\(' src/
grep -rnE 'strcpy|strcat|sprintf|gets|alloca' src/
grep -rnE 'printf\([^"]|syslog\([^,]+, *[^"]' src/ # non-literal format strings
# the analysers, which disagree usefully
gcc -fanalyzer -c src/*.c
clang-tidy src/*.c --checks='clang-analyzer-*,cert-*,bugprone-*' --
cppcheck --enable=all --error-exitcode=1 src/
# and fuzz anything that parses
make fuzzWhere to spend the attention
Sanitizers and hardening flags handle memory. -Wformat=2 and -Wconversion handle two more categories at compile time. What is left for a human is the part on this page that no tool can see: is every length bounded, is every path resolved, is every privilege drop checked, and did anything from outside reach a shell. Four questions, and they cover most of what actually gets exploited.
Common questions#
Is generated C less secure than human C?#
On memory safety, comparable — models know the idioms and often check allocations that a human would skip. On this page's content, worse, because it is all context-dependent: a model does not know which of your inputs are attacker-controlled, and it will happily write a parser that is correct for a trusted file and exploitable for an upload.
What is the single highest-value flag?#
-Wformat=2 -Wformat-security, because it turns an entire vulnerability class into a compile error at no cost and with essentially no false positives. -D_FORTIFY_SOURCE=3 is a close second for the same reason.
Do I need a fuzzer if I have sanitizers?#
Yes — they answer different questions. Sanitizers find bugs on the paths you execute; fuzzing finds the paths you never thought to execute. For anything parsing untrusted bytes, the combination is what makes the code defensible, and a fuzz target is fifteen lines.
How much of this applies if my program only reads local config?#
Less, but check the assumption first. "Local config" becomes attacker-controlled the moment your program runs setuid, is invoked by a web server, or reads a file from a directory another user can write to. The trust boundary is about who can write the input, not where the file lives.
Get the C agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for C. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.