Buffer Overflow Defenses: How a Breach Became a Crash

By Sergey Nosov

16 October 2026

On the evening of 2 November 1988, a program released from a computer at MIT began spreading across the early internet. Within 24 hours, by the FBI's estimate, it had reached 6,000 of the roughly 60,000 computers then connected. One way in was the finger daemon, which reported who was logged in. Eugene Spafford's 1988 analysis traced the flaw to gets, a C library routine that "takes input to a buffer without doing any bounds checking." The worm overfilled the buffer, the extra bytes overwrote the function's return address, and the function returned into the attacker's data.

The finger attack did not work everywhere. On Sun computers, Spafford noted, it "simply resulted in a core file," because the worm carried no code for the Sun version of the daemon. In 1988, a crash instead of a breach was an accident. This article is about how it became the default.

The bug did not go away. As late as 2019, Microsoft reported that around 70 percent of the vulnerabilities it fixed and assigned a CVE were memory-safety issues. That family is wider than the overflow. The Chromium project reports a similar share of its high-severity security bugs, and half of those memory-safety bugs are use-after-free. Out-of-bounds access, the overflow's own branch of the family, was behind 40 percent of the memory-safety exploits that Google's Project Zero tracked in the wild from 2014 through 2023.

What changed is what happens when someone tries to exploit the bug: on a modern system, an exploit written the 1988 way crashes the process instead of taking it over. Determined attackers still get through, with more bugs and more work. The defenses arrived over almost three decades, most of them answers to the attack that beat the previous defense. No single layer is enough; together they made a crash the default, and memory safety removes the bug class itself.

The Anatomy of the Smash

The bug has three ingredients. First, a fixed-size buffer, often an array of bytes on the stack. Second, code that copies input into that buffer without checking that it fits. Third, something worth corrupting within reach. On the classic stack layout, the return address sits just past the local buffers, so an overflow that runs far enough reaches it. Overwrite it, and the program goes where the attacker says when the function returns. Because the overflowing input and the redirect ride in on the same request, one unchecked copy becomes control of the program.

The C standard library made this easy for decades, and Spafford's report warned that gets was not alone. Both strcpy and strcat write without knowing the size of their target, and a careless sprintf can do the same. The scanf family's string conversions can overrun a buffer unless each has a field width that fits it, with room left for the terminating null. C11 removed gets from the standard outright, but the pattern it stood for outlived it.

And the stakes never fell. Code Red, in 2001, spread through a buffer overrun in Microsoft's web server. SQL Slammer, in January 2003, exploited a buffer overrun in a database service six months after its fix shipped, and, researchers found, "infected more than 90 percent of vulnerable hosts within 10 minutes." Each was, at bottom, the 1988 bug with a new address on the envelope. So the industry went to work, and it went to work in layers.

Defense One: The Stack Canary

The first layer in this story comes from the compiler. It does not try to stop the overflow at all; it just makes sure the damage is noticed before the return address is used. The compiler adds code to functions with a local buffer. On entry, that code places a random value, a canary, between the buffers and the saved return address; on exit, it checks that the value is still there. An overflow that reaches the return address must write through the canary first, so a changed canary means an overwrite, and the program aborts.

The idea and the name come from the 1998 StackGuard paper by Crispin Cowan and his colleagues, which describes placing "a 'canary' word next to the return address on the stack." The name is deliberate: a footnote in the paper calls it "a direct descendent of the Welsh miner's canary." The bird does not stop the gas. It makes sure somebody notices in time.

Today the canary costs at most one compiler flag. In MSVC, Microsoft's C and C++ toolchain, /GS is on by default and works on the same principle as StackGuard: it places a security cookie before the return address on entry and, on exit, terminates the process if the cookie changed. GCC and Clang offer -fstack-protector-strong, which extends the check to functions that keep a local array on the stack or take the address of a local variable. Upstream GCC leaves it off, and distributions' defaults vary. GCC 14 added -fhardened, an umbrella flag for production builds that includes it.

How did attackers answer? Two ways. If the canary can be read first, through a separate information leak, the attacker writes it back unchanged and the check passes. And the check runs only on exit, so a function pointer or C++ vtable that the overflow corrupts and the program uses before then never meets it. The /GS documentation's own example of a miss is a buffer next to a vtable in one object. A stack canary does nothing for overflows on the heap, either. The canary raised the bar. It did not close the road.

Defenses Two and Three: Make It Non-Executable, Then Hide It

Data Execution Prevention blocks the easy version of the exploit, the one that writes new machine code into the buffer and jumps to it. DEP marks the regions where data lives, the stack and the heap, as non-executable, and the CPU enforces the mark: injected code faults instead of running. On Windows, the linker marks programs compatible with DEP by default, through the /NXCOMPAT flag.

