5. run it (first start builds the trail set; takes a minute)

August 10, 2026 · View on GitHub

Maltrail

License Sensor Server Trails X

Malicious traffic detection system. Maltrail watches your network for contact with things that are known to be bad — and tells you, in one line, what was seen and why it is considered bad.

"2026-08-07 09:14:22.117034" gw 10.13.13.2 57809 1.1.1.1 53 UDP DNS malware.bakewithdavid.com "asyncrat (malware)" (static)

No rule language, no tuning ritual, no ML. A trail is a domain, URL, IP address, IP:port or User-Agent known to belong to something malicious, and Maltrail tells you when one appears on the wire.


Content


Why Maltrail

Most network detection tools ask you to describe behaviour. Maltrail asks a simpler question that answers most real incidents: is this host talking to something we already know is bad?

  • More than 1.5 million trails, from more than 3,000 curated static lists plus 46 public feeds, refreshed daily and growing. Heavily weighted toward malware — C2 domains, droppers, stealers, APT infrastructure — because that is what shows up in a real compromise.
  • Trails are plain text. One indicator per line, in a file you can read, grep and send a pull request against. That is why coverage stays current, and why you can always answer "why did this fire?".
  • Fast enough to stop thinking about it. A single core handles a 10 GbE link on a realistic traffic mix; see Performance.
  • Heuristics on top, not instead — each one named in the event, never a bare score: port, UDP and web scanning, DNS exhaustion, DGA-shaped lookups (entropy and consonant thresholds, excessive NXDOMAIN), sinkholed, seized and parked domains, long domains, direct-IP and IoT-malware downloads, suspicious user agents and proxy probes.

Architecture

Two independent processes. Run them on one box or many.

   ┌──────────┐   events (UDP or file)   ┌──────────┐
   │  sensor  │ ───────────────────────► │  server  │ ◄── browser
   └──────────┘                          └──────────┘
    Rust                                  Python
    libpcap + PACKET_FANOUT               reporting UI + API
    trail matching, heuristics

A sensor can log locally (LOG_DIR), ship to a remote server (LOG_SERVER), or both. For an existing SIEM it also emits CEF over syslog (SYSLOG_SERVER) and Logstash JSON (LOGSTASH_SERVER).


Performance

The sensor is Rust, one thread per capture worker, sharing a single immutable trail store. Cost per packet, by traffic type:

trafficper packet
ICMP echo (58 B)101 ns
TCP SYN (70 B)302 ns
bulk TLS (1,473 B)402 ns
DNS query, warm cache (93 B)452 ns
mixed traffic (866 B average)552 ns
HTTP request (169 B)602 ns
DNS query, every name unique (DGA flood)1,102 ns

Workers share nothing mutable, so that cost is what each additional core buys you. Replaying the 866-byte mix:

workerspackets/sGbit/svs 1 worker
11,687,99111.691.00×
23,209,62722.241.90×
45,379,43637.273.19×
88,552,23159.255.07×
1610,165,77370.436.02×

One core saturates 10 GbE. Scaling is near-linear to four workers and then tapers on an eight-physical-core box, because the rest is SMT — hardware, not lock contention.

Against the old Python sensor, replaying the same 300,000-packet capture with the same 1,505,265 trails and the same configuration, one worker each — startup excluded, so this is steady-state packet cost. Both figures here are whole-process (pcap read and dispatch included), which is why the sensor's number is higher than the 552 ns packet path measured in isolation above:

per packetpackets/s
sensor (Rust)865 ns1,156,423
old sensor (Python)23,448 ns42,648
27× faster

Reproduce it yourself — the harness is committed, and it prints both sensors' event counts so a throughput number can never be quoted without its correctness context:

python3 sensor/tools/bench_compare.py --packets 300000 --trails ~/.maltrail/trails.csv --repeat 3

Memory does not grow with cores: the 1.5M-trail store is 68.5 MB, built in 1.2 s and shared immutably by every worker.

One capture worker by default, which is ~1.1M packets/s and enough for almost any sensor host. Extra workers are an explicit opt-in (CAPTURE_FANOUT), because the kernel flow-hashes capture while the scan heuristics count per source: of the heuristic alerts one worker raises, 91% survive at 2 sockets, 86% at 4, 65% at 8. Exact trail detection is identical at every worker count. Scale out when maltrail_capture_dropped_total says to, not before.

