← Back to list

What Happens to Rust Ownership When You Compile It?

A binary analysis of drop flags, drop glue, and the erasure of borrow semantics across debug and release builds

Yen Wang in RE: Exploit · 2026-05-28 12:01 · 0 claps · 10.3 min read
#rust-ownership #resever-engineer-rust #reverse-engineering #binary-analysis #rust-programming-language
Open on Medium ↗
Wiki topics: LNG · Linguistics & Language 💻 · Programming

What Happens to Rust Ownership When You Compile It?

A binary analysis of drop flags, drop glue, and the erasure of borrow semantics across debug and release builds

Rust’s ownership system is the language’s defining feature. The borrow checker enforces, at compile time, that every value has exactly one owner, that references do not outlive the values they point to, and that mutable and immutable borrows cannot coexist. These guarantees are central to Rust’s memory safety story.

But what actually survives in the compiled binary? If you hand a stripped Rust executable to a reverse engineer, how much of the ownership structure can they recover?

This post answers that question with a concrete binary analysis. We compiled a controlled Rust programme exercising five ownership patterns, exported the Binary Ninja disassembly for both debug and release builds (Windows x64, MSVC toolchain), and traced each ownership concept from its MIR representation down to the final machine instructions. The results are specific and in some cases surprising.

The short answer: the drop sequence survives, drop flags partially survive in debug builds, and almost everything else is gone.

The Test Programme

The programme exercises five distinct ownership patterns explicitly:

fn ownership_test() {
    // Pattern 1: move semantics
    let a = String::from("hello");
    let b = a;              // a is moved, borrow checker invalidates a
    println!("{}", b);
    // Pattern 2: shared borrows
    let s = String::from("world");
    let r1 = &s;
    let r2 = &s;            // two shared borrows, legal
    println!("{} {}", r1, r2);
    // Pattern 3: mutable borrow
    let mut t = String::from("foo");
    let m = &mut t;
    m.push_str("bar");
    // Pattern 4: ownership transfer into function
    takes_ownership(b);     // b moved here, no longer valid after this line
    // Pattern 5: clone preserves both values
    let orig = String::from("original");
    let copy = orig.clone();
    println!("{} {}", orig, copy);
}
fn takes_ownership(s: String) {
    println!("{}", s);
}   // s dropped here

We compiled this with --debug and --release, exported the Binary Ninja linear disassembly, and also captured the MIR output (-Z dump-mir=all) for both profiles. The platform is Windows x64 with the MSVC toolchain, so the calling convention is Windows fastcall (first four arguments in rcx/rdx/r8/r9) and the allocator is MSVC HeapAlloc/HeapFree rather than jemalloc.

The MIR Layer: Where Ownership Is Still Visible

Before examining the binary, it is worth pausing at MIR (Mid-level Intermediate Representation), which is the last stage of the Rust compiler where ownership semantics are fully explicit. Looking at the MIR output is instructive because it shows exactly what the compiler knows about ownership before codegen discards it.

The debug MIR for the move (let b = a) contains this:

_54 = const false;
_1 = <String as From<&str>>::from(const "hello") -> [return: bb1, ...];
// bb1:
_54 = const true;
_2 = move _1;       // b = move a

_54 is a drop flag -- a boolean that tracks whether _1 (bound to a, then moved into _2 which is b) needs to be dropped on an unwind path. When b is subsequently moved into takes_ownership, the flag is cleared:

_54 = const false;
_34 = move _2;
_33 = takes_ownership(move _34)

Drop flags exist because of panic unwinding. If an exception unwinds the stack mid-function, the runtime needs to know which values are currently live and need dropping. The flag encodes that information. In release mode, the compiler proves statically which values are live at every unwind point and eliminates the flags entirely.

The scope structure in the MIR also encodes the full nesting of borrows:

