Skip to main content
Defensive Security · lesson 3 of 8
Defensive Security intermediate Lesson 3 of 8

Suricata on a Packet Capture: Write an IDS Rule That Fires

Capture your own HTTP traffic, run Suricata 8 over the pcap with a local rule, and see why checksum offload and a port-80 rule both cause silent misses.

By SkillByExample · Published

defensive-security suricata ids pcap eve-json docker

The previous lessons read logs that a server chose to write. A network IDS doesn’t depend on that: it watches the packets themselves. Here you’ll record two HTTP requests to your own lab server, write a Suricata rule for one of them, and run Suricata over the capture. It takes three tries to get an alert, and both failures are silent. Tested with Docker Engine 29.8.2, Suricata 8.0.7 (jasonish/suricata:8.0), Alpine 3.22.6 with tcpdump 4.99.5 and curl 8.14.1, and Python 3.12.15 (python:3.12-slim).

The lab

Reuse the internal network from the auth logs lesson, and run Python’s built-in web server on it. The capture container has tcpdump and curl.

# capture/Dockerfile
FROM alpine:3.22
RUN apk add --no-cache tcpdump curl
docker network create --internal --subnet 172.30.0.0/24 ds-lab   # skip if it still exists
docker build -t capture-lab capture
docker run -d --name web --network ds-lab --ip 172.30.0.11 python:3.12-slim python -m http.server 8000

The capture script starts tcpdump in the background, makes two requests, and stops it. The second request is for /.env, a file that often holds secrets and is one of the first paths automated scanners probe.

# capture.sh: record two HTTP requests to the lab web server
tcpdump -i eth0 -U -w /work/http.pcap port 8000 2>/dev/null &
sleep 2
curl -s -o /dev/null -w "GET /      -> %{http_code}\n" http://172.30.0.11:8000/
curl -s -o /dev/null -w "GET /.env  -> %{http_code}\n" http://172.30.0.11:8000/.env
sleep 1
kill $!; wait
echo "captured $(wc -c < /work/http.pcap) bytes"
$ docker run --rm --network ds-lab --ip 172.30.0.20 -v "$PWD":/work -w /work capture-lab sh capture.sh
GET /      -> 200
GET /.env  -> 404
captured 3742 bytes

The -U flag makes tcpdump write each packet to the file as it arrives. Without it, packets wait in a buffer, and a container that stops at the wrong moment leaves a 24-byte file: the pcap header and nothing else. The /.env request got a 404, because the file doesn’t exist, but the attempt is still worth an alert.

The rule

A Suricata rule has an action, a protocol, the addresses and ports, then options in brackets.

# local.rules
alert http any any -> any any (msg:"LAB request for a .env file"; flow:established,to_server; http.uri; content:"/.env"; endswith; classtype:attempted-recon; sid:1000001; rev:2;)

alert http matches HTTP wherever Suricata finds it. http.uri points the next content at the request path, and endswith anchors it, so /.env.example doesn’t match. sid is the rule’s ID; the range 1,000,000-1,999,999 is reserved for local rules. rev counts edits to the rule.

First run: no alert

suricata -r reads a pcap instead of a live interface. -S loads only your rules file, and -l sets the output directory.

$ mkdir -p out
$ docker run --rm --entrypoint suricata -v "$PWD":/work -w /work jasonish/suricata:8.0 -r http.pcap -S local.rules -l out
i: suricata: This is Suricata version 8.0.7 RELEASE running in USER mode
i: threads: Threads created -> RX: 1 W: 12 FM: 1 FR: 1   Engine started.
i: suricata: Signal Received.  Stopping engine.
W: pcap: 1/1th of packets have an invalid checksum, consider setting pcap-file.checksum-checks variable to no or use '-k none' option on command line.
i: pcap: read 1 file, 24 packets, 3334 bytes
$ wc -l < out/fast.log
0

