Skip to content

perf + security: what the fast path is worth, and eleven defects the audit of the cores found - #38

Merged
MotherSphere merged 4 commits into
mainfrom
perf/measure-fast-path
Sep 6, 2026
Merged

perf + security: what the fast path is worth, and eleven defects the audit of the cores found#38
MotherSphere merged 4 commits into
mainfrom
perf/measure-fast-path

Conversation

@MotherSphere

@MotherSphere MotherSphere commented Sep 6, 2026

Copy link
Copy Markdown
Member

Two rounds in one branch: the measurement TODO.md 1a asked for, and the fixes an audit of CFC's cores turned up while the harness that produced it was being built.

The measurement

Per new outbound TCP flow, median, Linux 7.2.2 under KVM:

300 flows 3000 flows
no firewall at all 0.0158 ms 0.0162 ms
the fast path 0.0268 ms 0.0269 ms
queue, a 200 us idle beat 0.7703 ms 2.3646 ms
queue, the shipped 5 ms beat 5.6745 ms 7.6083 ms

The fast path saves 5.6 ms per new flow at 300 flows and 7.6 ms at 3000, and costs 0.011 ms over having no firewall. scripts/vm-bench boots a throwaway guest from this machine's own kernel and binaries so CFC can be measured armed.

Two documentation claims the attribution disproved are corrected: nfqueue.rs predicted half an idle beat per queued flow and it is a whole one, and docs/ARCHITECTURE.md still described the blocking-recv design that was replaced.

The security round

Eleven defects, every one verified against the code before a line was written. The two that matter most are the same shape - a rule the user wrote was not the rule being applied:

  • The fast path could grant what the packet path denies. The two deciders read different uids (exec-time against live), so for a program that drops privileges a uid-scoped deny above a broad allow was answered both ways. The grant side now abstains wherever a uid-scoped rule could reach the program, mirroring the guard the deny side already had.
  • A rule that could not be decided was walked past. A deny scoped to a digest the daemon cannot compute handed the flow to a lower allow. lookup is three-valued now.
  • A hostname nobody confirmed could admit traffic. The kernel gate for an observed DNS answer is "source port 53" and nothing else. Such a name may now refuse but not admit - one direction only, because refusing it both ways would have disarmed every deny --dst-host.

Also: ICMP refusals forged with a multicast source for ordinary mDNS and DHCP traffic; those refusals unbudgeted off the machine, which is a reflector; four raw sockets receiving a copy of every TCP segment on the host for the daemon's life; enforcing latched true after one packet, including after the ruleset was removed under it; an unbounded exe_path from the wire; an unbounded event offset; a hostname cache whose bound one insert path did not honour.

One performance change rides along: attribution opened a netlink socket per queued packet (0.28 ms of every queued flow), now one per thread - safe only because each request carries its own sequence number and the reply is checked against it.

Verification

402 daemon tests, 17 suites, clippy clean with -D warnings in both feature configurations, fmt clean, version guard green. Every fix carries the test that fails without it, including one that attaches the send-only filter to a UDP socket and proves the kernel really drops. Validated end to end in the VM: the daemon comes up, arms, filters, the fast path takes zero packets through the queue, and the daemon logs no warning.

Two mistakes of my own were caught by that verification rather than by assuming, and both were the fix being wrong rather than the finding: refusing an unconfirmed hostname in both directions, and reporting an absent nftables table verbatim when the shipped units load it after the daemon.

Left open, deliberately

The full DNS fix is to check the answer's source against a configured resolver, which needs the source address in the ring record and therefore an ABI bump. The mitigation here removes the security consequence without it. Also open: the queued cost that is not the idle beat grows with the live socket count, and that growth is the daemon's own per-packet work.

scripts/bench-latency.sh has been in the repo since 0.4.0 and could not
answer the question it exists for. The comparison that matters needs CFC
armed - the fail-closed queue rule loaded, the daemon deciding, the fast
path granting - and arming that on a development machine takes its
network with it if anything is wrong.

