Skip to content

Use-after-free

Level: 201 · for C and C++ programmers

One line: A freed block is not empty, it is available — and the most instructive use-after-free is not a crash but a read that quietly returns somebody else's data.

Half of Chromium's memory-safety bugs are this one. Their own count ↗: around 70% of high-severity security bugs are memory unsafety, and half of those are use-after-free.

The program

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    int *score = malloc(sizeof *score);
    *score = 41;
    free(score);                       /* the block goes back to the allocator */

    char *name = malloc(16);           /* which hands it straight back out */
    strcpy(name, "Ada");

    printf("%d\n", *score);            /* reading Ada's bytes as an int */
    return 0;
}

What it did

Real runs — Apple clang 21.0.0, x86_64 macOS, -Wall (no warnings), three runs of each build
-O0   6382657      6382657      6382657
-O2   -1094213456  -1097953104  -1182064464

6382657 is not garbage. It is 0x00616441, which is the bytes A, d, a, \0 read back as a little-endian 32-bit integer:

65 + (100 × 256) + (97 × 65536) = 6382657
       'A'   'd'          'a'

The free did not erase anything and did not invalidate score. It moved the block onto a free list; the next malloc of a small size took the same block; strcpy wrote a name into it; and *score read the name. No crash, exit status 0, and a program that will print 41 on any run where the allocator happens to hand that block to nobody.

That is the shape the security bugs take. The interesting version is not the read — it is the write, where the value you set through the stale pointer lands inside whatever structure now lives there.

AddressSanitizer catches it, and its report is the clearest explanation of the bug you will get:

Real output — clang -fsanitize=address -g -O0, abridged to the two stacks that matter
==85776==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000d0
READ of size 4 at 0x6020000000d0 thread T0
    #0 0x00010fff486b in main use_after_free.c:13

0x6020000000d0 is located 0 bytes inside of 4-byte region [0x6020000000d0,0x6020000000d4)
freed by thread T0 here:
    #0 0x00011055289b in free+0x8b (libclang_rt.asan_osx_dynamic.dylib:x86_64h+0xa689b)
    #1 0x00010fff480f in main use_after_free.c:8

Which is worth pausing on, because it is the strongest thing that can be said for the C tooling: this diagnosis is better than a compiler error. It has both stacks and the exact byte range. The catch is that you only get it on a run that executes the bug, in a build clang's own documentation puts at 2× slower ↗, which is why it lives in CI rather than in production.

Why the standard allows it

The value of a pointer becomes indeterminate when the object it points to reaches the end of its lifetime, and using it — reading through it, or even just copying it — is undefined behaviour ↗. The C standard has no concept of the pointer knowing it is stale, because there is nowhere to record it.

What Rust does instead

References borrow; they never own. The compiler tracks how long each borrow lives and refuses any arrangement where one outlives its owner — the whole apparatus of lifetimes exists for this single question.

let names = vec!["Ada".to_string()];
let borrowed = &names[0];
println!("{borrowed}");      // Ada
drop(names);                 // allowed only because the borrow ended above

Verified output of use_after_free.rs — regenerated by tools/run_examples.py, never hand-typed.

copied out, then freed -> 5
borrowed while alive   -> Ada
dropped once the borrow was over

The refusal

Swap the last two lines and the arrangement is refused:

Real rustc output — borrow_then_drop.rs, --edition 2024
error[E0505]: cannot move out of `names` because it is borrowed
 --> borrow_then_drop.rs:5:10
  |
2 |     let names = vec!["Ada".to_string()];
  |         ----- binding `names` declared here
3 |     let borrowed = &names[0];
  |                     ----- borrow of `names` occurs here
4 |
5 |     drop(names);
  |          ^^^^^ move out of `names` occurs here
6 |
7 |     println!("{borrowed}");
  |                -------- borrow later used here

Read the three markers in order: where the borrow starts, where the value would be freed, where the borrow is used afterwards. The error is not "you used a dangling pointer" — the compiler will not let the arrangement exist, so there is no moment at which the pointer is dangling.

