Chapter 53

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.

Scenario

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

texttestmalware-detection.rules
# 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

bashrule-fp-analysis.sh
#!/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"
Common mistake: writing rules that match on infrastructure rather than behavior

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.