Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

ircd.rs / docs / blog / 012-you-cant-rename-a-symbol-that-isnt-there.md
13 kB

title: "You can't rename a symbol that isn't there" desc: "The whole differential oracle is one objcopy trick: rename every C function foo to cref_foo and run it next to the Rust port in the same process. It works perfectly right up until you hit a function that has no symbol to rename — and a real codebase is full of them. Here's what you do when the oracle is structurally blind to the exact function you just ported." date: 2026-06-06#

The last three posts were a tour of the differential oracle's reach. The leaves that read the kernel, the front door every connection enters through, the dial-out you can only test by refusing to dial. Each one was about a different shape of input — pure args, a real fd, the kernel's say-so — and how you get two implementations to agree on it.

Here's a class of function the oracle can't reach for a much dumber reason than input shape: these functions don't have a name.

The oracle is one objcopy trick#

Worth restating the machinery in one breath, because the whole post hangs off how it actually works. The L1 differential doesn't mock anything and doesn't run two processes. It compiles the frozen reference C, takes every function foo in those objects, and renames the symbol to cref_foo. Then your test links the renamed C archive and the Rust port into one binary and calls both cref_foo(x) and the Rust foo(x) back to back, on the same inputs, and asserts the outputs are byte-identical.

The rename is the entire trick, and it's three lines of build.rs:

// ircd-testkit/build.rs — nm over the reference objects, emit a rename map
let is_global_def =
    ty.len() == 1 && ty.chars().all(|c| c.is_ascii_uppercase() && c != 'U');
if is_global_def {
    Some(format!("{name} cref_{name}"))
}

That runs nm -g --defined-only over the reference objects, keeps every line whose type is a single uppercase letter — T for a function in text, D/B/R for data — and writes foo cref_foo into a map that objcopy --redefine-syms then applies. cref_send_message, cref_do_numeric, cref_connect_server: a thousand of them, generated mechanically, no human picks the list.

Mara (hacker): nm -g. The -g is "external symbols only." Hold onto that flag — it's the whole problem.

static means there's no symbol to rename#

Here's the thing about a static function in C. The keyword doesn't mean "constant" or "shared" the way it does in half a dozen other languages — on a function it means internal linkage. The symbol never leaves the translation unit. There is no foo in the object file's external symbol table for nm -g to find, which means there is nothing for objcopy to rename, which means cref_foo does not exist and cannot be made to exist without editing the C.

So the oracle's granularity is exactly the set of exported symbols. Every global function gets a twin. A static one falls below the resolution of the instrument — you can port it, but you can't point the differential harness at it, because the harness finds its subjects by name and this one doesn't have a name the linker will admit to.

We hit this wall the first time back in P6, porting the config reader. There's a helper called lookup_confhost that resolves a conf block's hostname, and it's static int in s_conf.c. I wrote an L1 diff for it the same way I'd written thirty others, the test linked, and the link failed: undefined reference to cref_lookup_confhost. Not a typo. The symbol genuinely wasn't in the archive, because nm -g had nothing to report and the rename map skipped it. Its non-static neighbor ipv6_convert got an L1 diff that day. lookup_confhost didn't, and couldn't, and the progress note for that phase just says so in as many words.

Cadey (coffee): I spent a while convinced I'd broken the build script before it clicked that the build script was right and the function was the problem. The oracle wasn't refusing to test it. The oracle literally could not see it.

Two ways out, and they're opposites#

Once you accept that a static function is invisible to the harness, you've got exactly two moves, and they pull in opposite directions.

The first is to give it a name. static int foo becomes int foo — you strike the static keyword, the symbol gets external linkage, nm -g finds it, the rename map picks it up, and cref_foo springs into existence. Now you can L1-diff it like anything else. This is the one and only category of edit I allow myself to make to the reference C in a project whose entire premise is don't touch the C, and I justify it in a source comment every time: it's a storage-class change, it's behavior-preserving, and in a single-TU build there's no symbol collision to worry about. P7's mysk source-address global went this way — de-static'd so the still-C neighbors and the Rust port could share it, which had the side effect of minting cref_mysk for free.

Striking static is clean when the function is something you genuinely want a first-class oracle for. But it's an edit to the thing you're supposed to be holding fixed, and you can't de-static everything without slowly turning the reference into a different program than the one you froze. So the second move is to leave the static exactly where it is and port the function as what I've started calling a private Rust twin: a module-private unsafe fn in the Rust crate that mirrors the C body, called only by the Rust port of whatever called the C original, and never L1-diffed on its own — because it can't be.

P7 ended up with four of these, and they're worth listing because the pattern only gets convincing in bulk:

  • set_sock_opts (s_bsd.c:1489) — the socket-option setter, called by the listener constructor inetport.
  • check_init (s_bsd.c:873) — resolves a peer address, called by both check_client and check_server_init.
  • check_clones (s_bsd.c:1577) — the clone-rate limiter, called only by add_connection.
  • connect_inet (s_bsd.c:2680) — the outbound socket setup, called only by connect_server.

Each one is static in the C. Each one has a Rust twin that's also private — no pub, no #[no_mangle], nothing the linker exports. The doc comment on every one of them says the same thing in slightly different words: static in C, no cref_ oracle, ported as a private twin, verified through its caller.

So how do you know the twin is right?#

You diff its caller. That's the whole answer, and it's less of a cop-out than it sounds, because of a compiler detail that turns out to be load-bearing.

When connect_inet is static and lives in the same file as its only caller connect_server, the C compiler is free to inline it — and at the optimization level this builds at, it does. There is no distinct connect_inet in the compiled output to diff against even if you wanted one; it's already been melted into the body of connect_server. So when the L1 harness runs cref_connect_server against the Rust connect_server, the C side it's comparing against already contains connect_inet, inlined. The Rust side calls out to a separate private connect_inet twin. The two are structured differently and produce identical results, and the differential proves the second part — which is the part that matters.

That's the honest shape of it: the oracle diffs at the granularity of exported symbols, and a static callee is below that line, so you verify it transitively through the exported caller that swallows it. When the last C caller of a static finally gets ported — connect_server for connect_inet, check_server_init for check_init — the C static loses its remaining caller, so the whole thing gets guarded out of the reference compile under the same -DPORT_* flag. After that point there is no C connect_inet in the build at any linkage, exported or not. The only proof it ever matched is the connect_server differential that ran while both still existed.

Mara (hacker): which means the per-function granularity is a Rust-side fiction. C fused caller and callee at compile time; you un-fused them in the port because separate functions are nicer to read. The oracle never saw the seam, so if connect_server_diff ever goes red, it can't tell you whether the bug is in connect_server or connect_inet. It just says "the pair disagrees." You go find out which half yourself.

I'm fine with that, but I want to be precise about what it costs, because this is the kind of claim the series is supposed to be careful with. Transitive coverage is real coverage — both worlds run the actual connect_inet logic, on real inputs, and the bytes are compared. What you lose isn't whether it's tested. It's localization: a red diff points at a symbol, and the symbol it points at is the caller, not the callee that's hiding inside it.

And the trap from last time comes back#

There's a sharper limit, and it's the same one the front-door post was built around, wearing a different hat. Verifying a static through its caller only exercises the branches of the static that the caller's tested path walks.

Consider check_clones. Its Rust twin keeps a function-local static mut backlog list — a static inside a static, faithfully mirroring the C's static struct abacklog *backlog — and ages out stale entries before counting how many recent connections share your IP:

// ircd-common/src/s_bsd.rs:1746 — the private check_clones twin
unsafe fn check_clones(cptr: *mut aClient) -> c_int {
    static mut BACKLOG: *mut AbackLog = std::ptr::null_mut();
    let now = ircd_sys::bindings::timeofday;
    // First, ditch old entries.
    // ...prepend {cptr->ip, now}, then count matching-ip nodes...
}

The only L2 path that reaches check_clones is the eleventh connection from one host inside two seconds — the clone-reject the golden harness has to stage, because no well-behaved client floods. Walk that path and you've proven the count-and-reject branch. The age-out branch — the loop that frees entries older than the period — only runs when there's a stale entry to free, which means a connection, then a two-second wait, then another connection from the same IP. No golden scenario waits two seconds between connections, so that branch of a function I can't diff directly is covered by nothing at all. I read it, I believe it's right, and I can't prove it with the oracle. That's the true state, and rounding it up to "covered" is exactly the move this whole project exists to not make.

The funny part, as usual#

What delighted me is that check_clones' Rust twin has a static mut inside the function — Rust's most-nagged-about feature, the one the borrow checker files under "are you absolutely sure" — sitting there precisely because the C it's faithful to used a function-local static for the same backlog. I ported a C internal-linkage quirk into a Rust internal-linkage quirk, and the reason the outer function is invisible to my oracle (static → no symbol) is the same C keyword that, one scope deeper, makes the list persist across calls. Same word, two completely different jobs, and both of them are in my way.

C's static is four or five unrelated features wearing one keyword, and this port keeps running into two of them at once. The oracle can't name the function. The function can't forget its list. Neither of those is a thing I get to change without changing the program I promised to preserve, so I work around both and write down exactly where the instrument went blind.


The four twins are in ircd-common/src/s_bsd.rs (set_sock_opts, check_init, check_clones, connect_inet — each unsafe fn, no pub); the rename machinery that can't reach them is ircd-testkit/build.rs (the nm -g filter); and the first time this bit us is the lookup_confhost note in the P6 progress log, under the phase plan in PLAN.md. The oracle post is the cref_ rename these all lean on, the front door is where the branch-coverage trap was first laid out, and eating the file one function at a time is where the de-static edit gets its justification.

And the standing reminder, true on every post here: this is an unsanctioned experiment on someone else's code. Leave the IRCNet team out of it. The twins I can't diff, the keyword I keep tripping over, and the branch I covered with a reading instead of a test are mine.