Smart Pointers Can't Solve Use-After-Free
2025 February 24th
A common question: "If we use smart pointers everywhere, can C++ be as 'safe' as Circle or Rust?"
There are several reasons the answer is no, but the immediate reason is that
you can't use smart pointers everywhere, because there are internal raw
pointers in types you don't control.​For a language that uses smart pointers everywhere automatically, see
Swift. For example, here's an iterator
invalidation mistake with std::vector:
std::vector<int> my_vector = {1, 2, 3};
for (auto element : my_vector) {
if (element == 2) {
my_vector.push_back(4);
// The next loop iteration reads a dangling pointer.
}
}
==1==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000018
READ of size 4 at 0x502000000018 thread T0
This fails ASan with a heap-use-after-free error,​Click the "Godbolt" button to see it run. because vector
iterators are raw pointers.​A vector holds all its elements in an array on the heap. When
we call push_back here, it notices that the array doesn't have any empty
slots. So it allocates a bigger array, copies all the elements over, and
frees the old array. The problem is that the begin and end iterators
created by the for loop are still pointing to the old array. Putting each int in a shared_ptr,
or putting the vector itself in a shared_ptr, doesn't help.​A shared_ptr<int> can't become dangling, but a
shared_ptr<int>* (i.e. a pointer to a shared_ptr<int>) can. It's
similar to how an int** becomes dangling when you destroy the int* that
it points to, even if the int is still alive.
You can make the same mistake with std::span (C++20):
std::vector<int> my_vector{1, 2, 3};
std::span<int> my_span = my_vector;
my_vector.push_back(4);
// This line reads a dangling pointer.
int first = my_span[0];
==1==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010
READ of size 4 at 0x502000000010 thread T0
You can even make the same mistake with std::lock_guard (C++11):​TSan and Valgrind both catch this one, but ASan doesn't. ASan doesn't
instrument pthread_mutex_unlock, but TSan replaces it,
and I suppose Valgrind instruments everything at runtime.
std::shared_ptr<std::mutex> my_mutex = std::make_shared<std::mutex>();
std::lock_guard my_guard(*my_mutex);
my_mutex.reset();
// my_guard calls my_mutex->unlock() in its destructor.
WARNING: ThreadSanitizer: heap-use-after-free (pid=1)
Atomic read of size 1 at 0x721000000010 by main thread
This sort of thing is why std2::lock_guard in Circle and
std::sync::MutexGuard in Rust both have "lifetime
annotations".