AMD Ryzen 7 PRO 4750U (8 physical cores), heuristics on, real trail set, fastest of three runs. The ratio has ranged 14–27× across runs and hardware; the per-packet costs above are the ones that bound how much traffic a worker can absorb. These are software-path figures — a live NIC adds driver and ring costs, so measure your own hardware and watch maltrail_capture_dropped_total. Method, per-protocol breakdown, instruction counts and the profiler output are in sensor/docs/REPORT.md.


Quick start

Linux, libpcap, Rust 1.74+ for the sensor, and Python 3.7+ — the sensor uses it to build trails.csv, so on 3.6 (openSUSE Leap 15 / SLE 15) the trail set stays empty and nothing is detected. setcap comes from libcap2-bin / libcap / libcap-progs. -T checks both.

Prebuilt sensor binaries for x86_64 and aarch64 are attached to every release with a SHA-256 checksum, so a Rust toolchain is only needed to build from source. To build it anyway:

git clone --depth 1 https://github.com/stamparm/maltrail.git
cd maltrail

# 1. build the sensor
cd sensor && cargo build --release && cd ..

# 2. let it capture without running as root
sudo setcap cap_net_raw,cap_net_admin=eip sensor/target/release/maltrail-sensor

# 3. give it somewhere to write events (LOG_DIR, /var/log/maltrail by default)
#    ('id -gn', not "$USER": not every distribution gives each user their own group)
sudo install -d -o "$USER" -g "$(id -gn)" -m 750 /var/log/maltrail

# 4. check the deployment before trusting it — exits non-zero if anything is wrong
sensor/target/release/maltrail-sensor -T

# 5. run it (first start builds the trail set; takes a minute)
sensor/target/release/maltrail-sensor

In another terminal, or on another machine:

python3 server.py

Then open http://127.0.0.1:8338 and log in with the credentials in maltrail.conf (USERS).

-T is the shortcut for "will this work?" — it validates the configuration, trails, whitelist, log directory, capture filter and privileges, and tells you exactly what is missing:

