Writing Detection Rules
Writing a detection rule from an incident or threat report is a core detection engineering skill. The workflow is: study the attack's network indicators → identify which indicators are stable (not easily changed) → encode them as Suricata keywords → validate against positive and negative PCAPs → measure false positive rate on production traffic → deploy. This chapter walks through the full rule development lifecycle.
A threat report describes a new banking trojan, "TestMalware," that: (1) does an HTTP GET to a random-looking domain, (2) with a specific HTTP header X-Client-ID: [16-char hex string], (3) the response is exactly 32 bytes long (encryption key exchange). You need to write a Suricata rule that detects this check-in pattern reliably.
Rule Development Workflow
Detection Rule Development Lifecycle
═══════════════════════════════════════════════════════════════════
Step 1: Identify indicators (from threat report / sandbox / pcap)
│
│ Classify each indicator:
│ ┌────────────────────────────────────────────────────────┐
│ │ Stable (don't change per deployment): │
│ │ - Protocol (HTTP, DNS, TLS) │
│ │ - Header names ("X-Client-ID:") │
│ │ - URI patterns ("/submit.php") │
│ │ - Magic bytes in payload │
│ │ - JA3 fingerprint │
│ │ │
│ │ Unstable (change per operator/deployment): │
│ │ - Domain names → encode as TLD pattern only │
│ │ - IP addresses → use threat intel, not hardcode │
│ │ - Payload encryption keys → can't match │
│ └────────────────────────────────────────────────────────┘
│
Step 2: Write rule using stable indicators
│
Step 3: Test against positive PCAP (must alert)
│
Step 4: Test against negative PCAP (must NOT alert)
│
Step 5: Measure FP rate on 1 week production traffic
│
Step 6: Tune thresholds and conditions if FP > acceptable
│
Step 7: Deploy, monitor, iterate
Rule Writing Workshop
# GOAL: Detect TestMalware HTTP check-in
# Indicator: X-Client-ID header with 16-char hex value
# Indicator: response body exactly 32 bytes
# Rule 1: Detect the X-Client-ID header (custom C2 header)
alert http $HOME_NET any -> $EXTERNAL_NET any (
msg:"ET MALWARE TestMalware HTTP Check-in X-Client-ID Header";
flow:established,to_server;
http.header;
content:"X-Client-ID|3a 20|"; # "X-Client-ID: "
pcre:"/X-Client-ID: [0-9a-f]{16}\r\n/i";
classtype:trojan-activity;
priority:1;
sid:9010001; rev:1;
)
# Rule 2: Detect the 32-byte response (key exchange response)
alert http $EXTERNAL_NET any -> $HOME_NET any (
msg:"ET MALWARE TestMalware 32-byte Key Exchange Response";
flow:established,to_client;
dsize:32;
http.response_body;
content:!"|00|"; # Not all-null (would be padding, not a key)
classtype:trojan-activity;
priority:2;
sid:9010002; rev:1;
)
# Rule 3: Combined detection (same stream, both indicators)
# Use flowbits to correlate rules 1 and 2:
alert http $HOME_NET any -> $EXTERNAL_NET any (
msg:"ET MALWARE TestMalware Check-in Request (flowbit set)";
flow:established,to_server;
http.header;
pcre:"/X-Client-ID: [0-9a-f]{16}\r\n/i";
flowbits:set,testmalware.checkin;
flowbits:noalert; # Don't alert here, only set the bit
sid:9010003; rev:1;
)
alert http $EXTERNAL_NET any -> $HOME_NET any (
msg:"ET MALWARE TestMalware Confirmed C2 (Request+Response)";
flow:established,to_client;
dsize:32;
flowbits:isset,testmalware.checkin;
classtype:trojan-activity;
priority:1;
sid:9010004; rev:1;
)
False Positive Reduction Techniques
#!/bin/bash
# Analyze false positive rate for a new rule
RULE_FILE="${1:-custom.rules}"
PCAP_FILE="${2:-production-baseline.pcap}"
OUTPUT_DIR="/tmp/rule-test-$$"
mkdir -p "$OUTPUT_DIR"
echo "=== Testing rule against production PCAP ==="
suricata -r "$PCAP_FILE" -S "$RULE_FILE" -l "$OUTPUT_DIR" \
-c /etc/suricata/suricata.yaml --no-random-seed 2>/dev/null
echo ""
echo "=== Alert count per SID ==="
jq -r 'select(.event_type=="alert") | .alert.signature_id' \
"$OUTPUT_DIR/eve.json" 2>/dev/null | sort | uniq -c | sort -rn
echo ""
echo "=== Sample alerts (first 5) ==="
jq -r 'select(.event_type=="alert") | [
.alert.signature_id,
.src_ip,
.dest_ip,
.dest_port,
.alert.signature
] | @tsv' "$OUTPUT_DIR/eve.json" 2>/dev/null | head -5
echo ""
echo "=== Alert rate (alerts per hour) ==="
TOTAL_ALERTS=$(jq -r 'select(.event_type=="alert")' "$OUTPUT_DIR/eve.json" 2>/dev/null | wc -l)
DURATION_HOURS=$(python3 -c "
import json, sys
times = []
with open('$OUTPUT_DIR/eve.json') as f:
for line in f:
try:
evt = json.loads(line)
if 'timestamp' in evt:
from datetime import datetime
t = datetime.strptime(evt['timestamp'][:19], '%Y-%m-%dT%H:%M:%S')
times.append(t.timestamp())
except:
pass
if len(times) >= 2:
print(f'{(max(times)-min(times))/3600:.1f}')
else:
print('0')
")
echo "$TOTAL_ALERTS alerts in ${DURATION_HOURS}h = $(python3 -c "
t=$TOTAL_ALERTS; h=${DURATION_HOURS:-1}
print(f'{t/float(h):.1f} alerts/hour' if float(h) > 0 else 'N/A')
")"
rm -rf "$OUTPUT_DIR"
A rule that matches on content:"malicious.example.com" will stop working the moment the attacker rotates their C2 domain — which they will, often within hours of public exposure. Infrastructure-based rules (IP addresses, domain names) have a half-life of hours to days. Behavior-based rules (User-Agent strings, custom HTTP headers, response sizes, JA3 fingerprints, protocol patterns) have a half-life of months to years, because changing them requires modifying the malware itself. When writing rules: prioritize behavior over infrastructure. Use domain names only when they represent something that can't change (e.g., a unique DGA pattern), and augment them with behavioral indicators so the rule stays useful even after the infrastructure is burned. For IOC-based blocking of known-bad infrastructure, use Zeek's Intel framework (which can be updated dynamically without rule reloads) rather than Suricata rules.
Q & A
Q: What's the difference between flowbits and flowints in Suricata rules?
Both are mechanisms for stateful multi-packet detection — tracking information across packets in the same flow. flowbits are boolean flags: you set a flowbit when you see the first indicator, then check with isset in a subsequent rule. This lets you write two-stage rules: "alert only when BOTH request X and response Y are seen in the same flow." The flowbit name is a string: flowbits:set,malware.checkin;. flowints are integers: you can increment a flowint counter each time you see a specific pattern, then alert when the counter exceeds a threshold — all within a single flow. Example: flowint:tgs_count,+,1; to count Kerberos TGS requests, then flowint:tgs_count,>,5; to alert when more than 5 are seen in the same flow. Flowbits are simpler and sufficient for two-phase detections. Flowints are needed when you want to count occurrences within a single flow. Both work within Suricata's connection tracking — if Zeek's uid correlates a conn.log entry to an ssl.log entry, the same flow that carries the flowbit/flowint states is what Zeek assigns a uid to.