The check is on every path, without running anything, which is the difference from the sanitizer above. What it costs is that some safe programs are refused too, because the borrow checker's model is conservative — that is the fortnight everyone spends arguing with it.

If you are coming from another language

  • C++shared_ptr solves this by counting, weak_ptr by checking, and a raw T* obtained from either solves nothing at all. The rule you already follow — do not hold a pointer to something whose owner might go away — is exactly the rule here; the change is that it is checked rather than reviewed. Dangling iterators are the same bug with a nicer name: see iterator invalidation.
  • Python — a reference keeps the object alive, so this bug is not available to you and the transferable idea is the opposite one: in Rust, holding a reference does not keep anything alive. & is a borrow with a deadline, not a co-ownership claim. When you genuinely want the Python behaviour, that is Rc or Arc, and you ask for it by name.
  • ABAP — the runtime frees an object when the last reference goes, so the failure you know is the opposite one: a TYPE REF TO that is still bound and keeps something alive longer than intended. There is no ABAP equivalent of reading through a freed reference, and the closest analogue — dereferencing an initial reference and getting CX_SY_REF_IS_INITIAL — is the null dereference page, not this one.

Practice

Write the bug and read the error. In Rust: create a String, drop it, then use it. Record the error code and say why drop — an ordinary function, not a keyword — is what makes the compiler notice.

Then the harder one, which has no C equivalent at all: make a reference outlive the value it points at, and say which checker catches it and how that differs from the first. Finish with what the whole thing costs at runtime.

Solution

use_after_free_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: write the C bug in Rust, and read the error instead.
//!
//!   rustc --edition 2024 use_after_free_kata.rs -o /tmp/uaf && /tmp/uaf

fn main() {
    println!("THE C SHAPE");
    println!("  char *p = malloc(16); strcpy(p, \"secret\");");
    println!("  free(p);");
    println!("  printf(\"%s\", p);        <- reads a block the allocator now owns");
    println!("  It usually prints something. That is the instructive part: a");
    println!("  freed block is not blanked, it is marked AVAILABLE, so the read");
    println!("  returns whatever lives there now -- often another allocation's");
    println!("  data, which is how a use-after-free becomes an information leak");
    println!("  rather than a crash.");
    println!();

    println!("THE SAME PROGRAM IN RUST");
    println!("  let s = String::from(\"secret\");");
    println!("  drop(s);");
    println!("  println!(\"{{s}}\");        <- E0382: borrow of moved value: `s`");
    println!();
    println!("  drop() is not a special form. It is a function taking its");
    println!("  argument BY VALUE, so calling it moves the String in -- and the");
    println!("  compiler already tracks that `s` no longer owns anything. The");
    println!("  free happens when drop's own parameter goes out of scope, one");
    println!("  line later, and the check that catches the misuse is the same");
    println!("  move checker that catches passing a value to any function twice.");
    println!();

    println!("SO WHAT ACTUALLY HAPPENS HERE");
    let s = String::from("secret");
    let len = s.len();
    drop(s);
    println!("  s was dropped; its length, copied out first, is {len}");
    println!("  There is no way to write the read. Not 'it panics' -- the");
    println!("  program does not exist.");
    println!();

    println!("THE HARDER CASE: A REFERENCE THAT OUTLIVES ITS OWNER");
    println!("  let r;");
    println!("  {{ let v = vec![1, 2, 3]; r = &v[0]; }}   <- v dropped here");
    println!("  println!(\"{{r}}\");                       <- E0597: `v` does not");
    println!("                                            live long enough");
    println!("  That is the borrow checker rather than the move checker, and it");
    println!("  is the one that has no C equivalent at all: C will happily give");
    println!("  you a pointer into a block that is about to be freed and say");
    println!("  nothing, at any warning level.");
    println!();

    let r;
    let v = vec![10, 20, 30];
    r = &v[0];
    println!("  the version that compiles: v outlives r, so r = {r}");
    println!("  Moving `let v` inside a block would break it, and the error");
    println!("  names both the borrow and the drop.");
    println!();

    println!("WHAT THIS COSTS AT RUNTIME");
    println!("  Nothing. There is no free-list check, no tombstone, no refcount");
    println!("  -- the analysis happened at compile time and the generated code");
    println!("  is the same malloc/free pair C would emit. That is the whole");
    println!("  claim: not a safer allocator, an earlier question.");

    assert_eq!(len, 6);
    assert_eq!(*r, 10);
}

