Asnim Ansari.Backend, Rust and trading systems
Postrust, unsafe
Published

What's actually inside a Rust vtable

Rust 1.98.1 fixed a compiler bug that could put a null function pointer into a dyn Trait vtable. A look at fat pointers, vtable layout, and why a bad entry is undefined behavior instead of a guaranteed crash.

On September 3, Rust 1.98.1 shipped to fix a bug reported two weeks earlier in issue #161441. In certain generic, dynamically dispatched code, rustc’s trait resolution could decide a method’s predicates were impossible to satisfy and skip emitting a function pointer for it. Instead of refusing to compile or inserting something that panics, it wrote a zero into that vtable slot. The reported case involved a boxed async service erased behind a trait object with several associated types, shaped roughly like Arc<dyn DynService<Input, Output = Output, Error = Error> + Send + Sync>, and it reproduced consistently on aarch64-apple-darwin. No unsafe code was needed to hit it. An ordinary call through the trait object would eventually load that null pointer and jump to it, which the fix’s own write-up is careful to describe as undefined behavior that “may just cause segfaults,” not a guaranteed one.

That’s a good excuse to open a vtable up and look at what’s actually in there, and why a bad entry doesn’t necessarily give you the clean crash you’d hope for.

The fat pointer#

A reference to a sized type, &T, is one machine word: the address of T. A reference to a trait object, &dyn Trait, is two words. The first is the same data pointer, to wherever the concrete value lives. The second is a pointer to a vtable, generated once per concrete-type- and-trait pair at compile time, not once per value. Box<dyn Trait> and Arc<dyn Trait> carry the same second word for the same reason.

That second word is the whole trick behind dynamic dispatch. Calling a trait method on a generic T: Trait gets monomorphized: the compiler knows the concrete type at every call site and can call the function directly, or inline it. Calling a method on dyn Trait can’t work that way, because the concrete type was erased the moment the value went behind the trait object. The vtable is where that information goes instead of disappearing: given only the two words in a &dyn Trait, the vtable pointer is enough to find every method the trait object can call, plus enough about the underlying type to drop it correctly.

Rust doesn’t currently give you a stable way to inspect this. There’s a ptr_metadata feature with a DynMetadata type that exposes size and alignment safely, but as of this Rust version it’s still nightly-only, behind tracking issue #81513. On stable, looking inside a vtable means reaching for unsafe and treating the fat pointer as raw bytes.

What’s in the vtable#

The layout isn’t part of the language spec. It’s an implementation detail of rustc, and it has looked the same for a long time in practice: a pointer to the type’s drop_in_place function, the type’s size, its alignment, and then the trait’s methods in declaration order. Four words of header before the first method pointer shows up.

The drop_in_place entry is why a Box<dyn Trait> can be dropped correctly without ever knowing the concrete type at the call site. The size and align entries are why a Box<dyn Trait> can be deallocated correctly. Both come from the vtable, not from the thin data pointer next to it.

Looking at it directly#

cargo asm (or its actively maintained successor, cargo show-asm) will show you the generated call site for a dyn Trait method call. Reduced to its shape, invoking a method through a trait object compiles to loading the vtable pointer, then an indirect call through it at a fixed offset, something like call qword ptr [rax + 24] on x86-64. The offset is fixed at compile time because the compiler knows where each method sits in the layout it generated. It has no idea whether the value actually sitting at that offset, at run time, is still a valid function pointer.

To read the raw entries yourself:

trait Speak {
    fn speak(&self) -> &'static str;
}

struct Dog;

impl Speak for Dog {
    fn speak(&self) -> &'static str {
        "woof"
    }
}

fn main() {
    let dog = Dog;
    let obj: &dyn Speak = &dog;

    #[repr(C)]
    struct FatPointer {
        data: *const (),
        vtable: *const usize,
    }

    // SAFETY: &dyn Trait has this two-word layout on every rustc
    // version in use today, but nothing in the language guarantees
    // it. The transmute is only sound because the result is treated
    // as opaque bytes from here on, never as anything the type system
    // still vouches for.
    let fat: FatPointer = unsafe { std::mem::transmute(obj) };

    unsafe {
        let drop_fn = *fat.vtable;
        let size = *fat.vtable.add(1);
        let align = *fat.vtable.add(2);
        let speak_fn = *fat.vtable.add(3);

        println!("drop_in_place: {drop_fn:#x}");
        println!("size:          {size}");
        println!("align:         {align}");
        println!("speak:         {speak_fn:#x}");
    }
}

Run that and speak_fn prints a real address, the address of Dog’s speak implementation. That’s the entry the 1.98.0 bug could leave as zero instead.

Why this is UB, not a guaranteed segfault#

Calling through a function pointer that doesn’t point to a valid function is undefined behavior in Rust, full stop. On a typical desktop target, address zero happens to be unmapped, so jumping to it trips a hardware fault and you get a clean SIGSEGV. That’s a property of the host environment, not a language guarantee. It doesn’t hold everywhere: WASM linear memory treats address zero as ordinary data, and plenty of embedded targets have real, executable memory mapped at or near zero. A null call on those doesn’t fault at all, it just executes whatever happens to be there.

Even where it does fault, the deeper problem isn’t the one bad jump. Once the compiler has convinced itself, incorrectly, that a code path is unreachable, it’s allowed to have optimized everything around that path on the assumption it never runs. Proving that assumption false at run time doesn’t undo the optimizations that trusted it. The original report puts it plainly: a null vtable entry “may just cause segfaults, but it is possible for it to be justification for arbitrary effects.” The crash, when you get one, is the good outcome.

Pinning your toolchain doesn’t make miscompiles impossible, it makes them a version bump you control#

A rust-toolchain.toml with an exact channel keeps CI and every dev machine on the same compiler, so a fresh bug in a newly released stable doesn’t reach production the moment someone runs rustup update. It wouldn’t have stopped 1.98.0 from shipping with this bug. What it changes is where you find out: instead of a report from production, it’s a CI run you kicked off on purpose when you decided to bump the pin, with the release notes open in another tab. A patch release that’s purely a correctness fix, like 1.98.1, is worth taking as soon as you’re already on that minor version.

What Miri can and can’t tell you#

Miri interprets your program’s MIR directly and checks pointer validity on every operation, instead of compiling to native code and trusting whatever’s at a given address. Calling through a vacant or null vtable entry is exactly the shape of bug it’s built to catch, with a diagnostic naming the call, not a bare segfault three stack frames removed from the cause. If the affected function had run under cargo miri test, there’s a good chance it would have caught this before it ever reached a release build.

The hedge in that sentence is the whole point. Miri only checks the paths your tests actually exercise, and it’s slow enough that most projects run it against a slice of the suite rather than all of it. “Miri would have caught this” only holds for code that ran under Miri. For everything else, a compiler bug in code you never happened to interpret ships exactly the way this one did: quietly, until someone’s production trait object calls through a zero.

Asnim Ansari is a senior backend engineer at Sensibull (by Zerodha), building real-money trading systems in Rust and Golang.

Follow the posts by feed →

→More postsAll posts