CodeGym /Courses /C++ SELF /Manual memory bugs — leak / use-after-free / double-free

Manual memory bugs — leak / use-after-free / double-free

C++ SELF
Level 41, Lesson 4
Available

1. Introduction

When a programmer first sees new and delete, a dangerous thought appears: “Oh, now I can create objects whenever and wherever I want! I am the ruler of memory!” That feeling quickly fades when the program starts crashing not where the bug is, but “somewhere around std::cout” (classic). The reason is simple: manual memory management is a contract that is very easy to break. Today we'll cover the three most popular ways to break that contract.

To keep a reference point, let's state the “golden invariant” of manual memory (what must always be true):

Every resource obtained via `new` / `new[]` must be released exactly once
with the corresponding `delete` / `delete[]` and only after it is no longer used.

If the invariant is violated, we get one of the three protagonists of this lecture.

Terrain map: the three errors and how they differ

Before diving into examples, it's useful to pause for a minute and make a "table of common sense" so we don't mix concepts. These bugs are often confused because the outcome looks similar: “everything broke”. But they fail for different reasons.

Bug What was violated Typical symptom Why it's tricky
leak
the resource was not released the program “grows” in memory, sometimes crashes after a long time may not show up on small tests
double-free
the resource was released twice crash, allocator abort, “heap corruption” the fault is often far from the crash site
use-after-free
the resource was used after being freed “garbage values”, rare crashes, strange results sometimes “works” until the memory is reused

From now on we'll go in this order: first leaks (the quietest), then double-free (the loudest), then use-after-free (the most mysterious).

2. Leak: memory leaked because you “lost” it

A memory leak is a situation where you allocated memory but did not free it. Important: memory does not have to be lost “forever” in the OS sense — when the process ends, the OS will reclaim it. But while the program runs, a leak means increased memory usage and sometimes degraded performance due to allocations and fragmentation.

The most unpleasant thing is that a leak often looks “innocent”: the program prints correct numbers, tests pass, and the problem appears only during long runs or under load. That is exactly where you least want surprises.

The simplest leak: forgot delete

Let's start with a “textbook” example. It's banal, but this is how a beginner's brain gets used to it: allocated — forgot.

#include <iostream>

int main() {
    int* p = new int{10};
    std::cout << *p << '\n'; // 10

    // delete p; // forgot => leak
}

The problem here is not that the program will necessarily crash. The problem is that you violated the contract: the resource lives longer than it should, and you no longer control it.

Leak via early return: “left the function and forgot to close the door”

This is closer to real life: logic branches, and on one branch you exit the function without freeing the resource.

#include <string>

int* parse_positive_or_null(const std::string& s) {
    int* p = new int{0};

    if (s.empty()) {
        return nullptr; // leak: p not freed
    }

    *p = 42; // suppose we parsed a value
    return p;
}

