A terminal-based passive network monitor for Linux, written in C99. Sloth does not
inject attack traffic — no probe requests, no deauth frames, no port scans, nothing
written to the monitored segment. Its shipped default is strict observation, and
that is enforced in code rather than promised in prose: the reverse-DNS resolver
worker is never started and no NL80211_CMD_TRIGGER_SCAN request is ever built, so a
default run originates no network traffic at all. Both of those are opt-in behind
--allow-active, which announces itself on stderr; optional telemetry (-o file.jsonl,
--data-socket) carries observations to a consumer you configured, over a path you
configured, never to the segment being watched. It observes what your host already
sees and turns it into 35 live views and 66 passive
alert rules, an embedded
WiFi-SIGINT toolkit (PNL aggregation, RSN/cipher/MFP inventory, EAPOL/PMKID
capture, hidden-SSID reveal, scored seqnum correlation across MAC rotations),
and an optional JSONL forensic log.
📖 Per-view deep dives live under docs/views/ — each
file explains the protocol, shows a text mockup, and lists what to watch
for in normal vs anomalous traffic.
📚 The complete reference is the wiki —
how Wi-Fi works, what sloth does, where exploits happen, monitor mode and
what it gathers, passive Wi-Fi SIGINT techniques, the full CLI reference,
and a living overview of the state of the art in Wi-Fi tech. The wiki is
authored in docs/wiki/ (source of truth) and mirrored to
GitHub automatically; start at docs/wiki/index.md.
v1.1 — WiFi SIGINT — sloth gained six new wireless capabilities on top of the v1.0 monitor: PNL aggregation per client MAC, RSN / cipher / AKM / MFP inventory from beacons, EAPOL / PMKID / 4-way handshake capture with hashcat-22000 export, hidden-SSID reveal, MAC-randomisation sequence-number correlation, and a dashboard alert-hot IP override that paints any IP appearing in a CRIT alert deep-red across every panel for 1 h. See the v1.1 release notes for the full diff.
[1] Interfaces [2] Connections [3] WiFi [4] Packets [5] Processes
[6] Stats [7] Probe [8] ARP [9] mDNS [0] NBNS
[d] DHCP [s] SSDP [b] Beacons [a] Deauth [h] HTTP
[t] TLS [u] QUIC [r] DNS [p] NTP [i] ICMP
[v] Alerts [g] Devices [o] Dashboard [l] OSI stack [?] Help
[x] Twins [y] KARMA [z] RADIUS ← evil-twin, PineAP & 802.1X lures
[f] Research ← sources behind each alert that fired
[c] FragAttacks ← per-BSSID counters for the seven CVE-2020-262xx/245xx rules
[k] PNL [e] EAPOL [j] Seqnum [w] Assoc ← WiFi SIGINT
[m] Channel ← per-channel histogram
| View | What it shows |
|---|---|
| Interfaces | Per-interface RX/TX rates, errors/drops, MTU, link speed; sparkline history |
| Connections | Active TCP/UDP sockets with PID, RTT, retransmits, per-conn bandwidth |
| WiFi | Nearby APs from nl80211 scan: signal, channel, encryption |
| Packets | Live pcap capture with BPF filter, hex detail panel, pcap export |
| Processes | Process tree with fold/unfold |
| Stats | Session totals — bytes, packets, rates per interface since reset |
| Probe | 802.11 probe-request sniffer — unassociated clients and the SSIDs they're looking for |
| ARP | Layer-2 neighbour table with OUI vendor lookup |
| mDNS | Bonjour/Zeroconf service table from passive UDP/5353 |
| NBNS | NetBIOS Name Service table from UDP/137 |
| DHCP | Live DHCP event log: DISCOVER/REQUEST/ACK |
| SSDP | UPnP device table from UDP/1900 NOTIFY / M-SEARCH |
| Beacons | Passive 802.11 beacon sniffer — APs visible to a monitor-mode iface, with pairwise cipher / AKM / MFP status from the RSN IE, and hidden-SSID reveal from probe-responses |
| Deauth | 802.11 deauth/disassoc frames with per-target flood detection, plus 802.11v BSS-Transition steering — the forced roam that moves a client with no deauth frame at all (BTM abuse) |
| HTTP | Plaintext HTTP requests: method, host, path |
| TLS | TLS ClientHello log: SNI host, version, and JA3 fingerprint |
| QUIC | QUIC Initial packets: version + DNS-resolved host |
| DNS | DNS query/response log: qname, qtype, answer, NXDOMAIN |
| NTP | NTP traffic: mode, stratum, reference ID |
| ICMP | ICMPv4 + ICMPv6 with named types (Echo, Unreachable, Neigh Sol, …) |
| View | What it shows |
|---|---|
| Alerts | Rule-derived events: port scans, deauth floods, NXDOMAIN bursts, threat-intel domain hits, threat-intel IP hits, periodic beaconing |
| Devices | One record per MAC, joined from ARP/DHCP/Beacons/Probe/Stations with OUI vendor |
| Dashboard | Composite at-a-glance view: interfaces, conns + top hosts, packets, and seven side-panel categories all tiled to fill the terminal |
| OSI stack | Seven-layer synthesis grid: every count sloth observes mapped onto its OSI layer (L7 application protocols, L6 TLS-version histogram, L5 sessions, L4 transport split, L3 host count, L2 ifaces/APs/STAs, L1 probe iface) — the "where is my traffic happening" cheat sheet |
| Twins | Evil-twin episode table — each SSID seen from a BSSID that doesn't match the one it was learned from, with the cipher/AKM mismatch that gave it away |
| KARMA | KARMA / PineAP candidate table — APs answering probes for SSIDs they never beacon, scored on PNL overlap, IE uniformity and deauth chaining (#30) |
| RADIUS | 802.1X EAP method inventory and identity leaks — the hostapd-wpe / eaphammer surface (#31, #38) |
| FragAttacks | Per-BSSID counters for the FragAttacks rules (CVE-2020-24586/24587/24588, CVE-2020-261xx) (#75) |
| Research | The cited source behind each alert that actually fired — CVE, advisory, IEEE clause or paper (#73) |
| Channel | Per-channel 802.11 activity histogram: APs beaconing and STAs associated on each channel |
Help [?] |
Keybindings, running version, and the embedded-data disclosures |
| View | What it shows |
|---|---|
| PNL | Per-MAC Preferred Network List — every directed probe-request's source MAC aggregated with the unique set of SSIDs it has probed for. Randomised MACs are flagged so randomised vs burned-in is one glance. A device's PNL fingerprints its owner. |
| EAPOL | Captured EAPOL-Key frames + 4-way handshake state machine. M1 with a PMKID KDE = one-frame offline-crack vector. M1+M2 together = full handshake. --collect-handshakes --eapol-dir DIR writes captures in hashcat 22000 format; detection works without the opt-in. |
| Seqnum | Sequence-number correlation across MAC rotations. Reports pairs of addresses whose counters are consistent with one physical radio, with a calibrated score and the evidence window behind it — a hypothesis about a radio, never an identification of a person. --no-correlate switches it off. |
| Assoc | Client ↔ AP association inventory. Each row is a (BSSID, STA) pair we've observed confirmation for: EAPOL handshake completed, assoc-response status=0, or reassoc-response status=0. Disassoc / deauth removes the entry. |
sloth -o FILE— append a JSONL line for every DNS/TLS/QUIC/HTTP/NTP/ICMP record, every newly-fired alert, and every alert-incident lifecycle event (create / update / escalate / resolve, #98). See JSONL schema below.sloth --data-socket [SPEC]— same JSONL records, served over a read-only stream socket (unix:/pathortcp:HOST:PORT) for live consumers. Bare--data-socketwith no SPEC defaults totcp:127.0.0.1:8765(loopback only).tail -f-style with no filesystem polling. Read-only by design — nothing flows back from the wire (see MISSION.md §4). Read-only is a statement about commands, not data: the stream carries no authentication and no encryption, so reachability is the access control. Aunix:socket is created 0600, which makes the kernel's peer-credential check the authentication — no keys, nothing to rotate or expire — and is the recommended deployment. Binding any address outside127.0.0.0/8(including the0.0.0.0wildcard) is refused unless--data-socket-allow-remoteis also passed, and prints a warning naming the exposed address. To reach the socket from another host, tunnel it instead:ssh -L 8765:127.0.0.1:8765 user@sensor,examples/stunnel/, or push outbound withexamples/forwarder/. Full trust boundary indocs/wiki/data-socket-exposure.md. Multi-client (up to 16). Backpressure is per-client and whole-record: a slow consumer gets a bounded queue (512 KiB), never a glued or truncated line; on overflow whole records are dropped and asocket_gaprecord reports how many, and a consumer that accepts nothing for 30 s is disconnected. Healthy clients are unaffected. Full schema and consumer notes indocs/wiki/jsonl-schema.md. When the socket is bound to a routable address, sloth advertises it over mDNS (_sloth._tcp) so the sloth-ios client can discover it by name — via an Avahi service file (sloth transmits nothing;avahi-daemonannounces). Loopback/UNIX sockets never advertise; disable entirely with--no-discovery, or with--strict, which suppresses the advertisement but not the routable listener itself (see--strict). This is the sole opt-out carve-out in MISSION.md §2.sloth --pcap-dir DIR— when a rule fires with a known flow identifier (THREAT_IP, BEACONING, PORT_SCAN, NXDOMAIN_BURST, THREAT_DOMAIN), the matching packets are written to a per-alert pcap file underDIR.sloth --collect-handshakes --eapol-dir DIR— append each captured PMKID and 4-way handshake toDIR/eapol.22000in hashcat mixed format. Crack directly withhashcat -m 22000 eapol.22000 wordlist.txt. Sloth also writes a per-handshakeDIR/<bssid>_<sta>.pcap(raw 802.11, DLT 105) so each capture can be replayed throughaircrack-ng -w wordlist.txt -e <SSID> <file>.pcapor opened in Wireshark. This is crackable material — see below.
Crackable material is opt-in, and expires (#87). A PMKID or a paired M1+M2 supports offline password guessing by anyone who gets a copy, so writing one is a separate decision from observing one:
--collect-handshakes— off by default. Required before any.22000line or handshake pcap is written.--eapol-diron its own exits2rather than starting a run that silently writes nothing, and no export directory is created. The gate covers writing only: with it off, the EAPOL-Key parser, the M1..M4 state machine, the replay-counter pairing verdict, the PTK-generation counter the FragAttacks rule reads, M3 association evidence, the[e]view and the JSONL /--dbeapol_eventsrecords all behave exactly as before. sloth never cracks anything itself (MISSION.md §2.2).--handshake-retention DAYS— default 7,0= keep forever. Swept once at startup and once a day while running: artifacts under--eapol-dirlast written before the window are deleted. Only the names sloth writes (eapol.22000,<bssid>_<sta>.pcap,.*.tmppartials) — a file of your own in that directory is never touched. Symlinks are never followed, and a failed delete is counted and shown in the[e]header rather than swallowed. Granularity is the whole file by mtime, soeapol.22000goes only once nothing has been appended for the whole window — the 22000 format has no per-line timestamp. Details:docs/wiki/retention.md.
File permissions (#87). Everything sloth writes can hold captured traffic, and the --eapol-dir files are offline-crackable. So, independent of the umask: directories sloth creates (--eapol-dir, --pcap-dir) are 0700, and every file (eapol.22000, handshake and alert pcaps, -o, the --db file and its -wal/-shm, --report/--report-json, the Packets-view w export) is 0600. A path that already exists is validated, never repaired: it must be owned by sloth's effective uid, carry no group/other bits, and not be a symlink (files: a single link). Otherwise sloth refuses it with a reason and exits at startup — it will not chmod a path you gave it, and will not write into a shared directory like /tmp itself (--pcap-dir /tmp is refused; --eapol-dir /tmp/sloth-eapol is fine). A file left 0644 by an older build needs a one-time chmod 600. Write failures (full disk, refused file) are reported: one sloth: … export failed line on stderr, a count in the EAPOL view for the handshake export, and partial files are removed or rolled back. Details: docs/views/eapol.md, docs/wiki/pcap-export.md.
Sloth originates no network traffic of its own unless you ask it to. That is the shipped default, not a mode you enable.
Sloth has exactly two behaviours that are not pure observation, and both are off by default:
| What it would do | Default | Opt in | Locked off by | |
|---|---|---|---|---|
| Reverse-DNS resolution | PTR query for an observed address on a cache miss | off | --allow-active |
--strict |
| nl80211 scan trigger | NL80211_CMD_TRIGGER_SCAN on each wireless interface, so the kernel's cached AP list stays fresh |
off | --allow-active |
--strict |
sloth(no flag) — strict. The reverse-DNS resolver worker is never started, so sloth cannot emit a PTR query even by accident. NoTRIGGER_SCANrequest is built either — not built and dropped, not built, which is whattests/test_wifi_scan_trigger.casserts by counting requests at the message builder rather than at the socket. Hostnames still appear: they come from traffic sloth already watched go past (DNS answers, mDNS, NBNS, DHCP and TLS SNI), which is what MISSION.md §2 means by never resolves hosts it didn't already see. The WiFi view still lists APs — it reads whatever the kernel already had cached, it just stops asking the kernel to go and look.sloth --allow-active— opt in to both rows of that table. On a cache miss sloth may send a PTR query for an address it observed, and it may ask the kernel to scan. The scan request carries noNL80211_ATTR_SCAN_SSIDS, so Linux runs it as a passive scan and no probe request is transmitted; what it changes is kernel state on your own radio, not the monitored segment. It prints one line on stderr naming exactly what it turned on; a passive tool that quietly becomes active is the failure this exists to prevent, so the opt-in is never silent. Note the per-packet lookup in the UDP/443 QUIC decoder is not restored by this flag — that path stays passive unconditionally, because it runs on the capture thread where no operator toggle can reach it.sloth --strict— changes nothing by itself against the default, and that is mostly the point. It locks strict observation for the run, so any later attempt to enable active behaviour is refused rather than honoured;--strict --allow-activeexits non-zero (in either order) instead of quietly picking a winner. It does add one thing: it suppresses the mDNS advertisement (see--no-discovery). Since 2026-10-06 it also refuses a routable--data-socket, exit 2, even with--data-socket-allow-remote: a routable listener transmits to whoever connects, and a lock another flag can override is not a lock. The loopback default (tcp:127.0.0.1:8765) and aunix:path keep working, because neither leaves the host. It still does not refuse--hop(see WiFi SIGINT usage). Pass it when you want the operator's intent visible inpsand in an audit log — in a deployment somebody has to attest to, that is worth more than it costs.
The in-TUI [n] names/numeric toggle now gates resolution as well as display: with names off, the Top Hosts panel reads the cache and never resolves. Both gates compose — the toggle says whether you want names, the profile says whether sloth may go and get them.
What this does not claim. Passive channel-hopping (--hop, off by default) retunes sloth's own monitor interface, which is a kernel-state write on a receiver you dedicated to sloth — see MISSION.md §2's first carve-out. The JSONL log and the data socket exist to move observations off the host, and a routable --data-socket is a listener that transmits to whoever connects. Those are telemetry over a path you configured, not traffic on the segment you are watching, and the distinction is the operator's to enforce with routing. Over-the-air confirmation with an independent receiver — the only way to prove a driver does not transmit something sloth never asked for — has not been done; the guarantees above are what the code and the test suite enforce.
-
sloth --headless— draw nothing and never touch the terminal: no screen clears, no escape sequences, no raw-mode termios change, no key handling. Capture, alerting and every sink (-o,--data-socket,--db,--report) run exactly as normal. This is the mode for appliance and systemd deployments where the output is a journal, not a screen.sloth warns (but still runs) if
--headlessis given with no sink configured, since that run produces nothing observable. -
sloth --no-color— keep drawing but emit no colour escape sequences. Also honoured via theNO_COLORenvironment variable (no-color.org). Useful over a serial console or when capturing a session to a file. -
sloth --with-research research.db— load the research corpus so--reportcites the sources behind each alert that fired. Additive: an unreadable or missing corpus logs one line and sloth runs without it. See the corpus notes. -
make research-mcp— buildsloth-research-mcp, an MCP server over the same corpus for consumers that are not sloth. Not part ofall; the monitor links the query layer directly rather than talking JSON-RPC to its own data file. -
sloth --version(or-V) — print the version and exit. The TUI banner shows it too, but that needs the interface running, which is no use on a headless sensor or in a deployment script checking what it just rolled out.Note for existing headless deployments. Before this, running the no-ncurses build with stdin redirected (systemd,
< /dev/null) made the poll loop spin:select()reported the EOF stdin readable immediately, so the refresh interval never applied. Measured at ~76 000 redraws in two seconds against an intended ~10, which both burned a core and flooded the journal.tui_poll_keynow waits on stdin only when it is a terminal, so this is fixed for--no-colorand interactive runs too — not just under--headless.
-
sloth --db FILE— persist entity state to a SQLite database (off by default).-oand--data-socketare unchanged and remain the wire format; this is the retained artifact.A
-orun writes ~38 GB/day because snapshot rows re-serialise every poll. The same information as durable state is megabytes, because sloth's cardinality is bounded by construction — every entity table ininclude/sloth.his a fixed array, so rows are upserted per entity withfirst_seen/last_seenrather than appended per tick. That pair is what makes the file a history: "who was here between 2 and 4 AM" becomes a range scan instead of a log grep.Read it with the
sqlite3CLI — as the user that wrote it: the file and its WAL are 0600 (file permissions). sloth exposes no query surface — the database is never served over the data socket and gets no RPC (MISSION.md §4). Build without it viamake WITH_SQLITE=0;make embeddedalready excludes it.sudo ./sloth --hop --db /var/lib/sloth/sloth.db sudo sqlite3 /var/lib/sloth/sloth.db \ "SELECT mac, ssid FROM pnl_ssids WHERE ssid='CorpWiFi';"Repeat surveys: each run records a session, and
--reportgrows a New since last survey section listing APs, devices and probe clients first seen since the previous visit.--site-labelnames the site. This works becausefirst_seenis preserved across visits, so carried-over entities are excluded rather than re-reported.--db-interval-secs Nsets the write cadence (default 1).Retention is tiered, because not all rows are worth the same on a disk that is filling up.
--db-retain-days N(default 30) sets the window for observation rows; entities keep 3× that and alerts plus credential exposures keep 12×. The order reflects what an investigator reaches for months later: what fired outlives who was here, which outlives the individual observations. Age is measured from a row'slast_seen, so something still being observed is never aged out, and the pass runs at most hourly and only while sloth is running.--db-max-mb N(default 512, 0 = unlimited) is a pruning trigger, not a cap. On breach the oldest observation rows go first, in bounded rounds — entity, alert and credential rows are never dropped by this guard. A sensor that fills its disk should lose telemetry, not the findings the disk was being kept for. The file is therefore allowed to exceed the target: if pruning every eligible row leaves it over, that is reported once and accepted, because the alternative is discarding evidence to satisfy a number. The guard also measures only the main database file, not its-wal/-shmsidecars.This is an investigative tradeoff, not a deletion promise. Outside the database, only the handshake exports are retained (7 days by default, see
--handshake-retentionabove) —-oJSONL,--pcap-dirand--reportoutputs grow until you remove them — and row deletion is logical, not secure erasure. Full behaviour and the artifact classes it does and does not cover:docs/wiki/retention.md.Schema-level guardrails from MISSION.md §2 are enforced by the test suite: no password column on credential exposures, no PMKID / nonce / MIC columns anywhere — crackable material stays in the
--eapol-dirfile the operator explicitly asked for.
-
sloth --my-ssid SSID/sloth --my-bssid BSSID(both repeatable, max 16 each) — tell sloth which networks are yours. This is a labelling input only: nothing about capture changes and nothing is transmitted, so the passive guarantee in MISSION.md §2 is untouched.It answers the question sloth previously could not be asked — is anyone reconnoitring my network? A client whose Preferred Network List names a designated SSID while it is not associated to that network raises
MY_NET_RECON. Association is the exoneration, checked by designated BSSID or SSID, so your own users never trip it and you do not have to enumerate every BSSID of a multi-AP deployment. A correlated sibling address counts too — a handset that probes with a rotating MAC and associates with its per-network one is not reconnoitring you — and so does the--known-macroster.The alert is LOW until something corroborates it (#94). A returning employee, a roaming device and a capture that simply missed the association all satisfy the bare observation, so uncorroborated it reports what was seen — probed for your network — and does not call it reconnaissance. Sustained probing, or a PNL naming two or more of your networks, escalates it to WARN and says which corroborator fired. These records alone are not grounds for personnel action or physical identification; see docs/wiki/alerts.md.
Designations also sharpen two existing detectors: deauth and auth floods aimed at a designated BSSID escalate WARN → CRIT, and a designated BSSID is never named the impostor half of an evil-twin pair (which the RSSI heuristic would otherwise often get backwards, since your own AP is usually the closest one).
sudo ./sloth --hop --my-ssid CorpWiFi --my-bssid aa:bb:cc:dd:ee:ff
With nothing designated every check is inert and behaviour is unchanged.
-
sloth --inventory FILE/sloth --site TEXT— tell sloth which BSSIDs are authorised for which SSID. Slice 1 of #89 removed three things the evil-twin rules had been treating as proof of ownership — a matching vendor OUI, an 802.11k neighbour report and RSSI — because all three are values an attacker writes into a frame. This is what replaces them, and it is the only trust input in that family that does not arrive over the air.{ "version": "2026-09-24.1", "site": "hq-3f", "networks": [ { "ssid": "CorpWiFi", "security_profile": "wpa2-enterprise", "bssids": ["aa:bb:cc:00:11:22", "aa:bb:cc:00:11:23"] } ] }sudo ./sloth --hop --inventory /etc/sloth/inventory.json --site hq-3f
A BSSID that is not approved for an SSID the file declares is hard evidence of impersonation: it makes a same-OUI clone visible (three matching bytes and nothing else is an empty evidence set, which is why it is otherwise silent) and a spoofed neighbour report cannot erase it. A pair whose both halves are approved stops alerting, which is how a declared mixed-vendor deployment goes quiet. An SSID the file does not mention gets no verdict at all — silence is not approval — and with no inventory configured, nothing above applies and every heuristic behaves exactly as before.
The file is treated as hostile data: malformed JSON, wrong types, over-size, duplicate SSIDs or BSSIDs, bad addresses and control bytes each fail with a reason and a byte offset, and the load is all-or-nothing — sloth exits rather than start with half an anchor you believe is whole. It is read once at startup and never re-read.
--my-ssid/--my-bssidkeep working and are unioned into the approved set, never intersected: a radio the file has not caught up with can be waved through with a flag.--siteis the operator's label for where this sensor is and is part of the evil-twin dedup key. It comes from--siteor the file'ssitefield and from nothing else — never from the uplink, which would both make an unauthenticated SSID a trust input and re-key the incident on every roam. (Distinct from--site-label, which titles a--snapshot-outreport.)Every alert that consulted the inventory carries its content hash, so a finding in an archive names the exact file that produced it. Format and full semantics:
docs/wiki/inventory.md.
-
sloth --known-mac MAC/sloth --known-macs FILE— tell sloth which devices you already recognise. The file is one MAC per line with#comments; malformed lines are reported with their line number and skipped, so one typo does not discard a roster of 200 good entries.Combined with a network designation above, a device associated to your network that is not on the roster raises
UNKNOWN_DEVICE. Both inputs are required, so the check is silent unless you opted into each.This is what separates unfamiliar from intrinsically odd. Without a roster, sloth can only score a device on properties — randomised MAC, unknown vendor, no hostname — and with randomisation default on every handset, that fires on your own staff.
Roster reliability: per-probe MAC randomisation rotates constantly, so a roster cannot follow a device that is only probing. Per-SSID randomisation used for association is stable — iOS and Android derive one MAC per network and keep it across reconnects — so a device rostered while on the network keeps that address. Roster associated devices; use the presence classification in
[7] Probefor passers-by.sudo ./sloth --hop --my-ssid CorpWiFi --known-macs /etc/sloth/roster.txt
Sloth transmits nothing over the air — it never injects probe requests, never sends
deauth frames, never associates with anything. All wireless data is
sniffed by a monitor-mode interface that's been put into monitor mode
by an external tool (iw, airmon-ng, etc.) before sloth starts.
Two kernel-state writes exist and both are opt-in, because "monitor mode" is not by
itself a transmit interlock and it would be dishonest to imply otherwise: --hop
retunes sloth's own monitor interface, and --allow-active lets sloth ask the kernel
to run a (passive, SSID-less) scan on a wireless interface. Neither is on by default.
--strict refuses --allow-active for the whole run. It does not currently
refuse --hop: channel-hopping is the separate MISSION.md §2.1
carve-out, and --strict --hop still retunes the monitor interface. Whether
--strict should block it is an open owner decision (#84).
# 1. Set an adapter to monitor mode (external — sloth never touches link state).
sudo ip link set wlan1 down
sudo iw dev wlan1 set type monitor
sudo ip link set wlan1 up
# 2. Run sloth. It auto-discovers the monitor iface. Crackable material
# is opt-in: --collect-handshakes is what lets --eapol-dir write
# captured PMKIDs + 4-way handshakes in hashcat format, and they are
# swept after --handshake-retention days (default 7).
sudo ./sloth --collect-handshakes --eapol-dir /tmp/sloth-eapol \
-o /tmp/sloth.jsonl
# 3. While sloth runs:
# [k] PNL — devices and the SSIDs they're probing for
# [b] Beacons — APs + cipher / AKM / MFP inventory + hidden-SSID reveal
# [e] EAPOL — captured handshakes (PMKID = single-frame crack)
# [j] Seqnum — randomised MACs correlated to the same physical radio
# [7] Probe — raw probe-request feed (Roaming clients)
# 4a. Offline crack against the streamed handshake / PMKID file.
hashcat -m 22000 /tmp/sloth-eapol/eapol.22000 rockyou.txt
# 4b. OR replay an individual handshake through aircrack-ng.
aircrack-ng -w rockyou.txt -e "SSID" \
/tmp/sloth-eapol/<bssid>_<sta>.pcapmake # full build (ncurses + pcap + nl80211)
make WITH_PCAP=0 # no capture, no probe view
make WITH_NCURSES=0 # headless / embedded
make embedded # shortcut: no ncurses, no pcap
make test # the whole suite (no root, no terminal, no network)
make mutate # mutation-test the suite itself (verify the verifier)Requires libpcap-dev and libncursesw-dev for the full build. The test build needs neither. The mutate target is opt-in and offline — see docs/wiki/mutation-testing.md.
Use [?] inside sloth for an up-to-date reference card.
| Key | View | Key | View | Key | View |
|---|---|---|---|---|---|
1 |
Interfaces | d |
DHCP | u |
QUIC |
2 |
Connections | s |
SSDP | r |
DNS |
3 |
WiFi | b |
Beacons | p |
NTP |
4 |
Packets | a |
Deauth | i |
ICMP |
5 |
Processes | h |
HTTP | v |
Alerts |
6 |
Stats | t |
TLS | g |
Devices |
7 |
Probe | ? |
Help | o |
Dash |
8 |
ARP | k |
PNL | e |
EAPOL |
9 |
mDNS | j |
Seqnum | w |
Assoc |
0 |
NBNS | m |
Channel | l |
OSI stack |
x |
Twins | ||||
y |
KARMA | ||||
z |
Rogue RADIUS |
| Key | Action |
|---|---|
Tab |
Cycle views forward |
n |
Toggle DNS hostname resolution (in conn/proc/stats views) |
/ |
Filter current log view — type to refine, Enter to commit, Esc to cancel |
\ |
Clear filter |
q/Q |
Quit |
| Key | Action |
|---|---|
↑/↓ |
Navigate rows |
c |
Clear the current log view's ring buffer |
t |
(Interfaces) Toggle iface visibility |
Enter |
(Interfaces/Packets) Open detail panel |
f |
(Conns/Packets) Cycle filter |
s |
(Conns) Cycle sort |
66 rules feed VIEW_ALERTS — one per ALERT_TYPE_* in include/sloth.h, each with a row in docs/views/alerts.md. New keys also append to the JSONL stream and (if --pcap-dir is set) trigger a per-alert pcap dump. Escalations, changed evidence and expiry ride the alert.* incident-lifecycle records (#98).
Six of them, as a sample of the shape:
| Rule | Severity | Trigger | match_ip / port |
|---|---|---|---|
PORT_SCAN |
CRIT | scanner's IP touched ≥ 8 distinct local ports | scanner IP / 0 |
DEAUTH_FLOOD |
WARN | ≥ 5 distinct deauth/disassoc frames in any 5 s sliding window at one (BSSID, victim); observed frames, not confirmed disruption | — (L2 only) |
NXDOMAIN_BURST |
WARN | ≥ 10 NXDOMAIN replies to one src in 60 s | src / 53 |
THREAT_DOMAIN |
CRIT | DNS qname matches embedded IOC list | src / 53 |
THREAT_IP |
CRIT | conn remote IP matches embedded IOC list | remote IP / port |
BEACONING |
WARN | flow with ≥ 5 samples, mean ≥ 10 s, jitter/mean ≤ 0.25 | remote IP / port |
⚠️ THREAT_DOMAINandTHREAT_IPship with no threat feed. The IOC lists insrc/threat_intel.care synthetic demo data — four RFC 5737 documentation IPs and six obviously-fake sentinel domains — so both rules detect nothing until you replace them. They exist to exercise the alerts pipeline in tests and to show the shape of your own list. Sloth ships no feed and fetches none; a fetch is a network write, which MISSION.md §2 forbids. The alert row saysdemo IOCand the help view ([?]→ Embedded data) says so too. Details:docs/wiki/threat-intel.md.
Each line is one JSON object. ts is a Unix timestamp; strings are RFC 8259 escaped.
{"type":"dns","ts":1700000000,"src":"192.168.1.5","qname":"example.com","qtype":"A","answer":"93.184.216.34","is_resp":1}
{"type":"tls","ts":1700000001,"src":"10.0.0.5","dst":"93.184.216.34","host":"example.com","ver":"TLS 1.3","ja3":"deadbeefcafef00d00112233445566ff"}
{"type":"quic","ts":1700000002,"src":"10.0.0.5","dst":"1.1.1.1","host":"cloudflare.com","ver":"v1"}
{"type":"http","ts":1700000003,"src":"10.0.0.5","host":"example.com","method":"GET","path":"/index.html"}
{"type":"ntp","ts":1700000004,"src":"10.0.0.1","dst":"192.168.1.5","mode":"server","version":4,"stratum":1,"ref":"GPS"}
{"type":"icmp","ts":1700000005,"src":"192.168.1.5","dst":"8.8.8.8","desc":"Echo Req","ty":8,"code":0,"seq":42,"v6":0}
{"type":"alert","ts":1700000006,"title":"THREAT_DOMAIN","detail":"192.168.1.5 queried malware.testing.com (demo IOC malware.testing.com)","key":"threat-d:malware.testing.com","sev":2,"ty":3,"count":1,"incident_id":"9f2c41ab77e30d58"}
{"type":"alert.escalate","ts":1700000041,"event_id":"9f2c41ab77e30d58-0002","incident_id":"9f2c41ab77e30d58","key":"mgmtfuzz:ba:ad:f0:0d:00:01","title":"MGMT_FUZZ","detail":"…","sev":2,"ty":31,"prev_sev":1,"observations":2,"evaluations":36,"count":36}alert is written only when a dedup key is new. Everything that
happens afterwards — a WARN→CRIT escalation, changed evidence, expiry —
rides the alert.create / alert.update / alert.escalate /
alert.resolve lifecycle records (#98), joined by incident_id, so a
pipeline that pages on CRIT must read alert.escalate. count and
evaluations are rule ticks, not packets; observations only moves
when the evidence does. Full field tables in
docs/wiki/jsonl-schema.md.
Sloth's JSONL stream is the contract for any downstream consumer. Two
runnable reference programs ship in examples/ — stdlib-
only Python 3, no pip install step — so the schema has a
companion you can hand to a SIEM team in five minutes.
examples/consumer/sloth-stream.py
— connects to the data socket, parses every record type from the
schema, supports filtering and pretty-print. Read it as the
textbook consumer loop (connect → stream → json.loads → filter → format → reconnect); the same shape ports directly to Go
(bufio.Scanner), Node (readline), or Swift's
Network.framework.
# Tail every record sloth emits, with ANSI colour
python3 examples/consumer/sloth-stream.py unix:/run/sloth/sloth.sock
# Only alerts, from any source
python3 examples/consumer/sloth-stream.py tcp:127.0.0.1:8765 --type alert
# Raw JSON pass-through into jq
python3 examples/consumer/sloth-stream.py unix:/run/sloth/sloth.sock --raw | jq .
# 5-second rolling tally by record type
python3 examples/consumer/sloth-stream.py unix:/run/sloth/sloth.sock --countexamples/forwarder/sloth-forward.py
— reads from the data socket, batches, and pushes to a downstream
SIEM. Three sinks ship:
| Sink | Wire format |
|---|---|
hec |
Splunk HTTP Event Collector (JSON envelopes over HTTPS POST) |
syslog |
RFC 5424 over UDP or TCP |
elastic |
Elasticsearch / OpenSearch Bulk API (NDJSON to /_bulk, time-rolled indices via strftime patterns, basic auth or API key) |
# Splunk HEC
python3 examples/forwarder/sloth-forward.py unix:/run/sloth/sloth.sock \
--sink hec \
--hec-url https://splunk.example.com:8088/services/collector \
--hec-token-env SLOTH_HEC_TOKEN
# RFC 5424 syslog (UDP)
python3 examples/forwarder/sloth-forward.py unix:/run/sloth/sloth.sock \
--sink syslog --syslog-host siem.example.com --syslog-port 514
# Elasticsearch with daily-rolled indices
python3 examples/forwarder/sloth-forward.py unix:/run/sloth/sloth.sock \
--sink elastic \
--es-url https://elastic.example.com:9200 \
--es-index 'sloth-events-%Y.%m.%d' \
--es-api-key-env SLOTH_ES_API_KEYBatching (--batch-size and --batch-ms), exponential-backoff
retries (--max-retries, --retry-backoff), and 30-second stderr
stats (received=N forwarded=N dropped=N retries=N) are built in.
Adding a sink is ~30 lines — the sink interface is two members
(.name, .send(batch)); the retry loop, batching, filtering, and
reconnect logic all stay in main(). See
examples/forwarder/README.md for the
"how to add a sink" recipe and production patterns (systemd unit
with EnvironmentFile, "one forwarder per sink", when to combine
with -o FILE for durability).
Delivery semantics: the forwarder mirrors sloth's
non-durable contract (MISSION.md §4). After
--max-retries, batches drop. If you need durability, also pass
-o FILE to sloth and ship the file via a separate log-shipping
agent (filebeat, vector, fluent-bit) — that gives you durability,
this gives you low-latency triage.
The codebase is built around a platform vtable (platform_ops_t in include/sloth.h):
typedef struct {
int (*get_ifaces)(iface_stat_t *out, int max);
int (*get_conns)(conn_t *out, int max);
int (*wifi_scan)(wifi_ap_t *out, int max);
int (*get_wifi_stations)(wifi_sta_t *out, int max);
int (*get_arp)(arp_entry_t *out, int max);
int (*get_dhcp)(dhcp_lease_t *out, int max);
void (*init)(void);
void (*cleanup)(void);
} platform_ops_t;Adding a new data source means adding one function pointer here and implementing it in each backend (src/platform/linux.c, bsd.c, stub.c, win32.c) and the test fake (tests/fake_platform.c). Views read from sloth_state_t; they never call platform ops directly.
capture thread main loop (poll_data, 1 Hz)
────────────── ───────────────────────────
pcap_loop() g_platform.get_ifaces()
│ g_platform.get_conns()
▼ g_platform.get_arp()
dns_log_parse / record … other platform reads
tls_log_parse / record │
quic_log_parse / record ▼
http_log_parse / record *_snapshot() ◄── ring buffer copy
ntp_log_parse / record │
icmp_log_parse / record ▼
│ alerts_update()
├─► jsonl_emit_*() beacon_update()
│ (if -o set) devices_update()
└─► ring buffer │
▼
tui_draw()
Packet decode runs in a dedicated pcap thread. The eight log modules each have an independent ring buffer behind a pthread_mutex_t; *_snapshot() helpers copy the latest entries into sloth_state_t once per poll. Synthesis modules (alerts, devices, beacon-detect) read snapshot state and produce derived views.
include/sloth.h shared types: state, packet, conn, alert, device, ...
src/main.c CLI, signal handlers, main loop
src/tui.c ncurses / ANSI rendering, key polling
src/platform/linux*.c Linux backends (rtnetlink, nl80211, /proc, INET_DIAG)
src/capture/capture.c libpcap thread; per-protocol parser dispatch
src/{dns,tls,quic,http,
ntp,icmp,dns,...}_log.c ring buffers + per-record snapshot
src/{alerts,beacon_detect,
devices,threat_intel,
filter,jsonl,alert_pcap}.c synthesis + export
src/views/*.c one file per VIEW_*
src/md5.c RFC 1321 implementation used for JA3
tests/ unit tests, fake platform, scenarios
make test # the whole suite, no root, no terminal, no networkEvery real-data path is replaced by a controllable fake:
tests/fake_platform.c— implementsg_platformwith deterministic in-memory data. All vtable functions read from a globalfake_net_t.tests/null_tui.c— stubs ncurses functions as no-ops;TPRINTfalls back toprintf, so view draw functions execute their full rendering logic.tests/scenarios.c— named configurations (empty, idle, busy, many_conns, wifi_crowded, monitor_env) used by render smoke tests.
Protocol parsers (DNS, TLS, JA3, QUIC, HTTP, NTP, ICMP, mDNS, NBNS, DHCP, SSDP) are tested with raw byte arrays constructed by hand from the relevant RFCs. Each test computes byte values from first principles so the tests are not circular.
0x00,0x00, 0x84,0x00, // DNS hdr: ID=0, QR=1 AA=1
0x00,0x00, 0x00,0x01, // QDCOUNT=0, ANCOUNT=1
…
The MD5 implementation used for JA3 is independently validated against all RFC 1321 test vectors (tests/test_md5.c).
make test is the oracle this project trusts. To trust it, sloth runs
mutation testing — small faults are introduced into src/*.c,
the suite reruns, every mutant should be killed. Surviving mutants
are concrete test-suite gaps. The harness lives at
.github/scripts/mutate.py, runs via
make mutate, and is documented in
docs/wiki/mutation-testing.md
along with the equivalence-class taxonomy
(.github/scripts/mutate-equivalents.txt)
that suppresses structurally-untestable noise (function-parameter
array sizes, stack buffer sizing literals, loop bounds reading
zero-init tail, lookup-table data blocks). Current aggregate across
18 files: 51.0% of considered mutants killed — see the badge at
the top.
Code: ~48.6k lines of C99 across 147 .c files (285 counting headers). Tests: make test plus a make mutate harness. Reference Python consumer + 3-sink SIEM forwarder under examples/. License: Sloth Source-Available License 1.0 (free for individual non-commercial use; private modifications are allowed but modified versions may not be distributed; contact jeff@river.io for commercial, enterprise, or other licensing).
Sloth was built as a passive monitor. It will not fuzz, attack, or attempt to deauth or de-associate anything, and it puts no frame of its own on the air. The one thing it can be asked to do that resembles scanning is --allow-active's kernel scan trigger — a passive, SSID-less nl80211 scan on your own radio, off unless you ask for it. If active reconnaissance is what you need, use a different tool.
sloth is source-available, not open source. The Sloth Source-Available License 1.0 grants free use to individuals for non-commercial purposes, including private modifications on systems they control. Modified versions may not be distributed; commercial use, enterprise use, and law-enforcement use are not permitted. Packaging unmodified source for distribution repositories is permitted.
For any other use, contact River.io LLC at jeff@river.io.
