Network Tutorial · 11

When the packets turn nasty

Everything so far assumed a peer that means well. The internet is not that peer. This last chapter is the one the whole series was walking toward: what happens when the packets are malformed on purpose, flooded on purpose, forged on purpose — and why, in Alquist, the honest answer is mostly "a counter goes up."

[o-o] |=| /_\

Resilience is not a feature you add at the end. It is the shape of every decision you already made, seen from the attacker's side. We did not bolt on a firewall. We built a stack whose worst failure mode is "refuse and keep counting" — because every table has a fixed size, every field is validated before it is trusted, and the hardware lives behind a wall the protocol brain can never climb.

The threat, in four shapes

Hostile traffic comes in a small number of recognisable shapes, and this chapter walks each against the defences built in Chapters 2 through 7. None of these defences is new here — that is the point. They were the design all along.

1. The malformed packet

The oldest attack: send bytes that don't obey the format and hope the parser trips. Alquist's answer is a principle we called validation is refusal — every layer checks every length and every field before using it, and a failure drops the packet and increments a categorised counter (Chapter 10) rather than proceeding on a bad assumption:

if (frame_length < NET_ETH_HEADER_SIZE + NET_IPV4_HEADER_MIN_SIZE) {
    ++g_net_diag_stats.ipv4_drop_hdr;  return 0;   /* too short: refuse, count, done */
}
/* ... version, header-length sanity, total-length <= frame, checksum ... each a refusal */

There is no code path that reads past a validated length, and no field is trusted before it is bounded. A truncated header, a lying length field, a bad checksum, a fragment we refuse to reassemble — each is a two-line refusal, not an adventure. The blast radius of a malformed anything is one dropped packet and one incremented counter.

2. The flood

The second attack doesn't send bad packets — it sends too many good ones, hoping to exhaust memory or starve the CPU. Alquist has no dynamic allocation on the network path to exhaust: every table is a fixed-size array. Four connections, four listeners, an accept queue of two, bounded rings. A SYN flood cannot grow the connection table because the connection table cannot grow — once its four slots are full, new SYNs are refused with a RST and tallied in tcp_rst_tx:

conn = net_tcp_alloc_conn();
if (conn == 0) {                                    /* table full -- */
    net_tcp_send_rst(rx_info, &tcp_rx, local_mac, local_ip);   /* refuse, loudly */
    return;                                          /* no memory consumed, no slot leaked */
}

Starving the single cooperative core is defended the same way: every pass does bounded work then yields. The frame pump moves at most a fixed number of frames per pass (NET_PUMP_RX_MAX); the EL0 brain processes at most a fixed batch (NET_EL0_BATCH_MAX) before returning control. A million packets a second cannot make any single pass run long; they can only make the counters climb faster. The flood becomes throughput you drop, not a wedge that stops the box.

3. The spoof

The third attack forges the source: pretend to be someone trusted, or inject data into a connection you can't see. TCP's classic defence is an unpredictable initial sequence number, and Alquist generates one by mixing the timer, both addresses, both ports, and a rolling nonce (Chapter 6):

isn = net_tcp_mix32(nonce ^ counter_lo ^ src_ip);
isn = net_tcp_mix32(isn ^ counter_hi ^ dst_ip);
isn = net_tcp_mix32(isn ^ ports);
g_net_tcp_isn_nonce = nonce ^ net_tcp_mix32(counter_lo + ports + 0xA5A5A5A5u);

A blind attacker who cannot see our traffic cannot guess the sequence number, so cannot inject a valid segment into an established connection. And an out-of-window or out-of-order segment is never trusted — it is dropped and re-ACKed (Chapter 7), never blindly accepted. For the attack that gets the crypto involved, the answer is stronger still: once SSH's key exchange completes (Chapter 9), every byte is authenticated, and a forged packet fails the Poly1305 tag and is discarded. Spoofing buys the attacker a dropped packet.

4. The reflection

The fourth attack abuses you as a weapon: spoof a victim's address, send you a small request, and let your large reply flood the victim. The defence is a posture we kept from the very first chapter: silence is the default, and we open exactly one door at a time. We answer ARP only for our own address, ICMP echo only for well-formed pings, UDP only on the one port we chose — everything else is a counted drop (udp_drop_port), never an error message. We never generate unsolicited replies, never emit ICMP errors an attacker could amplify, and never reply larger than the request. There is almost nothing here to reflect, because the stack's reflex, faced with anything it did not explicitly agree to, is to say nothing.

The wall behind all of it

Under every defence above sits the structural one from Chapter 2: the protocol brain runs at EL0, unprivileged, and can only ever touch a copy of a frame that the EL1 pump handed it across a bounded, pointer-validated channel. So even the worst case — a bug in the parser that a malformed packet actually triggers — is contained. A hostile packet might, at absolute worst, crash the network service or a single SSH session. It cannot reach the NIC, cannot forge a DMA descriptor, cannot corrupt the kernel, cannot touch the disk. The wall turns "remote code execution" into "the network task restarts." That is the difference between an incident and a catastrophe, and it is architectural, not a patch.

Watching it happen

Because of Chapter 10, none of this is faith. Point a tool at the box — a SYN flood, a malformed-header storm, a port scan — and the specific counter lights up: tcp_rst_tx for the refused flood, ipv4_drop_hdr for the malformed storm, udp_drop_port for the scan — while the box keeps answering legitimate traffic and never stops responding to the shell. The attack doesn't produce an outage; it produces a histogram. (Building out that adversarial test bench — the frameworks, the rules of engagement, the kill switch — is its own effort, running in parallel.)

An honest accounting of the limits

Resilience is a claim, and a claim deserves honesty. Alquist does not yet do per-source rate limiting, connection-attempt throttling, or RTT-adaptive timers; its connection table is small enough that a flood of legitimate-looking handshakes will deny service to new clients even as it harms nothing. These are real gaps. But they are gaps of the right kind: the failure mode is always "refuse new work," never "exhaust memory," "corrupt state," or "escalate privilege." A stack whose worst day is "it stopped accepting new connections for a while" is in a different universe from one whose worst day is a kernel compromise. We built the second universe on purpose, and we can add the throttles later — on a foundation that was safe first.

[^-^] |=| /_\

And that is the whole climb: from ARP asking "who has this address?" to a stack that meets a hostile internet with fixed tables, validated fields, an unclimbable wall, and a ledger of every refusal. Not because we added armour at the end — because we never built a soft place for an attacker to land. Small, honest, and counting. That's the machine.