Get listed

Buffer overflow: stop at the call that knows dest_cap

A teal inkwell overflowing coral ink.

A buffer overflow is a write past the end of a buffer, usually in C or C++, sometimes in a native module your higher-level app loaded.

CWE-787 stays high on the Top 25 because memory-unsafe code still ships. Compiler flags, bounds APIs, and memory-safe languages close different parts of the problem. A WAF does not.

The usual mistake is ‘we write Python’ while a wheel still vendors a C parser.

This page is the flags and APIs that belong in a C tree, and the places an overflow still reaches a web app.

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) re-entered the list at rank 11. Stack-based (CWE-121) landed at 14, heap-based (CWE-122) at 16. The OpenSSF Compiler Options Hardening Guide, dated 20 August 2026, still lists -fstack-protector-strong and -D_FORTIFY_SOURCE=3 as default belts. None of those belts know how many bytes fit. The write that lasts is the call that already has dest_cap.

The control is an API that takes the capacity, a compiler line that refuses the old libc calls it can see, and belts that crash the process when a write still gets past you. This page does not show how to overflow a buffer. It shows how to stop the write. Keep the C vulnerability guide next to this page. When the fuse is a format, read format strings. When the fuse is a wrapped count, read integer overflow.

Bounds first, then the belts

A buffer overflow is CWE-120 in the classic form: a copy that does not check the input size against the destination. CWE-787 is the broader write, past the end or before the start. The product bug is the same sentence. The destination has a room. The source has a length. The call has to know both before it writes.

The destination has a room. The call that lasts is the one that already names dest_cap, so a long source hits that length and stops.

SecureCoding

The destination has a room. The call that lasts is the one that already names dest_cap, so a long source hits that length and stops.

Belts come after that sentence. A canary, ASLR, NX, RELRO, and FORTIFY change what happens when the write is already wrong. They do not size dest. Start with the call. Then turn the belts on so a miss crashes you instead of continuing.

The compiler flags worth turning on

OpenSSF guide dated 20 August 2026. The default production set that applies to this page:

# FIX: OpenSSF default line, then the executable bits
CFLAGS += -O2 -Wall -Wformat -Wformat=2 -Werror=format-security \
 -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 \
 -fstack-protector-strong -fstack-clash-protection \
 -fno-strict-overflow
LDFLAGS += -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack -fPIE -pie

What those flags actually do, so you do not treat the line as a charm:

  • -D_FORTIFY_SOURCE=3 at -O1 or higher replaces some libc calls with checked variants when the compiler can see dest_cap. Level 3 uses __builtin_dynamic_object_size, available in GCC 12+ and Clang for years. It still needs a size it can compute. A char *dest that arrived from another translation unit often has none.
  • -fstack-protector-strong inserts a canary on more frames than the original -fstack-protector. A write past a local buffer can trip it. A heap write will not.
  • -fstack-clash-protection probes large stack allocations so a giant local array cannot skip the guard page.
  • -fPIE -pie plus ASLR on the host gives the binary a shuffled load address. kernel.randomize_va_space=2 is the Linux sysctl. The flag prepares the binary. The kernel does the shuffle.
  • -Wl,-z,relro -Wl,-z,now lock the GOT after resolve. -z,noexecstack keeps the stack non-executable. NX on the stack is a belt. A write into a function pointer still has somewhere to go if you left one next to the buffer.
  • -fno-strict-overflow stops the compiler from deleting a signed-overflow check it thinks cannot happen. That check is how you reject a wrapped need.

OpenSSF also lists -ftrivial-auto-var-init=zero for production and AddressSanitizer for instrumented tests. ASan is a build you run in CI. It is not a flag you ship to customers as the only bound.

Canaries and ASLR are belts

A stack canary is a secret the prologue writes next to the return address. The epilogue checks it. A linear write off a local array can trip it. That is the mitigation saagarjha named. Heap writes skip it. A write that lands past the canary slot skips it. A frame that never returns never checks it.

ASLR shuffles the base of the binary, the heap, and the stack. A write that still lands on a chosen object is a separate bug. ASLR makes a guessed address less stable. It does not know dest_cap.

Keep both. Measure them. On Linux, readelf -l should show GNU_RELRO and a GNU_STACK that is not executable. pax-utils hardening-check is the packaged report on Debian and Ubuntu. A missing canary on a file that has local arrays is a build-system bug, not a reason to rewrite the copy. Fix the flags, then go back to the call.

Do not add a custom canary in application code. You will get the secret wrong, and you will still have a copy that does not know dest_cap.