All 24 packets were read and nothing alerted. The warning explains it: the TCP checksums are invalid. The capture was taken inside the client container, and Docker’s virtual interfaces leave the checksum for “the hardware” to fill in later, which never happens on a virtual link. This is called checksum offload. The packets on the wire were fine; the copies tcpdump recorded carry a placeholder. Suricata treats packets with bad checksums as unreliable and doesn’t reassemble or inspect them by default. You’d see the same thing capturing on most laptops.

--entrypoint suricata skips the image’s start-up script, which checks capabilities that only live capture needs and prints warnings about them.

Second run: the alert

-k none turns off checksum validation for this run.

$ rm -f out/*
$ docker run --rm --entrypoint suricata -v "$PWD":/work -w /work jasonish/suricata:8.0 -r http.pcap -S local.rules -k none -l out
i: suricata: This is Suricata version 8.0.7 RELEASE running in USER mode
i: threads: Threads created -> RX: 1 W: 12 FM: 1 FR: 1   Engine started.
i: suricata: Signal Received.  Stopping engine.
i: pcap: read 1 file, 24 packets, 3334 bytes
$ cat out/fast.log
10/14/2026-08:09:44.333152  [**] [1:1000001:2] LAB request for a .env file [**] [Classification: Attempted Information Leak] [Priority: 2] {TCP} 172.30.0.20:50574 -> 172.30.0.11:8000

Suricata appends to eve.json rather than replacing it, so rm -f out/* clears the first run’s output first. One alert, for the /.env request only. [1:1000001:2] is generator 1 (the detection engine), sid 1000001 and rev 2. The classtype attempted-recon became “Attempted Information Leak” with priority 2.

Only use -k none for captures you know came from an offloading interface. A sensor on a real network tap should keep checksum checks on, because bad checksums there can be an evasion attempt.

Reading eve.json

fast.log is for people. eve.json is what tools ingest: one JSON object per line, with an event_type on each.

# alerts.py: summarise Suricata's eve.json
import json
from collections import Counter

events = [json.loads(line) for line in open("out/eve.json")]
print(Counter(e["event_type"] for e in events))

for e in events:
    if e["event_type"] == "http":
        h = e["http"]
        print("http ", e["src_ip"], h["http_method"], h["url"], h["status"])
    elif e["event_type"] == "alert":
        a, h = e["alert"], e["http"]
        print("ALERT", e["src_ip"], "->", f'{e["dest_ip"]}:{e["dest_port"]}',
              f'sid {a["signature_id"]}', repr(a["signature"]), h["url"])
$ docker run --rm -v "$PWD":/work -w /work python:3.12-slim python alerts.py
Counter({'http': 2, 'fileinfo': 2, 'flow': 2, 'alert': 1, 'stats': 1})
http  172.30.0.20 GET / 200
ALERT 172.30.0.20 -> 172.30.0.11:8000 sid 1000001 'LAB request for a .env file' /.env
http  172.30.0.20 GET /.env 404

Even without an alert, Suricata logged both requests as http events, with the method, path and status. That protocol log is often as useful as the alerts: it answers “what else did this IP request?” after something fires. Each alert also carries the HTTP details of the request that triggered it, so a script doesn’t have to join them.

The port-80 rule

Most rule examples you’ll find write HTTP rules against port 80. Change the destination port and rerun.

# local.rules
alert http any any -> any 80 (msg:"LAB request for a .env file"; flow:established,to_server; http.uri; content:"/.env"; endswith; classtype:attempted-recon; sid:1000001; rev:3;)
$ rm -f out/*
$ docker run --rm --entrypoint suricata -v "$PWD":/work -w /work jasonish/suricata:8.0 -r http.pcap -S local.rules -k none -l out
i: suricata: This is Suricata version 8.0.7 RELEASE running in USER mode
i: threads: Threads created -> RX: 1 W: 12 FM: 1 FR: 1   Engine started.
i: suricata: Signal Received.  Stopping engine.
i: pcap: read 1 file, 24 packets, 3334 bytes
$ wc -l < out/fast.log
0
$ docker run --rm -v "$PWD":/work -w /work python:3.12-slim python alerts.py
Counter({'http': 2, 'fileinfo': 2, 'flow': 2, 'stats': 1})
http  172.30.0.20 GET / 200
http  172.30.0.20 GET /.env 404

It’s the same pcap with the same checksum fix, and there’s no alert. The http events prove Suricata recognised the traffic as HTTP on port 8000, but the rule only applies to port 80. Suricata identifies protocols from the bytes, not the port number, so alert http any any -> any any already means “HTTP on any port”. Restricting the port only adds a way to miss things: development servers, admin panels on 8080, and anything an attacker sets up. The next lesson deals with the opposite problem: a rule that fires too often.

Clean up

docker rm -f web

The practice tasks only need http.pcap, so the web server can go.

Practice

  1. Add a second rule, sid 1000002, that alerts on any request whose User-Agent starts with curl/. How many alerts do you get now, and why?
Solution
# local.rules: add this line below the .env rule
alert http any any -> any any (msg:"LAB curl user agent"; flow:established,to_server; http.user_agent; content:"curl/"; startswith; classtype:policy-violation; sid:1000002; rev:1;)

With both rules loaded and -k none, fast.log has three lines. Both requests came from curl, so sid 1000002 fires twice, and sid 1000001 fires once for /.env. One request can raise several alerts, one per matching rule. A rule like this is mostly noise on a real network, which is the subject of the next lesson.

  1. A request for /.env that gets a 404 is an attempt; one that gets a 200 means the file leaked. Write a rule that only alerts when /.env is served with status 200. A normal -> rule that uses http.uri and http.stat_code together is rejected; read the error.
Solution

Suricata 8 prints rule 1000003 mixes keywords with conflicting directions, a transactional rule with => should be used. The URI is in the request and the status is in the response, so the rule must match the whole transaction:

# local.rules: add this line
alert http any any => any any (msg:"LAB .env file served"; http.uri; content:"/.env"; endswith; http.stat_code; content:"200"; classtype:successful-recon-limited; sid:1000003; rev:1;)

It loads and stays silent on this pcap, because the lab answered 404. To prove it isn’t just broken, copy it with content:"404" and a new sid. That copy fires once, and its alert is reported from 172.30.0.11:8000 to the client, because it matched on the response.

  1. Remove endswith from the .env rule. Which extra requests would match now?
Solution

content:"/.env" on its own matches the string anywhere in the path, so /.env.example, /.env.bak, /app/.env and /.envoy/config would all alert. Some of those you want (.env.bak is just as sensitive), and some are false positives. endswith is precise. A second rule for .env. backups is better than a loose pattern that you’ll end up tuning out.

Frequently Asked Questions

Can Suricata analyse a pcap file instead of live traffic?
Yes. suricata -r capture.pcap reads a packet capture offline, runs every loaded rule over it and writes the same outputs as a live sensor: fast.log for one-line alerts and eve.json for structured events. This is the standard way to test a new rule, because the input is fixed and you can rerun it as often as you like.
Why does Suricata say packets have an invalid checksum?
When a capture is taken on the machine that sent the packets, the kernel often leaves the TCP checksum for the network card to fill in later, which is called checksum offload. The recorded packets carry a placeholder checksum, and Suricata ignores them by default. If the capture came from such an interface, run with -k none, or set pcap-file.checksum-checks to no.
Why should Suricata rules use the http keyword instead of port 80?
Suricata detects application protocols from the traffic itself, so an alert http rule matches HTTP on any port. Adding port 80 as the destination restricts the rule to that port, and HTTP served on 8000, 8080 or anything else silently escapes it. Use any for the ports unless you have a specific reason to limit them.
What is eve.json in Suricata?
eve.json is Suricata's main output, one JSON object per line. Besides alerts, it records protocol events such as http and dns, file information and flow summaries, each with an event_type field. Because every alert carries the matching HTTP or flow details, it is what SIEMs and scripts ingest instead of the one-line fast.log.
What is a transactional rule in Suricata 8?
A rule written with => instead of -> that matches both the request and the response of one transaction, for example a URI in the request and a status code in the response. Suricata 8 refuses a normal rule that mixes keywords from both directions and tells you to use the transactional form instead.