Anatomy of a Network Intrusion Detection System
Layered protocol dissection, Snort-inspired rules, and stateful threat detection at 28M packets/sec.
The IDS Pipeline
Commercial intrusion detection systems like Snort and Suricata follow a common architecture: Capture → Parse → Detect → Alert. SentinelFlow implements this full pipeline in C++17 with libpcap, achieving 28M+ packets/sec parsing throughput on a single thread.
Capture: BPF Filters Run in Kernel Space
SentinelFlow uses libpcap with a polymorphic PacketCapture interface — LiveCapture for real-time traffic and PcapFileReader for offline analysis. The critical optimisation is BPF (Berkeley Packet Filter): a filter expression like tcp port 80 is compiled via pcap_compile() and runs inside the kernel. Packets that don't match are never copied to userspace — orders of magnitude more efficient than filtering in application code.
Layered Protocol Dissection
Network frames are nested structures. You cannot jump to a fixed byte offset because each layer has variable length:
- ▹Ethernet (14 bytes) —
ntohson EtherType dispatches to IPv4 (0x0800) or ARP (0x0806) - ▹IPv4 — IHL (Internet Header Length) field tells you where L4 starts. Variable due to IP options.
- ▹TCP —
data_offsetfield tells you where the payload begins. Variable due to TCP options. - ▹UDP (8 bytes) — Fixed header, but conditionally triggers DNS parsing when port is 53
- ▹DNS — Walks label-encoded query names (length-prefixed segments)
- ▹ICMP / ARP — Type codes and hardware addresses
Each parser is a stateless free function operating on raw const uint8_t* — zero copies, zero allocations. The ParsedPacket struct uses std::optional<T> for each layer: if a parse fails or the protocol isn't present, the optional stays empty and downstream layers are skipped.
Snort-Inspired Rule Engine
SentinelFlow implements a subset of the Snort rule language — the de facto standard for signature-based detection:
`
alert tcp any any -> any 22 (msg:"SSH brute force"; flags:S; threshold:10,60; sid:2001;)
`
Rules are declarative. The header acts as a fast pre-filter (protocol, IP, port), while options refine the match: flag bitmasks, payload content strings, DNS query length thresholds. The rule parser extracts these into structured objects for the signature matcher to evaluate against each packet.
Stateful vs. Stateless Detection
Signature matching catches known-bad patterns in individual packets. But many real attacks are distributed across multiple packets:
- ▹Port scans — Only visible when you track the set of destination ports per source IP over time. SentinelFlow maintains a
std::set<uint16_t>per IP pair, firing when 15+ unique ports are hit in 60 seconds. - ▹SYN floods — Detected by monitoring half-open connection rates. A
std::dequeof SYN timestamps per destination IP, pruned lazily on access. Fires at 100+ SYNs in 10 seconds. - ▹DNS tunnelling — Exfiltration via encoded DNS queries produces anomalously long query names. The detector tracks queries exceeding 50 characters and fires when volume passes threshold.
All stateful detectors use sliding time windows with lazy pruning — old entries are removed on the next access rather than by a background thread. This eliminates synchronisation overhead.
Zero-Copy Parsing Performance
The parsers operate directly on the raw buffer provided by libpcap. No intermediate copies, no dynamic allocation per packet. The memcpy + ntohs/ntohl pattern is the standard approach in high-performance packet processing (used in DPDK, PF_RING, and production NIDS). The benchmark proves 28M+ packets/sec on a single thread — pure parsing throughput measured over 5 million synthetic TCP SYN packets.
Alert Outputs
The alert system uses a strategy pattern — AlertManager dispatches each alert to all registered outputs:
- ▹Console: ANSI color-coded by severity (green → yellow → red → bold red)
- ▹CSV: RFC 4180-compliant with proper escaping, ready for SIEM ingestion
Adding a new output (syslog, webhook, Kafka) means implementing a single emit() method.