DEV Community

Christo
Christo

Posted on

Reading a Rust crate's capabilities out of its compiled symbols

Cargo tells you once_cell 1.19.0 → 1.20.2. It doesn't tell you whether the new
version started opening a socket. A dependency bump is a trust decision, and the
lockfile diff gives you almost nothing to make it with.

I kept chewing on this after xz. Not the specific attack so much as its shape: the
change that mattered was invisible at the level anyone actually reviews. Nobody
reads the full diff of every transitive dep on every patch bump. So I wanted a
cheaper question answered automatically on the PR: did this bump change what the
crate can do?

You can get further than I expected just by reading what the compiler emits.

v0 symbols are legible

Rust's v0 symbol mangling demangles back to real paths. Build a crate, look at
the rlib:

RUSTFLAGS="-C symbol-mangling-version=v0" cargo build --release
nm target/release/deps/libnetcap-*.rlib \
  | awk '{print $NF}' | grep '^_R' | rustfilt | sort -u
Enter fullscreen mode Exit fullscreen mode

For a crate that opens a socket and spawns a process, some of what falls out:

<std::process::Command>::spawn
<std::sys::net::connection::socket::TcpStream>::connect::inner
<str as std::net::socket_addr::ToSocketAddrs>::to_socket_addrs
Enter fullscreen mode Exit fullscreen mode

My first cut used nm --defined-only, and it found nothing. Took me longer than
I'd like to admit to see why. The capability you care about isn't in the crate's
own functions. It's in the calls it makes out to std, and those are the
undefined symbols in the object files. --defined-only throws away the whole
signal. Once you keep the undefined refs, a crate that reaches for TcpStream
carries an undefined symbol that says so.

Two nice properties fall out of v0 specifically. Demangling drops the per-crate
disambiguator hash, so the same symbol is byte-identical across two versions and
you can just comm -13 the old and new sets. And forcing
-C symbol-mangling-version=v0 means you don't care what the toolchain default is;
the audit reproduces anywhere.

So the core is boring in a good way: for each crate whose version moved in
Cargo.lock, build old and new, diff the demangled symbols, and look at what got
added.

The build.rs blind spot

Then I hit the thing that actually keeps me up, and it's the xz-shaped one. A build
script compiles to a separate binary that Cargo runs at build time. None of that
lands in the crate's rlib. A dependency can add a build.rs that decodes a blob
and drops it in ~/.cargo/bin, and the symbol diff stays perfectly quiet.

// build.rs — runs on your machine, at compile time
fn main() {
    println!("cargo:rerun-if-changed=build.rs");
    let _ = std::process::Command::new("sh")
        .arg("-c").arg(payload())   // symbol analysis never sees this
        .status();
}
Enter fullscreen mode Exit fullscreen mode

The only way to catch it is to read the source, not the artifact. One Rust detail
made that easier than I feared: Cargo extracts a crate into
~/.cargo/registry/src/.../<crate>-<version>/ during resolution, before it
compiles anything. So you can diff a new or changed build.rs, catch a flip to
proc-macro = true, or a fresh links = "…", even for proc-macro-only or bin-only
crates that never build as a lib. Those are exactly the crates the symbol lane
can't touch.

Where it breaks: generics

I don't want to oversell it, because it has a real ceiling and the ceiling is
monomorphization. A generic function only produces a symbol once it's
instantiated. If a crate does this and never calls it with a concrete type itself:

pub fn send<A: ToSocketAddrs>(addr: A) -> io::Result<TcpStream> {
    TcpStream::connect(addr)   // monomorphized in the *caller's* crate, not here
}
Enter fullscreen mode Exit fullscreen mode

then TcpStream::connect shows up in your rlib, not the dependency's. Capability
reached purely through uninstantiated generics is invisible to this. It's a triage
signal — "the surface changed, go look" — not a sandbox and definitely not a proof.
I'd rather say that plainly than pretend otherwise.

Making it something you'd leave on

A scanner that re-flags every bump gets muted within a week, so the part I put the
most thought into is a sign-off ledger. You approve a (crate, version) once, and
after that it only re-alarms on the unreviewed delta. That's what lets you set
fail-on: high and block merges without drowning in noise. The one rule I was
careful about: a sign-off suppresses only the capability lanes. A newly-published
RustSec advisory or a changed crates.io publisher is never hidden by an old
approval, because those are new facts, not the thing you reviewed.

It comes out as one comment per PR:

🟠 HIGHsmallvec has a known security advisory (RUSTSEC-2021-0003)

If you want to poke at it

It's a composite GitHub Action, MIT, no server, runs entirely in your CI. There's
a live demo PR where it catches a real advisory:
https://github.com/Booyaka101/rust-symbol-audit-demo/pull/1, and the repo is
https://github.com/Booyaka101/rust-symbol-audit.

The part I'm least sure about is the false-positive ergonomics — the allow-list and
ledger design — so if you have opinions there, that's the feedback I actually want.


Disclosure: I built this with heavy help from an AI coding assistant (Claude), and
the first draft of this post came out of that too. I've reworked it and I stand
behind every claim here — the design decisions and the opinions are mine. Flagging
it because that's the honest thing to do.

Top comments (0)