[o] log directory: '/var/log/maltrail' is writable
[o] capture privileges: CAP_NET_RAW present
[o] capture filter: udp or icmp or (tcp and (tcp[tcpflags] == tcp-syn or port 80 or port...
[o] interface: any
[o] workers: 16 (PACKET_FANOUT required; verify with tools/fanout_check.py as root)
[o] whitelist: 3440 entries, 18 CIDR range(s)
[o] trails: 1505265 loaded (0 malformed row(s)), ipv4=144758 ipv4:port=253517 ipv6=2014 wildcard=29
[o] heuristics: on (disabled: none)
[i] configuration test PASSED

Skipping step 2 or 3 is the single most common way to end up with a sensor that starts and detects nothing; -T names both.

As a service

sudo useradd --system --no-create-home --shell /usr/sbin/nologin maltrail
sudo rsync -a --exclude .git . /opt/maltrail/
sudo cp /opt/maltrail/maltrail-{server,sensor}.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now maltrail-server maltrail-sensor

That is the whole installation — no directories to create, no setcap. The units create and own /var/log/maltrail (events) and /var/lib/maltrail (the trail set) via systemd's LogsDirectory=/StateDirectory=, run both processes as the unprivileged maltrail user with a read-only filesystem, and give the sensor exactly CAP_NET_RAW and CAP_NET_ADMIN — nothing else, and no root anywhere. The sensor runs -T as ExecStartPre, so a broken deployment fails at systemctl start instead of running blind.

Check it: systemctl status maltrail-sensor and journalctl -u maltrail-sensor -f.

Docker

docker compose -f docker/docker-compose.yml up -d

See docker/README.md.


Configuration

Everything lives in maltrail.conf, split into [Sensor] and [Server]. The options most worth knowing:

optionwhat it does
MONITOR_INTERFACEinterface(s) to capture on, or any
CAPTURE_FILTERBPF filter; the default keeps bulk line-rate traffic out of userspace
PROCESS_COUNTcapture workers — one per core is a reasonable default
LOG_DIRwhere events are written (/var/log/maltrail)
TRAILS_FILEwhere the built trail set lives (~/.maltrail/trails.csv; /var/lib/maltrail under the units)
LOG_SERVERship events to a remote server instead of, or as well as, logging locally
STATS_ADDRESSexpose Prometheus metrics (sensor; off unless set)
UPDATE_PERIODhow often trails are refreshed
USER_WHITELISTyour own never-alert list
CUSTOM_TRAILS_DIRyour own trails, alongside the shipped ones

Trails

trails/static/malware/asyncrat.txt      # one indicator per line
trails/static/malicious/…
trails/static/suspicious/…
trails/feeds/*.py                       # public feeds, pulled on update

Adding an indicator is adding a line to a text file. Adding a feed is a small Python module. Both are ordinary pull requests, and that low friction is why the set stays useful.

Your own indicators go in CUSTOM_TRAILS_DIR; anything you never want to hear about goes in USER_WHITELIST.


Events

One line per detection, whitespace-separated, CSV-quoted where needed:

"<time>" <sensor> <src_ip> <src_port> <dst_ip> <dst_port> <proto> <type> <trail> "<info>" <reference>

type is what matched — DNS, IP, IPORT, URL, PATH, HTTP, UA, PORTinfo is why it is considered bad, and reference is where the trail came from: (static), a feed name, or (heuristic).


Operating it

  • -T validates a configuration and exits. Usable as a deployment gate; the systemd unit runs it as ExecStartPre.

  • STATS_ADDRESS exposes Prometheus metrics. The four worth alerting on, all of which mean this sensor is not detecting what you think it is:

    metricwhat it means
    maltrail_up == 0no capture worker is alive — this host is not monitored
    rate(maltrail_capture_dropped_total)the ring is dropping packets — missed detections
    rate(maltrail_local_log_errors_total)detections were produced and then lost
    maltrail_trail_generation not advancingtrails have stopped refreshing

    Also useful: maltrail_log_dir_free_bytes (see below) and maltrail_state_saturations_total, which is non-zero when a state-exhaustion flood has narrowed the heuristics. Exact trail matching is unaffected by that, by design.

  • systemctl reload (SIGHUP) reloads trails without a restart. Trails refreshed by anything else are picked up within a second, with an atomic swap — no restart, no dropped packets.

  • The condensed observable store (USE_CONDENSED_STORAGE, meta.sqlite) that feeds the server's /meta novelty and retro-hunt views is written in the same format the old sensor produces, and the two are compared row for row by the parity harness. Every deliberate difference between the sensors is listed in sensor/docs/COMPATIBILITY.md.

Event retention

Maltrail never deletes event evidence. There is no retention setting that expires your logs, and that is deliberate: these are the records you go back to after an incident, and a tool that quietly discards them is worse than useless during the one week you need them.

That makes free space something you operate rather than ignore:

  • Ship the durable copy off-box. LOG_SERVER (or SYSLOG_SERVER / LOGSTASH_SERVER) makes the server or your SIEM the system of record, and the sensor's local file a buffer. This is the retention strategy; local disk is not one.
  • Alert on maltrail_log_dir_free_bytes with real headroom. -T reports it too, and warns below 10 GB. When it reaches zero the sensor cannot append and detections are lost.
  • Archiving is yours to decide. Compress or move old daily logs on your own schedule if you need the space. Note that the reporting UI serves historical logs as plain seekable files, so compressing them in place removes those days from the interface — archive them elsewhere.

If your policy requires deletion (event logs contain IP addresses and domains, which are personal data in some jurisdictions), that is an explicit operator decision — make it with your own tooling, deliberately, rather than having a sensor default do it quietly.


Documentation

sensor/docs/INSTALL.mdinstallation, privileges, configuration, troubleshooting
sensor/docs/ARCHITECTURE.mdhow the sensor works internally
sensor/docs/COMPATIBILITY.mdevery deliberate difference from the old sensor
sensor/docs/REPORT.mdmeasurements, profiles and test results
sensor/docs/ROADMAP.mdwhat is still open
old/README.mdthe previous Python sensor, kept as reference and test oracle

Contributing

Trails are the most valuable contribution: a line in the right file, with a source. Feeds, bug reports and sensor work are equally welcome.

The sensor's full gate is one command:

bash sensor/tools/check.sh

It runs formatting, lints, the test suite in both debug and release profiles, and replays a corpus through both the current sensor and the old Python one, requiring byte-identical events. The Python side is bash tests/run.sh.


Licence

MIT. See LICENSE.


Sponsors

Developers

Presentations

  • 47th TF-CSIRT Meeting, Prague (Czech Republic), 2016 (slides)

Publications

Blacklist

  • Maltrail's daily updated blacklist of malware-related domains can be found here. It is based on trails found at trails/static/malware and can be safely used for DNS traffic blocking purposes.

Thank you

  • Thomas Kristner
  • Eduardo Arcusa Les
  • James Lay
  • Ladislav Baco (@laciKE)
  • John Kristoff (@jtkdpu)
  • Michael Münz (@mimugmail)
  • David Brush
  • @Godwottery
  • Chris Wild (@briskets)
  • Keith Irwin (@ki9us)
  • Simon Szustkowski (@simonszu)

Third-party integrations

1 Using (only) trails

2 Connector to trails (only)