i wrote a packet sniffer to learn protocols, then lied about what it detects

cybersecurity · Oct 4, 2026 · 5 min read

i built sniffcli because i wanted to understand what actually crosses my network. i had used tcpdump for years and remained completely unable to explain what a single line of its output meant.

the plan was simple: write the thing myself, read the packets, learn the protocols.

the plan worked. but not in the direction i expected.

the honest part first

the readme says the tool does "ssh brute-force detection". it does not. here is the entire feature:

if dstPort == 22 {
sshCounters[info.Source]++
if sshCounters[info.Source] > 3 {
info.IsAlert = true
info.Info = "POTENTIAL SSH BRUTE FORCE"
}
}

that is the whole thing. it counts packets to port 22, per source address, and shouts after the fourth one.

this cannot detect a brute-force attack. it cannot see a password, because ssh encrypts the entire session. the authentication exchange happens inside an encrypted tcp stream, and a passive sniffer sitting on the wire has no way in. even if i turned on tcpdump -A i would see binary ciphertext.

so what it actually detects is "something on my network talked to port 22 more than three times". that is not brute-force detection. that is a connection counter with a scary message attached.

why i still kept the feature

because it is not useless, it is just badly named. more than three connections to port 22 in a short window is a real signal — i have triggered it myself with ssh retries after a typo. and the same mechanism catches a scanner that is walking the network.

what i should have done is label it for what it is. "repeated ssh connections" is honest. "brute-force detection" promises something the code cannot deliver, and i wrote that promise.

the general lesson, which took me an embarrassingly long time to learn: a feature named after a threat implies a capability you may not have built. if your tool claims to detect attacks, the test is whether it can see the thing attackers actually send. mine cannot see any of it.

what i learned about the protocols

more than I expected, and none of it from documentation.

the layering is the part that finally clicked. a single frame is not one thing, it is a stack, and gopacket will hand you each layer separately:

if arpLayer := packet.Layer(layers.LayerTypeARP); arpLayer != nil {
// ...
} else if ipLayer := packet.Layer(layers.LayerTypeIPv4); ipLayer != nil {
// ...
} else if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil {
// ...
}

tcp is not "the packet". it is a layer that can exist without ip (some encapsulations), which is why the real code checks tcp and udp in their own branch after the address layers, instead of assuming a fixed order.

"who has" is what an arp broadcast is: a machine asking the whole network "who has this ip, tell me your mac". it is the first thing you see if you start sniffing on a wired network, and it floods constantly because arp has no memory.

multicast and discovery traffic is most of the noise. 224.0.0.0/24 for ipv4, ff02:: for ipv6, plus mdns on 5353, ssdp on 1900, dhcp on 67/68. my first twenty minutes of sniffing were almost entirely this. the -c flag exists purely to hide it.

a connection is bidirectional and a packet is not. the tool reads tcp.SrcPort and tcp.DstPort on every single frame, so the same conversation appears twice with the ports swapped. there is no built-in reassembly, and i do not write one. that is a real limitation and i would rather name it than pretend.

the part i got wrong twice

isLocalIP decides whether a packet is "unusual outbound". it treats loopback, link-local, multicast and the three private ranges as local.

the bug: anything inside 192.168.0.0/16 counts as local, including other machines on my own network. so a device quietly exfiltrating to the nas next to it produces zero alerts. my rule says "not leaving the house", which is not the same as "not leaving my machine".

the honest fix is to compare against the actual interface address, not against a range of possible networks. i have not fixed it. it is written down here instead, which is the only reason this paragraph exists.

the part i am happiest about

a concurrent ui that does not stutter.

the sniffer runs in its own goroutine and pushes packets into the tea program:

p := tea.NewProgram(m, tea.WithAltScreen())
go startSniffing(iface, watchlist, p)
if _, err := p.Run(); err != nil {
// ...
}

the loop never touches the ui directly, it only sends messages. that is the whole reason the interface stays responsive during a traffic spike — a rule i already knew from the frontend side of my own site, and only appreciated properly once the ui was a terminal instead of the dom.

it is also why HighPerformanceRendering is explicitly set to false: this is a read-only log that the user scrolls, so letting bubbletea skip frames is cheaper than keeping them smooth.

and the small thing that made it pleasant to use: a device alias map, so 192.168.1.185 renders as "main rig" and a watched mac shows up in orange. security tools have a reputation for being unpleasant, and half of making one pleasant is letting the user see their own house in human words.

what i would tell my past self

open the file you wrote and count the lines that actually do what the name claims.

if the answer is three lines and they count something adjacent to the threat, you have written a connection counter. rename it before someone trusts it — including you, six months later, when you wonder why the alerts feel wrong.