Attackers answered with real ingenuity: if you cannot bring your own code, reuse the code already in the process. Return-oriented programming stitches together short sequences of the program's own instructions, each ending in a return, into a chain that performs the attacker's work without ever executing a byte of injected code. DEP raised the cost of the attack; it did not end it. Its own documentation says as much: DEP "is not intended to be a comprehensive defense against all exploits."

Address Space Layout Randomization raises the cost again. A return-oriented chain needs to know where its borrowed instructions live. ASLR moves them: when a program loads, its code, libraries, stacks, and heaps are placed at randomized addresses, so the hard-coded targets a chain depends on are no longer where it expects. On Windows, the /DYNAMICBASE linker option, on by default, marks a program to be rebased at a random address when it loads.

Randomness has limits. On 32-bit systems, Microsoft's documentation notes, the small address space limits how much entropy ASLR can add, which makes the layout easier to guess. The move to 64-bit changed the arithmetic: the /HIGHENTROPYVA option, on by default for 64-bit images, opts a program into high-entropy ASLR, which the same documentation says adds 24 bits of entropy to its bottom-up allocations. ASLR still falls to a good information leak, where the program hands out a real address the attacker can compute from. But it forces that extra step.

A canary, plus DEP, plus ASLR does not make exploitation impossible. It forces an attacker to solve three different problems before anything useful happens: get past the canary, find executable instructions despite DEP, and locate them despite ASLR. A wrong guess along the way usually crashes the process, and a crash is something somebody can notice. That is what the Software Development Principles entry on defense in depth calls a multiplicative work factor, and it is why the layers are worth stacking even though each one, alone, can be beaten.

The same entry warns that the multiplication needs layers that fail independently; layers with a common weakness give only "the illusion of depth." The canary and ASLR share one. Both rest on a secret, a random value and a random layout, so a single information leak can defeat both. The next two layers rest on no secret at all.

Defenses Four and Five: Guard the Flow, Then Retire the Bug

The modern era arrives in two moves. The first limits where a corrupted pointer can send control; the second aims to make the bug class impossible.

On Windows, Control Flow Guard (CFG) watches the forward edge of the call graph, the indirect calls through function pointers. The compiler puts a check before each one, Windows keeps the set of valid targets, and a failed check terminates the program at once. A corrupted pointer can no longer land in the middle of some borrowed instruction sequence, but any valid function entry still passes, whether or not it is the one the call meant. CFG is also opt-in: MSVC's /guard:cf is off by default, and a .NET app published with native AOT turns it on with <ControlFlowGuard>Guard</ControlFlowGuard>.

The return side gets its own guard from the processor. Intel's Control-flow Enforcement Technology (CET) and AMD's matching support add a shadow stack: a second stack that holds only return addresses and that ordinary program code cannot write to. As the Linux kernel documentation describes it, on every call the processor pushes the return address to both stacks, and on every return it compares the two copies and raises a control-protection fault if they differ. The return address, the prize in the 1988 attack, now has a second copy the overflow cannot reach.

Windows ships this as Hardware-enforced Stack Protection, on processors with Intel CET or AMD shadow stacks, and pairs it with Control Flow Guard: CFG guards indirect calls, and the shadow stack guards returns. Linux gained user-space shadow stacks on x86 in kernel 6.6, in October 2023, and the GNU C Library (glibc) adopted that interface in version 2.39, in January 2024.

The second move is the one that changes the game rather than raising its cost: write in a language where the overwrite cannot happen. I ran the plainest possible demonstration on .NET 10:

var buffer = new byte[16];

// an index from the outside
int index = 41;

try
{
    buffer[index] = 0xFF;
}
catch (IndexOutOfRangeException e)
{
    Console.WriteLine(e.Message);
}

The write never lands. The runtime throws, and the program prints "Index was outside the bounds of the array." The mistake from 1988 is still in the source: the program tried to write past the end of a 16-byte buffer. It just ends as an exception instead of an exploit. The classic smash does not exist in safe managed code, because the runtime now makes, on every access, the check the C programmer forgot.

The payoff shows up in the numbers. By Google's counts, memory-safety issues fell from 76 percent of Android's vulnerabilities in 2019 to below 20 percent in 2025, as new development moved to memory-safe languages. Google's 2024 explanation is counterintuitive. The problem "is overwhelmingly with new code," and old code "gets safer with time," so rewriting it pays less the older it gets. The way to drive the rate down quickly is to stop adding the bug in new code.

The Door Back to the Old Rules

