Sentinel: a packet analyzer and IDS from scratch in Python
Reading the wire one byte at a time, then checking it against tshark and real traffic.
Sentinel is a packet analyzer and rule-based intrusion detection system written from scratch in Python. The protocol parsers are hand-written with struct: no Scapy, no dpkt, no pyshark, and no runtime dependencies. It reads pcap and pcapng files and live interfaces on Linux and Windows, prints tcpdump-style lines, reassembles TCP streams, filters with its own language, prints JA3 fingerprints, and raises JSON alerts for port scans, SYN floods, ARP spoofing, DNS and ICMP tunneling and SSH brute force.
It was built in 13 stages. The first seven built the core, with the focus on what the parsers do with bad input and how to prove they are right. The next five left the lab: files people actually have, a field-by-field comparison with Wireshark's tshark, more detectors and Windows capture. The last one built nothing: it ran the unchanged detectors on 2.7 million packets of real traffic made by other people, and asked what that allows me to conclude.
A parser never raises
A packet analyzer reads bytes anyone on the network can send. So no parser raises on bad input: not on a truncated packet, a header that lies about its length, or random bytes. Every parser returns a frozen dataclass with three shared fields:
@dataclass(frozen=True, slots=True, kw_only=True)
class Layer:
payload: bytes = b""
error: str | None = None # header unusable; other fields mean nothing
anomalies: tuple[str, ...] = () # parsed, but something is offAn error means the header cannot be trusted: too short, wrong IP version, a TCP data offset below 5. An anomaly means it parsed but something is off, like a bad checksum. The packet is kept, because the IDS wants to see oddities, not lose them. TCP and UDP checksums are only verified when the whole segment was captured, otherwise a snap-length cut would look like a bad checksum.
$ python -m sentinel read demo.pcap # 2 of 19 lines 2023-11-14 22:13:20.005000 IP 10.0.0.1.40000 > 10.0.0.2.80: Flags [S], seq 1000, win 64240, options [mss 1460,sackOK,TS val 1000 ecr 0,nop,wscale 7], length 0 2023-11-14 22:13:20.018000 IP 10.0.0.1.43000 > 10.0.0.2.443: Flags [P.], seq 1, ack 1, win 64240, length 161: TLS ClientHello, sni example.com, versions [TLS 1.3, TLS 1.2], ciphers (15) [TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, +12 more]
The SYN above is 74 bytes. Each parser reads its own header, checks every length before reading, and passes the rest on. decode() stops at the first layer with an error:
decode(frame) returns (Ethernet, IPv4, Tcp)
Every byte is copied from the generated packet. The checksums read "valid" because the parsers verified them.
The pcap reader handles all four magic numbers, keeps timestamps as integer nanoseconds, and caps each record at 262,144 bytes before reading. It is the one place that raises, because a broken file is not a packet.
What the payload says
DNS is found by port 53; HTTP and TLS by content (HTTP/1. or a method, 16 03 plus a ClientHello), so HTTP on 8080 still works. Every application parser works on one segment; stage 3 handles streams.
DNS compression pointers. A name can end in a pointer to an earlier name. A pointer to itself, or two pointing at each other, loops forever. So a pointer must point backward into the message body, a name may follow at most 32 pointers, and a name is at most 255 bytes. The record counts in the header (up to 65,535) are untrusted too; parsing stops at the first missing record:
if not _HEADER_LEN <= target < pos:
raise _Malformed("compression pointer does not point backward into the message")
# a header that claims 65,535 records in each section, and nothing after it
parse_dns(header).anomalies == ('truncated dns message: question 1 of 65535 is missing',)HTTP/1.x parsing is size-capped and flags conflicting Content-Length values (a request smuggling sign). The TLS ClientHello gives the SNI, versions and cipher suites, with GREASE values hidden in the printed line.
Printing what the network sends. A terminal escape sequence inside a hostname would run in the analyst's terminal. Parsers escape control characters as \xNN at parse time, so filters and JSON alerts get the same safe strings.
From packets to conversations
A flow is a protocol and two endpoints; the client is whoever sent the SYN. Each direction of a TCP connection is reassembled into one byte stream. This is a POST split into three segments, out of order with one duplicate, replayed through the real reassembler:
When two copies of the same bytes overlap, the first one wins, and different bytes in the later copy count as a conflict. Sending different bytes for the same position is how an attacker confuses an IDS, and a capture does not say which OS receives the data, so reporting beats guessing. Only bytes contiguous from the stream start are handed out; a gap is never filled with zeros. Positions are signed offsets from the first sequence number seen, so 232 wrap needs no special case.
This fixes what per-packet reading gets wrong. The demo capture has a POST body with text that looks like a request line at the start of its own segment, and a ClientHello split in two with the second half first. read prints a request nobody made and a half hello with no server name. flows jumps over the body with Content-Length, finds one POST, and reads the hello from the whole stream:
$ python -m sentinel flows streams.pcap # 1 of 6 flows tcp 10.0.0.1:45000 > 10.0.0.2:8443: established, pkts 4/1, bytes 161/0, 0.004000s [1 out-of-order client segment] | -> TLS ClientHello, sni example.com, versions [TLS 1.3, TLS 1.2], ciphers (15) [TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, +12 more]
read stays stateless on purpose: constant memory, and a line never depends on the rest of the capture. Everything that grows is capped and counted: 1 MiB per stream, 4,096 byte ranges per stream, 256 MiB in total, 200,000 flows.
A filter language
A lexer, a parser and an evaluator, no library. not binds tighter than and, and tighter than or, and tcp port 80 means tcp and port 80 as in tcpdump. and/or keep their operands in one flat node, so a thousand-term chain is not a thousand levels of recursion.
A protocol word matches even if that layer has an error (a broken TCP header is still TCP), but host, net and port need an intact header, so the zeros an error leaves behind never match port 0. A filter comes from a person, so a typo is an answer, not a traceback: parse_filter returns an error and a position, and the command exits with 2 before opening the file.
$ python -m sentinel read demo.pcap -f "tcp and port http" # exit code 2
sentinel: invalid filter: expected a port number (0-65535), got 'http'
tcp and port http
^Python's int() raises on very long digit strings, so numbers are capped at ten digits. The evaluator is checked against 34 hand-written predicates over 80 packets; the parser against 25,000 random texts that must not raise.
Raising the alarm
Four detectors, each counting something simple over a sliding window and saying what it counted:
| detector | what it looks for | default |
|---|---|---|
port_scan | one source probing many ports of one host, or one port of many hosts (SYN and stealth scans) | 15 ports or 30 hosts in 10 s |
syn_flood | many SYNs to one service, few completed by the handshake's ACK | 100 SYNs in 1 s, at most 30% completed |
arp_spoof | an IP moving to another MAC, or an ARP sender that is not the Ethernet source | any change |
dns_tunnel | long names, random-looking subdomains, many subdomains of one domain, bursts of NXDOMAIN | 100 / 50 chars, 4.2 bits, 50 subdomains, 20 answers |
Every detector needs a test in both directions. A harmless capture kept just below every threshold (647 packets) gives no output; the attack capture gives exactly 13 alerts:
$ python -m sentinel ids attacks.pcap --format text # 3 of 13
2023-11-14T22:13:20.140000Z [medium] port-scan: 10.9.9.1 probed 15 ports on 10.0.0.30 in 0.1s
2023-11-14T22:13:32.099000Z [high] syn-flood: 100 SYNs to 10.0.0.2:80 in 0.1s from 100 sources, 20 completed
2023-11-14T22:13:50.000000Z [medium] dns-tunnel: 10.0.5.5 asked for a random-looking name under evil-cdn.testA busy server is not a flood. A SYN rate alone flags any busy server. What differs is what comes next: a real client answers the SYN-ACK with an ACK, a spoofed source never does. The detector matches ACKs to SYNs by 4-tuple. A busy server with 150 SYNs in a second, all completed, stays quiet; a flood of 400 SYNs in 0.4 s with 20 completed alerts at the hundredth SYN.
Measure before choosing a threshold. My first entropy threshold was a guess, and wrong. Measured on 40-character subdomains: base32 is above 4.2 bits in 290 of 300 samples, base64 in 300 of 300, hex never (it tops out at 4), and readable made-up hostnames reach 4.10. So the default is 4.2, only names of 40+ characters are judged, and entropy is one of four signals. Hex tunnels are missed.
Thresholds come from TOML rule files (standard library). A rule can carry a stage 4 filter, so one rule can watch SSH more closely than the rest. An unknown key is an error, so a typo cannot silently keep a default. Exit codes: 0 when the capture was read, alerts or not; 1 for an unreadable capture; 2 for bad rules.
A real network
live reads Ethernet frames from one Linux interface through an AF_PACKET socket and feeds the same decode, filter, print, detect and write steps. It only listens and does not enable promiscuous mode. I develop on Windows, so the socket layer is as thin as possible and a stand-in socket replays the generated captures. The strongest test: live on the attack capture must print exactly the 13 alerts ids prints, each one as it is raised. Output and --write flush after every packet, so a pipe shows lines at once and a killed capture leaves a readable file.
Filters and rules are checked before the interface opens, so a typo never needs root, and interface names are validated so ../eth0 cannot escape /sys/class/net.
Asking what changes when input never ends found a stage 5 bug: detectors never forgot a source, and past 100,000 keys they stopped tracking new ones, so a busy network would go blind within days. Tables now drop keys idle longer than the window, whose window is empty anyway. In a replay of 150,000 sources followed by a real scan, the old table missed the scan; the new one stays near 5,000 keys and finds it.
Measuring it
python -m tools.bench runs every stage over the same 100,000 generated packets (about 65 bytes each), three times, fastest run kept. On one core (Windows 11, Python 3.13.5): pcap read 1,564,676 packets per second, write 2,567,763, decode 98,710, read 44,135, ids 42,033. A profile of decoding shows no single hot spot: the biggest share is 13%, the rest is spread over parsers, checksums and ipaddress objects. The live loop runs 27,635 to 37,716 packets per second, about 2 MB/s at 65 bytes a packet. A busy network will make the kernel drop packets; the summary line says how many.
python -m tools.fuzz damages generated packets and small pcap files (bit flips, extreme lengths, cuts, splices) and sends them through the decoder, the summary, eight filters, a flow table and the detectors, all keeping state. A failure is an exception, a non-printable summary line, or an alert that is not one line of JSON. 3,000,000 inputs found nothing. Against the 148 deliberate breaks of stages 1 to 6, the fuzzer alone caught 2 and the tests all 148: it finds crashes, not wrong answers that look fine.
CI runs ruff, strict mypy, the tests, 200,000 fuzz inputs and a benchmark on Python 3.12 and 3.13, Linux and Windows, plus a root job on Linux that runs the real packet socket on loopback and fails if those tests were skipped. It took four pushes: an unquoted step name with a colon that YAML read as a new key, then a float tolerance that only failed on Python 3.12 on Windows. The fourth run was green, and the real socket captured, decoded and saved a UDP datagram on loopback. The kernel delivers each loopback packet twice; like libpcap, live drops the outgoing copy, and CI confirmed two copies with that filter off and one with it on.
Verification
The generator uses the same checksum function as the parsers, so a bug there would cancel itself out. Wherever possible, tests compare against something written separately: a textbook IPv4 header with a known checksum, a hand-built TCP pseudo-header, one hand-written hex packet per protocol, a big-endian pcap built with struct, and 300 random payloads cut into overlapping, shuffled pieces near the 32-bit wrap that must reassemble exactly.
Then I broke the code on purpose, one line at a time: 196 breaks across all stages, all caught. 110 of them are caught by a single test, so coverage is not deep.
The file people actually have
After seven stages Sentinel had only seen traffic I generated, and could not open the files people actually have. The next five stages took the most important items from that limits list, under the same rules: one stage at a time, tests, ruff and strict mypy clean, a sabotage run, and a plain note on what I could not check.
Wireshark has saved pcapng by default for years, and the stage 1 reader only knew classic pcap:
$ python -m sentinel read demo.pcapng
sentinel: demo.pcapng: not a pcap file: bad magic 0a0d0d0aTrue and useless. Now the same command prints exactly what it prints for the classic file. The reader is 183 lines in sentinel/pcap/pcapng.py, and open_reader picks the format for read, flows and ids.
A pcapng file is a list of blocks. Each starts with a type and total length, ends with the length again, and is a multiple of four bytes, so a reader can skip any block it does not understand. A section header gives the byte order, an interface description gives the link type and timestamp resolution, and each packet is an enhanced packet block. The same 19 packets are 1,948 bytes as pcap and 2,312 as pcapng.
Format detection is four bytes. The section header type 0a 0d 0d 0a is a palindrome, so it reads the same in either byte order, and it can never be a classic pcap magic:
magic = fp.read(4)
fp.seek(-len(magic), io.SEEK_CUR)
return PcapngReader(fp) if magic == SHB_BYTES else PcapReader(fp)Byte order is kept per section, because two appended captures can differ, and the interface list resets at each section. Timestamps are 64-bit counts in units set per interface by if_tsresol (10-n or 2-n seconds, microseconds if missing), plus an optional if_tsoffset. The reader converts them to integer nanoseconds with integer arithmetic only. A float keeps 53 bits, and a current timestamp in nanoseconds needs 61, so floats would round every packet to 256 ns steps:
ts_ns = (high << 32 | low) * 1_000_000_000 // interface.ticks_per_second
ts_ns += interface.offset_ns
if not 0 <= ts_ns <= MAX_TS_NS:
raise PcapError(f"pcapng timestamp {ts_ns} ns is out of range")The range check exists because of a mistake I almost made. With whole-second resolution, a 64-bit count reaches 585 billion years, but datetime stops at year 9999, so a hostile timestamp would have made read raise OverflowError while printing. Timestamps outside 1970 to 9999 are now refused.
Name resolution, statistics, decryption secrets and custom blocks are skipped by length. The reader refuses, with a PcapError after yielding every good packet: a bad or unrepeated block length, a cut block, a version other than 1, a packet for an undescribed interface, an oversized captured length, a malformed option. A packet on a non-Ethernet interface stops the read with a message instead of being decoded as Ethernet by mistake.
My tests build files by hand from the spec, not with my generator. I also checked against python-pcapng, a package someone else wrote, installed only in a scratch folder: their writer to my reader agreed on 66 packets across both byte orders and six resolutions, my writer to their reader on 2,190. The fuzzer now sends half of its damaged files as pcapng, some spliced with classic headers: 3,000,000 inputs, 0 failures.
The sabotage run had 57 new breaks, all caught, but the first run hung on three. They broke timestamps, so a test comparing the 647-line pcap and pcapng outputs failed, and pytest spent minutes building a diff of two long texts. The test now reports the first differing line number instead, and fails in six seconds.
Comparing with tshark
Everything so far was checked against my own reading of the protocols, so a misreading could sit in both code and tests. tshark was written by other people and has read far more traffic. tools/compare_tshark.py (a dev tool, not a dependency) runs tshark with reassembly off and checksums verified, asks for about 75 fields per packet, translates them into Sentinel's form, and compares. Most of the work was defining "equal": tshark prints 12-bit TCP flags where Sentinel keeps nine, counts fragment offsets in 8-byte units, and prints scaled windows. Each translation has a test. TCP and UDP conversations are compared too.
Four captures are generated, six are public Wireshark sample captures (1,245 packets, about 110 KB), downloaded with permission and kept out of the repository. One, win_scale, is a pcapng that Wireshark itself wrote. tshark 4.6.8:
| capture | packets | values compared | differences | explained |
|---|---|---|---|---|
sample.pcap (generated) | 19 | 529 | 0 | 20 |
streams.pcap (generated) | 32 | 892 | 0 | 27 |
benign.pcap (generated) | 647 | 17,906 | 0 | 156 |
attacks.pcap (generated) | 1,044 | 29,884 | 0 | 333 |
dns.cap (DNS over UDP) | 38 | 1,356 | 0 | 57 |
ipv4frags.pcap (fragmented ICMP) | 3 | 57 | 0 | 0 |
win_scale.pcapng (written by Wireshark) | 26 | 719 | 0 | 0 |
v6.pcap (IPv6, 1999) | 161 | 3,548 | 0 | 126 |
vlan.cap (802.1Q, 1999) | 395 | 7,645 | 0 | 25 |
arp-storm.pcap | 622 | 6,220 | 0 | 0 |
Zero differences, but only after three real fixes. The first run on the public captures disagreed.
Three bugs other people's traffic found
Checksum offload. win_scale was recorded on the sending machine, where the network card completes the TCP checksum after the capture point. The field holds only the uncomplemented pseudo-header sum. Sentinel flagged 14 of 26 TCP packets as bad; tshark says "matches partial checksum, likely caused by TCP checksum offload". This was a false positive I already knew about. For the first packet, the pseudo-header words fold to 0x1215, exactly the field value. A checksum equal to the partial sum is no longer flagged, for TCP and UDP over IPv4 and IPv6. Tests check that the partial sum ±1, complemented, or with the top bit flipped are still flagged:
$ python -m sentinel read win_scale.pcapng before: 20:22:25.360596 IP 192.168.200.135.6711 > 192.168.200.21.2000: Flags [S] [bad tcp checksum] after: 20:22:25.360596 IP 192.168.200.135.6711 > 192.168.200.21.2000: Flags [S]
The first fragment. decode stopped at the IP header of every fragment, but the first fragment carries the transport header. In ipv4frags.pcap the echo request printed as ip-proto-1. The first fragment is now decoded (checksum not verified, since the message is incomplete), and filters and flows follow.
A length that looked like an ethertype. A type field below 0x0600 is an 802.3 length with LLC behind it. vlan.cap has many, and read printed ethertype 0x00a6. That was a wrong statement, not a missing feature, so it now prints 802.3.
Differences that are not disagreements
744 field differences remain, and each has a rule with a reason. The report counts them by reason instead of hiding them, and tests check that a rule cannot explain a different kind of difference. 618 are DNS flags that tshark omits from queries. 76 are headers quoted inside ICMP errors, which tshark decodes and Sentinel does not. 25 are LLC frames. 21 are DNS-over-TCP messages split across segments. To prove the tool can notice anything, a test runs real tshark with every Sentinel TCP window changed to 1, and the tool must report exactly that field on exactly the nine TCP segments.
Seven of the 46 sabotage breaks for this stage survived the first run, all in the tool's own tests (a rule that explained too much, a packet count checked in only one direction). I tightened them. Wireshark's editcap also converted 9 captures to pcapng, and the reader returned the same 2,961 packets, timestamps included.
Two more detectors
| detector | looks for | default |
|---|---|---|
ssh_brute_force | one client completing many TCP connections to one server's SSH port | 10 in 60 s |
icmp_tunnel | echo requests of 512+ bytes, and replies that do not repeat their request's data (IPv4) | 10 large, or 5 changed, in 60 s |
SSH is encrypted, so failed logins are invisible; connection rate is not. Only completed handshakes count, because SYNs without answers are scans, and those already have a detector. A retransmitted SYN is still one connection, and later ACKs on the same connection do not count again.
ICMP. A real ping sends 56 bytes (Linux) or 32 (Windows) and gets them back unchanged. A tunnel sends more and gets different data back. To compare a reply with its request, the detector keeps an 8-byte digest of the request data, not the data. A hundred thousand 64 KB requests would otherwise be six gigabytes. Messages cut by the snap length are ignored, since a cut request looks small and a cut reply looks changed.
The thresholds are guesses: I had no capture of real SSH guessing or a real tunnel. The harmless capture sits exactly one below each threshold (9 SSH connections, 9 large pings answered correctly, 4 rewritten replies), and tests lower each threshold by one to prove it is at the edge. On the six public captures neither new detector fired, but an old one did: dns.cap raised a dns-tunnel alert for an Active Directory lookup with a GUID label (_ldap._tcp.05b5292b-…._msdcs.utelsystems.local). The entropy test reads a GUID as random, which it is. The fix is recognizing the GUID shape, not moving the threshold; it is in the README limits.
One sabotage break survived: a message that printed the threshold instead of the count. The first alert of a burst always has exactly threshold connections, so the text looked the same. The test for a later alert now checks the count in the message.
JA3 fingerprints
The server name says who a client talks to. The TLS library decides which ciphers and extensions it offers and in what order, so the ClientHello also says what is talking. JA3 is an MD5 of five fields in decimal, lists joined with dashes and fields with commas: version, ciphers, extension types, curves, point formats. read and flows print it, and the filter language gained ja3 HASH:
$ python -m sentinel read sample.pcap --filter "ja3 61279becc80ab0e3aca57f5913c3e1a0" 2023-11-14 22:13:20.018000 IP 10.0.0.1.43000 > 10.0.0.2.443: Flags [P.], seq 1, ack 1, win 64240, length 161: TLS ClientHello, sni example.com, versions [TLS 1.3, TLS 1.2], ciphers (15) [TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, +12 more], ja3 61279becc80ab0e3aca57f5913c3e1a0
GREASE values (0x0a0a to 0xfafa) change on every connection, so JA3 drops them from every list. A test draws GREASE twice and gets the same hash, while look-alikes such as 0x0a1a are real values and stay.
A fingerprint of half a hello is worse than none, because it is a valid-looking hash that belongs to no client. The stage 2 parser keeps whatever it can read from a damaged hello, which is right for a summary line. So TlsClientHello now has a complete flag, and ja3 is empty unless every length checks out. A test cuts a good hello at every position and checks that no prefix gets a fingerprint. In the demo capture, read sees a split hello in two pieces and prints no JA3; flows reassembles it and does.
The spec's own example (769,47-53-5-10-…,0-10-11,23-24-25,0) gives ada70206e40642a3e4461f35503241d5 as required, and tshark's JA3 agrees on 203 field values. Two sabotage survivors turned out to be a dead branch, which I deleted, and two were real: an extensions block whose inner lengths lie was taken as complete. Cutting a hello cannot produce those cases, so they got their own tests. Computing JA3 costs 4.56 µs and only runs when asked.
Capture on Windows
Windows has no AF_PACKET. The options are Npcap, which would be a dependency in a project with none, or a raw socket with SIO_RCVALL, which needs Administrator and sees less. Before writing code, I opened one and probed it. Whole IP packets arrive with no Ethernet header, each once, including loopback (Linux gives two). Outgoing TCP checksums are offload partial sums, which stage 9 already handles. IPv6 gave nothing, and Windows reports no drop count.
One thing is wrong: in all seven TCP connections I recorded, the handshake's ACK arrives before the SYN-ACK it answers:
$ python -m sentinel live 192.168.1.136 --write out.pcap --duration 5 2026-09-20 02:55:55.363998 IP 192.168.1.136.51293 > 192.168.1.1.80: Flags [S], seq 944683354, win 65535, length 0 2026-09-20 02:55:55.364504 IP 192.168.1.136.51293 > 192.168.1.1.80: Flags [.], seq 944683355, ack 2354516038, win 255, length 0 2026-09-20 02:55:55.364588 IP 192.168.1.1.80 > 192.168.1.136.51293: Flags [S.], seq 2354516037, ack 944683355, win 42340, length 0
Timestamps are read times, so they cannot fix the order, and there is nothing honest to repair it with. It is documented. The detectors are unaffected: 150 handshakes in this order raise no SYN-flood alert, and the same SYNs without ACKs do.
Capture only ever needed recvfrom, settimeout, getsockopt and close, so Windows is one more socket. IpSocket prepends a 14-byte Ethernet header with zero addresses (an invented MAC would look like data) and the ethertype from the version nibble, for 0.274 µs a packet. Interfaces are named by IPv4 address. A real 5-second run captured 87 packets; read printed the same 87 lines, flows saw two closed connections, and ids was silent.
45 tests use a stand-in socket; 5 run only as Administrator on the real one. A new CI job runs those as Administrator and fails if they were skipped. On the first run one failed: the runner leaves the loopback IPv4 header checksum at 0 for the card to fill in, which my machine never did. The test now accepts exactly that anomaly, and all six jobs pass. One machine had told me everything was fine; that is what the job is for. The sabotage run also exposed an old gap: a stage 7 break removing Windows from the CI matrix survived, because its test only searched for windows-latest, which the new job also contains. It now checks the matrix line itself.
Finding real traffic
After stage 12 almost everything in Sentinel had been tested on traffic I made myself or on a handful of small public files, and hardly at all on a busy real network. Stage 13 is that test. I took real captures, some of them attacks made by other people and some of them ordinary traffic that nobody has labelled, and ran the detectors over them without changing a line of the program; the rules are the ones it ships with. There is nothing to build in this stage, and the interesting part is what I am allowed to conclude.
This took longer than the measuring. What I wanted was labelled data, where someone says which packets are the attack, and the well-known datasets have it. They are also big. CIC-IDS2017 is about 50 GB and asks for a registration form. Kitsune has labels for every packet, including ARP spoofing and SYN floods, and its page lists files of 2 GB, but those are feature tables and not captures; the captures are 147 to 334 MB. The only way to get them is one zip of 19 GB, and the server answers a request for a part of a file with the whole file, so I could not take six files out of it. CTU-13 has captures that hold only the botnet's own traffic. On a slow connection, that was three ways of not getting it.
What I used instead is small, and I wrote each file down with its source before I fetched it:
| capture | what it is | source | packets |
|---|---|---|---|
pkt.TCP.synflood.spoofed | a spoofed SYN flood: only SYNs, from 37,623 different addresses, in 23.7 s | StopDDoS | 37,841 |
pkt.TCP.DOMINATE.syn.ecn.cwr | a SYN flood with the ECN and CWR flags set | StopDDoS | 9,878 |
arpspoofing | ARP spoofing on a small LAN | cs3 (a course repository) | 1,887 |
portscan | one host sends 1,365 SYNs to 1,000 ports of another | cs3 | 2,401 |
dns-tunnel-iodine | a DNS tunnel made by iodine | Elastic examples | 438 |
2026-08-07 | seven days of scans and probes at a web server | malware-traffic-analysis.net | 500,923 |
MAWI 2025-01-01 14:00 | the first 14.6 s of a 1 Gbps backbone link, packets cut to 96 bytes | MAWI (WIDE project) | 2,163,268 |
7 files, 2,716,636 packets. Two of them needed a trick. The backbone trace of MAWI is 3.9 GB compressed for 15 minutes, but that server does answer a request for a part of a file, and a gzip cut in the middle still opens up to the cut, so I took the first 60 MiB and got 2,163,268 packets, the last one cut in half. The file of the web server is a zip with a password, and the password is on the site's about page, as a picture.
One file I did not trust. The two cs3 captures come from a course repository that does not say where they come from, so before using them I looked inside with tshark: in the ARP file one MAC address (7c:d1:c3:94:9e:b8) answers for both the gateway and another host, which is what ARP spoofing looks like, and in the other 192.168.0.100 sends SYNs to 1,000 different ports of 192.168.0.101, which is what nmap's default scan looks like. They are what their names say. Where they come from is still not known, and this post says so again at the end.
Do the detectors fire on real attacks?
| capture | alerts | time |
|---|---|---|
| spoofed SYN flood | 4 syn-flood | 1.236 s |
| SYN flood with ECN and CWR | 1 syn-flood | 0.307 s |
| ARP spoofing | 5 arp-spoof | 0.049 s |
| port scan | 5 port-scan | 0.062 s |
| iodine tunnel | 3 dns-tunnel | 0.02 s |
Yes, all five, and each raised only the alert it should. This is what the port scan and the ARP spoofing look like from the command line, the real output:
$ python -m sentinel ids --format text portscan.pcap
2014-02-25T05:11:20.956948Z [medium] port-scan: 192.168.0.100 probed 15 ports on 192.168.0.101 in 0.1s
2014-02-25T05:11:30.974438Z [medium] port-scan: 192.168.0.100 probed 46 ports on 192.168.0.101 in 1.4s
2014-02-25T05:11:41.015451Z [medium] port-scan: 192.168.0.100 probed 64 ports on 192.168.0.101 in 2.7s$ python -m sentinel ids --format text arpspoofing.pcap
2014-02-25T06:11:11.070534Z [high] arp-spoof: 192.168.0.1 moved from f8:1a:67:cd:57:6e to 7c:d1:c3:94:9e:b8
2014-02-25T06:11:15.399497Z [high] arp-spoof: 192.168.0.1 moved from 7c:d1:c3:94:9e:b8 to f8:1a:67:cd:57:6e
2014-02-25T06:14:03.123374Z [high] arp-spoof: ARP says 192.168.0.103 is at d8:96:95:01:a5:c9, but the frame came from 7c:d1:c3:94:9e:b8In the ARP file the alerts include 7c:d1:c3:94:9e:b8, the address that tshark showed claiming two hosts. The labels of these files are one per file, "this is a SYN flood", so the honest reading is small: on one real example of each attack the detector for it fires. The two StopDDoS files have nothing but attack traffic in them, so they cannot show a false alarm, and one file of each kind says nothing about how many attacks would be missed.
Traffic that nobody labelled
The other two captures are the ones I was afraid of: a week of what reaches a web server on the internet, and 14.6 seconds of a link that carries a gigabit. Nobody can say which of their packets are attacks, so I cannot score the alerts as right or wrong. I asked tshark two things that I can check without trusting Sentinel. Is what an alert says true: a port-scan alert says a source probed 30 hosts on one port within 10 seconds, so I count the SYNs again from tshark's own output. And does an independent recount of the same rule find scanners that Sentinel did not report.
A week at a web server
27 port-scan alerts and 1 DNS alert in 500,923 packets. tshark counts the same number of ports for all 27 of the alerts, and the recount finds 16 scanning sources, the same 16 that Sentinel reported. None missed, none extra. The one DNS alert is a random-looking name under icann.org, which I did not look into.
A gigabit for fourteen seconds
2,163,268 packets and 795 alerts: 627 port scans, 165 DNS tunnels and 3 SYN floods.
The port scans. 624 of them are one source probing one port on 30 or more hosts. They come from 376 sources, and the recount finds the same 376. All 626 alerts that can be counted again reach the threshold in tshark's count, 551 of them with exactly the number in the alert. In the others the recount is higher, and that fits something I could see in the code: a window holds at most four times the threshold in events, so the number in an alert is between 30 and 120, and 443 say exactly 30, whatever the size of the sweep. The alert is true, and it understates a fast sweep. The ports that were probed most were 4117, 23, 22, 80, 443, 34567, 8080, 2222: mostly telnet, ssh and web ports, and a few odd ones such as 4117 and 34567.
The state limit. The last port-scan alert is not a scan:
19629 packets were not tracked: the detector's state limit was reachedEvery detector keeps bounded state, and reports it when it has to forget something. One table of the port-scan detector, the sources it is following, reached its 100,000 entries. That took fourteen seconds on this link. Sentinel said so, as it was written to in stage 5, and the recount found no scanning source that it had failed to report, so the limit did not hide a scanner from this slice. On a longer one it could.
The DNS alerts. All 165 come from one check: twenty "no such name" answers to one client within a minute. tshark confirms all 165, and shows 31,006 such answers to 1,815 clients in those 14.6 seconds, one client with 757. None of the other signals, a long name, a random-looking name or many different subdomains, fired once. So these are not tunnels, I think: on a backbone a client is often a resolver or a gateway that asks for many names for many people, and a threshold made for one client fires at once. I cannot prove it, because nobody labelled it.
The SYN floods. Three alerts, all for one address and one port, 131.112.115.241:993 (IMAPS): 100 SYNs from 16, 18 and 22 addresses within a second, and tshark counts the same. Over the whole slice 844 SYNs from 110 addresses went to that port, and no SYN-ACK from it was captured. That could be an attack, or clients trying again and again against a mail server that is down. The capture cannot tell me which. The link does not help: only 5.3% of the SYNs on it have a SYN-ACK in the capture (57.5% for port 443), because a backbone carries only some of the return paths.
A mistake of mine
The first recount told me Sentinel had missed a scanner. 202.121.34.125 looked like a source of SYNs to 30 hosts within 10 seconds, and Sentinel had not reported it. I looked at its packets: they were ip:icmp:ip:tcp. It was a router. What it sent were ICMP errors that quote the packet they refuse, and my tshark filter counted the SYN inside the quote as if the router had sent it. Sentinel ignores those, and it was right. I fixed the filter, and with it, and IPv6 counted, the two lists of sources are the same. It would have been easy to write "one scanner missed" and move on, and the number would have looked like a finding.
Speed
50,394 packets a second through the detectors on the backbone slice (35,073 on the week at the web server, 30,618 on the SYN flood), on the same machine as the benchmark above. The link carried about 148,000 packets a second, so Sentinel ran at a third of real time. Pure Python on one core. For a gigabit link that is a limit, and the README says so.
Limits
- Protocols: IPv6 fixed header only (no extension headers, no ICMPv6), no IP fragment reassembly, HTTP/1.x only, TLS ClientHello only.
- Streams: offline only, first megabyte per stream, first copy wins on overlaps.
- Filters: no host names, no byte offsets like
tcp[13], no TCP flag words;flows --filterfilters packets, not flows. - pcapng: name resolution, statistics, comments and packet flags are skipped;
.pcapng.gzis not opened; there is no pcapng writer, andlive --writesaves classic pcap. - Live capture: about 35,000 packets per second through the loop. Linux needs root and is tested only on a CI runner's loopback. Windows needs Administrator, sees IPv4 only, with no MAC addresses, no drop count and handshake ACKs reordered, and was tested on one machine for five seconds.
- tshark comparison: header fields and packet counts only, not reassembled bytes. One tshark version, six small public captures (two from 1999). Not run in CI.
- Detectors: each fired on one real example of its attack, which says nothing about how many attacks would be missed. SSH and ICMP thresholds are untested on real attacks, and backups and MTU tests will trigger them. Scans slower than the window or spread over many sources are missed, and a port-scan alert's count stops at four times the threshold. The DNS entropy test flags GUIDs and passes hex tunnels, and its NXDOMAIN check fires on busy resolvers. The port-scan state limit is reached in fourteen seconds on a backbone.
- JA3: client side only, no JA3S or JA4. Clients that shuffle extensions get a new JA3 per connection.
- Real traffic: labels are per file and the two big captures have none, so what I checked is that what the alerts say is true, not that each one is an attack. One slice of one link and one week at one server; where the cs3 files come from is not known.
- Speed: pure Python on one core runs at about a third of a gigabit link's packet rate.
I could turn a number down until the backbone slice looked clean, but that would only tune Sentinel to fourteen seconds of one link, so these stay in the README's limits list, where the next person will find them.
Next steps
There is no stage 14 planned. What would make this stronger is data with a label for every packet, so that "fires" can become "gets right, this many times". Kitsune has it, and it is 19 GB behind a server that will not hand over a part. If I find a mirror that does, that is the next measurement.