Safeguard
Application Security

A hands-on stack buffer overflow in C++: root cause and mitigations

The Morris Worm hit ~6,000 machines in 1988 via one unsafe gets() call. We build and hijack a stack overflow in C++ to teach why canaries and ASLR exist.

Safeguard Research Team
Research
7 min read

On November 2, 1988, the Morris Worm sent a 536-byte crafted string to a gets() call inside BSD fingerd, overflowed a fixed-size stack buffer, overwrote the saved return address, and hijacked control flow on an estimated 6,000 of the roughly 60,000 machines then connected to the internet. Thirty-eight years later, the exact same primitive — an unbounded write past the end of a stack array clobbering adjacent memory including a return address — still shows up in CVE databases every year, because C and C++ never added bounds checking to the language itself; they left it to the programmer. This post builds a minimal, deliberately vulnerable C++ program, compiles it with every modern protection turned off, walks through exactly how a return-address overwrite hijacks execution, and then turns each step around to show what stack canaries, ASLR, and non-executable memory actually detect or block. The goal is not offense — it's understanding the failure mode well enough to recognize it in a code review, because the fix is almost always the same one-line change: swap an unbounded copy for a bounded one. We compile with -fno-stack-protector -z execstack -no-pie specifically because Ubuntu, Debian, and Fedora have shipped stack protector, PIE, and ASLR on by default for years — this lab configuration does not reflect what ships to production.

What does the vulnerable code actually look like?

The vulnerability is almost always this shape: a fixed-size stack buffer paired with a copy function that doesn't know the buffer's size. A canonical minimal example is a function like void copy_input(char *input) { char buf[64]; strcpy(buf, input); }, called with attacker-controlled input from argv or a network read. strcpy() copies bytes until it hits a null terminator in the source, with zero awareness that buf only has 64 bytes behind it. On x86-64 with the standard stack layout, buf sits below the saved base pointer and the saved return address in memory, so a source string longer than 64 bytes plus the saved-registers padding starts overwriting those adjacent stack slots — including the return address the CPU will jump to when the function executes ret. This isn't a Safeguard-invented example; it's structurally identical to the pattern in CWE-121 (Stack-Based Buffer Overflow) and to real-world flaws tracked across decades of CVEs in C parsers, video codecs, and network daemons.

How does overwriting a return address hijack control flow?

When a function is called, the call instruction pushes the return address onto the stack; when the function finishes, ret pops that address off the stack and jumps to it — the CPU trusts whatever value is sitting there. If a strcpy()-style overflow has overwritten that stack slot with attacker-chosen bytes before ret executes, the CPU jumps to the attacker's address instead of back to the caller. In our lab build (compiled -no-pie -z execstack so addresses are fixed and the stack is executable, matching how early-2000s Linux behaved by default), an attacker who knows the buffer's fixed stack address can place shellcode in the buffer itself and pad the overflow so the overwritten return address points straight back into that buffer — the exact technique the Morris Worm and a generation of early-2000s "smashing the stack" exploits (Aleph One's 1996 Phrack article "Smashing the Stack for Fun and Profit") relied on. Every mitigation described below exists specifically to break one link in that chain.

What does a stack canary actually detect?

A stack canary is a known value the compiler inserts between local buffers and the saved return address on function entry, and checks against that same value right before ret executes — if the two don't match, the program aborts instead of returning to a corrupted address. The technique originated as StackGuard, published by Crispin Cowan and colleagues at USENIX Security 1998 as a GCC 2.7 backend patch; the original prototype used a hardcoded canary value, 0xDEADBEEF, which an attacker could simply hardcode into their overflow payload to sail past the check undetected. Modern GCC and Clang generate a random canary per process via -fstack-protector, with the more thorough -fstack-protector-strong variant (added in GCC 4.9) enabled by default on Ubuntu since Ubuntu 14.10 in 2014, and MSVC's equivalent is /GS. Recompiling our lab example without -fno-stack-protector is enough to turn the same strcpy() overflow from a silent hijack into an immediate *** stack smashing detected *** abort — the bug is still there, but the exploit primitive is gone.

Why doesn't ASLR alone stop this class of bug?

Address Space Layout Randomization randomizes the base addresses of the stack, heap, and loaded libraries on every process start, so an attacker who hardcodes "jump to address 0x7fffffffe100" in their payload has no idea that address will be correct on the target's next run — the technique PaX pioneered for Linux in the early 2000s, later upstreamed into the mainline kernel. Compiling our lab build with -no-pie deliberately disables position-independent execution so the return-address overwrite in the walkthrough works reliably for teaching purposes; on a modern default Ubuntu build, the same overflow would need to first leak a stack or library address (an information-disclosure bug elsewhere in the program) before it could reliably redirect execution. That's the honest limitation of ASLR: it doesn't fix the memory-safety bug, it just removes the attacker's ability to guess a fixed address, which is why any bug that also leaks memory contents — like an out-of-bounds read — can defeat ASLR on its own.

Why does NX force attackers toward ROP, and why is that still incomplete defense?

Marking the stack and heap non-executable (NX on the hardware bit, DEP on Windows, part of the broader W^X model PaX also pioneered) means the CPU refuses to execute instructions fetched from those pages, so even a successful return-address overwrite that points into buf can no longer run shellcode planted there. Our lab's -z execstack flag deliberately turns this protection off; removing that flag alone would make our injected shellcode simply fault instead of run. The historical response from attackers was Return-Oriented Programming (ROP) — chaining together short existing instruction sequences already present in the binary's own executable code rather than injecting new code — which is why no single mitigation is described as sufficient on its own. We don't walk through building a ROP chain here because it adds no defensive teaching value; the point to take away is that NX, ASLR, and stack canaries are complementary layers, and CFI (Control-Flow Integrity) and stack-clash protection exist specifically to close the ROP-shaped gap NX leaves open.

What actually fixes the bug, and where does Safeguard fit?

The only fix that removes the root cause is bounding every write to the size of its destination: replace strcpy()/gets()/sprintf() with fgets(), strlcpy(), or snprintf() with an explicit length, or better, stop using raw char arrays and index arithmetic entirely in favor of std::string, std::array, and std::span, which carry their own bounds and make the equivalent unbounded copy a compile error or a checked runtime exception instead of silent corruption. Mitigations like canaries, ASLR, and NX are correctly understood as defense in depth that makes exploitation harder and noisier, not as substitutes for fixing the write. On the detection side, it's worth being precise about tooling maturity here: Safeguard's SAST engine currently covers JavaScript/TypeScript, Python, and Java in its first-phase language rollout, so it does not lex-scan C/C++ source for patterns like this one today. Safeguard's function-level reachability analysis — which tells you whether your code's actual call graph reaches a vulnerable dependency function rather than just flagging that the dependency is present — is likewise scoped to that same JavaScript/TypeScript, Python, and Java coverage today, so it doesn't yet extend the same call-path pruning to C/C++ or its package managers. For teams shipping C/C++ today, that means the fix described above — bounding every write, and adopting bounds-checked types — is the front line, with Safeguard's SAST and reachability coverage well positioned to extend to native code as that support matures.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.