Managed code is safe by default, but the platform gives you several ways out of the garden. Pointers, stackalloc into a pointer, calls into native code through P/Invoke, and every native library you pull in behind a NuGet package all run under the old rules: the runtime does not bounds-check the bytes they touch. A stackalloc into a Span<T> is different. It needs no unsafe context, and the span checks every index, though its contents start out undefined until you write them.

Only pointer code needs the unsafe keyword. A typical P/Invoke declaration does not, and most members of the System.Runtime.CompilerServices.Unsafe and System.Runtime.InteropServices.Marshal classes need neither the keyword nor a project setting. Microsoft warns that most Unsafe methods do no argument checking or validation, and that misuse "could corrupt process memory or destabilize the .NET runtime." When you step through any of these doors, you are the bounds check.

Still, the stackalloc documentation says that using it turns on the runtime's buffer overrun detection, and that on an overrun the runtime ends the process as fast as it can, to make it less likely that malicious code runs. That is the stack canary's bargain again: the process dies, and the bug remains. And even the safe form of stackalloc trusts a length. A size derived from user input must be checked for negatives and capped, the same page warns, because asking for too much ends in a fatal StackOverflowException.

The sharpest reminder that the old rules still bite came in September 2023. A heap buffer overflow in libwebp, the library that decodes WebP images, was cataloged as CVE-2023-4863, and Google's Chrome team said an exploit for it already existed in the wild. That is a C bug, but it does not stay in C. The GitHub advisory lists two .NET libraries among those affected: SkiaSharp, vulnerable from 2.0.0 until the fix in 2.88.6, and Magick.NET, whose packages were fixed in 13.3.0.

A managed application that decoded an untrusted WebP image through either library inherited a native memory-corruption bug. NuGet's restore audit, fed by the same GitHub database, warns about such packages. Managed is not immune; it is managed up to its native edges, and you have to know where those edges are.

So the practical defense inside a safe language is the same discipline the whole history teaches: check the length at the boundary. Before your code trusts a length, a count, or a size from the outside, validate that number against the limit you chose and against what actually arrived. Here is the shape, again on .NET 10, for a simple length-prefixed record:

static bool TryReadRecord(
    ReadOnlySpan<byte> input,
    out ReadOnlySpan<byte> payload)
{
    const int MaxPayload = 4096;
    payload = default;

    if (input.Length < 2)
        return false;

    int declared =
        BinaryPrimitives.ReadUInt16BigEndian(input);

    // the cap you chose
    if (declared > MaxPayload)
        return false;

    // the bytes that arrived
    if (declared > input.Length - 2)
        return false;

    payload = input.Slice(2, declared);
    return true;
}

Fed an honest record, it returns the payload. Fed a record that claims five bytes but carries one, or that claims 16,384, or that is too short to hold even its own length, it refuses. Underneath is a safety net: in my test, slicing by the declared length without the guards threw ArgumentOutOfRangeException rather than reading past the buffer. The guards turn that exception into a clean rejection, which is better, but the language has already taken a compromise off the table.

One more trap sits in the fast path. Pooled buffers, from ArrayPool<T>, are a common way to avoid allocations, and the pattern is to rent, use, and return:

byte[] rented = ArrayPool<byte>.Shared.Rent(count);
try
{
    int filled = source.Read(rented, 0, count);
    destination.Write(rented, 0, filled);
}
finally
{
    ArrayPool<byte>.Shared.Return(rented);
}

The documentation for Rent makes two easy-to-miss points. It returns a buffer at least the requested length, not exactly that length, and the array it hands back "may not be zero-initialized." In my test, Rent(100) returned an array of length 128, and when I returned it and rented again, the same array came back with my bytes still in it.

Treat a rented buffer as holding only the bytes you actually wrote, and track that count yourself. The rest may hold an earlier renter's data, perhaps from another request, and reading it as new is an information leak. If the bytes are sensitive, Return(rented, clearArray: true) wipes the array before the next renter gets it. And return each array exactly once, after its last use: Microsoft calls a second return, or any use after the return, "a high-severity security issue."

What You Already Get, and How to Check

The .NET runtime is hardened too. W^X (write xor execute) has protected the code it generates at run time by default since .NET 7, after being opt-in on most platforms in .NET 6. Since .NET 9, the native launcher that starts a .NET application is marked compatible with CET shadow stacks. The .NET documentation lists this as a breaking change, because a native library that is not compatible can now get the process terminated, and offers <CETCompat>false</CETCompat> as the opt-out.

