Skip to content

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:

Real run — Apple clang 21.0.0, x86_64 macOS, -O0, captured on a terminal
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:

Real output — clang -fsanitize=address -g -O0, abridged to the first frames
==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.

filing Ada
freeing Ada
main is done

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:

Real rustc output — use_after_move.rs, --edition 2024, abridged to the error and its first note
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_ptr is this idea, and Rust's difference is that it is not opt-in. A unique_ptr still lets you call .release() and hold the raw pointer, std::move leaves behind an object that is valid but unspecified and therefore still usable, and a plain new/delete pair 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 Drop does 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 why Rc is 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 TO around. 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

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