|
| 1 | +<!-- Β©AngelaMos | 2026 --> |
| 2 | +<!-- README.md --> |
| 3 | + |
| 4 | +``` |
| 5 | + ________ _ _ ____ _____ _ _ |
| 6 | +|__ /_ _| \ | |/ ___| ____| | / \ |
| 7 | + / / | || \| | | _| _| | | / _ \ |
| 8 | + / /_ | || |\ | |_| | |___| |___ / ___ \ |
| 9 | +/____|___|_| \_|\____|_____|_____/_/ \_\ |
| 10 | +``` |
| 11 | + |
| 12 | +[](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/advanced/zig-stateless-scanner) |
| 13 | +[](https://ziglang.org) |
| 14 | +[](https://www.kernel.org/doc/html/latest/networking/af_xdp.html) |
| 15 | +[](https://musl.libc.org) |
| 16 | +[](https://www.gnu.org/licenses/agpl-3.0) |
| 17 | + |
| 18 | +> A stateless, line-rate mass TCP/UDP port scanner in the lineage of masscan and zmap. The name is Zulu for "to hunt." It holds no per-connection state, encodes probe identity in a SipHash cookie, walks the address space with a cyclic-group permutation, and ships as a single static binary with no libpcap, no libgmp, and no PCRE. It proves its packet logic against known-answer tests and stays memory-safe under Zig's ReleaseSafe. |
| 19 | +
|
| 20 | +## Why a stateless scanner in Zig |
| 21 | + |
| 22 | +A normal TCP client tracks every connection: a socket, a state machine, a retransmit timer. That is fine for thousands of connections and impossible for billions. To sweep the routable IPv4 space you cannot keep a table, so the state moves into the packet itself. zingela encodes each probe's identity in the TCP sequence number with a keyed hash, and validates a reply with one hash recompute. No table, no timers, constant memory, and the transmit and receive engines share nothing and run flat out. |
| 23 | + |
| 24 | +That makes it a sharp showcase for Zig: `extern struct` wire formats with compile-time size assertions, an RFC 1071 checksum in both scalar and `@Vector` form checked against the published vector, raw `AF_PACKET` and `AF_XDP` reached straight through `std.os.linux`, and every checksum and cookie pinned to a known-answer test. The result is a scanner that is fast where it can be, honest where it cannot, and safe by construction. |
| 25 | + |
| 26 | +## The honest positioning |
| 27 | + |
| 28 | +Read this before you believe any speed claim, from us or anyone else. |
| 29 | + |
| 30 | +On stock Linux, masscan, zmap, and zingela all hit the same wall: the kernel `AF_PACKET` transmit path tops out around 1.5 to 2.5 million packets per second on a single core. Every one of these tools meets that ceiling. There is no raw-throughput number to win on identical hardware, and zingela does not claim one. |
| 31 | + |
| 32 | +The only road past that ceiling in masscan is proprietary PF_RING ZC: a paid license, an out-of-tree kernel module, and specific NICs. Its headline figures, roughly 10 Mpps on a single 10GbE NIC and higher in dual-NIC demonstrations, are all PF_RING, never a stock kernel. zingela's road is `AF_XDP`, mainline in Linux since kernel 4.18, with no license and no proprietary module. That backend ships today behind the `-Dxdp` flag (pure syscalls, no libxdp). The head-to-head 10GbE benchmark against masscan is future work, and this README will not quote a line rate that has not been measured on real hardware. |
| 33 | + |
| 34 | +The defensible win is the combination: `AF_XDP` acceleration with no PF_RING paywall, memory-safe Zig, a single static binary, correctness masscan never cleanly solved (RST suppression, validated-ICMP classification, accurate dedup), and a modern colorful interface. |
| 35 | + |
| 36 | +## What Works Today |
| 37 | + |
| 38 | +Every capability below is exercised by unit tests, an in-namespace end-to-end scan, and read-only audit passes. |
| 39 | + |
| 40 | +**Scanning** |
| 41 | +- Stateless TCP SYN scan over IPv4 and IPv6 via a raw `AF_PACKET` + `PACKET_TX_RING` + `PACKET_QDISC_BYPASS` datapath |
| 42 | +- UDP scan with per-protocol payloads, ICMP type-3 code-3 classified as closed, silence reported honestly as `open|filtered` |
| 43 | +- TCP connect() scan (`--connect`) for unprivileged or raw-blocked environments, IPv4 and IPv6 |
| 44 | +- Cloud and VM raw-send auto-detection (`--backend auto`): a two-socket self-probe detects hypervisors that silently drop raw sends and falls back to connect mode with a clear notice |
| 45 | + |
| 46 | +**Statelessness and coverage** |
| 47 | +- SipHash SYN-cookie identity in the TCP sequence number, validated by `ack == cookie +% 1` |
| 48 | +- zmap-style multiplicative cyclic-group address permutation, seeded per scan from the OS CSPRNG, with the prime computed at runtime for the exact target space |
| 49 | +- Token-bucket rate control, default 10,000 pps, responsible by default |
| 50 | +- An RFC 6890 reserved-range exclusion floor that cannot be overridden |
| 51 | + |
| 52 | +**Detection and evasion** |
| 53 | +- Service and banner detection (`--banners`): a two-phase NULL-probe plus HTTP grab on open ports; TLS is detected, not decrypted; there is no JA4 (a dedicated Rust tool covers that) |
| 54 | +- A stealth suite gated behind `--authorized-scan`: OS-realistic SYN templates, `fin|null|xmas|maimon|ack|window` scan types, Poisson jitter, source-port rotation, decoys, and scoped RST suppression |
| 55 | + |
| 56 | +**Acceleration and output** |
| 57 | +- An `AF_XDP` TX backend behind `-Dxdp` (pure-syscall UMEM and rings, with a zero-copy then `XDP_SKB` then `AF_PACKET` selection ladder) |
| 58 | +- A truecolor live dashboard and a clean results table on stderr, with NDJSON on stdout (`--json`) so results stay greppable |
| 59 | + |
| 60 | +## Quick Start |
| 61 | + |
| 62 | +```bash |
| 63 | +curl -fsSL https://angelamos.com/zingela/install.sh | bash |
| 64 | +zingela scan --target 192.0.2.0/24 --ports 80,443 --rate 20000 |
| 65 | +``` |
| 66 | + |
| 67 | +One command takes a fresh Linux box to `zingela` on your PATH. The installer downloads a prebuilt static musl binary when a release is available and otherwise builds from source (fetching Zig 0.16 if it is missing), then grants `cap_net_raw,cap_net_admin` so raw scans run without sudo. |
| 68 | + |
| 69 | +The target shown, `192.0.2.0/24`, is a reserved documentation range (RFC 5737) that zingela skips by design, so it reports zero targets. Substitute a range you own or are authorized to scan. For an unprivileged scan that needs no capabilities, add `--connect`. |
| 70 | + |
| 71 | +> [!TIP] |
| 72 | +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see every recipe grouped by area: `just safe` for a ReleaseSafe build, `just test-all` for the full matrix, `just bench` for the hot-path numbers, `just dist` for the musl release binaries, `just setcap` to grant capabilities. |
| 73 | +> |
| 74 | +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` |
| 75 | +
|
| 76 | +## Learn |
| 77 | + |
| 78 | +This project ships a full teaching track. Read it in order, or jump to what you need. |
| 79 | + |
| 80 | +| Doc | What it covers | |
| 81 | +|-----|----------------| |
| 82 | +| [`learn/00-OVERVIEW.md`](learn/00-OVERVIEW.md) | What a mass scanner is, why stateless scanning exists, and a quick tour | |
| 83 | +| [`learn/01-CONCEPTS.md`](learn/01-CONCEPTS.md) | The handshake, SYN cookies, the permutation, responsible scanning, with real breaches | |
| 84 | +| [`learn/02-ARCHITECTURE.md`](learn/02-ARCHITECTURE.md) | The two-engine model, the module map, and the backend ladder, with diagrams | |
| 85 | +| [`learn/03-IMPLEMENTATION.md`](learn/03-IMPLEMENTATION.md) | A code walkthrough, module by module | |
| 86 | +| [`learn/MECHANICS.md`](learn/MECHANICS.md) | The cookie, checksum, cyclic group, dedup, and token bucket, byte by byte, with the measured numbers | |
| 87 | +| [`learn/04-CHALLENGES.md`](learn/04-CHALLENGES.md) | Extension ideas, from a real AF_XDP benchmark to new probe modules | |
| 88 | + |
| 89 | +## Architecture |
| 90 | + |
| 91 | +Two cooperating machines: a stateless line-rate transmit and receive core that owns the packet hot path, and a memory-safe control plane that parses intent, paces the sending, and presents results. |
| 92 | + |
| 93 | +``` |
| 94 | + you --> cli / scancmd control plane: parse, validate, select backend |
| 95 | + | |
| 96 | + +---------+---------+ |
| 97 | + v v |
| 98 | + tx (transmit) rx (receive) data plane: no heap alloc after startup |
| 99 | + targets.next() read frame |
| 100 | + cookie.seq classify open / closed / filtered |
| 101 | + template.stamp cookie.validateSynAck (ack == cookie + 1 ?) |
| 102 | + ratelimit gate | |
| 103 | + packet_io v |
| 104 | + | dedup.insert --> output (dashboard / table / NDJSON) |
| 105 | + v |
| 106 | + AF_XDP --> AF_PACKET TX_RING --> the wire --> AF_PACKET RX |
| 107 | +``` |
| 108 | + |
| 109 | +Nothing links the transmit column to the receive column except arithmetic: the cookie written into the sequence number is the same cookie recomputed on receipt. A reply without a valid cookie is dropped before it can pollute the results. |
| 110 | + |
| 111 | +**Design decisions:** the address permutation is a multiplicative cyclic group (zmap's approach, uniform coverage), not masscan's BlackRock Feistel cipher, which the 2024 "Ten Years of ZMap" retrospective found finds fewer hosts due to randomization bias. The prime is computed at runtime for the exact scan size rather than drawn from a fixed table. Raw packet I/O goes straight through `std.os.linux`, never `std.posix`, which dropped its socket wrappers. The `AF_XDP` path is asymmetric: it accelerates transmit and pairs with an `AF_PACKET` receive, because replies are sparse and this avoids receive-side steering. |
| 112 | + |
| 113 | +## Build and Test |
| 114 | + |
| 115 | +```bash |
| 116 | +zig build # debug build at zig-out/bin/zingela |
| 117 | +zig build -Doptimize=ReleaseSafe # the shipped artifact |
| 118 | +zig build -Dxdp=true # include the AF_XDP TX backend |
| 119 | +zig build test # unit tests (240) |
| 120 | +zig build bench # hot-path microbenchmarks |
| 121 | +zig build release # static musl binaries for x86_64 + aarch64 |
| 122 | +just ci # build + test |
| 123 | +``` |
| 124 | + |
| 125 | +> [!NOTE] |
| 126 | +> Plain `zig build` produces a **Debug** binary; the shipped artifact is `--release=safe` (ReleaseSafe), which keeps every undefined-behavior check live as a fail-closed trap rather than silent corruption. Sending raw packets needs `CAP_NET_RAW` and `CAP_NET_ADMIN`: run `just setcap` (or `sudo setcap cap_net_raw,cap_net_admin=eip ./zig-out/bin/zingela`) once, then run without sudo. The capability is cleared whenever the binary is rebuilt, so reapply it after every build. |
| 127 | +
|
| 128 | +## Performance |
| 129 | + |
| 130 | +The hot path is CPU-cheap by design, which is the whole basis of the honest positioning above: the bottleneck is the kernel transmit path, never packet construction. These are measured with `zig build bench` on an Intel Core i7-14700KF, single core, ReleaseFast, with inputs varied per iteration and results folded into a printed sink so nothing is optimized away. Reproduce them with `just bench`. |
| 131 | + |
| 132 | +| Operation | ns/op | Throughput | |
| 133 | +|---|---|---| |
| 134 | +| RFC 1071 checksum, scalar, 40 B header | 6.0 | 6.7 GB/s | |
| 135 | +| RFC 1071 checksum, SIMD, 1500 B frame | 30.4 | 49 GB/s | |
| 136 | +| SipHash SYN-cookie generate | 14.0 | 71 M/s | |
| 137 | +| SipHash SYN-cookie validate | 14.8 | 68 M/s | |
| 138 | +| SYN frame stamp (full Ethernet+IP+TCP) | 22.9 | 44 M/s | |
| 139 | +| Cyclic-group address permutation step | 4.6 | 216 M/s | |
| 140 | +| RX dedup insert | 15.4 | 65 M/s | |
| 141 | + |
| 142 | +A single core builds about 44 million complete SYN frames per second, roughly twenty times faster than the `AF_PACKET` kernel can drain them at 1.5 to 2.5 Mpps. The CPU is never the wall. That is exactly why the only real path to higher throughput is bypassing the kernel with `AF_XDP`, and why there is no honest raw-pps advantage to claim on stock hardware. |
| 143 | + |
| 144 | +## Project Structure |
| 145 | + |
| 146 | +``` |
| 147 | +zig-stateless-scanner/ |
| 148 | +βββ build.zig # module graph, `release` (musl x2) + `bench` steps, version |
| 149 | +βββ build.zig.zon # package manifest |
| 150 | +βββ install.sh # one-shot curl|bash: prebuilt-first, source fallback, setcap |
| 151 | +βββ src/ |
| 152 | +β βββ main.zig # entry: parse args, dispatch smoke / tx / scan |
| 153 | +β βββ cli.zig # argument parsing, help, the violet banner, --version |
| 154 | +β βββ scancmd.zig # the scan command: validate, select backend, launch engines |
| 155 | +β βββ targets.zig # cyclic-group permutation, primitive-root finder, exclusion floor |
| 156 | +β βββ numtheory.zig # modexp, primality, primitive-root test for the permutation |
| 157 | +β βββ packet.zig # wire headers, RFC 1071 checksum (scalar + @Vector), SYN presets |
| 158 | +β βββ cookie.zig # SipHash SYN-cookie generate + validate, v4 and v6 |
| 159 | +β βββ template.zig # the SYN frame template stamped per target |
| 160 | +β βββ ratelimit.zig # the token-bucket pacer |
| 161 | +β βββ tx.zig rx.zig # the transmit and receive engines |
| 162 | +β βββ classify.zig # reply classification open / closed / filtered |
| 163 | +β βββ dedup.zig # the lockless open-addressed dedup table |
| 164 | +β βββ packet_io.zig # backend interface: afpacket.zig, afxdp.zig, connect.zig |
| 165 | +β βββ udp.zig # UDP payloads and UDP classification |
| 166 | +β βββ service.zig # opt-in banner and service detection (no JA4) |
| 167 | +β βββ stealth.zig # the --authorized-scan gated evasion features |
| 168 | +β βββ output.zig # live dashboard, results table, NDJSON |
| 169 | +β βββ netutil.zig ndp.zig rawprobe.zig # iface + gateway resolution, NDP, cloud probe |
| 170 | +β βββ bench.zig # the hot-path microbenchmark harness |
| 171 | +βββ learn/ # the teaching track (this is public) |
| 172 | +``` |
| 173 | + |
| 174 | +## License |
| 175 | + |
| 176 | +[AGPL 3.0](LICENSE). |
0 commit comments