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, fingerprints the
servers they reach with JA4S, 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
Everything below is the actual output of tracehound replay testdata/demo.pcap -min-severity high, regenerated by make readme-samples and checked by CI, which fails
if this file and the program disagree.
[HIGH ] TH-0002 Probable DNS tunnelling to exfil.example
2026-03-14T09:12:29Z 10.0.0.66 -> 10.0.0.1:53 score 0.96
ATT&CK: T1071.004, T1048.003, T1572
10.0.0.66 issued 46 queries under exfil.example, 100% of them for names never repeated,
averaging 46 characters of subdomain at 4.53 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.526 avg_subdomain_len=45.783 domain=exfil.example max_subdomain_len=46
queries=46 queries_per_min=93.404 txt_null_ratio=1 unique_names=46 unique_ratio=1
[HIGH ] TH-0001 Periodic beaconing to 198.51.100.23:443
2026-03-14T09:19:26Z 10.0.0.66 -> 198.51.100.23:443 score 0.96
ATT&CK: T1071.001, T1573
10.0.0.66 opened 20 connections to 198.51.100.23:443 at a mean interval of 59.1s with 6%
jitter. Regularity at this level is characteristic of automated check-in rather than user
activity.
connections=20 interval_cv=0.057 interval_mad_ratio=0.051 interval_mean_s=59.062
interval_stddev_s=3.385 jitter_pct=5.731 mean_bytes_to_server=870.222
periodicity_score=0.949 size_consistency=0.997
[HIGH ] TH-0003 Port scan: 10.0.0.99 probed 121 ports on 10.0.0.10
2026-03-14T09:21:11Z 10.0.0.99 -> 10.0.0.10 score 0.56
ATT&CK: T1046
10.0.0.99 sent SYNs to 121 distinct ports on 10.0.0.10 within 33s, of which 5 were accepted.
Enumerating a host's listening services is reconnaissance, not normal client behaviour.
answered_pct=2.778 duration_s=33.285 open_ports=5 ports_probed=121 scan_type=vertical
syn_count=180 target=10.0.0.10 targets_total=60
[HIGH ] TH-0002 Probable DNS tunnelling to exfil.example
2026-03-14T09:23:13Z 10.0.0.66 -> 10.0.0.1:53 score 0.96
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.52 avg_subdomain_len=45.889 domain=exfil.example max_subdomain_len=46
queries=90 queries_per_min=92.916 txt_null_ratio=1 unique_names=90 unique_ratio=1
[HIGH ] TH-0001 Periodic beaconing to 198.51.100.23:443
2026-03-14T09:30:19Z 10.0.0.66 -> 198.51.100.23:443 score 0.96
ATT&CK: T1071.001, T1573
10.0.0.66 opened 31 connections to 198.51.100.23:443 at a mean interval of 59.6s with 6%
jitter. Regularity at this level is characteristic of automated check-in rather than user
activity.
connections=31 interval_cv=0.057 interval_mad_ratio=0.046 interval_mean_s=59.639
interval_stddev_s=3.376 jitter_pct=5.66 mean_bytes_to_server=870.276 periodicity_score=0.954
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.
Two of those findings appear twice. That is deliberate: a detector restates a finding
once its cooldown has passed and the evidence has materially changed, so the beacon
seen at 20 connections is reported again at 31. Identical restatements are dropped, and
on this capture 67 of them were.
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.
Persistence
Findings survive a restart when you point the sensor at a database:
tracehound sniff -i eth0 -db findings.db -listen 127.0.0.1:8080
tracehound query -db findings.db -min-severity high -since 24h
tracehound query -db findings.db -devices
Note the loopback bind. The dashboard and API have no authentication, and they
serve this network's address inventory, MAC addresses, fingerprints and a live
feed of what has been detected. An attacker on the network being watched can use
that feed to see their own activity being caught, in real time, and stop before
anyone acts on it.
A bare -listen :8080 binds every interface, which is ordinary Go behaviour and
is kept so the container deployment keeps working, but the sensor prints a
warning when it does. To reach the dashboard from another machine, put an
authenticating reverse proxy in front of a loopback bind. SECURITY.md
sets out the trust boundary in full.
The dashboard reloads stored findings on startup, so a restarted sensor opens
showing what it already knows rather than an empty page.
Two things shape the implementation. The packet loop never waits on a disk:
alerts go to a buffered queue and a background goroutine batches them into
transactions, and if that queue fills, alerts are dropped and counted rather
than allowed to back up into the capture path. Losing the record of a finding is
bad, losing the packets that would have produced the next one is worse.
The driver is modernc.org/sqlite, a pure-Go translation of SQLite, so
CGO_ENABLED=0 still produces the static binary everything else depends on.
The cgo binding is faster and would have cost the single-binary property
outright.
Flows are deliberately not stored. A busy network produces millions a day, and
keeping them is what a flow collector is for.
A sensor left running would otherwise fill its disk, so retention is enforced at
startup:
tracehound sniff -i eth0 -db findings.db -db-retention 720h -db-max-alerts 500000
tracehound query -db findings.db -vacuum # return freed space to the filesystem
Both limits are worth setting. An age cutoff expresses how far back you want to
look, but it is not a bound: during an incident a single hour can produce more
findings than a normal month, and only the count ceiling caps that. Deleting
rows frees pages for reuse without shrinking the file, which is the right
default for a database still being written to, so compaction is a separate
command rather than something that happens on a timer.
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.
The server half
JA4 says what software connected. JA4S, taken from the ServerHello, says what it
connected to, and the pair is considerably stronger than either alone: the same
combination seen across several victims is a command-and-control framework
rather than one unusual host. In the demo capture the implant and its server
show up together:
t12d040400_93657e4f18b9_2327112d5371 + t120300_c030_dd32164b0f22
A ServerHello is a much smaller message than a ClientHello, because a server
states decisions rather than offers: one cipher suite instead of a list, one
ALPN instead of a preference order. That makes the fingerprint narrower, but
also harder to vary. A server has no reason to shuffle its extension order the
way browsers now shuffle theirs, so JA4S keeps that ordering as signal where
JA4 has to sort it away. The chosen cipher is printed rather than hashed, since
hashing a single value only makes it harder to read.
HelloRetryRequest is excluded. It is a ServerHello on the wire but not the
server's real answer, and fingerprinting it would give every server that ever
asks for a different key share the same hash.
QUIC contributes a JA4 but never a JA4S: the server's reply is protected with
keys derived from its own connection ID, which a passive observer does not have.
That is a real limit rather than an unimplemented feature.
GREASE is the one place the two algorithms genuinely disagree: JA4 strips those
reserved values and JA4S keeps them, in both the extension count and the hash.
That is not obviously principled, but it is what the reference does, and a
fingerprint exists to be compared.
Both algorithms were checked line by line against FoxIO's reference Python
implementation, which found three real defects: JA4S was stripping GREASE, a
one-character ALPN was being doubled instead of left alone, and a non-ASCII ALPN
was hex-encoded rather than reported as 99. The last two affected JA4 as well.
What has not been done is running both implementations over the same capture and
diffing the output, so treat conformance as verified by reading rather than by
experiment.
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.
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.96 Probable DNS tunnelling to exfil.example
found TH-0003 10.0.0.99 sev=high score=0.56 Port scan: 10.0.0.99 probed 121 ports on 10.0.0.10
found TH-0004 10.0.0.99 sev=medium score=0.15 Network sweep: 10.0.0.99 probed port 445 across 60 hosts
found TH-0005 10.0.0.66 sev=medium score=1.00 Large outbound transfer to files.storage-sync.example (17.9 MiB)
found TH-0007 10.0.0.66 sev=medium score=0.60 Rare TLS fingerprint on 10.0.0.66
--- PASS: TestReplayFindsEveryPlantedBehaviour
6 benign hosts, none reported above info severity
--- PASS: TestReplayDoesNotAccuseBenignHosts
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: fingerprint 90%, pipeline 86%, detect 83%, rules 82%, flow 82%,
quic 82%, store 81%, api 79%.
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
tracehound query -db <file.db> Read findings back out of a database
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 |
-db findings.db |
Persist findings to SQLite so they survive a restart |
-db-retention 720h |
Discard stored findings older than this |
-db-max-alerts 500000 |
Hard ceiling on stored findings, newest kept |
-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
- JA4H, the HTTP variant
- QUIC v2 and the draft versions, which need only their own initial salts
- A detector for known client and server fingerprint pairs, once there is a feed
to check them against
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.