tracehound

module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 25, 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, 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
[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.


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.

--- 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 83%, detect 83%, rules 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.

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
  • QUIC support. JA4 already encodes the transport, but the decoder does not read QUIC yet
  • JA4S and JA4H, the server and HTTP variants

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.
rules
Package rules loads the YAML rule pack that governs detection policy.
Package rules loads the YAML rule pack that governs detection policy.

Jump to

Keyboard shortcuts

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