scripts/vm-bench boots a guest for one run instead. It assembles an
initramfs out of this machine's own kernel modules, nftables, iproute2,
python3 and the release binaries, boots it under KVM, and runs the bench
there against a real daemon. Nothing is downloaded, nothing outside
target/vm-bench is written, and the guest exists for one boot. One sudo,
once, to read the host's kernel image.

The states differ from each other in one thing at a time, so each
difference isolates one cost: no firewall, the queue, the queue with a
daemon built with a shorter idle beat, the fast path. ALT_DAEMON carries
that second daemon into the same image, so a constant can be attributed
by changing it and measuring both in one boot rather than by reasoning
about a distribution.

Two facts are recorded beside every measurement rather than assumed:
what `cfc status` says the fast path is, and `id_sequence` from
/proc/net/netfilter/nfnetlink_queue - one increment per packet the kernel
really handed to userspace. A state calling itself `fast` whose queue saw
one packet per connect did not take the fast path, and no latency figure
says that on its own. Both directions run everywhere, because `in` never
meets the queue and so measures the eBPF hooks alone.

Three defects of its own were found by running it, all of the class this
repo keeps meeting: a pipeline whose legitimate empty answer killed the
build under pipefail (a module this kernel builds in rather than builds),
a `/lib` created as a directory before being made a symlink, and
`modprobe --show-depends` silently omitting nf_conntrack because Arch
ships an install directive for it - which booted a guest with no
conntrack and refused `ct state new queue num 0` with a bare ENOENT.
TODO 1a has said since the fast path landed that the number justifying it
was not measured. It is now. Per new outbound TCP flow, median, Linux
7.2.2 under KVM:

                      300 flows    3000 flows
  no firewall           0.0158 ms    0.0162 ms
  the fast path         0.0268 ms    0.0269 ms
  the NFQUEUE queue     5.6745 ms    7.6083 ms

The fast path saves 5.6 ms per new flow at 300 flows and 7.6 ms at 3000,
and costs 0.011 ms over having no firewall at all. Its cost does not grow
with load, because those flows never reach the daemon; the queue's does.
The 0.28 ms this file used to quote came from a different bench and
understated the case by two orders of magnitude.

Attributing that cost rather than only recording it found two statements
in this repo that the measurement contradicts, and both are corrected
here rather than left standing beside the number that disproves them.

nfqueue.rs predicted "up to one RECV_POLL_INTERVAL (mean: half that)".
Half is right for arrivals independent of the beat and wrong for a client
connecting in series: each connect lands just after the worker observed
an empty queue and committed to a fresh wait, so it pays close to a whole
interval every time. Proved the way this file's own memory demands -
build the same daemon with the constant at 200 us, run both in one guest
- and not from the shape of a distribution, which is how this path has
been misread before: 4.90 ms of the 5.67 at 300 flows, 5.24 of the 7.61
at 3000.

docs/ARCHITECTURE.md still described the design nfqueue.rs replaced: a
worker blocking in `recv` with "no polling, no added latency" whenever no
prompt was outstanding. That has not been true since the blocking recv
became the ninety-second hang on every daemon stop.

What is left open, in TODO 1a: the remaining queued cost grows with the
number of live sockets - 0.77 ms at 300 flows against 2.36 at 3000, with
the beat removed - and that growth is the daemon's own per-packet work,
not the kernel's, because the floor moved 0.0003 ms across the same
range. Attribution is the suspect and is not yet proven.
…e my own reading did

An audit of the cores - the control socket, the packet parser and the
refusal path, the rule engine, DNS and provenance - returned findings I
then verified against the code myself, one at a time, before writing a
line. Two of them were the same shape and it is the shape that matters:
a rule the user wrote was not the rule being applied.

**The fast path could grant what the packet path denies.** The two
deciders read different uids - exec-time on one side, live on the other
- so for a program that drops privileges a uid-scoped deny above a broad
allow was answered both ways, and the grant is process-wide. The grant
side now stands aside wherever a uid-scoped rule could reach the
program, which is exactly what the deny side already did. Picking a uid
instead would have changed what every existing rule means.

