tracehound

module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 26, 2026 License: MIT

README

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.

ci go report go reference license

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.

tracehound analysing a capture

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.


Performance

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.

Directories

Path Synopsis
cmd
tracehound command
Command tracehound is a passive network sensor: it reads packets from a capture file or an interface, assembles them into flows, fingerprints TLS clients, and reports attacker behaviour mapped to MITRE ATT&CK.
Command tracehound is a passive network sensor: it reads packets from a capture file or an interface, assembles them into flows, fingerprints TLS clients, and reports attacker behaviour mapped to MITRE ATT&CK.
internal
api
Package api serves the alert feed, asset inventory, and live dashboard.
Package api serves the alert feed, asset inventory, and live dashboard.
capture
Package capture turns bytes off the wire (or off disk) into decoded model.Packet values.
Package capture turns bytes off the wire (or off disk) into decoded model.Packet values.
detect
Package detect hosts the detection engine and the built-in detectors.
Package detect hosts the detection engine and the built-in detectors.
fingerprint
Package fingerprint derives passive TLS client fingerprints (JA4 and JA3) from raw ClientHello bytes.
Package fingerprint derives passive TLS client fingerprints (JA4 and JA3) from raw ClientHello bytes.
flow
Package flow maintains the live table of bidirectional conversations assembled from the packet stream.
Package flow maintains the live table of bidirectional conversations assembled from the packet stream.
model
Package model defines the core domain types shared by every stage of the tracehound pipeline: capture, flow assembly, fingerprinting, and detection.
Package model defines the core domain types shared by every stage of the tracehound pipeline: capture, flow assembly, fingerprinting, and detection.
pcapgen
Package pcapgen writes a synthetic capture containing known, labelled attacker behaviour.
Package pcapgen writes a synthetic capture containing known, labelled attacker behaviour.
pipeline
Package pipeline wires the sensor together: capture feeds flow assembly, flow assembly feeds fingerprinting and detection, detection emits alerts.
Package pipeline wires the sensor together: capture feeds flow assembly, flow assembly feeds fingerprinting and detection, detection emits alerts.
quic
Package quic decrypts QUIC Initial packets far enough to recover the TLS ClientHello inside them.
Package quic decrypts QUIC Initial packets far enough to recover the TLS ClientHello inside them.
rules
Package rules loads the YAML rule pack that governs detection policy.
Package rules loads the YAML rule pack that governs detection policy.
store
Package store persists findings so they survive a restart.
Package store persists findings so they survive a restart.
tools
readmegen command
Command readmegen regenerates the sample output blocks in README.md from the program itself, so they cannot drift away from what tracehound actually prints.
Command readmegen regenerates the sample output blocks in README.md from the program itself, so they cannot drift away from what tracehound actually prints.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL