Suricata Rule Syntax
A Suricata rule has three parts: the header (action, protocol, addresses, ports), the rule options (payload matching, metadata, thresholds), and the message/metadata (classtype, priority, sid). Reading and writing rules fluently is the core skill that separates a detection engineer who builds detections from one who only deploys pre-made rulesets. This chapter covers the full rule syntax with real-world examples for each keyword.
Your threat intel team finds a new C2 framework that uses a distinctive HTTP User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.1; Trident/5.0) for its staging profile. You need to write a Suricata rule that alerts whenever this User-Agent is seen going to external IPs on port 443, but not when it's seen on the internal network or on port 80. You write it, test it against a PCAP of known traffic, and deploy it in 15 minutes.
Rule Anatomy
Suricata Rule Anatomy
═══════════════════════════════════════════════════════════════════
alert http $HOME_NET any -> $EXTERNAL_NET 443 (content:"C2"; sid:9001;)
───── ──── ──────────────────────────────────────────────────────────────
│ │ │ │
│ │ header (direction) │
│ │ rule options
│ protocol
action
Action:
alert Generate alert, log event, continue processing
pass Allow, stop processing rules (use for allowlisting)
drop Drop packet (IPS mode only), generate alert
reject Send TCP RST / ICMP port unreachable, generate alert
rejectsrc / rejectdst Reject only from src or dst direction
Protocol: tcp / udp / icmp / http / dns / tls / smtp / ssh / smb / ftp / any
Header: src_addr src_port direction dst_addr dst_port
$HOME_NET: variable from suricata.yaml
!$HOME_NET: negation ("not home net" = external)
[80,443,8080]: port group
1:1024: port range
Direction:
-> Src to dst (unidirectional)
<> Bidirectional (match packets in either direction)
Critical Rule Keywords
| Keyword | What It Matches | Example |
|---|---|---|
| content:"text" | Raw bytes in payload (case sensitive) | content:"cmd.exe" |
| nocase | Case-insensitive content match | content:"cmd.exe"; nocase; |
| pcre:"/.../i" | Perl-compatible regex | pcre:"/\/[a-z]{8}\.php$/i" |
| http.user_agent | HTTP User-Agent header value | http.user_agent; content:"MSIE 7.0"; |
| http.uri | HTTP request URI | http.uri; content:"/submit.php"; |
| http.method | HTTP request method | http.method; content:"POST"; |
| http.header | Any HTTP header | http.header; content:"X-Custom:"; |
| tls.sni | TLS SNI from ClientHello | tls.sni; content:".onion.to"; |
| tls.cert_subject | TLS certificate subject | tls.cert_subject; content:"C2Corp"; |
| dns.query | DNS query name | dns.query; content:".xyz"; endswith; |
| ja3.hash | JA3 fingerprint of TLS ClientHello | ja3.hash; content:"51c64c77e60f3980eea90869b68c58a8"; |
| threshold | Limit alert frequency | threshold: type threshold, track by_src, count 5, seconds 60; |
| flow:established | Match only established TCP connections | flow:established,to_server; |
| dsize:N | Payload size constraint | dsize:<4; (Cobalt Strike empty response) |
Real Detection Rules
# Cobalt Strike: IE7/XP User-Agent (default staging profile)
alert http $HOME_NET any -> $EXTERNAL_NET any (
msg:"ET MALWARE Cobalt Strike Default User-Agent";
flow:established,to_server;
http.user_agent;
content:"MSIE 7.0";
content:"Windows NT 6.1";
content:"Trident/5.0";
classtype:trojan-activity;
sid:9001001; rev:1;
)
# Cobalt Strike: /submit.php URI (common default check-in path)
alert http $HOME_NET any -> $EXTERNAL_NET any (
msg:"ET MALWARE Cobalt Strike Default Check-in URI /submit.php";
flow:established,to_server;
http.uri;
content:"/submit.php";
endswith;
classtype:trojan-activity;
sid:9001002; rev:1;
)
# Cobalt Strike: 4-byte empty HTTP response (heartbeat response)
alert http $EXTERNAL_NET any -> $HOME_NET any (
msg:"ET MALWARE Cobalt Strike Typical 4-byte Response";
flow:established,to_client;
dsize:4;
http.response_body;
content:"|20 20 20 20|"; # 4 spaces
classtype:trojan-activity;
sid:9001003; rev:1;
)
# DNS tunneling: very long query (label > 40 chars)
alert dns $HOME_NET any -> any 53 (
msg:"ET DNS Suspiciously Long DNS Query (Potential Tunneling)";
dns.query;
pcre:"/[A-Za-z0-9+\/=]{40,}\./";
threshold: type threshold, track by_src, count 5, seconds 60;
classtype:bad-unknown;
sid:9002001; rev:1;
)
# Kerberoasting: bulk TGS requests in short period
alert krb5 $HOME_NET any -> $HOME_NET 88 (
msg:"ET ATTACK Potential Kerberoasting - Bulk TGS Requests";
krb5.msg_type; content:"|0c|"; # msg_type 12 = TGS-REQ
threshold: type threshold, track by_src, count 5, seconds 10;
classtype:credential-theft;
sid:9003001; rev:1;
)
# Sliver mTLS: mutual TLS on non-standard port (client cert in TLS)
alert tls $HOME_NET any -> $EXTERNAL_NET !443 (
msg:"ET SUSPICIOUS Mutual TLS on Non-Standard Port (Potential Sliver C2)";
tls.cert_fingerprint;
flow:established;
app-layer-event:tls.client_cert_mismatch;
classtype:bad-unknown;
sid:9004001; rev:1;
)
# JA3 match: Cobalt Strike default Java TLS fingerprint
alert tls $HOME_NET any -> $EXTERNAL_NET any (
msg:"ET MALWARE Cobalt Strike Java TLS JA3 Fingerprint";
ja3.hash;
content:"51c64c77e60f3980eea90869b68c58a8";
classtype:trojan-activity;
sid:9005001; rev:1;
)
Beginners write Suricata rules as byte-pattern matchers: "if these bytes appear in the packet, alert." Production detection engineers think in terms of network state: "if an established HTTP connection from internal hosts to external port 443 uses this User-Agent AND this URI, alert." The distinction matters because: (1) Keyword modifiers like http.user_agent and tls.sni match in the context of the decoded protocol, not the raw byte stream — they're more reliable and faster than pcre on raw payload. (2) flow:established,to_server restricts the rule to fully established TCP connections in the client-to-server direction — avoiding false positives from SYN packets, ACK packets, or TLS handshake data. (3) The threshold keyword makes rules stateful: they count events before alerting, which lets you write detections for behaviors that are only suspicious in aggregate (e.g., 5 TGS-REQ in 10 seconds). Always use the most specific protocol keyword available rather than raw content matching — the parser does the heavy lifting and your pattern is less likely to false-positive on non-matching traffic that happens to contain the same bytes.
Q & A
Q: How do I test a new Suricata rule without deploying it to production?
Three-step test workflow: (1) Offline testing against PCAP: run suricata -r capture.pcap -S custom.rules -l /tmp/output/ --disable-detection then add back detection: suricata -r capture.pcap -S custom.rules -l /tmp/output/. Check /tmp/output/eve.json and fast.log for alerts and stats.log for performance impact. (2) Rule syntax check: suricata --test -S custom.rules — this parses all rules and reports syntax errors without processing any traffic. (3) Unit test with a crafted PCAP: build a PCAP that should trigger the rule (using Scapy or tcpreplay) and one that should not. Run both through Suricata and verify the first triggers an alert and the second doesn't. This is the most reliable way to confirm your rule fires exactly when intended. For the craft-a-packet approach: in Scapy, pkt = IP(dst="1.2.3.4")/TCP(dport=443)/Raw(load=b"MSIE 7.0") followed by wrpcap("test.pcap", [pkt]) gives you a minimal PCAP to test against. Always test with both positive and negative examples before deploying.