scope 5 { debug s => _10;
  scope 6 { debug r1 => _11;
    scope 7 { debug r2 => _12;
      scope 10 { debug t => _22;
        scope 11 { debug m => _23; }

Every borrow is named, scoped, and given a lifetime. r1 and r2 are both &std::string::String, distinguished from m which is &mut std::string::String. This information exists in MIR. It does not exist in the compiled binary.

Debug Build: Pattern 1 — Move Semantics

Source:

let a = String::from("hello");
let b = a;

Binary (0x140002440 region, sub_140002440 = ownership_test):

; String::from("hello") -- allocates heap, fills String struct
lea  rdx, [rel "hello"]
lea  rcx, [rbp-0x18]         ; destination: a's stack slot (var_1c0)
mov  r8d, 0x5                ; length
call sub_140003930            ; <String as From<&str>>::from

; drop flag: a is now live
mov  byte [rbp+0x167], 0x1
; move a into b -- three field copies (ptr, len, cap)
mov  rax, qword [rbp-0x8]    ; a.cap
mov  qword [rbp+0x10], rax   ; b.cap
movups xmm0, xmmword [rbp-0x18]  ; a.ptr + a.len (16 bytes, SSE load)
movaps xmmword [rbp], xmm0       ; b.ptr + b.len

The move is a movups/movaps pair for the first 16 bytes (ptr and len) and a separate mov for the cap field. Three field copies. The source slot at rbp-0x18 is not zeroed. No instruction marks a as invalid. The borrow checker's enforcement that a cannot be read after this point is not present in the binary.

The only trace of the ownership transfer is the drop flag at rbp+0x167, which is 0x1 from this point until b is moved into takes_ownership.

Debug Build: Pattern 4 — Ownership Transfer Into a Function

Source:

takes_ownership(b);

Binary (0x1400025e1):

mov  byte [rbp+0x167], 0x0   ; drop flag cleared -- b is no longer owned here
mov  rax, qword [rbp+0x10]   ; b.cap
mov  qword [rbp+0xe0], rax   ; copy to argument slot
movaps xmm0, xmmword [rbp]   ; b.ptr + b.len
movaps xmmword [rbp+0xd0], xmm0  ; copy to argument slot
lea  rcx, [rbp+0xd0]         ; rcx = &arg (Windows ABI: by-value String as pointer)
call sub_140002800            ; takes_ownership(b)

The drop flag goes to zero immediately before the call. This is the binary encoding of “b is no longer the owner”. Inside sub_140002800 (which is takes_ownership), the function ends with:

mov  rcx, qword [rbp-0x38]
call sub_140003a90            ; drop_in_place<String>

sub_140003a90 calls sub_140003ab0, which calls sub_140003b10 (the dealloc path wrapping HeapFree). This is drop glue -- compiler-generated destructor code that runs at the end of the callee because the callee now owns the value.

Two things are observable here. First, the drop call in takes_ownership confirms that the function received ownership -- a function that merely borrowed would not produce this destructor. Second, the absence of a drop for b in ownership_test after the call confirms the transfer -- b's stack slot is not cleaned up here because it no longer owns anything.

Both of these are indirect signals. Neither encodes the borrow checker’s actual constraint (“b cannot be used after this call”).

Debug Build: Patterns 2 and 3 — Borrows, Shared and Mutable

Source:

let r1 = &s;
let r2 = &s;
// ...
let m = &mut t;
m.push_str("bar");

Binary for shared borrows (0x1400024f4):

lea  rax, [rbp+0x48]         ; address of s (var_160)
mov  qword [rbp+0x60], rax   ; r1 = &s
mov  qword [rbp+0x68], rax   ; r2 = &s

Binary for mutable borrow (0x14000259a):

lea  rcx, [rbp+0xb8]         ; address of t (var_f0)
mov  qword [rbp+0x180], rcx  ; m = &mut t

These are identical operations: take the address of a stack slot, store it in another slot. The lea for r1 and the lea for m produce the same instruction shape. The borrow checker enforced that r1 and r2 are shared (no mutation allowed), that m is exclusive (no other borrow can coexist), and that none of these references outlive s or t. None of that information is present in the binary.

A disassembler looking at mov qword [rbp+0x60], rax cannot determine whether rax is a shared borrow, a mutable borrow, or a raw pointer. The distinction is a compile-time property that has been fully erased.

Debug Build: Drop Sequence at Scope Exit

MIR (bb23--bb26):

drop(_36)   // copy
drop(_35)   // orig
drop(_26)   // t
drop(_12)   // s

LIFO order — innermost scope drops first.

Binary (0x1400026ca--0x1400026f8):

lea  rcx, [rbp+0x108]    ; &copy
call sub_140003a90        ; drop_in_place<String>

lea  rcx, [rbp+0xf0]     ; &orig
call sub_140003a90        ; drop_in_place<String>
lea  rcx, [rbp+0xb8]     ; &t
call sub_140003a90        ; drop_in_place<String>
lea  rcx, [rbp+0x48]     ; &s
call sub_140003a90        ; drop_in_place<String>

Four call sub_140003a90 instructions, each preceded by a lea loading a different stack offset. The LIFO order matches the MIR exactly.

This is the richest ownership signal in the entire debug binary. From this sequence alone, a reverse engineer can determine:

  1. Four distinct heap-owning values were live in this function
  2. They were heap-allocated (not borrowed — borrowed values do not get drop calls)
  3. They were destroyed in LIFO order, consistent with Rust’s scoping rules

What cannot be determined from the drop sequence: the borrow structure (which values had borrows taken out against them), the lifetime relationships between the values, or whether any of the dropped values were ever borrowed mutably.

Note that b and a are notably absent from the drop sequence. a was moved into b, then b was moved into takes_ownership. Neither is dropped here -- the drop happened inside takes_ownership. This absence is itself an ownership signal: a missing drop implies a value was moved out rather than owned to scope end.

Drop Flags: The Debug-Only Ownership Trace

The drop flag at rbp+0x167 toggles throughout the function. Here is the complete timeline:

The unwind handler sub_140002710 reads this flag with test byte [rbp+0x167], 0x1 and conditionally drops b only if the flag is set. This handles the case where a panic occurs after b is allocated but before it is moved into takes_ownership.

Drop flags are a direct artefact of Rust’s ownership model. No equivalent mechanism exists in C, because C has no compiler-enforced ownership and therefore no automatic destructor to conditionally invoke. The presence of these toggling booleans is a Rust-specific binary signature — though one that only appears in debug builds.

Release Build: What Remains

The release build (sub_140001000) is substantially more compact. Binary Ninja lifts most of it to HLIL pseudocode that resembles C. Here is the drop sequence equivalent:

// Conditional HeapFree calls at function exit (0x140001234--0x140001276)
if (rdx_8 != 0)
    j_sub_140001650(rax_2, rdx_8, 1)   // free t ("foobar")

if (rdx_7 != 0)
    j_sub_140001650(rax_5, rdx_7, 1)   // free orig ("original")
if (rdx_6 != 0)
    j_sub_140001650(rdx_6, ...)         // free copy
if (rdx_9 != 0)
    j_sub_140001650(rax_1, rdx_9, 1)   // free s ("world")

j_sub_140001650 is a thunk to HeapFree. The null checks (!= 0 on the capacity field) are the release-mode equivalent of drop flags -- a static null check rather than a dynamic boolean. The LIFO ordering of the four frees is preserved.

The drop glue abstraction (drop_in_place<String>) is gone. There are no sub_140003a90 calls. Instead, the compiler inlined the destructor down to a direct conditional HeapFree.

Drop flags are entirely absent. The compiler proved statically that no value’s liveness is ambiguous at any unwind point in this function, so no runtime flags are needed.

For the borrows (r1, r2, m): they do not produce any binary output at all in the release build. The borrowed values are used transiently and optimised away -- no stack slots are allocated for them, no lea instructions appear. The entire s/r1/r2 pattern collapses to an allocation, a sub_140003b80 call (= println!), and a later HeapFree.

Comparison Table

What This Means for Static Analysis

The findings above have direct implications for anyone attempting to detect or characterise Rust malware through static binary analysis.

What is recoverable without symbols: The drop sequence — both the count of owned heap values and their LIFO destruction order — is visible in both debug and release builds. The presence of drop_in_place calls (or inlined HeapFree sequences) is a Rust-characteristic pattern distinguishable from C's manual free calls. In the debug build, drop flags are a reliable Rust-specific signal.

What is not recoverable: Borrow relationships, lifetime scopes, and the exclusivity constraints enforced by the borrow checker are completely absent. A mutable borrow and a shared borrow compile to the same instruction. A move and a copy compile to the same field-copy sequence. The source-level ownership structure that a Rust programmer reasons about does not exist in the binary.

The asymmetry matters for detection: Rust’s drop glue density (number of drop_in_place calls per function) is measurably higher than equivalent C code for the same data structure manipulation. This is because Rust inserts destructors at every scope exit for every owned value, whereas C requires the programmer to insert free calls manually. This difference in destructor density is a detectable binary signature even in stripped, release-mode binaries.

Drop flags are a debug-only signal: If you are analysing a debug build (which some deployed malware is, particularly early variants during development), the presence of toggling boolean slots adjacent to String or Vec stack frames is a strong Rust indicator. In release builds, the flags are gone, but the conditional null-checked HeapFree pattern at function exits is structurally distinct from typical C cleanup code.

Connection to the Akira v2 Analysis

The patterns identified here correspond directly to what was observed in the Akira v2 ransomware binary. In the lock function analysis, three of the same patterns appear:

The files.into_iter() move at the loop setup (0x4d398x region) produces three field copies of the Vec<PathBuf> fat pointer -- exactly the same three-field pattern seen here for String. No source invalidation. The IntoIter::drop call at 0x4d3a2a is the downstream evidence of the move, analogous to the sub_140003a90 calls at scope exit here.

The MutexGuard RAII drop at 0x4d3a0b and 0x4d3b40 is the same drop_in_place pattern. It confirms ownership of the guard, not the borrow constraints the guard enforced.

The Arc reference counting (lock inc qword [rax] at 0x4d3e7e) is a pattern that does not appear in the simple String programme above, because Arc introduces reference-counted shared ownership that has no equivalent here. In Akira, the lock inc is the only binary trace of the Thread/JoinHandle shared ownership -- but it encodes only that a reference count was incremented, not the borrow rules on the Arc's contents.

In both the controlled programme and the Akira binary, the same conclusion holds: the drop sequence survives, borrow semantics do not.

Conclusion

Rust’s ownership system is a compile-time abstraction. It exists fully at the source level, partially at MIR (where drop flags and scope annotations are still present), and residually at the binary level (where only the consequences of ownership — destructor calls and drop flags — survive).

The key findings from this analysis:

  1. Moves leave no direct trace. The three-field copy pattern is identical to any struct copy. The only indirect evidence is a drop flag toggle (debug builds) or the absence of a subsequent drop (both builds).
  2. Borrow semantics are completely absent. Shared borrows and mutable borrows compile to the same lea/mov pattern. Lifetime bounds produce no binary output at all.
  3. Drop glue is the primary ownership artefact. The call sequence to drop_in_place (or the inlined HeapFree equivalent in release) is the most reliable ownership signal available in the binary. Its presence confirms heap ownership; its absence at a call site implies a value was moved rather than owned to scope end.
  4. Drop flags are a Rust-specific debug signal. The toggling boolean slots are generated specifically to support Rust’s unwind-safe ownership model and have no direct equivalent in C or C++ binaries.
  5. The information loss is architectural, not a tooling limitation. Better decompilers will not recover borrow semantics, because the information does not exist in the binary. This is a fundamental consequence of ownership being a compile-time abstraction that the code generator does not need to preserve.

For static malware detection, this means that ownership-based characterisation of Rust binaries must focus on what survives: destructor density, drop flag patterns, Arc refcount operations, and the structural shape of scope-exit cleanup code. The richer ownership semantics that distinguish Rust from C at the source level are, by compilation, rendered invisible.


메타데이터
post_id
509d4e1ce509
slug
what-happens-to-rust-ownership-when-you-compile-it-509d4e1ce509
url
https://medium.com/re-exploit/what-happens-to-rust-ownership-when-you-compile-it-509d4e1ce509
canonical_url
https://medium.com/re-exploit/what-happens-to-rust-ownership-when-you-compile-it-509d4e1ce509
author_url
https://medium.com/@MonlesYen
status
ok
fetched_at
2026-08-05 18:05:34