Lesson 5 of 7
A dangling pointer still holds an address, but the object that was there is gone. The pointer looks perfectly valid. Nothing about it says "expired."
Using one is use-after-free, and it is among the most serious bug classes in C++ — the foundation of a large share of real-world security vulnerabilities.
Three ways to make one
1. Delete the object, keep the pointer.
int* p = new int(42);
delete p;
// p still holds the old address. The memory belongs to the allocator again.
std::cout << *p; // use-after-free: undefined behaviour
2. Return the address of a local.
int* make_dangling() {
int local = 42;
return &local; // local dies when the function returns
}
This one the compiler does catch:
warning: address of local variable 'local' returned [-Wreturn-local-addr]
Note "warning" — the program still compiles and runs. The same applies to returning a reference
to a local, which produces the matching reference to local variable 'x' returned.
3. Keep a pointer into a container that reallocates.
std::vector<int> v = {1, 2, 3};
int* p = &v[0];
v.push_back(4); // may move the whole buffer to a new address
std::cout << *p; // p may now point at freed memory
This third one surprises people, because no delete appears anywhere. Growing a std::vector
past its capacity allocates a bigger buffer, copies the elements, and frees the old one — so
every pointer, reference and iterator into it can become dangling.
Watching a pointer go stale, safely
You can observe the pointer keeping its value after the object dies without ever dereferencing it:
#include <iostream>
int main() {
int* p = new int(42);
int* remembered = p;
std::cout << "before delete, *p = " << *p << "\n";
delete p;
p = nullptr; // good practice: mark it dead
// `remembered` was never cleared, so it still holds the old address.
std::cout << "p is null now: " << (p == nullptr) << "\n";
std::cout << "remembered is not null: " << (remembered != nullptr) << "\n";
std::cout << "and still holds the freed address, indistinguishable from a live one\n";
// Dereferencing `remembered` here would be use-after-free. We do not.
return 0;
}
before delete, *p = 42
p is null now: 1
remembered is not null: 1
and still holds the freed address, indistinguishable from a live one
That is the heart of the problem: a dangling pointer and a valid pointer are the same eight bytes. No check you can write distinguishes them.
Why nothing warns you
Plain compilation is silent on all of this:
int* p = new int(1);
delete p;
int value = *p; // no diagnostic from g++ -Wall -Wextra
delete p; // double delete: also no diagnostic
The compiler cannot know at compile time which pointers are still live — that depends on run-time control flow. So it says nothing, and the bug reaches production.
What does catch it is a run-time tool. Build with AddressSanitizer:
g++ -std=c++20 -fsanitize=address -g main.cpp -o main
and the same program reports:
ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010
READ of size 4 at 0x502000000010 thread T0
#0 0x... in main main.cpp:2
The exact operation, the exact line. Module 11 covers the sanitizers properly; for now, know that this class of bug is invisible to the compiler and obvious to ASan.
Habits that help
Set pointers to nullptr after deleting. A null dereference crashes immediately and
predictably; a dangling dereference corrupts silently. Note this only helps the pointer you
nulled — copies elsewhere are unaffected, as the example above showed.
Never return the address of a local. Return by value, or take an output parameter.
Assume any pointer into a vector is invalidated by anything that can grow it. push_back,
insert, resize, reserve. Store an index instead, which survives reallocation:
#include <iostream>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3};
std::size_t index = 0; // an index, not a pointer
for (int i = 0; i < 100; ++i) {
v.push_back(i); // may reallocate many times
}
// Still correct: the index does not care where the buffer lives now.
std::cout << "v[" << index << "] = " << v[index] << "\n";
std::cout << "size = " << v.size() << "\n";
return 0;
}
v[0] = 1
size = 103
Best of all: do not hold raw owning pointers. With std::vector, std::string and
std::unique_ptr, the object's lifetime is tied to a scope, so there is no window in which a
pointer can outlive it.
The scope version of the same bug
#include <iostream>
int main() {
int* p = nullptr;
{
int local = 42;
p = &local;
std::cout << "inside the block: " << *p << "\n";
} // local dies here
// *p here would be undefined behaviour — the stack slot may already be reused.
std::cout << "after the block, p is stale; we do not dereference it\n";
return 0;
}
inside the block: 42
after the block, p is stale; we do not dereference it
No new and no delete, and still a dangling pointer. Stack memory is reclaimed at the closing
brace just as surely as heap memory is at delete.
Try it
Write a function that returns a pointer to a local variable, compile it, and read the warning.
Then dereference the result and see what your program prints. Finally rebuild it with
-fsanitize=address and compare.
Checkpoint
- A dangling pointer holds the address of an object that no longer exists.
- Using one is use-after-free — undefined behaviour and a major security bug class.
- You get one by deleting and keeping the pointer, returning a local's address, or holding a pointer into a vector that grows.
- A dangling pointer is byte-for-byte indistinguishable from a valid one.
- The compiler catches only the returned-local case, and only as a warning.
- AddressSanitizer catches the rest at run time, with an exact line.
- Set pointers to
nullptrafter deleting; store indices rather than pointers into containers.
Questions about this lesson
Log in to ask a question.