tracehound
A passive network sensor that finds command-and-control traffic, DNS tunnels, and data
exfiltration, and shows the numbers behind every call it makes.

tracehound reads packets from a capture file or a live interface, assembles them into
flows, fingerprints TLS clients with JA4 over both TCP and QUIC, and reports attacker
behaviour mapped to MITRE ATT&CK. It builds to a single static binary with no libpcap
and no cgo, and the web dashboard is compiled into the executable.

The animation above is generated by make demo-gif, which re-runs the sensor and
redraws whatever it printed, so it stays in sync with the code.
Quick start
git clone https://github.com/baldoseri/tracehound && cd tracehound
make demo
That builds the binary, generates a synthetic capture containing real attacker
behaviour, and analyses it. You do not need a network to sniff or a malware sample to
download.
For the live dashboard, which replays 33 minutes of capture in about 17 seconds:
make dashboard # then open http://localhost:8080
With Docker, which needs nothing else installed:
docker compose up demo
What comes out
[HIGH ] TH-0002 Probable DNS tunnelling to exfil.example
2026-03-14T09:22:47Z 10.0.0.66 -> 10.0.0.1:53 score 0.95
ATT&CK: T1071.004, T1048.003, T1572
10.0.0.66 issued 90 queries under exfil.example, 100% of them for names
never repeated, averaging 46 characters of subdomain at 4.52 bits/char
entropy. Legitimate resolution reuses names and caches; this pattern only
makes sense if the name itself is the payload.
avg_entropy_bits=4.515 avg_subdomain_len=45.889 domain=exfil.example
max_subdomain_len=46 queries=90 queries_per_min=95.106 txt_null_ratio=1
unique_names=90 unique_ratio=1
[HIGH ] TH-0001 Periodic beaconing to 198.51.100.23:443
2026-03-14T09:27:50Z 10.0.0.66 -> 198.51.100.23:443 score 0.96
ATT&CK: T1071.001, T1573
10.0.0.66 opened 28 connections to 198.51.100.23:443 at a mean interval of
60.7s with 6% jitter. Regularity at this level is characteristic of
automated check-in rather than user activity.
connections=28 interval_cv=0.059 interval_mad_ratio=0.048
interval_mean_s=60.696 jitter_pct=5.931 periodicity_score=0.952
size_consistency=0.997
Every alert carries the measurements that produced it. Analysts stop trusting a tool
they cannot argue with, so each finding has to be checkable.
What it detects
| Rule |
Detection |
Signal |
ATT&CK |
TH-0001 |
C2 beaconing |
Dispersion of connection intervals, plus request-size consistency |
T1071.001, T1573 |
TH-0002 |
DNS tunnelling |
Name uniqueness, subdomain length, Shannon entropy, TXT/NULL ratio |
T1071.004, T1048.003, T1572 |
TH-0003 |
Vertical port scan |
Distinct ports touched on one host |
T1046 |
TH-0004 |
Horizontal sweep |
Distinct hosts touched on one port |
T1046, T1018 |
TH-0005 |
Data exfiltration |
Outbound/inbound byte asymmetry on a completed flow |
T1041 |
TH-0006 |
New device |
First traffic from a previously unseen host |
none |
TH-0007 |
Rare TLS stack |
A JA4 fingerprint used by exactly one host on a network with a shared baseline |
T1573 |
Tuning
Thresholds, severities, ATT&CK mappings and allowlists live in YAML, not in the
binary. Copy the built-in pack out, edit it, and point the sensor at your version:
tracehound rules -dump ./rules
tracehound replay capture.pcap -rules ./rules
tracehound rules prints what is currently loaded. A rule looks like this:
id: TH-0001
name: Periodic command-and-control beaconing
detector: beaconing
enabled: true
severity: medium
techniques:
- id: T1071.001
name: "Application Layer Protocol: Web Protocols"
tactic: command-and-control
tuning:
min_connections: 8
min_interval: 2s
max_interval: 6h
threshold: 0.75
exceptions:
- description: NTP clients poll on a fixed interval by definition
dst_port: 123
Omit any key and the compiled-in default applies, so a tuning file only needs the lines
you changed. Exceptions match on source, destination, port, domain suffix, or JA4
fingerprint, and every one requires a description, since an undocumented allowlist entry
is indistinguishable from a bug six months later.
Unknown keys are rejected when the pack loads, naming the file, the rule and the field:
tracehound: rules: th-0002-dns-tunnel.yaml: rule TH-0002: tuning:
line 2: field min_querys not found in type rules.dnsTuning
A typo that silently kept the old threshold would leave you convinced you had tuned
something away when you had not, so the loader refuses rather than guesses.
Why JA4 is worth the effort
TLS encrypts the payload, not the handshake. Which cipher suites, extensions and
signature algorithms a client offers, and in what order, is a property of its TLS
stack rather than its traffic, so it survives encryption, proxies and domain fronting.
In practice a JA4 hash identifies the application. Chrome looks different from curl,
which looks different from the Go runtime, which looks different from a Cobalt Strike
beacon. "A host on this network started speaking TLS with a stack no other host uses" is
a cheap statement to make and a hard one to explain away.
The ClientHello parser is hand-written against the wire format instead of being handed
to crypto/tls, because crypto/tls only parses handshakes it is willing to negotiate,
and the handshakes most worth fingerprinting are the ones it would refuse.
Two details are easy to get wrong here. The first is GREASE (RFC 8701): clients inject
random reserved values into their cipher and extension lists specifically to break
middleboxes that ignore them, so leaving them in gives the same client a different
fingerprint on every connection. They are stripped from every list.
The second is fragmentation. A current Chrome or Firefox hello carrying a hybrid
post-quantum key share runs past one TCP segment, so a sensor that parses only the first
payload packet quietly stops fingerprinting the modern clients you most want to see.
Hellos are reassembled, and there is a test that feeds one through a byte per segment,
because splitting a handshake into minimal segments is a long-standing way to evade
inline inspection.
QUIC
Roughly a third of web traffic is HTTP/3, and to a TCP-only sensor all of it is opaque
UDP. tracehound decrypts QUIC Initial packets and fingerprints the handshake inside them.
That sounds like an attack and is not one. QUIC protects Initial packets with keys
derived from the Destination Connection ID, which travels in the clear precisely so that
load balancers and observers can do this. Recovering the ClientHello is a key schedule
and an AEAD open:
initial_secret = HKDF-Extract(initial_salt, destination_connection_id)
client_initial_secret = HKDF-Expand-Label(initial_secret, "client in", "", 32)
key, iv, hp = HKDF-Expand-Label(client_initial_secret, "quic key" / "quic iv" / "quic hp")
Strip the header protection with an AES block over a ciphertext sample, open the payload
with AES-128-GCM, pull the CRYPTO frames out, and the handshake is the same ClientHello
the TCP path already parses. The key schedule is checked against the worked example in
RFC 9001 Appendix A, so the test fails if the implementation is wrong rather than
agreeing with itself.
The same client over both transports produces the same fingerprint apart from JA4's
leading character, t for TCP and q for QUIC, and there is a test asserting exactly
that. QUIC hellos are reassembled across datagrams and in any order, because UDP
guarantees neither and a post-quantum hello does not fit in one Initial.
See internal/quic.
How it works
capture ─────▶ decode ─────▶ flow table ─────▶ detectors ─────▶ alerts
pcap file gopacket bidirectional beaconing ATT&CK-mapped
AF_PACKET zero-alloc 5-tuple, LRU dns-tunnel + evidence
layer parser expiry port-scan │
│ exfiltration ▼
└────────▶ JA4 / JA3 ──────▶ inventory HTTP API + SSE
ClientHello embedded dashboard
reassembly
The whole data path runs on one goroutine. At the packet rates a single commodity core
can decode, coordinating workers costs more than the work being split, and a
single-threaded pipeline is far easier to reason about and to test deterministically.
Scaling out belongs at the capture layer, one pipeline per RSS queue, rather than inside
this loop.
Detection policy is deliberately kept out of the detectors. A detector answers whether
traffic is periodic, which is arithmetic and changes rarely. A rule answers whether you
care about it on this network today, which changes constantly and belongs in a file
someone can edit at 2am without a Go toolchain.
Decisions behind the code
Flow expiry costs O(expired) rather than O(total). Scanning every entry on a timer would
degrade exactly when the table is largest, which is during the scan or flood you most
want to detect. Every flow is threaded onto an intrusive recency list instead, so
reaping pops from the head while the head is too old.
See internal/flow/table.go.
Beaconing scores on whichever of two dispersion measures is more favourable. Coefficient
of variation catches drift, median absolute deviation forgives a missed check-in. Real
beacons skip intervals, and a single doubled gap inflates a standard deviation enough to
bury the pattern. See internal/detect/beacon.go.
DNS tunnelling weights name uniqueness highest of its four axes. It is the one property
a working tunnel cannot avoid: every packet of smuggled data has to be a fresh name, or
caching swallows it and the channel stops working.
See internal/detect/dnstunnel.go.
Rarity waits for a baseline. Early in a capture every host has contributed exactly one
fingerprint, so everything looks unique, and the detector will happily indict the entire
network. It now refuses to judge until it has seen stacks that are demonstrably shared.
That was a real false positive, caught by replaying the demo capture and reading the
output. See internal/detect/inventory.go.
The parser treats its input as hostile. A bounds-checked cursor turns every read past
the end into a failure rather than a panic, which lets the parser read as straight-line
code with one validity check at the end. It has a fuzz target, because a network parser
that panics is a remote denial of service.
See internal/fingerprint/clienthello.go.
Measured on an AMD Ryzen 9 3900X with go test -bench . -benchmem:
| Operation |
Time |
Allocations |
| Flow table update, existing flow |
68 ns |
0 |
| Non-TLS payload rejected |
15 ns |
0 |
| ClientHello parse + JA4 + JA3 |
1.16 µs |
16 |
| Full pipeline, end to end |
~1,050,000 packets/sec |
|
The fingerprint path started at 4.1 µs and 51 allocations. fmt.Sprintf("%04x") was
allocating once per cipher suite; formatting the nibbles by hand made it 3.6 times
faster.
Packet decoding uses gopacket's DecodingLayerParser with pre-allocated layer structs
rather than gopacket.NewPacket, which allocates a fresh object per layer per packet
and dominates the profile at line rate.
How correctness is established
Unit tests alone do not tell you much about a detector, since a threshold low enough to
fire on anything still passes its own test. The demo capture therefore doubles as a
detection harness. The generator declares what it planted, and the integration test
requires that every planted behaviour comes back out attributed to the right host, and
that none of the six benign hosts is ever accused.
--- PASS: TestReplayFindsEveryPlantedBehaviour
found TH-0001 10.0.0.66 sev=medium score=0.96 Periodic beaconing to 198.51.100.23:443
found TH-0002 10.0.0.66 sev=high score=0.95 Probable DNS tunnelling to exfil.example
found TH-0003 10.0.0.99 sev=high score=0.56 Port scan: 121 ports on 10.0.0.10
found TH-0004 10.0.0.99 sev=medium score=0.15 Network sweep: port 445 across 60 hosts
found TH-0005 10.0.0.66 sev=medium score=1.00 Large outbound transfer (17.9 MiB)
found TH-0007 10.0.0.66 sev=medium score=0.60 Rare TLS fingerprint
--- PASS: TestReplayDoesNotAccuseBenignHosts
6 benign hosts, none reported above info severity
The second test is the one that does the work. Any detector can be made to fire by
lowering a threshold; staying quiet about the ordinary traffic sitting beside the attack
is the difficult half.
Coverage: flow 97%, fingerprint 91%, pipeline 87%, detect 83%, rules 82%,
quic 82%.
CI also runs the race detector, a 90 second fuzz of the TLS parser on every pull
request, cross-compilation for five platforms, and an end-to-end demo that fails the
build if any rule stops firing.
Usage
tracehound replay <file.pcap> Analyse a capture file
tracehound sniff -i <iface> Capture live (Linux; needs CAP_NET_RAW)
tracehound gen-demo <file.pcap> Write a synthetic capture containing known attacks
tracehound rules List the loaded detection rules
Useful flags:
| Flag |
Meaning |
-listen :8080 |
Serve the live dashboard and JSON API |
-speed 120 |
Replay at 120 times real time so detections appear progressively |
-rules ./rules |
Load a YAML rule directory instead of the built-in pack |
-json |
Emit alerts as JSON Lines, for piping into a SIEM |
-min-severity high |
Raise the reporting floor |
-home-nets 10.0.0.0/8,192.168.0.0/16 |
Define which addresses count as inside |
Live capture needs CAP_NET_RAW. Grant it narrowly rather than running as root:
sudo setcap cap_net_raw,cap_net_admin=eip ./bin/tracehound
JSON API
| Endpoint |
Returns |
GET /api/alerts?limit=&min_severity= |
Alerts, newest first |
GET /api/devices |
Passive asset inventory with JA4 fingerprints |
GET /api/flows?limit= |
Active flow table |
GET /api/stats |
Throughput and detector counters |
GET /api/attack |
Observed ATT&CK techniques with counts |
GET /api/stream |
Server-sent events, one per alert |
Limitations
Live capture only works on Linux, because it uses pure-Go AF_PACKET. Every platform can
replay capture files, which is a better development workflow anyway since it is
reproducible.
TCP stream reassembly stops after the ClientHello. That is enough to fingerprint a
client but not to analyse the payload of a protocol.
QUIC support covers version 1 client Initials only. Draft versions and QUIC v2 use
different initial salts, so they are rejected rather than decrypted with the wrong keys,
and everything after the handshake is protected by keys an observer never sees.
IP fragments are not reassembled. IPv6 extension header chains are walked, so a first
fragment decodes normally, but a non-initial fragment carries no transport header and is
counted as undecodable.
The public suffix table is partial. Around sixty common two-label suffixes are built in,
so a.example.co.uk groups under example.co.uk correctly. The full Public Suffix List
would be exhaustive, at the cost of a megabyte of embedded data and a standing update
obligation, for a detector whose scoring is dominated by entropy.
Alerts repeat as evidence accumulates. Identical evidence is dropped and an escalation in
severity is reported immediately, but a finding whose numbers keep climbing will restate
itself once per cooldown.
Detector thresholds are tuned against synthetic traffic. Treat them as a starting point
rather than a calibration for your network, and edit the rule pack once you know what
your own traffic looks like.
Roadmap
- SQLite persistence so findings survive a restart
- JA4S and JA4H, the server and HTTP variants
- QUIC v2 and the draft versions, which need only their own initial salts
License
MIT. See LICENSE.
The synthetic capture uses only RFC 5737 and
RFC 1918 documentation addresses, so it cannot be mistaken for, or replayed against, real
infrastructure. No real network traffic is included in this repository.