Double-free¶
Level: 201 · for C and C++ programmers
One line: Two free calls on one block is a bug about responsibility, and Rust removes it by making responsibility a property of the value that exactly one binding can hold at a time.
The program¶
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *score = malloc(sizeof *score);
*score = 41;
free(score);
free(score); /* the same block, twice */
printf("still here\n");
return 0;
}
Nobody writes it this way. It arrives as an error path that frees and then falls through to the cleanup label that frees again, or as two functions that each believe they own the pointer.
What it did¶
clang -Wall says nothing at all, and then:
double_free(85690,0x7ff84aa5f840) malloc: Double free of object 0x7fde647062c0
double_free(85690,0x7ff84aa5f840) malloc: *** set a breakpoint in malloc_error_break to debug
Killed by SIGABRT, and still here never printed. That is macOS's allocator being helpful — it noticed. It notices because this block was still sitting at the head of a free list where a second free is cheap to detect; interleave an allocation between the two calls, or use a size class that recycles differently, and the second free corrupts the allocator's bookkeeping instead, which surfaces as a crash somewhere else entirely, later, in code that is not wrong.
AddressSanitizer catches this one properly, with both stack traces:
==85705==ERROR: AddressSanitizer: attempting double-free on 0x6020000000d0 in thread T0:
#0 0x00010f72289b in free+0x8b (libclang_rt.asan_osx_dynamic.dylib:x86_64h+0xa689b)
#1 0x00010f1c4818 in main double_free.c:8
Why the standard allows it¶
Passing a pointer to free that has already been freed is undefined behaviour ↗. The standard says nothing about what happens next, which is what lets one allocator abort cleanly and another quietly corrupt a list it will walk ten minutes from now.
What Rust does instead¶
There is no free to call twice. A value has exactly one owner, the owner frees it when it goes out of scope, and passing it to a function moves that job rather than copying it:
struct Ballot { voter: &'static str }
impl Drop for Ballot {
fn drop(&mut self) { println!("freeing {}", self.voter); }
}
fn file(ballot: Ballot) {
println!("filing {}", ballot.voter);
} // the parameter owns it: freed here
let ada = Ballot { voter: "Ada" };
file(ada); // ownership moves in
Verified output of double_free.rs — regenerated by tools/run_examples.py, never hand-typed.
One freeing Ada, printed inside file, before main is done. The free happened where the responsibility was, not where the value was created.
The refusal¶
Use the value after handing it over, and the second free is not something you have to remember not to write:
error[E0382]: borrow of moved value: `ada`
--> use_after_move.rs:12:20
|
10 | let ada = Ballot { voter: "Ada" };
| --- move occurs because `ada` has type `Ballot`, which does not implement the `Copy` trait
11 | file(ada);
| --- value moved here
12 | println!("{}", ada.voter);
| ^^^^^^^^^ value borrowed here after move
|
note: consider changing this parameter type in function `file` to borrow instead if owning the value isn't necessary
That message is doing more than blocking a double-free — it is the compiler asking whether file needed to own the ballot at all. Nine times in ten the answer is no, the parameter becomes &Ballot, and the question of who frees it never comes up.
Note what is not claimed: unsafe code can still call drop twice through a raw pointer, and ManuallyDrop exists precisely to hand the job back to you. See what unsafe turns off.
If you are coming from another language¶
- C++ —
unique_ptris this idea, and Rust's difference is that it is not opt-in. Aunique_ptrstill lets you call.release()and hold the raw pointer,std::moveleaves behind an object that is valid but unspecified and therefore still usable, and a plainnew/deletepair sits beside it in the same file. In Rust the moved-from binding is not usable at all, so there is no "valid but unspecified" state to reason about — which removes a genre of code review rather than a line of code. - Python — you never free anything, so this bug does not exist; the reference count does the work
Dropdoes here. The idea that transfers is that both languages free at a deterministic moment. What changes is that Rust decides the moment at compile time, so there is no count to maintain at run time and no cycle to leak — which is whyRcis a thing you reach for rather than the default. - ABAP — there is no free either, and the analogous confusion is about which variable still refers to a live object after you have passed a
TYPE REF TOaround. Ownership is the convention you already keep in your head; here the compiler keeps it.
Practice¶
Whose job is it to free? Make a type that prints when dropped, pass it to a function by value, then use it again. Record what the compiler says and which line it blames.
Then show the drop happening exactly once in three different places — a block, a Vec, the end of main — and say what Rc changes about the answer without reintroducing the bug. Finish by stating the C convention this replaces and why the compiler can check the Rust version.
Solution
double_free_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: responsibility as a property of the value.
//!
//! rustc --edition 2024 double_free_kata.rs -o /tmp/dfk && /tmp/dfk
struct Tracked(&'static str);
impl Drop for Tracked {
fn drop(&mut self) { println!(" freeing {}", self.0); }
}
fn consume(t: Tracked) { println!(" consume() took {}", t.0); }
fn main() {
println!("THE C SHAPE");
println!(" free(p); ... free(p);");
println!(" Two calls, one block. The second one corrupts the allocator's");
println!(" bookkeeping, and the crash -- if there is one -- happens in some");
println!(" unrelated malloc much later. The bug is not really about");
println!(" memory: it is that TWO PIECES OF CODE both believed they were");
println!(" responsible for the same block.");
println!();
println!("RUST MAKES RESPONSIBILITY A PROPERTY OF THE VALUE");
println!(" let a = Tracked(\"one\");");
let a = Tracked("one");
println!(" consume(a); <- moves it: consume is now responsible");
consume(a);
println!(" consume(a); <- E0382: use of moved value");
println!();
println!(" Exactly one binding owns the value at a time, and passing it by");
println!(" value transfers that. So the second call is not a runtime");
println!(" double-free, it is a compile error naming the line that took");
println!(" ownership -- which is also the line a C reviewer would have had");
println!(" to notice by reading the callee.");
println!();
println!("AND THE DROP HAPPENS EXACTLY ONCE, WHEREVER RESPONSIBILITY ENDED");
println!(" three values, three scopes:");
{
let _b = Tracked("two (block scope)");
println!(" inside the block");
}
let c = Tracked("three (moved into a Vec)");
let v = vec![c];
println!(" the Vec owns it now, and owns the freeing");
drop(v);
let d = Tracked("four (end of main)");
println!(" d will go last");
println!();
println!("WHAT ABOUT Rc?");
println!(" Rc<T> shares ownership between several holders and frees when");
println!(" the LAST one goes. That is a different answer to the same");
println!(" question -- responsibility held jointly, counted at runtime --");
println!(" and it still cannot double-free, because no holder can free on");
println!(" its own. You pay a counter for it, which is why it is not the");
println!(" default.");
println!();
println!("THE PART WORTH CARRYING TO C");
println!(" The C fix is a convention: 'the caller frees', written in a");
println!(" comment. Rust's fix is the same convention, moved into the type");
println!(" system where the compiler can check it -- and the reason it can");
println!(" is that a value has exactly one owner unless you ask otherwise.");
println!();
println!(" end of main:");
let _ = &d;
}
Verified output of double_free_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
THE C SHAPE
free(p); ... free(p);
Two calls, one block. The second one corrupts the allocator's
bookkeeping, and the crash -- if there is one -- happens in some
unrelated malloc much later. The bug is not really about
memory: it is that TWO PIECES OF CODE both believed they were
responsible for the same block.
RUST MAKES RESPONSIBILITY A PROPERTY OF THE VALUE
let a = Tracked("one");
consume(a); <- moves it: consume is now responsible
consume() took one
freeing one
consume(a); <- E0382: use of moved value
Exactly one binding owns the value at a time, and passing it by
value transfers that. So the second call is not a runtime
double-free, it is a compile error naming the line that took
ownership -- which is also the line a C reviewer would have had
to notice by reading the callee.
AND THE DROP HAPPENS EXACTLY ONCE, WHEREVER RESPONSIBILITY ENDED
three values, three scopes:
inside the block
freeing two (block scope)
the Vec owns it now, and owns the freeing
freeing three (moved into a Vec)
d will go last
WHAT ABOUT Rc?
Rc<T> shares ownership between several holders and frees when
the LAST one goes. That is a different answer to the same
question -- responsibility held jointly, counted at runtime --
and it still cannot double-free, because no holder can free on
its own. You pay a counter for it, which is why it is not the
default.
THE PART WORTH CARRYING TO C
The C fix is a convention: 'the caller frees', written in a
comment. Rust's fix is the same convention, moved into the type
system where the compiler can check it -- and the reason it can
is that a value has exactly one owner unless you ask otherwise.
end of main:
freeing four (end of main)
See also¶
- Ownership and moves — the mechanism, in full
- Scope is about names — when the free actually happens, which is not always the closing brace
- Use-after-free — the same ownership rule, aimed at the pointer rather than the block
- The bugs Rust is a reply to — the other eight
Po polsku¶
Podwójne zwolnienie (double-free) wygląda na błąd o pamięci, a jest błędem o odpowiedzialności: dwa miejsca w kodzie uważają, że to one mają posprzątać. W C nic tego nie zapisuje — free(p) nie zmienia p, wskaźnik dalej wygląda na dobry, a wiedza „ja to już zwolniłem" mieszka wyłącznie w głowie autora i w komentarzu.
Rust usuwa ten błąd, czyniąc odpowiedzialność własnością wartości, którą naraz może trzymać dokładnie jedno wiązanie. Nie ma czego zwolnić dwa razy, bo po przeniesieniu własności stara nazwa przestaje obowiązywać. To dokładnie ta sama reguła, którą poznaje się na pierwszej lekcji o przenoszeniu — tyle że oglądana od strony błędu, któremu zapobiega.
Warto zauważyć, czego to nie wymaga: żadnego odśmiecacza (garbage collector) ani liczenia czegokolwiek w czasie działania. Zwolnienie następuje dokładnie tam, gdzie w C postawiłbyś free, tylko wpisuje je kompilator — i nie umie ani zapomnieć, ani zrobić tego dwa razy.
Szukaj po polsku: podwójne zwolnienie pamięci · własność w Ruscie · przenoszenie własności · rust double free · rust ownership