**A rule that could not be decided was walked past.** A deny scoped to a
digest the daemon cannot compute handed the flow to a lower allow.
`lookup` is three-valued now and stops there. The connection half is
tested first so a rule its own destination excludes still abstains for
nothing - the trap in the obvious version of this fix.

**A hostname nobody confirmed could admit traffic.** The kernel gate for
an observed DNS answer is "source port 53" and nothing else: no
transaction id, no resolver address, no question-to-answer check. Such a
name may now refuse but not admit. One direction, deliberately: refusing
it both ways is the obvious change and would have quietly disarmed every
`deny --dst-host` on a machine running the DNS observer.

The rest, each verified before and after: ICMP refusals forged with a
multicast source for mDNS and DHCP traffic; the same refusals unbudgeted
off the machine, which is a reflector; four raw sockets receiving a copy
of every TCP segment on the host for the daemon's life; `enforcing`
latched true after one packet, including after the ruleset was removed
under it; an unbounded `exe_path` from the wire; an unbounded event
offset; a hostname cache whose bound one insert path did not honour.

One performance change rides with them because it is the same file as a
security one: attribution opened and closed a netlink socket per queued
packet, measured at 0.28 ms of every queued flow. It is now one socket
per thread - which is only safe because each request carries its own
sequence number and the reply is checked against it. Every request used
to carry seq 1, so a late answer to a timed-out request was
indistinguishable from the next one's: a wrong attribution, not a slow
one. The socket is discarded on anything but a cleanly-sequenced answer.

CHANGELOG has the full account. Every fix carries the test that fails
without it, including one that attaches the send-only filter to a UDP
socket and proves the kernel really drops - the raw-socket version needs
privileges the test suite does not have.

Two mistakes of my own, caught by verifying rather than by assuming, are
worth recording because both were the fix being wrong rather than the
finding: refusing an unconfirmed hostname in both directions (it would
have disarmed every deny by name), and reporting an absent nftables
table verbatim (the shipped units load it *after* the daemon, so that
called every boot unprotected for its first minute).
@MotherSphere MotherSphere changed the title perf: what the fast path is worth, measured on a VM that may be armed perf + security: what the fast path is worth, and eleven defects the audit of the cores found Sep 6, 2026
…e it has

The section on observed answers framed a forgery as something an
attacker must win against the resolver - "the same attacker who could
also forge the forward lookup FCrDNS depends on". That is a race for a
transaction id and an ephemeral port, and it is not what the code
allows. The kernel gate is `source port == 53` and nothing else: any
peer the host sends a UDP datagram to can answer from that port and
assert any name for any address, with no spoofing, no guessing, and no
involvement from the resolving library at all.

Rewritten to say that, and to say what now follows from it: such a name
may refuse but not admit. The `dst_host` warning above it gains the same
note, because that advice - do not lean on a hostname allow rule - is
now enforced rather than only given.

Also here, found by reading the whole diff back rather than by a test:
the fast path's own event consumer named its flows with `with_host`,
which marks a name unverified whatever it is. Those connections never
reach the matcher, so nothing was decided on it, but a field that says
"nobody confirmed this" about a name that was confirmed is a trap for
the next reader. It uses the same seam the packet path does.
@MotherSphere
MotherSphere merged commit 745b72c into main Sep 6, 2026
26 checks passed
@MotherSphere MotherSphere mentioned this pull request Sep 6, 2026
MotherSphere added a commit that referenced this pull request Sep 6, 2026
Carries the measurement and the security round from #38.

Minor rather than patch because behaviour changes: a hostname the daemon
did not confirm against the address no longer satisfies an
`allow --dst-host`, `enforcing` now answers about the nftables table
rather than about whether a packet was ever seen, and a rule scoped to a
digest that cannot be computed abstains instead of falling through to a
lower allow. docs/HARDENING.md explains the first.

Every hardcoded version moves together, the kernel crate's own lock
included - which check-versions.sh learned to guard this round, after the
0.3.0 and 0.4.0 bumps had both silently skipped it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant