Skip to content

Lesson 8.5 · Encoding, Crypto & Signatures· 50 min

Writing Network Signatures

Turn a sample's network code and captured traffic into Suricata rules that target durable protocol structure, then test them against pcaps.

Objectives

  • Rank network indicators by durability and explain why protocol structure outlives IPs and domains
  • Combine static and dynamic evidence to classify each field of a request as hard-coded, config-derived, host-derived or random
  • Write Suricata rules with sticky buffers, flow, content modifiers, pcre, flowbits, thresholds and metadata
  • Test a rule against a pcap of the malicious traffic and against benign traffic, and keep it fast
  • Describe what still works when traffic is encrypted: SNI, JA3/JA4, certificate fields and Zeek logs

In Writing Your First YARA Rules you turned artefacts inside a file into a rule that finds the next file. Network signatures do the same job one layer out. Malware that talks to its operator has to put bytes on the wire, and those bytes pass through sensors you control: a Suricata instance on a span port, a Zeek cluster, a proxy log. A good network rule catches every infected host at once, including hosts whose copy of the sample was repacked, renamed or never reached disk.

This lesson closes Module 8. The earlier lessons gave you the decoding and configuration extraction skills to find out what a sample sends and where it gets each field from. Here you turn that knowledge into detections, test them against captured traffic, and prepare them for sharing.

Indicator levels: brittle to durable

Not all network indicators age the same way. David Bianco's "Pyramid of Pain" makes the point: the cheaper an indicator is for the attacker to change, the less it is worth to you.

IndicatorExampleHow long it lastsWhere it belongs
IP address203.0.113[.]44Days; cloud hosts and proxies rotateBlocklists, retro-hunting in logs
Domainupdate-check[.]exampleDays to weeks; domains are cheapDNS sinkholes, dns.query rules, intel feeds
Full URL/lab/gate.php?id=ecefe37b...Often one requestNowhere as a literal: part of it is random
URI structure/lab/gate.php?id= + 16 hex charsMonths; changing it means changing the server tooSuricata rules
Header set and order, odd user-agentLabAgent/1.0 plus X-Lab: 1Until the author rewrites the clientSuricata rules, Zeek scripts
Protocol behaviourFixed-interval beacons, a custom handshake, a TLS fingerprintLongest; tied to the codebaseRules, JA4 lists, behavioural analytics

IPs and domains still go in your triage report, defanged, because they are the fastest thing a SOC can block today. But a rule that only says "traffic to this IP" is an IOC wearing a rule's clothes. The operator moves infrastructure and the rule goes silent while the implant keeps running. The durable targets are the ones baked into the client code: the shape of the request, the fields the author hard-coded, the encoding of the payload. The server has to parse all of these, so changing them means redeploying both ends.

Static and dynamic evidence together

A single capture from a single run is a dangerous basis for a rule. Everything in it looks constant because you only have one example. The analyst's job is to classify every field of the traffic by where it comes from:

SourceExample in a requestConstant across runs?Constant across hosts?Signature use
Hard-coded in the binaryLabAgent/1.0, /lab/gate.php, X-Lab: 1YesYesMatch literally
ConfigurationC2 host, beacon interval, campaign IDYes, per buildYes, per buildMatch per campaign, or match the format
Host-derivedHash of hostname or volume serial in the IDYesNoMatch the format, never the value
Random or time-basedNonce, session key, timestampNoNoMatch length and character set only
Library-suppliedAccept, Connection, header order from WinINetYesYesWeak; shared with legitimate software

Dynamic analysis tells you what varies. Run the sample several times in the lab, ideally on two differently named VMs, capture with the setup from Simulating and Capturing Network Traffic, and diff the requests. A field that changes between two runs on the same host is random. A field that is stable on one host but differs on another is host-derived.

Static analysis tells you why it varies and what the full range is. Find the networking code through the imports (InternetOpenA, HttpOpenRequestW, WinHttpSendRequest, send, getaddrinfo; see Reading Capabilities from Imports), or through API hashing resolvers when the imports are hidden. Follow the arguments back. The user-agent passed to InternetOpenA is often a literal, perhaps XOR-encrypted and recoverable with the scripts from Scripting String Decryption. The id parameter might come from a wsprintfA("%08x%08x", ...) call fed by a random number generator, which tells you it is always 16 lowercase hex characters. That is a fact you could not safely infer from five captured requests.

Tip: Dynamic runs miss code paths. A sample that only beacons in your lab may also have upload, error and "task result" requests with different paths or headers. Read the code that builds requests and list every format it can produce, then decide which ones your rule set must cover. The Module 7 lesson on command and control goes deeper into protocol design.

Anatomy of a Suricata rule

Suricata rules descend from Snort's syntax, but modern Suricata inspects parsed protocol fields through sticky buffers rather than raw payload offsets. A rule has an action, a header, and options in parentheses:

text
alert http $HOME_NET any -> $EXTERNAL_NET any (
    msg:"MAL LabImplant HTTP beacon";
    flow:established,to_server;
    http.method; content:"GET";
    http.uri; content:"/lab/gate.php?id="; startswith; fast_pattern;
    pcre:"/^\/lab\/gate\.php\?id=[0-9a-f]{16}$/";
    http.user_agent; content:"LabAgent/"; startswith;
    threshold:type limit, track by_src, count 1, seconds 3600;
    reference:url,example.invalid/TR-2026-0929-01;
    classtype:trojan-activity;
    sid:1000001; rev:2;
    metadata:created_at 2026_09_29, updated_at 2026_09_29, confidence High;
)

Rule files need each rule on one line (or lines joined with a trailing backslash). It is spread out here for reading. The parts:

PartMeaning
alertAction. Others are pass, drop and reject (the last two need inline IPS mode)
httpProtocol. Suricata identifies the application layer itself, so http matches HTTP on any port. Also tcp, udp, dns, tls, quic and more
$HOME_NET any -> $EXTERNAL_NET anySource, source port, direction, destination, destination port. Variables come from suricata.yaml
msgThe alert text. Write it for the person triaging the alert
flow:established,to_serverOnly inspect client-to-server data on a completed TCP session
http.method, http.uri, http.user_agentSticky buffers: every following content or pcre applies to that field until another buffer is named
contentA literal to find. Hex bytes go between pipe characters (X-Lab followed by 3a 20 for ": "); a leading ! negates
startswith, endswith, nocase, bsizeModifiers that anchor, relax or size-limit the preceding match
pcreA Perl-compatible regex over the current buffer
thresholdRate-limits alerts: here one per source per hour, however often the implant beacons
reference, classtype, metadataContext for humans and tooling; ignored when matching
sid, revUnique rule ID and its revision number

Other buffers you will reach for: http.host, http.header (all request headers, normalised, one Name: value\r\n line each), http.header_names (names only, which exposes header order), http.request_body, http.stat_code, dns.query, tls.sni, tls.cert_subject, tls.cert_issuer, ja3.hash and ja4.hash. For a raw TCP protocol with no parser, you fall back to content on the payload with depth, offset, distance and within.

Flowbits: state across a conversation

Some protocols only become distinctive over several messages. Flowbits set a named flag on a flow in one rule and test it in another:

text
alert tcp $HOME_NET any -> $EXTERNAL_NET any (msg:"LAB handshake seen"; flow:established,to_server; content:"LABHELLO|00|"; depth:9; flowbits:set,lab.hello; flowbits:noalert; sid:1000010; rev:1;)
alert tcp $EXTERNAL_NET any -> $HOME_NET any (msg:"LAB server tasking after handshake"; flow:established,to_client; flowbits:isset,lab.hello; content:"TASK"; depth:4; sid:1000011; rev:1;)

The first rule never alerts; it only marks the flow. The second fires on a four-byte string that would be far too generic alone, but is specific after the client's handshake. xbits extends the same idea across flows, tracked per host or IP pair.

Targeting the stable parts

With the field classification in hand, rule-writing becomes mechanical:

  1. Anchor on the most specific hard-coded literal, in the narrowest buffer. LabAgent/ in http.user_agent is better than the same string searched anywhere in the payload.
  2. Describe variable fields by shape, not value. A random ID becomes [0-9a-f]{16}; a base64 blob becomes a character class and a length range. Use bsize or urilen when the length is fixed.
  3. Require two or more independent traits. The path alone collides with a legitimate application sooner or later; path plus user-agent plus a custom header will not.
  4. Allow for the versions you expect. Match LabAgent/ rather than LabAgent/1.0 if the version string came from the config and changes per build.
  5. Watch for mimicry. Authors copy real browser user-agents, sometimes with a typo, an outdated version or a missing space. The typo is the signature; the rest of the string is camouflage.

Performance

Suricata does not evaluate every rule against every packet. It groups rules by protocol and port, then runs a multi-pattern matcher (MPM) over each buffer, looking for one fast pattern per rule. Only rules whose fast pattern appears are fully evaluated. This is the same idea as YARA's atoms.

  • By default Suricata chooses the fast pattern itself, generally the longest content in the highest-priority buffer. Mark a better one with fast_pattern when you know the most distinctive string, as the lab rule does.
  • A rule with no content at all, only a pcre, has nothing to prefilter on. It is evaluated for every transaction that matches its header, which is expensive on a busy link. Always pair a regex with a literal that must also be present.
  • Short or common literals (GET, .php, Mozilla) make poor fast patterns because they hit constantly.
  • Keep the header tight: alert http rather than alert ip, and flow:to_server whenever you inspect requests.

suricata --engine-analysis writes a report on how each loaded rule will be prefiltered and warns about rules with poor or missing fast patterns. Run it as part of every rule change.

TLS-era realities

Most malware traffic today is HTTPS. Without decryption at a proxy, the payload, URI and headers are invisible. What remains:

Visible fieldSuricata bufferNotes
Server name (SNI)tls.sniChosen by the client; brittle like a domain, but visible. Encrypted Client Hello hides it where deployed
DNS lookup before the connectiondns.queryUnless the sample uses DNS over HTTPS
Client fingerprintja3.hash, ja4.hashSummarises the ClientHello. JA3 broke when browsers began shuffling extension order; JA4 sorts them and stays stable
Server fingerprintja3s.hashCharacterises the server's TLS stack
Certificate subject, issuer, validitytls.cert_subject, tls.cert_issuerSelf-signed or default certificates from C2 frameworks are strong signals
Timing and sizesZeek conn.log, flow recordsRegular beacons of similar size stand out over hours

A JA3 or JA4 fingerprint identifies a TLS library and its configuration, not a program. A sample using the Windows TLS stack shares its fingerprint with every other WinHTTP client on the host, so treat it as supporting evidence. It becomes strong when the malware ships its own TLS library, as many Go and Rust implants do.

Warning: When the channel is encrypted end to end, network detection shrinks to metadata. The durable detections then move to the endpoint: YARA on memory after decryption, EDR telemetry on the process making the connection, and the host artefacts from your triage report. Record that shift in your report rather than shipping an SNI rule and calling the family covered.

Zeek: the same traffic as logs

Suricata answers "did this pattern occur?" Zeek answers "what happened?" by turning traffic into structured logs: conn.log (every connection, with durations and byte counts), http.log (method, host, URI, user-agent, status), dns.log, ssl.log (SNI, version, certificate chain; JA3/JA4 via packages) and files.log. Running zeek -r capture.pcap in an empty directory writes these logs from a capture.

The two tools complement each other. A Suricata rule is precise and fires in real time. Zeek logs let you hunt backwards across months with a query like "every http.log entry whose user-agent starts with LabAgent/", or find beaconing by grouping conn.log by source and destination and looking at the spread of the intervals. Zeek scripts and its Intel framework can also raise notices, so small detections do not always need a Suricata rule.

Sharing and versioning rules

Rules are code, and a shared rule set is a shared codebase:

  • SIDs. The range 1000000 to 1999999 is conventionally reserved for local rules. Public sets such as Emerging Threats have their own ranges. Never reuse a SID for a different rule.
  • rev. Bump it on every change so sensors, SIEM correlations and colleagues know which version fired.
  • metadata. Emerging Threats-style keys such as created_at, updated_at, malware_family, confidence and signature_severity let tooling filter and route alerts.
  • Tests. Keep the pcap each rule was written from next to the rule, together with a benign pcap, and run suricata -r against both in CI.
  • Distribution. suricata-update pulls rule sources onto sensors, and the rules travel in your report's detection section, labelled with the report's TLP.

Lab: signature for a lab beacon

You will generate a harmless pcap containing HTTP "beacons" from a demo client to 127.0.0.1, mixed with benign browser traffic, write a Suricata rule that targets the beacon's stable fields, and test it. No packet is sent anywhere: the script builds packets in memory and writes them to a file. You need Python 3 and, ideally, Suricata (brew install suricata, apt install suricata, or the Windows MSI).

  1. Create a working directory with its own virtual environment:

    bash
    mkdir m8c && cd m8c
    python3 -m venv .venv
    .venv/bin/pip install scapy dpkt
  2. Save make_pcap.py. It writes nine complete TCP sessions: five beacons 60 seconds apart, each with a fresh random ID, plus four benign requests. One benign request deliberately uses the same path as the beacon, the way a real web application might:

    python
    """make_pcap.py - write a harmless pcap of lab "beacons" plus benign traffic."""
    import random
    from scapy.all import Ether, IP, TCP, Raw, wrpcap
    
    random.seed(1337)                      # reproducible "random" IDs and ports
    CLIENT, SERVER = "127.0.0.2", "127.0.0.1"
    T0 = 1790676000.0                      # 2026-09-29 10:00:00 UTC
    BROWSER_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
    packets = []
    
    def session(t, path, headers, body=b"OK"):
        sport = random.randint(49152, 65535)
        cseq, sseq = random.getrandbits(32), random.getrandbits(32)
        c = Ether() / IP(src=CLIENT, dst=SERVER)
        s = Ether() / IP(src=SERVER, dst=CLIENT)
        req = f"GET {path} HTTP/1.1\r\nHost: {SERVER}\r\n"
        req += "".join(f"{k}: {v}\r\n" for k, v in headers) + "\r\n"
        req = req.encode()
        resp = (b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n"
                b"Content-Length: %d\r\nConnection: close\r\n\r\n" % len(body)) + body
        seq = [
            c / TCP(sport=sport, dport=80, flags="S", seq=cseq),
            s / TCP(sport=80, dport=sport, flags="SA", seq=sseq, ack=cseq + 1),
            c / TCP(sport=sport, dport=80, flags="A", seq=cseq + 1, ack=sseq + 1),
            c / TCP(sport=sport, dport=80, flags="PA", seq=cseq + 1, ack=sseq + 1) / Raw(req),
            s / TCP(sport=80, dport=sport, flags="PA", seq=sseq + 1,
                    ack=cseq + 1 + len(req)) / Raw(resp),
            c / TCP(sport=sport, dport=80, flags="FA", seq=cseq + 1 + len(req),
                    ack=sseq + 1 + len(resp)),
            s / TCP(sport=80, dport=sport, flags="FA", seq=sseq + 1 + len(resp),
                    ack=cseq + 2 + len(req)),
            c / TCP(sport=sport, dport=80, flags="A", seq=cseq + 2 + len(req),
                    ack=sseq + 2 + len(resp)),
        ]
        for i, p in enumerate(seq):
            p.time = t + i * 0.001
        packets.extend(seq)
    
    # Five beacons, one every 60 seconds, each with a fresh random 8-byte ID.
    for n in range(5):
        beacon_id = random.getrandbits(64).to_bytes(8, "big").hex()
        session(T0 + 60 * n, f"/lab/gate.php?id={beacon_id}",
                [("User-Agent", "LabAgent/1.0"), ("X-Lab", "1"), ("Accept", "*/*")],
                body=b"sleep=60")
    
    # Benign traffic, interleaved in time, for the false-positive check.
    session(T0 + 15, "/index.html", [("User-Agent", BROWSER_UA), ("Accept", "text/html")])
    session(T0 + 95, "/static/app.js", [("User-Agent", BROWSER_UA), ("Accept", "*/*")])
    session(T0 + 130, "/lab/gate.php?page=2",                       # path collision
            [("User-Agent", BROWSER_UA), ("Accept", "text/html")])
    session(T0 + 200, "/health", [("User-Agent", "curl/8.7.1"), ("Accept", "*/*")])
    
    packets.sort(key=lambda p: p.time)
    wrpcap("beacons.pcap", packets)
    print(f"wrote beacons.pcap: {len(packets)} packets, {len(packets) // 8} TCP sessions")
    bash
    .venv/bin/python make_pcap.py
    text
    wrote beacons.pcap: 72 packets, 9 TCP sessions
  3. Look at the traffic the way you would a real capture. In Wireshark, filter on http.request; or use tshark to list the beacons and the time since the previous one (tshark 4.6.4 here):

    bash
    tshark -r beacons.pcap -Y 'http.user_agent contains "LabAgent"' \
        -T fields -e frame.time_delta_displayed -e http.request.uri
    text
    	/lab/gate.php?id=ecefe37b9e250d03
    60.000000000	/lab/gate.php?id=2a6a7b5fbb5d75b8
    60.000000000	/lab/gate.php?id=5c1ed35fca2410fd
    60.000000000	/lab/gate.php?id=34775758b2767375
    60.000000000	/lab/gate.php?id=a29c4db3d2083355

    Classify the fields as the table earlier in this lesson does. The path, user-agent and X-Lab header never change; the ID changes every request; the interval is fixed (in real malware it would come from the config, often with jitter).

  4. Write lab.rules with a naive first draft and a rule that targets the stable parts. Each rule must be on a single line in the file:

    text
    # Naive first draft: the path alone.
    alert http $HOME_NET any -> $EXTERNAL_NET any (msg:"LAB naive gate.php request"; flow:established,to_server; http.uri; content:"/lab/gate.php"; classtype:trojan-activity; sid:1000000; rev:1;)
    
    # Targets the stable parts: method, URI shape, user-agent prefix, custom header.
    alert http $HOME_NET any -> $EXTERNAL_NET any (msg:"LAB LabAgent HTTP beacon to gate.php"; flow:established,to_server; http.method; content:"GET"; http.uri; content:"/lab/gate.php?id="; startswith; fast_pattern; pcre:"/^\/lab\/gate\.php\?id=[0-9a-f]{16}$/"; http.user_agent; content:"LabAgent/"; startswith; http.header; content:"X-Lab|3a 20|1|0d 0a|"; nocase; reference:url,example.invalid/TR-2026-0929-01; classtype:trojan-activity; sid:1000001; rev:1; metadata:created_at 2026_09_29, updated_at 2026_09_29, malware_family LabImplant, confidence High;)
  5. Run Suricata against the pcap. The lab traffic flows from 127.0.0.2 to 127.0.0.1, so set HOME_NET to the client; $EXTERNAL_NET defaults to everything else. -S loads only this rule file, and -k none disables checksum validation, which is common with generated or offloaded captures:

    bash
    mkdir -p logs
    suricata -r beacons.pcap -S lab.rules -l logs -k none \
        --set 'vars.address-groups.HOME_NET=[127.0.0.2]'
    cat logs/fast.log

    Expect fast.log to contain one line per alert, naming the SID, message, classification and the IP pair. logs/eve.json holds the same alerts with the full HTTP metadata of each transaction.

  6. If you do not have Suricata, the following script is a stand-in. It is not an IDS: it parses each request with dpkt and applies only the content, nocase, startswith and pcre options of each rule to the matching buffers, ignoring everything else. It was used to produce the output below because Suricata was not installed on the machine that generated this lesson's output. Save it as match_rules.py:

    python
    """match_rules.py - a STAND-IN for Suricata. It understands only the sticky
    buffers http.method, http.uri, http.user_agent and http.header, and the
    options content (with |hex| bytes), nocase, startswith and pcre."""
    import re, sys
    import dpkt
    
    def parse_rule(line):
        opts = line[line.index("(") + 1: line.rindex(")")]
        parts, cur, quoted = [], "", False
        for ch in opts:                       # split on ';' outside quotes
            if ch == '"' and not cur.endswith("\\"):
                quoted = not quoted
            if ch == ";" and not quoted:
                parts.append(cur.strip()); cur = ""
            else:
                cur += ch
        rule, checks, buf = {}, [], None
        for p in filter(None, parts):
            key, _, val = p.partition(":")
            if key in ("http.method", "http.uri", "http.user_agent", "http.header"):
                buf = key
            elif key == "content":
                raw = val.strip('"')
                raw = re.sub(r"\|([0-9a-fA-F ]+)\|",
                             lambda m: bytes.fromhex(m.group(1)).decode("latin-1"), raw)
                checks.append({"buf": buf, "type": "content", "value": raw})
            elif key in ("nocase", "startswith"):
                checks[-1][key] = True
            elif key == "pcre":
                body = val.strip('"')
                pat, flags = body[1:body.rindex("/")], body[body.rindex("/") + 1:]
                checks.append({"buf": buf, "type": "pcre",
                               "re": re.compile(pat, re.I if "i" in flags else 0)})
            elif key in ("msg", "sid"):
                rule[key] = val.strip('"')
        rule["checks"] = checks
        return rule
    
    def buffers(req):
        hdr = "".join(f"{k}: {v}\r\n" for k, v in req.headers.items())
        return {"http.method": req.method, "http.uri": req.uri,
                "http.user_agent": req.headers.get("user-agent", ""),
                "http.header": hdr}
    
    def check(c, bufs):
        data = bufs[c["buf"]]
        if c["type"] == "pcre":
            return bool(c["re"].search(data))
        needle = c["value"]
        if c.get("nocase"):
            data, needle = data.lower(), needle.lower()
        return data.startswith(needle) if c.get("startswith") else needle in data
    
    rules = [parse_rule(l) for l in open(sys.argv[1]) if l.startswith("alert")]
    for ts, frame in dpkt.pcap.Reader(open(sys.argv[2], "rb")):
        tcp = dpkt.ethernet.Ethernet(frame).data.data
        if not (isinstance(tcp, dpkt.tcp.TCP) and tcp.dport == 80 and tcp.data):
            continue
        req = dpkt.http.Request(tcp.data)
        bufs = buffers(req)
        hits = [r["sid"] for r in rules if all(check(c, bufs) for c in r["checks"])]
        ua = bufs["http.user_agent"][:14]
        print(f"{ts - 1790676000:6.1f}s  {req.uri:36} {ua:14}  "
              f"{'ALERT sid ' + ','.join(hits) if hits else '-'}")
    bash
    .venv/bin/python match_rules.py lab.rules beacons.pcap
    text
       0.0s  /lab/gate.php?id=ecefe37b9e250d03    LabAgent/1.0    ALERT sid 1000000,1000001
      15.0s  /index.html                          Mozilla/5.0 (W  -
      60.0s  /lab/gate.php?id=2a6a7b5fbb5d75b8    LabAgent/1.0    ALERT sid 1000000,1000001
      95.0s  /static/app.js                       Mozilla/5.0 (W  -
     120.0s  /lab/gate.php?id=5c1ed35fca2410fd    LabAgent/1.0    ALERT sid 1000000,1000001
     130.0s  /lab/gate.php?page=2                 Mozilla/5.0 (W  ALERT sid 1000000
     180.0s  /lab/gate.php?id=34775758b2767375    LabAgent/1.0    ALERT sid 1000000,1000001
     200.0s  /health                              curl/8.7.1      -
     240.0s  /lab/gate.php?id=a29c4db3d2083355    LabAgent/1.0    ALERT sid 1000000,1000001
  7. Read the result as a detection engineer. Both rules catch all five beacons. The naive rule also fires on the browser's /lab/gate.php?page=2, a false positive that would reach a SOC queue on every visit to a legitimate application using that path. SID 1000001 stays silent on all four benign requests, because the browser request fails the ID pattern, the user-agent and the header checks at once. Delete SID 1000000.

  8. Make the rule production-ready. Add threshold:type limit, track by_src, count 1, seconds 3600; so a host that beacons every minute raises one alert an hour rather than sixty, bump rev to 2, and re-run step 5 (or step 6, which ignores thresholds). Then run suricata --engine-analysis -S lab.rules -l logs and read the rule analysis file it writes: check which content was chosen as the fast pattern.

  9. Check for false positives at scale. Replay a larger benign capture through the same rule: a recording of your own browsing in the lab VM, or public benign pcaps. Any alert there is a trait you believed unique that is not.

Questions to answer: Which field of the beacon would you classify as random, which as hard-coded, and what static analysis would confirm it? If the next build sent LabAgent/1.1 and lowercased the header to x-lab: 1, would SID 1000001 still fire, and which modifiers are responsible? Why is fast_pattern placed on the URI content rather than on GET? If the author moved the beacon to HTTPS with the Windows TLS stack, which parts of this rule would still work, what could replace them, and what would you hand to the endpoint team instead?

Key takeaways

  • IPs and domains are IOCs with a short life; durable network rules target what the client code hard-codes: URI structure, headers, encodings and protocol behaviour.
  • Classify every field as hard-coded, config-derived, host-derived, random or library-supplied, using repeated dynamic runs for what varies and static analysis for why and how much.
  • A Suricata rule is action, header and options; sticky buffers such as http.uri, http.user_agent, dns.query and tls.sni keep matches precise, and flowbits add state across messages.
  • Require several independent traits, describe variable fields by shape, and give every rule a distinctive literal as its fast pattern; never ship a pcre-only rule.
  • Test every rule against a pcap of the malicious traffic and against benign traffic, then version it with sid, rev, metadata and its test captures.
  • Under TLS, detection shifts to SNI, JA4, certificates and timing, and ultimately to the endpoint; Zeek logs let you hunt the same traffic retrospectively.