Verified output of use_after_free_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

THE C SHAPE
  char *p = malloc(16); strcpy(p, "secret");
  free(p);
  printf("%s", p);        <- reads a block the allocator now owns
  It usually prints something. That is the instructive part: a
  freed block is not blanked, it is marked AVAILABLE, so the read
  returns whatever lives there now -- often another allocation's
  data, which is how a use-after-free becomes an information leak
  rather than a crash.

THE SAME PROGRAM IN RUST
  let s = String::from("secret");
  drop(s);
  println!("{s}");        <- E0382: borrow of moved value: `s`

  drop() is not a special form. It is a function taking its
  argument BY VALUE, so calling it moves the String in -- and the
  compiler already tracks that `s` no longer owns anything. The
  free happens when drop's own parameter goes out of scope, one
  line later, and the check that catches the misuse is the same
  move checker that catches passing a value to any function twice.

SO WHAT ACTUALLY HAPPENS HERE
  s was dropped; its length, copied out first, is 6
  There is no way to write the read. Not 'it panics' -- the
  program does not exist.

THE HARDER CASE: A REFERENCE THAT OUTLIVES ITS OWNER
  let r;
  { let v = vec![1, 2, 3]; r = &v[0]; }   <- v dropped here
  println!("{r}");                       <- E0597: `v` does not
                                            live long enough
  That is the borrow checker rather than the move checker, and it
  is the one that has no C equivalent at all: C will happily give
  you a pointer into a block that is about to be freed and say
  nothing, at any warning level.

  the version that compiles: v outlives r, so r = 10
  Moving `let v` inside a block would break it, and the error
  names both the borrow and the drop.

WHAT THIS COSTS AT RUNTIME
  Nothing. There is no free-list check, no tombstone, no refcount
  -- the analysis happened at compile time and the generated code
  is the same malloc/free pair C would emit. That is the whole
  claim: not a safer allocator, an earlier question.

See also

Po polsku

Zwolniony blok nie jest pusty — jest dostępny. To jedno słowo tłumaczy, dlaczego najbardziej pouczające użycie po zwolnieniu (use-after-free) nie jest wywaleniem programu, tylko odczytem, który spokojnie zwraca cudze dane. Pamięć wciąż należy do procesu, więc nic nie protestuje; alokator zdążył ją tylko oddać komuś innemu.

Dlatego jest to błąd niebezpieczny w sensie bezpieczeństwa, a nie samej poprawności: „cudze dane" bywają hasłem albo kluczem z innej części programu, a odczyt wygląda w kodzie zupełnie zwyczajnie. Objaw zależy od tego, co akurat zdążyło wejść w to miejsce, więc potrafi znikać przy próbie powtórzenia — stąd ludowa diagnoza „wywala się raz na dziesięć uruchomień".

Rust likwiduje całą tę klasę przy kompilacji i robi to bez odśmiecacza: wartość znika, gdy właściciel wychodzi z zasięgu, a kontroler pożyczeń nie pozwala, by jakakolwiek referencja przeżyła to, na co wskazuje. Wiszący wskaźnik (dangling pointer) nie jest tu błędem, który trzeba znaleźć — jest programem, którego nie da się zbudować.

Szukaj po polsku: użycie po zwolnieniu · wiszący wskaźnik · kontroler pożyczeń · rust use after free · rust dangling reference