Smart Pointers Can't Solve Use-After-Free
2025 February 24
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 = {};
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. I have no
idea why. Does anyone know?
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".