← News
/PLAYBOOK · 2026-08-01

Suricata Rule Writing in Practice: Building IDS Rules from Scratch

Covers Suricata's three-part rule syntax and three detection rules for SQL injection, abnormal SSH, and malicious domains. Includes syntax checks, load, replay triggering, false-positive tuning, FAQ, and verification commands.

Scope

Enterprise intranet or cloud servers that need real-time monitoring of network traffic for intrusion without changing the application: scan probes, SQL injection, brute force, malware C2, and similar. Suricata is an open-source IDS/IPS engine with multithreading, protocol parsing, and custom rules. Deploy it on a gateway TAP/SPAN (mirror port) or at the server edge; it emits alerts for the security team. This article covers Suricata rule syntax from scratch so you can write working IDS rules.

Prerequisites

Mechanism

A Suricata rule (signature) describes what traffic to match and what action to take. A rule has three parts: action + header + options, for example:

alert tcp $HOME_NET any -> $EXTERNAL_NET any \
    (msg:"Example rule"; content:"test"; sid:1000001; rev:1;)

`alert` is the action (alert/drop/reject). `tcp $HOME_NET any -> $EXTERNAL_NET any` is the header (protocol, source, destination). Options are in parentheses: `content` matches payload, `msg` is the alert message, `sid` is the unique rule ID (custom rules should start at 1000000 to avoid colliding with official rules), `rev` is the revision. Suricata also has application-layer keywords for HTTP, DNS, TLS, and similar (`http.uri`, `dns.query`), which are more precise than raw byte matching.

Procedure

1. Confirm install and version

suricata --build-info | head -n 5
sudo suricata --list-keywords > /dev/null 2>&1; echo "keyword list ok"

2. Create a local rules file

sudo mkdir -p /etc/suricata/rules
sudo nano /etc/suricata/rules/local.rules

3. Write three typical rules

Rule 1: Detect SQL injection patterns in HTTP requests (union select / common injection functions):

alert http $EXTERNAL_NET any -> $HOME_NET any \
    (msg:"ET LOCAL SQLi - union select in URI"; \
     flow:established,to_server; \
     http.uri; content:"union"; nocase; \
     http.uri; content:"select"; nocase; distance:1; \
     classtype:attempted-recon; sid:1000001; rev:1;)

The rule requires both `union` and `select` in the URI (case-insensitive, within 1 byte). This cuts false positives.

Rule 2: Detect SSH brute-force indicators (repeated auth failures in a short window are hard to judge from a single packet; this rule matches a malformed SSH version string):

alert tcp $EXTERNAL_NET any -> $HOME_NET 22 \
    (msg:"ET LOCAL SSH - abnormal version string"; \
     flow:established,to_server; \
     content:"SSH-"; offset:0; \
     content:"!"; \
     classtype:attempted-admin; sid:1000002; rev:1;)

Rule 3: Detect common backdoor/malware C2 domains (DNS query hits a denylisted domain):

alert dns $HOME_NET any -> any any \
    (msg:"ET LOCAL Malware - known C2 domain query"; \
     dns.query; content:"malware-c2.example.com"; nocase; \
     classtype:trojan-activity; sid:1000003; rev:1;)

A more practical approach is to put the C2 domain list in a `suricata-update`-managed ruleset, or use `dns.query; content:!;` with file-based rules (`datasets` can be configured outside `--rule`). This article keeps a single-rule form for clarity.

4. Enable local rules in the config

sudo nano /etc/suricata/suricata.yaml

Append under `rules-files:`:

rules-files:
  - suricata.rules
  - local.rules

5. Validate rule syntax and start

# Syntax and config check (-T test mode)
sudo suricata -T -c /etc/suricata/suricata.yaml \
    -S /etc/suricata/rules/local.rules

# Start for real (systemd)
sudo systemctl enable --now suricata

# Check runtime status
sudo journalctl -u suricata -n 20 --no-pager

Note: capturing traffic needs packet-capture privileges. Default is `af-packet` or `pcap`. Set the NIC in `/etc/suricata/suricata.yaml` (`af-packet.interface`) to the real interface name.

6. Replay traffic to trigger and verify rules

# Manually trigger one SQL injection-pattern request
curl -s "http://example.com/index.php?id=1%20union%20select%201" > /dev/null

# Read alerts (JSON log)
sudo tail -n 5 /var/log/suricata/eve.json | jq -r \
    'select(.alert) | .timestamp, .alert.signature, .alert.signature_id'

Expected output includes `ET LOCAL SQLi - union select in URI` and sid `1000001`.

Config Verification

# Ruleset stats (confirm local.rules is loaded)
sudo suricata -c /etc/suricata/suricata.yaml -T 2>&1 | grep -i "local"
# Should show a local.rules line, no syntax errors

# Count loaded rules
sudo suricata --dump-rule-counts -c /etc/suricata/suricata.yaml 2>/dev/null | head

FAQ

FAQ 1: sid conflict or duplicate?

Rule IDs must be globally unique; collisions cause load failure. Official rulesets use the 1-1000000 range; custom rules should use IDs above 1000000. Track multiple rules via `suricata-update list-sources` or `# sid` comments at the top of the rules file. If startup logs show `duplicate sid`, find the file, remove the duplicate, and reload.

FAQ 2: Too many alerts (high false positives)?

Tune in this order: 1) Tighten match conditions—combine multiple `content` matches with `nocase`, `distance`, `within` to pin location; 2) Limit protocol context (e.g. `http.uri` only, not full-payload match); 3) Use `threshold` for a time-window count (e.g. alert only if the same sid fires more than 5 times in 60 seconds); 4) After feeding `eve.json` into a SIEM, aggregate by source IP, promote high-confidence alerts, demote the rest to watch.

Summary

This article started from Suricata's three-part rule structure, wrote three working detection rules (SQL injection, abnormal SSH, malicious domains), and covered syntax check, load, and replay verification. Key points: custom sids start at 1000000; use application-layer keywords (`http.uri`/`dns.query`) for precision; run `suricata -T` before restarting; send alerts to eve.json for later analysis. Next step: feed Suricata alerts into ELK or an existing log platform for visualization and alert correlation.