On my own machine, Windows 11 on an AMD processor with shadow-stack support, I queried the mitigations of a running .NET 10 console application. DEP, bottom-up and high-entropy ASLR, Control Flow Guard, and a user-mode shadow stack were all active, and the launcher's headers confirmed the same set. For that application on that hardware, none of it needed configuring.

That does not prove every loaded module is covered. The shadow stack ran in compatibility mode, in which a violation is fatal only inside modules marked /CETCOMPAT, and Control Flow Guard checks only code compiled and linked with /guard:cf. Your own native libraries need both flags. Where to look:

Where This Is Going

Further out, the language is changing what unsafe means. In a post on 21 May 2026, Microsoft's Richard Lander described a redefinition in which "unsafe stops marking a kind of syntax and starts marking a kind of contract." Today the keyword unlocks pointer features and places no obligation on callers. Under the new model, marking a member unsafe moves the obligation to vouch for its safety to every caller, and the plan marks the Unsafe and Marshal members too. The C# reference draws the new line plainly: "the existence of a pointer isn't unsafe; the dereference of a pointer is."

In .NET 11, the new model remains a preview, separate from C# 15, behind the preview language version and a compiler feature flag. Microsoft plans a production release in .NET 12, which The Register expects in late 2027, and will turn it on in project templates, as it did for nullable reference types.

Once the markings are explicit and propagate, Lander writes, "it is reasonable to use grep as a safety audit tool." For a project that also leaves AllowUnsafeBlocks off, the default, he goes further: "Memory-safety auditing collapses from inspecting every diff to checking one project property." That audit reaches only what the compiler can see. A legacy package's unsafe surface without pointers, such as a P/Invoke signature, carries no marking, Lander notes, and the contract an unsafe marking declares is one "the compiler can't verify."

This is not a Microsoft-only direction. In June 2025, the United States Cybersecurity and Infrastructure Security Agency (CISA) and the National Security Agency published guidance on adopting memory-safe languages. CISA's Product Security Bad Practices, written with the FBI and revised in January 2025, asks software manufacturers to publish a memory-safety roadmap by the end of 2025. It encourages them to plan for "mitigating memory safety vulnerabilities in the short term and eliminating them in the long term," which is this article's two halves in one sentence.

Even in C++, Google reported in November 2024 that it had made a bounds-checked standard library the default across its server-side production systems, at an average performance cost of 0.30 percent. Fixing the bugs the checks uncovered cut the baseline segmentation fault rate by 30 percent.

Apple's Memory Integrity Enforcement, announced in September 2025 for the iPhone 17 and iPhone Air, combines secure allocators with Arm's Enhanced Memory Tagging Extension, which aims at the overflow itself rather than its use. The allocator tags neighboring allocations differently, and the hardware blocks an access that spills from one into the next. Some bugs survive, such as an overflow within one allocation, though Apple calls them "extremely rare." Apple argues that such protections need to be "strictly synchronous, on by default, and working continuously," because an asynchronous mode leaves "a race window open for attackers."

The direction of travel is unmistakable: make the safe thing the default, and make the unsafe thing loud.

What the History Teaches

Not one of these layers is enough on its own. The canary can be leaked, DEP sidestepped with borrowed code, and ASLR undone by an information leak. Control Flow Guard still lets a corrupted pointer reach any valid function, and shadow stacks alone "can be bypassed in some contexts," as Google's Chrome team puts it. Even a memory-safe language has doors back to the old rules, and native dependencies that walk straight through them.

In November 2025, Google described a bug that came through such a door: a linear buffer overflow in unsafe Rust code in Android (CVE-2025-48530), caught before any public release. Scudo, Android's default hardened allocator, made the bug non-exploitable wherever Scudo runs: guard pages turned the overflow "from silent memory corruption into a noisy crash." The crash reports, though, did not clearly show an overflow, which slowed triage until Google fixed them.

What changed since 1988 was not the arrival of a perfect defense. It was the stacking of imperfect ones, each raising the attacker's cost, until the default outcome of the old attack moved from a shell to a crash. That is defense in depth as it is meant to work: a design that survives when one layer fails. It is no excuse for a weak layer, since each must still be correct on its own terms.

And underneath all of it is the ingredient that was never really about C: trusting a length. Every overflow in this article began with a program that let its input decide how much to write, or where, without checking that against the room it had. The language and the platform can make the failure loud, and layer by layer they have.

But the cheapest, oldest, most portable defense is still the one Spafford pointed at in 1988: do not write past the end of the buffer. That means knowing how big the buffer is, and how much is arriving, at every boundary where the two could disagree.

A Checklist

For more on the boundary-checking discipline, I have written separately on the boundary defense principle and on threat modeling with STRIDE, one good way to find the boundaries worth checking.

Further Reading