Review & Verify Updated 2026-09 12 min read View as Markdown

The C mistakes language models actually make

In most languages a generated bug is a wrong answer. In C it is a vulnerability. This is the catalogue, and the tooling that makes it survivable.

C is the language where the review stakes are highest. Everywhere else, a generated bug produces a wrong answer, an exception, a test failure. Here it produces a heap overflow that works fine for six months and then does not.

The good news is that C has the best free tooling of any language for finding exactly these bugs, and almost nobody turns it on. If you are writing C with an agent, the sanitizers are not optional.

Memory#

1. Allocation results used unchecked#

c
char *buf = malloc(n);
memcpy(buf, src, n);        // NULL deref if malloc failed

The single most common generated C bug. It appears because most C in the training data is example code where the check was elided for brevity.

Catch it with: -fanalyzer (GCC) or clang --analyze. Both flag it reliably.

2. Off-by-one on the null terminator#

c
char dst[16];
strncpy(dst, src, sizeof(dst));    // no null terminator if src is >= 16
printf("%s", dst);                 // reads past the buffer

strncpy does not guarantee termination. Neither does strncat's size argument mean what people think. Generated string handling gets this wrong constantly.

Correct: snprintf(dst, sizeof dst, "%s", src), which always terminates. Or strlcpy where available.

3. sprintf, strcpy, strcat, gets#

Still generated, because there is an enormous amount of pre-2000 C in the training data. There is no safe use of gets; the others need a bounded variant.

Catch it with: -D_FORTIFY_SOURCE=3 -O2 turns many of these into runtime aborts, and -Wformat-security catches the format-string cases at compile time.

4. sizeof on a pointer#

c
void process(int *arr) {
    int n = sizeof(arr) / sizeof(arr[0]);   // 1 on 64-bit, or 2. never the length.
}

Arrays decay to pointers at function boundaries. The idiom is correct in the caller and wrong in the callee, and generated code copies it across the boundary.

Catch it with: -Wsizeof-pointer-div (on by default in recent GCC/Clang).

5. Use after free, double free#

Most often via an error path that frees and then falls through to a cleanup label that frees again.

Catch it with: AddressSanitizer. It finds these with a precise report including both stack traces. This is the single highest-value flag on this page.

6. Returning a pointer to a local#

c
char *greet(void) {
    char buf[64];
    snprintf(buf, sizeof buf, "hello");
    return buf;                 // dangling
}

Catch it with: -Wreturn-local-addr (default in GCC), and ASan at runtime.

7. realloc losing the original pointer#

c
p = realloc(p, n);              // if realloc fails, p is leaked and now NULL

Correct: assign to a temporary, check, then reassign.

Integers and undefined behaviour#

8. Signed integer overflow#

Undefined behaviour, and the compiler is permitted to assume it does not happen — which is how overflow checks written after the arithmetic get optimised away entirely.

c
if (a + b < a) { /* overflow */ }   // UB; the compiler may delete this

Correct: __builtin_add_overflow(a, b, &r), or check before the operation.

Catch it with: UndefinedBehaviorSanitizer (-fsanitize=undefined).

9. Allocation size computed by multiplication#

c
void *p = malloc(count * sizeof(struct item));   // overflows -> tiny allocation

The classic path to a heap overflow. Correct: calloc(count, sizeof(struct item)), which checks the multiplication.

10. int for sizes and indices#

Generated C uses int where size_t is correct, which breaks on large inputs and introduces signed/unsigned comparison bugs. Catch it with: -Wconversion -Wsign-compare (noisy, and worth it on new code).

11. Shifting by too much, or shifting a signed value#

1 << 31 on a 32-bit int is UB. Use 1u << 31. UBSan catches it.

Concurrency and resources#

12. Non-atomic shared state#

Generated pthread code frequently shares a counter or a flag with no mutex and no _Atomic. Catch it with: ThreadSanitizer (-fsanitize=thread).

13. Leaked file descriptors on error paths#

The happy path closes; the four early returns do not. Catch it with: LeakSanitizer (bundled with ASan) plus a goto cleanup convention — which is one of the few places where goto is the right answer, and worth stating in AGENTS.md.

14. Ignoring return values#

read, write, close, fclose all fail. Generated code checks fopen and then ignores everything after it. Catch it with: -Wunused-result and __attribute__((warn_unused_result)) on your own APIs.

The build that makes C survivable#

This is the whole point of the page. Turn these on and most of the list above becomes a loud failure during development rather than a quiet one in production.

Makefile
CC      = clang
WARN    = -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-compare \
          -Wformat=2 -Wformat-security -Wnull-dereference -Wstrict-prototypes
HARDEN  = -D_FORTIFY_SOURCE=3 -fstack-protector-strong
SAN     = -fsanitize=address,undefined -fno-omit-frame-pointer

debug: CFLAGS = -g -O1 $(WARN) $(HARDEN) $(SAN)
debug: LDFLAGS = -fsanitize=address,undefined
debug: $(TARGET)

.PHONY: check
check: debug
	./$(TARGET) $(TESTARGS)
	clang-tidy src/*.c -- $(WARN)
	cppcheck --enable=all --error-exitcode=1 src/
shell
export ASAN_OPTIONS=detect_leaks=1:abort_on_error=1
export UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1
make check

Run the sanitized build as your default development build. The 2–3x slowdown is irrelevant while developing and it converts silent corruption into an immediate stack trace.

If you do one thing

-fsanitize=address,undefined on your debug build. It finds items 1, 2, 3, 5, 6, 8, 9, 11 and 13 on this page, automatically, with precise reports. Nothing else on this page comes close to that return.

And fuzz the parsers#

Any function that takes bytes from outside — a file format, a protocol, a config parser — should be fuzzed. It is about fifteen lines, and it is how you find the input nobody thought of.

fuzz_target.c
#include <stddef.h>
#include <stdint.h>

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    parse_packet(data, size);
    return 0;
}
shell
clang -g -O1 -fsanitize=fuzzer,address,undefined fuzz_target.c src/parser.c -o fuzz
./fuzz -max_total_time=300 corpus/

Five minutes of fuzzing on a generated parser finds things no amount of reading does.

Common questions#

Should I let an agent write C at all?#

Yes, with the tooling above, and with more review attention than any other language. Generated C is often good — models know the idioms — but the cost of the residual errors is unbounded in a way it is not elsewhere. The sanitizers change the risk profile enough to make it a reasonable trade; without them it is not.

Do sanitizers catch everything?#

No. They are dynamic — they find bugs on the paths you actually execute. That is why the recommendation is sanitizers plus static analysis (-fanalyzer, clang-tidy, cppcheck) plus fuzzing for anything parsing untrusted input. The three have different blind spots.

Is Rust the answer instead?#

For new projects where you have the choice, memory safety by construction beats memory safety by tooling, and that argument gets stronger as generated code volume rises. But most C exists because of a constraint — an existing codebase, a platform, a kernel — and for that code, this page is what you have.

Why does generated C use unsafe string functions?#

Because decades of C in the training data uses them. strcpy was normal for a very long time. This is a good illustration of a general point: a model reproduces the distribution of its training data, and for C that distribution is heavily weighted towards code written before the current safety consensus.

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.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.