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
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
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();
}
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
}
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:
๐ HIGH - smallvec 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.
Comments
No comments yet. Start the discussion.