The API that already knows the length

The fix is a function whose signature contains the room.

// BAD: dest room is invisible
// strcpy(dest, src);
// sprintf(dest, "%s", src);
// strcat(dest, src);
// gets(dest);

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;
}

dest_cap is the size of the array you actually passed, including the byte for '\0' when this is a string. src_len is the number of bytes you already measured, not a second walk that can disagree. memcpy after that check is the write. strcpy cannot take dest_cap. sprintf cannot take dest_cap. gets cannot take dest_cap. Those four names are the hatch list.

int wrote = snprintf(dest, dest_cap, "%s", src);
if (wrote < 0 || (size_t)wrote >= dest_cap) {
 return -1;
}

In C++, std::string, std::vector<unsigned char>, and std::span already store a size. Prefer them over a raw char dest[64] that you then pass into a C helper. When you must call C, pass dest.size() as dest_cap. Do not take .data() and forget the size on the next line. std::copy_n still needs a count you already checked against dest.size(). .at(i) throws on a bad index. operator[] does not. For a value that came from a packet, use .at or a checked helper, not a raw subscript.

strlcpy is fine where the libc has it (BSD, some embedded). It is not on every glibc. Do not ifdef a project into three copy helpers. Pick copy_into or snprintf and use it everywhere.

Sizes that wrap before the copy

A checked memcpy(dest, src, need) is still wrong if need wrapped. need = header_n + src_len on an unsigned size_t becomes a small number when the add crosses the type. The check need <= dest_cap then passes. The write is short. The object you thought you reserved next to dest is the one that gets the leftover. That is an integer bug in front of a copy. The integer overflow guide is the longer treatment. The check that belongs on this page is:

if (src_len > dest_cap || header_n > dest_cap - src_len) {
 return -1;
}
need = header_n + src_len;

Subtract, do not add, when you test the room. Reject a src_len that came from a header before you allocate. malloc(need) with a wrapped need is a small heap block and a large copy. new char[need] is the same shape in C++.

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.

How you prove the bound

You are not walking an exploit. You are proving copy_into rejected a length you chose.

  1. Build with the OpenSSF line and with -fsanitize=address,undefined in the CI variant. ASan on your own test is the report you want.
  2. Grep strcpy, strcat, sprintf, gets, and scanf("%s". A hit is a rewrite to copy_into or snprintf.
  3. Unit-test copy_into with src_len == dest_cap (reject, no room for '\0'), src_len == dest_cap - 1 (accept), src_len == 0 (accept, empty string), and a NULL src (reject).
  4. Unit-test the wrap check with src_len = SIZE_MAX and a non-zero header_n. Expect -1. Do not send that length at a host you do not own.
// Tests for copy_into. dest_cap is 8, including '\0'.
char dest[8];
assert(copy_into(dest, 8, "hello", 5) == 0);
assert(copy_into(dest, 8, "hello!!", 7) == 0);
assert(copy_into(dest, 8, "hello!!!", 8) == -1);
assert(copy_into(dest, 8, "", 0) == 0);
assert(copy_into(dest, 0, "x", 1) == -1);

Run hardening-check on the shipped binary. Expect stack canary, Fortify, PIE, RELRO. A miss is a linker line, not a new copy helper.

Heap and stack are different rooms. A canary watches the frame. malloc and new do not get one. For the heap, the control is still dest_cap you stored next to the pointer, plus ASan in CI, plus a allocator that can poison on free if you opt into that in tests. Do not invent an application-level heap canary. Store the capacity. Pass it.

Network-facing parsers are where CWE-787 still earns KEV listings. A length field in a header is an untrusted src_len. Cap it against a constant you chose (MAX_REC), then against dest_cap, then copy. A length field of 0xffffffff is a wrap test, not a size you honor. The unit test with SIZE_MAX above is that case, in your process, on your function.

Questions we keep getting

If ASLR and canaries are on, do I still have to replace strcpy?

Yes. Those are belts. They crash or shuffle after a write that already left dest. strcpy still cannot see dest_cap. Replace the call. Leave the belts on.

Is memcpy always safe once I pass a length?

It is safe for dest only if that length is the room you actually have. A wrapped need, a signed-to-unsigned cast, or a length you took from the packet without capping it, is a small destination and a large write. Check the arithmetic, then copy.

Does switching to C++ close this?

std::string closes 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 and no .at(). The signature still has to name the room.

Malav Vyas

Malav Vyas / About Author

Software Engineer | All things command-line | Arch | Infosec guy Twitter