Defensive Security · lesson 3 of 8
Intermediate
6- 2 Sigma Detection Rules: Catch SSH Brute Force and Map It to ATT&CK
- 3 Suricata on a Packet Capture: Write an IDS Rule That Fires
- 4 Alert Triage and Tuning: From 41 Alerts to the 3 That Matter
- 5 Incident Response: Preserve the Evidence, Then Contain
- 6 Building an Incident Timeline from Auth, Web and IDS Logs
- 7 Static File Analysis with EICAR and YARA: Hashes, file, strings and Rules
Also part of Defensive Security
6- + A Firing Alert Doesn't Mean Anyone Was Notified from Monitoring & Observability
- + MTTR Isn't a Guess — Compute It From Alertmanager's Own Timestamps from Monitoring & Observability
- + Logging Security Events Without Logging Secrets from Security Fundamentals
- + Watching the TCP Handshake with tcpdump from Networking
- + Host Firewalls with nftables: DROP Times Out, REJECT Refuses from Networking
- + Hunting in AWS CloudTrail Logs with Python from Cloud Security
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
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
- 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.
- A request for
/.envthat gets a 404 is an attempt; one that gets a 200 means the file leaked. Write a rule that only alerts when/.envis served with status 200. A normal->rule that useshttp.uriandhttp.stat_codetogether 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.
- Remove
endswithfrom the.envrule. 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.