The Bugs That Hide
Some bugs announce themselves. Segfaults, assertion failures, error messages in the logs — these are loud and they get fixed. The bugs worth worrying about are the ones that don’t announce themselves. Code that compiles cleanly, passes all tests, runs in production for months, and is quietly wrong.
After auditing a moderately large C codebase recently, a few categories kept appearing. Not in obviously broken code — in code that was working, had been reviewed, and had test coverage. The bugs were hiding in the gap between “behaves correctly under normal conditions” and “behaves correctly always.”
The Use-After-Free That Tests Don’t Catch
Use-after-free bugs are notorious, but the ones that survive code review aren’t the obvious ones. They look like this:
char *result = compute_something(input);
if (result == NULL) return;
process(cache, result); // might invalidate result
log_outcome(result); // use-after-potential-invalidation
The problem isn’t that result is freed directly. The problem is that process() might free it as a side effect — and the call site doesn’t know, because the contract isn’t documented. The pointer is live when it’s passed in, but may not be when the next line runs.
These survive because:
- The function name (
process) doesn’t suggest ownership transfer - Under normal test conditions, the cache doesn’t invalidate
resultduring the call - The bug only surfaces when the cache is in a specific state
Finding these requires reading ownership contracts, not running code. If a function takes a pointer, ask: does it take ownership? Can it free or reallocate the pointed-to memory? The answer should be in the documentation. If it isn’t, that’s worth investigating.
The Off-By-One at the Buffer Boundary
while (pos < len - 1) {
if (buf[pos] == '/' && buf[pos + 1] == '*') {
/* handle block comment start */
}
pos++;
}
This looks correct until len is 0. len - 1 underflows to SIZE_MAX for unsigned types, and the loop runs forever (or until a crash). The edge case only matters when the input is empty, which is exactly the test case that gets skipped because “why would the input be empty?”
A related pattern: two-character lookahead at the end of a buffer. buf[pos + 1] is valid only when pos < len - 1, not when pos < len. Code that checks the latter and accesses the former is one byte past the end. It won’t crash in debug builds (adjacent stack memory is usually readable). It won’t trigger sanitizers unless the buffer is at a page boundary. It just reads garbage, occasionally.
The Unquoted Shell Argument
char *cmd = g_strdup_printf("tool --input %s --output %s", input_path, output_path);
system(cmd);
If either path contains a space, this breaks. If a path contains a semicolon or a backtick, this is a security vulnerability. The fix is simple (g_shell_quote() around each argument, or better, g_spawn_async() with a null-terminated argv array). The problem is that it works correctly in every test you ran, because your test paths don’t contain spaces or special characters.
Shell injection in internal tooling is easy to dismiss: “it’s not user-facing.” But paths come from user-controlled data eventually — filenames, workspace directories, configuration values. The code that works correctly in your controlled environment fails in someone else’s normal filesystem.
The Partial Write
ssize_t n = write(fd, buf, len);
if (n < 0) {
/* handle error */
return -1;
}
write() can return a value between 0 and len without setting errno — a partial write. The error check only handles the failure case; the partial-success case silently continues as if the write completed. The remaining bytes are never written.
This usually works. On local filesystems, writes to regular files rarely return partial results. The bug surfaces under load, on network filesystems, when the disk is nearly full, or in environments you didn’t test on.
The fix is a loop that retries until all bytes are written. The reason people don’t write it: because write() “always works” in development.
The Common Thread
These bugs share a structure: they work correctly under the conditions used to test them and fail under conditions that are real but uncommon. They survive review because they’re syntactically correct and semantically plausible. They survive testing because test suites cover the happy path.
Finding them requires asking a different question than “does this code work?” Ask instead: “what are all the ways this code’s implicit assumptions could be violated?” Empty inputs. Paths with spaces. Large files. Concurrent access. Ownership ambiguity.
The bugs that hide are always hiding in the assumptions.