
C vulnerabilities are still memory: writes past a buffer, reads from freed heap, and format strings.
CWE-787 stays in the Top 25 and in KEV because C still sits under parsers, kernels, and native modules. Compiler flags, sanitizers, and bounds-checked APIs are the daily controls. A rewrite in another language is a later project.
The usual mistake is shipping -Wall without -Werror on the flags that would have caught the last overflow.
This page is the C checks that belong in the build, and the classes to grep first.
CWE-787, Out-of-bounds Write, sits at rank 5 on MITRE’s 2025 CWE Top 25, with 12 mappings in CISA KEV. Classic buffer overflow (CWE-120) is rank 11. Use-after-free (CWE-416) is rank 7, with 14 KEV mappings. The OpenSSF Compiler Options Hardening Guide, dated 20 August 2026, still leads with -Werror=format-security, -D_FORTIFY_SOURCE=3, and -fstack-protector-strong. Those flags are belts. They do not name the room.
The control is an API that takes the capacity, a compiler that can see a literal format, and a multiply that can say no. This page does not show payloads. It shows the four gates you keep.
Four classes, one habit
Native memory bugs cluster. A wrap sizes a buffer too small. A copy then writes past it. A percent-format the caller did not mint becomes a write with a different fuse. A pointer you already freed is a later use of the same heap. The habit that cuts through them is: the function that touches memory has to name the room, the count, and the lifetime before it runs.
| CWE | Gate | Sibling |
|---|---|---|
| 787 / 120 | copy_into(dest, dest_cap, src, src_len) | bounds page |
| 134 | literal format, then the args | format page |
| 190 | calloc or ckd_mul | wrap page |
| 416 | one owner, no raw free beside the use | this page, lifetime section |
ROOM dest_cap is in the signature
reject when src_len does not fit
FORMAT printf("%s\n", user_name)
the format is a source literal
COUNT ckd_mul or calloc(n, size)
a wrap is deny, not a small malloc
BELT FORTIFY, canary, ASLR, ASan in CI
they run after a miss
The call that already knows dest_cap
C11 Annex K (strcpy_s, memcpy_s) is optional and often missing. Do not wait for it. The portable 2026 shape is a helper whose arguments include the destination room and the source length you already measured. C++ already has that shape in std::span, std::string, and std::vector. Use them. When you must call C, pass dest.size() as dest_cap.
int copy_into(char *dest, size_t dest_cap, const char *src, size_t src_len) {
if (dest == NULL || src == NULL) {
return -1;
}
if (dest_cap == 0 || src_len >= dest_cap) {
return -1;
}
memcpy(dest, src, src_len);
dest[src_len] = '\0';
return 0;
}
strcpy, sprintf, gets, and strcat cannot take dest_cap. Those four names are the hatch list. snprintf(dest, dest_cap, "%s", src) is the other portable shape. Check the return against dest_cap. In C++, .at(i) throws on a bad index. operator[] does not. For a value that came from a packet, use .at or a checked helper. Clang’s -Wunsafe-buffer-usage is the warning that flags a raw subscript on a pointer. Turn it on in CI even if you are not ready to -Werror it everywhere.
The buffer overflow guide is the longer treatment of this helper, the wrap-before-copy case, and the unit tests. Here the rule is the signature. If a function writes memory and the room is not an argument, rewrite the function.
C++ in 2026 should not be a char dest[64] that you then pass into a C helper and forget. std::span<char> carries the room. Construct it from the array or the vector, then copy through that view.
int copy_into_span(std::span<char> dest, std::string_view src) {
if (src.size() >= dest.size()) {
return -1;
}
std::memcpy(dest.data(), src.data(), src.size());
dest[src.size()] = '\0';
return 0;
}
char dest[8];
assert(copy_into_span(dest, "hello") == 0);
std::string_view is a borrow. Do not store it past the std::string that owns the bytes. A view into a temporary is CWE-416 with a nicer type name. std::string itself is the owner when the data must outlive the statement.
The 2026 compiler line
OpenSSF guide dated 20 August 2026. The default production set:
# FIX: OpenSSF default line. Order of the FORTIFY pair matters.
CFLAGS += -O2 -Wall -Wformat -Wformat=2 -Werror=format-security \
-Wconversion -Wimplicit-fallthrough \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 \
-fstrict-flex-arrays=3 \
-fstack-protector-strong -fstack-clash-protection \
-fno-strict-overflow
CXXFLAGS += $(CFLAGS) -D_GLIBCXX_ASSERTIONS \
-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST
LDFLAGS += -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack -fPIE -pie
GCC 14 added -fhardened. It turns on a consensus subset: FORTIFY 3, stack protector strong, clash protection, PIE, RELRO plus now, and trivial auto-var init. Use it as a starter on GCC 14+. Still write the explicit line so Clang and older GCC get the same belts. -fstrict-flex-arrays=3 treats only a true flexible array member ([]) as unbounded. A trailing array[1] or array[0] becomes a real bound the compiler can see.
What the belts actually do, so you do not treat the line as a charm:
-D_FORTIFY_SOURCE=3at-O1or higher replaces some libc calls with checked variants when the compiler can seedest_cap. Level 3 uses__builtin_dynamic_object_size. Achar *destthat arrived from another translation unit often has none.-Werror=format-securityrefuses a non-literal format. That is the CWE-134 door the compiler can close. Details sit on the format page.-fstack-protector-stronginserts a canary on more frames than the original-fstack-protector. A heap write will not trip it.-fno-strict-overflowstops the compiler from deleting a signed-overflow check it thinks cannot happen.
A format the user never wrote
CWE-134 is a printf family call whose format came from a request, a file, a translated catalog, or a config map. printf("%s\n", user_name) is the shape. printf(user_name) is the bug. -Werror=format-security refuses the second when the format is visible at the call site.
// BAD: user_name is grammar
// printf(user_name);
// FIX: the format is a literal. user_name is data.
printf("%s\n", user_name);
// FIX: wrappers must re-export the check
__attribute__((format(printf, 2, 3)))
void log_msg(int level, const char *fmt,...);
Mark every wrapper with format(printf, m, n) so the warning runs where you invoke the helper. Do not load a format from gettext or from a config key. If a translator needs a number in a sentence, keep the format in source and pass the number as an argument. The format-string guide covers syslog, errx, and the v-prefixed twins. The rule on this map is simple: the format is a source literal.
Check the multiply before malloc
ISO/IEC 9899:2024, C23, added stdckdint.h so ckd_mul can refuse a product that does not fit. GCC 14 shipped that header in April 2024. calloc(n, size) is specified to fail when n * size cannot be represented. malloc(n * sizeof *p) is not. A wrapped product is a small allocation and a later write that still walks n.
#include <stdckdint.h>
#include <stdlib.h>
void *alloc_n(size_t n, size_t size) {
size_t bytes;
if (n == 0 || size == 0) {
return NULL;
}
if (ckd_mul(&bytes, n, size)) {
return NULL;
}
return malloc(bytes);
}
// FIX: or let calloc do the product
// p = calloc(n, size);
Cap n against a constant you chose before the multiply. A length field of 0xffffffff from a header is a deny, not a size you honor. Signed lengths are worse. A negative int n from a parsed field becomes a huge size_t on the way into memcpy. Parse into size_t, or reject n < 0 before the cast. The integer overflow guide covers ckd_add, Go make, and why JavaScript bitwise is not a length. On this map, the product that sizes the buffer is the gate.
Use-after-free is a lifetime, not a copy
CWE-416 is rank 7 on the 2025 list, with more KEV mappings than CWE-787. The write is fine. The pointer is not. A raw free at one call site and a later use at another is the shape. The 2026 control is an owner the type system can see.
// C++ FIX: one owner. No raw new/delete at the call site.
std::unique_ptr<Session> sess = std::make_unique<Session>();
// sess.get() is a borrow. Do not store it past sess.
// C FIX: free sits next to the NULL. No second pointer to the same block.
void session_drop(Session **sp) {
if (sp == NULL || *sp == NULL) {
return;
}
free(*sp);
*sp = NULL;
}
ASan with detect_leaks=1 in CI is the report you want for a use after free in your own tests. It is not a flag you ship as the only lifetime rule. Do not invent an application-level “freed” poison byte as the product control. Own the pointer. Null it. Prefer unique_ptr. Reach for shared_ptr only when two owners are real.
realloc is the other lifetime hatch. A successful grow can move the block. Every other pointer at the old address is then CWE-416. A failed realloc leaves the old block allocated and returns NULL. Assigning p = realloc(p, n) leaks the old block on failure and leaves p NULL. Keep the old pointer until the new one is non-NULL, then replace it. In C++, std::vector::resize is the grow. Do not hold an element pointer across a resize you do not control.
void *grown = realloc(p, bytes);
if (grown == NULL) {
free(p);
return NULL;
}
p = grown;
Grep and tests you can run today
You are not walking an exploit. You are proving copy_into rejected a length you chose, and that the compiler refused a non-literal format.
- Build with the OpenSSF line. Build a second CI variant with
-fsanitize=address,undefined. - Grep
strcpy,strcat,sprintf,gets,scanf("%s", andmalloc(n *. A hit is a rewrite tocopy_into,snprintf, oralloc_n. - Grep
printf(and read every call. The first argument after any wrapper must be a string literal or aconst char *fmtthat the attribute already checked. - Unit-test
copy_intowithsrc_len == dest_cap(reject),src_len == dest_cap - 1(accept), andalloc_n(SIZE_MAX, 4)(NULL).
char dest[8];
assert(copy_into(dest, 8, "hello", 5) == 0);
assert(copy_into(dest, 8, "hello!!!", 8) == -1);
assert(alloc_n(SIZE_MAX, 4) == NULL);
Run hardening-check on the shipped binary on Debian and Ubuntu. Expect stack canary, Fortify, PIE, RELRO. A miss is a linker line. Fix the flags, then go back to the call.
Network-facing parsers are still where CWE-787 earns KEV listings. A length field in a header is an untrusted src_len. Cap it against a constant you chose, then against dest_cap, then copy. NULL-pointer dereference is CWE-476, rank 13 on the 2025 list. A malloc you did not check, or a copy_into you called after a failed alloc, is that rank. Treat NULL as deny. Do not log and continue into the write.
OpenSSF also lists -ftrivial-auto-var-init=zero for production and -fcf-protection=full on x86_64. Trivial init closes an uninitialized-read class that is not CWE-787 but still ships as CVEs. CET is a belt on the return and the indirect call. Turn both on when the toolchain accepts them. Put them in your build file so the next engineer does not have to rediscover the line.
Keep the secure coding checklist next to this map when the tree is mixed web and native. The three sibling pages are the deep dives. This page is the order you apply them: room, format, count, lifetime, then the belts.
Questions we keep getting
Does switching the tree to C++ close these classes?
std::string and std::span close the cases you stay inside the type. .data() handed to a C helper without .size() opens them again. So does operator[] with an untrusted index. The signature still has to name the room.
Is Annex K the 2026 answer?
No. It is optional. Many libcs never shipped it. copy_into plus snprintf plus ckd_mul is portable and grep-able. Do not ifdef three copy helpers.
If ASan is green, am I done?
ASan is a CI build. It is not the production bound. Green tests mean the paths you wrote did not trip it. The helper, the literal format, and the checked multiply still have to be the only way memory is touched.