The important point here: the problem is not the return itself. The problem is that delete ended up spread across branches (and on one branch it's missing). The more branches, the higher the chance to make a mistake. This is one reason manual memory doesn't scale well with code complexity.

Leak by overwriting a pointer: “lost the last address”

A very common error: you allocated memory, then assigned a new address to the pointer — and the first address is lost, so you cannot free it anymore (no reference left).

int main() {
    int* p = new int{1};

    p = new int{2}; // leak: address of {1} lost

    delete p;       // frees only {2}
    p = nullptr;
}

This often happens in code that “reuses a variable” thinking it's more economical. In the end you save only lines, not memory or nerves.

Leak in a loop: “little by little, every time”

Leaks become really dangerous when they happen inside repeated code. Then you get the “drip-drip — and a flood” effect.

#include <iostream>

int main() {
    for (int i = 0; i < 3; ++i) {
        int* p = new int{i};
        std::cout << *p << ' '; // 0 1 2
        // delete p; // forgot => leak on each iteration
    }
    std::cout << '\n';
}

On three iterations nobody notices. On three million — even your laptop will notice, start “suspiciously loud fans” and pretend takeoff.

4. Double-free: resource freed twice (this is UB)

double-free sounds like “I cleaned the room twice, what's wrong?” In memory it works the opposite: the second delete tries to free a resource that has already been freed. Then Undefined Behavior begins: the program may crash immediately, may crash later, or may simply corrupt allocator internals and crash somewhere else.

The most frequent cause of double-free among beginners is misunderstanding the difference between “copying a pointer” and “copying the object”. A pointer is just an address. If you copy an address you get two names for the same resource. If both names think they own it, both will try to delete.

Two pointers to one new: “two owners of one fridge”

int main() {
    int* p = new int{5};
    int* q = p;     // q and p point to the same object

    delete p;
    // delete q;    // UB: double-free (don't do this)
    q = nullptr;
}

Key idea: copying T* copies the address, it does not create a second object.

Double-free because of a “convenient” function that decided to delete

Sometimes code breaks because of a bad function contract. Suppose you passed a pointer to a function to “do some work”, and it decided that since the pointer came in — it must delete it. You, on the other hand, also thought you would delete it. Result — two deletes.

#include <iostream>

void print_and_delete(int* p) {
    std::cout << *p << '\n'; // suppose we print
    delete p;                // and unexpectedly delete
}

int main() {
    int* p = new int{7};
    print_and_delete(p);

    // delete p;             // UB: double-free (don't do this)
    p = nullptr;
}

This example is important not because “you must never do that” (although you shouldn't), but because it shows: ownership must be unambiguous. From the type int* it's unclear who owns the memory. This is one of the fundamental problems of a raw API.

Double-free due to “freed and forgot to null”: second delete on the old address

Sometimes the second delete happens not because someone copied the pointer, but because code “reused the variable” and forgot it had already been deleted.

int main() {
    int* p = new int{1};

    delete p;
    // ... lots of code ...
    // delete p; // UB: second delete of the same address (don't do this)

    p = nullptr;
}

Yes, setting p = nullptr; does not fix ownership architecture. But it often helps turn “quiet UB” into a more obvious error: if later someone does delete p, then delete nullptr is safe and won't break anything.

5. Use-after-free: the object is dead, but you still talk to it

Use-after-free is when you freed memory but continued to use a pointer that points to it. Technically after delete a pointer becomes dangling: it still holds an address, but there is no longer a “live” object at that address with a valid lifetime.

The trick is that it often seems: “Well the address is the same, so the data is there.” But C++ promises nothing: after delete that memory may be immediately repurposed, or it may “sit” for a while. So use-after-free sometimes “works” (which is worse than crashing immediately).

Direct use-after-free: dereferenced after delete

#include <iostream>

int main() {
    int* p = new int{99};
    delete p;

    // std::cout << *p << '\n'; // UB: use-after-free (don't do this)
    p = nullptr;
}

If you're lucky, the program will crash. If not, you'll get some number and think everything is fine. Later that “fine” will become a bug in production.

Use-after-free via a “saved address”: the pointer went wandering

A more realistic version: you saved the address somewhere else, then freed it, and later used the “saved copy”.

#include <iostream>

int main() {
    int* p = new int{10};
    int* alias = p;   // a second "label" for the same address

    delete p;
    // std::cout << *alias << '\n'; // UB: use-after-free (don't do this)

    alias = nullptr;
}

Note the psychological moment: you may honestly set p = nullptr;, but alias will still be dangling. So nulling the pointer is not a universal shield if the address was copied.

Use-after-free that looks like “random”: memory reused by a new new

A very instructive effect: you deleted an object and then somewhere did another new. The allocator may return the same address, and then your old dangling pointer suddenly “points” to a new object. You read through it and get data of another object. This is almost a detective story.

#include <iostream>

int main() {
    int* p = new int{1};
    delete p;

    int* q = new int{777};     // may sometimes get the same address as p
    // std::cout << *p << '\n'; // UB: use-after-free (don't do this)

    delete q;
}

Why it's scary: if the program crashed you'd quickly find the bug. When it “works, but wrongly”, you chase phantom bugs.

Why these bugs appear strangely: a short allocator model

You'd like to believe a bug should break the program immediately and honestly. But reality is: the memory manager (allocator) is a complex system that reuses freed blocks, keeps free lists, coalesces and splits blocks. So delete is not “zero out memory”, it's “return the block to the pool”.

Keep a simple lifecycle diagram in mind:

flowchart LR
    A[new allocated block] --> B[object lives]
    B --> C[delete ends lifetime]
    C --> D[block may be reused]
    D --> E[new new may return the same address]

From this diagram two unpleasant effects follow. First, use-after-free can sometimes yield “plausible” data because the block hasn't been reused yet. Second, double-free may not crash on the second delete immediately, but corrupt internals and crash later during some other new or delete.

6. Mini case: TaskBoard and legacy notes on char*

To avoid studying bugs in a vacuum, imagine a simple console application TaskBoard (an arbitrary name) where tasks live in a std::vector and titles are in std::string. Everything is fine… until someone decided to add a “C-style note” to a task because “it's faster” or “I want to practice new[]”.

We'll make a small function that allocates a C-string for a note and returns char*. This is not a recommendation, it's an educational example to demonstrate risks.

Allocating memory for a C-string

#include <cstring>
#include <string>

char* make_note_cstr(const std::string& note) {
    char* p = new char[note.size() + 1];
    std::memcpy(p, note.c_str(), note.size() + 1); // including '\0'
    return p; // WARNING: who will delete[]?
}

And here a classic design problem starts: from the return type char* you can't see who owns it and when to free it. Therefore all three of our bugs become “natural” scenarios.

Leak: received the note and forgot to free it

#include <iostream>
#include <string>

int main() {
    char* note = make_note_cstr("Call mom");

    std::cout << note << '\n'; // Call mom

    // delete[] note; // forgot => leak
}

One forgotten delete[] in one place seems small. But if notes are created often (e.g., on each task update), the leak becomes systemic.

Double-free: two modules both decided they "own the note"

#include <string>

void store_note_somewhere(char* note) {
    // ... stored ...
    delete[] note; // "we thought it's our responsibility"
}

int main() {
    char* note = make_note_cstr("Buy milk");
    store_note_somewhere(note);

    // delete[] note; // UB: double-free (don't do this)
}

This situation often occurs in team development when one person writes a function and another calls it, and ownership contract is not documented (or documented in a comment nobody reads — also realistic).

Use-after-free: the note was deleted but the pointer remained in the task

Suppose somewhere a task stores char* note;, and when updating the note you do delete[] note, but forget to update the field or forget that someone else still references it.

#include <iostream>

int main() {
    char* note = make_note_cstr("Old note");
    delete[] note;

    // std::cout << note << '\n'; // UB: use-after-free (don't do this)
    note = nullptr;
}

The moral is the same: if you manage memory manually, you must manually ensure correct lifetimes everywhere the address has "leaked".

7. Typical mistakes

Mistake #1: “Let's just return T* from a function and let them deal with it.”
Returning a raw pointer to a dynamic object almost always creates a hole in the ownership contract. The code compiles, works on small examples, and then unexpectedly you get a leak (nobody deleted) or double-free (deleted twice) because responsibility is not clearly assigned.

Mistake #2: copying a pointer is perceived as copying the object.
This is perhaps the most frequent cause of double-free and use-after-free among beginners. int* q = p; does not make a second int, it makes a second label for the same address. If you don't explicitly say “now we have two names for the same memory”, the brain automatically imagines a non-existent object copy.

Mistake #3: delete is “spread” across branches and early exits.
Once a function has multiple returns and multiple if branches, and delete sits “somewhere”, the probability of a leak becomes almost statistically inevitable. Usually you spot it in code when you scroll the function and think “did we free here for sure?”. If you have to think — it's already dangerous.

Mistake #4: after delete the code continues to use the pointer or passes it on.
Use-after-free often arises not from malice but from forgetfulness: the object was deleted but the address remained in a variable, a struct field wasn't updated, or a “temporary alias” was stored somewhere. After delete a pointer should be considered invalid. Nulling p = nullptr; helps but does not fix copies of the address.

Mistake #5: mismatch of new[] and delete (and vice versa) is treated as “almost the same”.
Although you've already seen this topic, it is directly related to today's crashes: wrong form of deallocation is also UB and often appears as “broken heap”, i.e., symptoms similar to double-free/use-after-free. If you allocated an array — free it with delete[], no compromises.

1
Task
C++ SELF, level 41, lesson 4
Locked
Square in memory
Square in memory
1
Task
C++ SELF, level 41, lesson 4
Locked
Bonus Only for Positive Amounts
Bonus Only for Positive Amounts
1
Task
C++ SELF, level 41, lesson 4
Locked
Backup copy
Backup copy
1
Task
C++ SELF, level 41, lesson 4
Locked
Two terminal notes
Two terminal notes
1
Survey/quiz
Nullable design, level 41, lesson 4
Unavailable
Nullable design
Nullable design
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION