Skip to main content
Defensive Security · lesson 2 of 8
Defensive Security intermediate Lesson 2 of 8

Sigma Detection Rules: Catch SSH Brute Force and Map It to ATT&CK

Write a Sigma rule for SSH password failures, watch a too-strict field match find nothing, fix it, add a brute-force correlation, map it to MITRE ATT&CK.

By SkillByExample · Published

defensive-security sigma detection-engineering mitre-attack ssh soc

The previous lesson found SSH password failures with grep and a Python script. Neither is something you can hand to another team. Sigma is the shared format for that: a detection written once in YAML and converted to whatever your log platform speaks. You’ll write a rule for the failures, watch it miss every one of them, fix it, turn it into a brute-force detection, and tag it with MITRE ATT&CK. Tested with Docker Engine 29.8.2, Python 3.12.15 (python:3.12-slim), sigma-cli 3.1.0, pySigma 1.5.1, pySigma-backend-sqlite 2.0.0 and pysigma-backend-splunk 2.1.0.

The tools

sigma-cli does the conversion. The SQLite backend turns rules into SQL you can run locally, so you can test a detection against the auth.log from the previous lesson without a SIEM. The Splunk backend shows the same rule in a product’s language.

# Dockerfile
FROM python:3.12-slim
RUN pip install --no-cache-dir --root-user-action=ignore sigma-cli==3.1.0 pySigma==1.5.1 \
    pySigma-backend-sqlite==2.0.0 pysigma-backend-splunk==2.1.0
docker build -t sigma-lab .
mkdir rules

Put auth.log from the previous lesson in the same directory. Every command below runs as docker run --rm -v "$PWD":/work -w /work sigma-lab <command>; the listings show just the command.

A Sigma query needs a table to run against. This script loads auth.log into an in-memory SQLite table called logs, with a column per field, then runs whatever SQL file you give it.

# hunt.py: load auth.log into SQLite, run a converted Sigma query against it
import re, sqlite3, sys

LINE = re.compile(r"^(\S+) (\S+) ([\w.-]+)\[(\d+)\]: (.*)$")
SRC = re.compile(r" from (\S+) port ")

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE logs (timestamp, host, process, pid, src_ip, message)")
with open("auth.log") as log:
    for line in log:
        if m := LINE.match(line):
            ts, host, proc, pid, msg = m.groups()
            ip = s[1] if (s := SRC.search(msg)) else None
            db.execute("INSERT INTO logs VALUES (?,?,?,?,?,?)", (ts, host, proc, pid, ip, msg))

cur = db.execute(open(sys.argv[1]).read())
cols = [c[0] for c in cur.description]
hits = [dict(zip(cols, row)) for row in cur]
print(f"{len(hits)} result(s)")
for h in hits:
    if "message" in h:
        print(h["timestamp"][11:19], h["process"], h["message"].split(" port ")[0])
    else:
        print(h["group_keys"], "count:", h["metric_value"])

This parsing step is what a log shipper does before data reaches a SIEM: it turns a line of text into named fields. The field names here, process, src_ip and message, are the ones the rules use.

A rule that looks right

The rule says: a log line from the sshd program whose message starts with Failed password for .

# rules/ssh_failed_password.yml
title: SSH Failed Password
id: 6f0d2b4e-1c7a-4a53-9e0b-8d1f2a3c4b5d
name: ssh_failed_password
status: experimental
description: A password login to sshd failed, for a real or a nonexistent user.
tags:
    - attack.credential-access
    - attack.t1110.001
logsource:
    product: linux
    service: sshd
detection:
    selection:
        process: sshd
        message|startswith: 'Failed password for '
    condition: selection
level: low
$ sigma convert -t sqlite rules/ssh_failed_password.yml > failed.sql
Parsing Sigma rules
$ cat failed.sql
SELECT * FROM logs WHERE process='sshd' AND message LIKE 'Failed password for %' ESCAPE '\'
$ python hunt.py failed.sql
0 result(s)

The conversion worked, the SQL is valid, and it finds nothing in a log with five failed passwords. Both conditions under selection must hold, and process='sshd' never does. As the previous lesson showed, OpenSSH 9.8 and later log authentication as sshd-session, and only the startup lines say sshd.

This is the failure that matters most in detection engineering. A broken rule doesn’t raise an error. It returns zero results, which looks exactly like “no attack”. The only defence is to test each rule against a sample you know contains the activity, and to treat zero hits on that sample as a bug.

The fix

A list under a field means “any of these”. Name both programs.

# rules/ssh_failed_password.yml (detection section)
detection:
    selection:
        process:
            - sshd
            - sshd-session
        message|startswith: 'Failed password for '
    condition: selection
$ sigma convert -t sqlite rules/ssh_failed_password.yml > failed.sql
Parsing Sigma rules
$ cat failed.sql
SELECT * FROM logs WHERE (process='sshd' OR process='sshd-session') AND message LIKE 'Failed password for %' ESCAPE '\'
$ python hunt.py failed.sql
5 result(s)
08:58:40 sshd-session Failed password for alice from 172.30.0.20
08:58:43 sshd-session Failed password for alice from 172.30.0.20
08:58:47 sshd-session Failed password for alice from 172.30.0.20
08:58:50 sshd-session Failed password for invalid user admin from 172.30.0.20
08:58:53 sshd-session Failed password for invalid user oracle from 172.30.0.20

All five are found, including both unknown-user attempts, because startswith stops before the username. The list became an OR, and the two fields are still joined by AND. Keeping sshd in the list means the rule still works on older servers.

From single events to brute force

One failed password isn’t an attack; people mistype. A Sigma correlation rule refers to the base rule by its name and fires only when enough matches share a field within a time window.

# rules/ssh_brute_force.yml
title: SSH Brute Force From One Source
id: 0b9e4c1d-7f3a-4e2b-a6d5-1c8f9e0a2b3c
status: experimental
correlation:
    type: event_count
    rules:
        - ssh_failed_password
    group-by:
        - src_ip
    timespan: 5m
    condition:
        gte: 5
tags:
    - attack.credential-access
    - attack.t1110.001
level: medium

Convert the whole rules/ directory, so the correlation can find the rule it names.

$ sigma convert -t sqlite rules/ > brute.sql
Parsing Sigma rules
$ python hunt.py brute.sql
1 result(s)
{"src_ip":"172.30.0.20"} count: 5

One alert instead of five events: one source, five failures inside five minutes. The base rule wasn’t output on its own; it’s folded into the correlation query. The generated SQL is about 3,600 characters of window functions, which is the point of Sigma: you write a few lines of YAML, not the SQL.

The same two rules for Splunk:

$ sigma convert -t splunk --without-pipeline rules/
Parsing Sigma rules
process IN ("sshd", "sshd-session") message="Failed password for *"

| bin _time span=5m
| stats count as event_count by _time src_ip

| search event_count >= 5

The detection is the same, but the window is not. Splunk’s bin _time span=5m cuts time into fixed five-minute buckets, so a burst from 07:58 to 08:02 is split across two buckets and may not reach five in either. The SQLite query uses a sliding window that ends at each event. Check how your backend implements timespan before relying on a threshold. --without-pipeline keeps the field names as written; a real Splunk deployment adds a processing pipeline that maps them to its own field names.

Tagging with ATT&CK

The tags connect each rule to MITRE ATT&CK. attack.credential-access is the tactic, the attacker’s goal. attack.t1110.001 is the technique, Brute Force: Password Guessing. Add one more rule, for the successful login that followed the guesses.

# rules/ssh_password_login.yml
title: SSH Password Login Accepted
id: 3c5a7e9b-2d4f-4b6a-8c1e-9f0a1b2c3d4e
status: experimental
description: A password (not key) login to sshd succeeded.
tags:
    - attack.initial-access
    - attack.persistence
    - attack.t1078
logsource:
    product: linux
    service: sshd
detection:
    selection:
        process:
            - sshd
            - sshd-session
        message|startswith: 'Accepted password for '
    condition: selection
level: informational

T1078 is Valid Accounts. ATT&CK files that technique under several tactics, and the rule tags the two that fit a remote login. sigma check validates every rule’s structure and tags before you rely on it.

$ sigma check rules/
Parsing Sigma rules
Checking Sigma rules

=== Summary ===
Found 0 errors, 0 condition errors and 0 issues.
No rule errors found.
No condition errors found.
No validation issues found.

With tags in place, a short script can report coverage: for each of the 14 tactics in the ATT&CK Enterprise matrix, which rules exist.

# coverage.py: which ATT&CK tactics do my Sigma rules cover?
import pathlib, yaml

TACTICS = ["reconnaissance", "resource-development", "initial-access", "execution",
           "persistence", "privilege-escalation", "defense-evasion", "credential-access",
           "discovery", "lateral-movement", "collection", "command-and-control",
           "exfiltration", "impact"]

covered = {t: [] for t in TACTICS}
for path in sorted(pathlib.Path("rules").glob("*.yml")):
    rule = yaml.safe_load(path.read_text())
    tags = {t.removeprefix("attack.") for t in rule.get("tags", [])}
    techniques = sorted(t.upper() for t in tags if t.startswith("t1"))
    for tactic in tags & set(TACTICS):
        covered[tactic].append(f"{path.stem} {','.join(techniques)}")

for tactic in TACTICS:
    print(f"{tactic:<21} {len(covered[tactic])}  {'; '.join(covered[tactic]) or '-'}")
gaps = [t for t in TACTICS if not covered[t]]
print(f"{len(TACTICS) - len(gaps)} of {len(TACTICS)} tactics have a rule")
$ python coverage.py
reconnaissance        0  -
resource-development  0  -
initial-access        1  ssh_password_login T1078
execution             0  -
persistence           1  ssh_password_login T1078
privilege-escalation  0  -
defense-evasion       0  -
credential-access     2  ssh_brute_force T1110.001; ssh_failed_password T1110.001
discovery             0  -
lateral-movement      0  -
collection            0  -
command-and-control   0  -
exfiltration          0  -
impact                0  -
3 of 14 tactics have a rule

The useful lines are the zeros. Three rules feel like progress, but they all watch one service, and eleven tactics have no detection at all. A real team runs this against its whole rule set to choose what to write next. Coverage measures intent, not quality: the broken process: sshd rule would have counted exactly the same, which is why every rule also needs the test you ran above.

The next lesson moves from host logs to the network, with an IDS rule that fires on a packet capture.

Practice

  1. Change the correlation to gte: 6 and run the hunt again. What happens, and what does it tell you about choosing a threshold?
Solution

python hunt.py brute.sql prints 0 result(s). The lab made exactly five failures, so a threshold of six can never fire. Before deploying, run the rule against a sample of the attack you want to catch and check that the threshold sits below it, then against normal traffic to check it sits above the noise.

  1. Replace the process list with process|startswith: sshd. Does it still find all five? What could it match that the list doesn’t?
Solution
detection:
    selection:
        process|startswith: sshd
        message|startswith: 'Failed password for '
    condition: selection

It converts to process LIKE 'sshd%' and finds all five. It would also match any other program whose name starts with sshd. OpenSSH 10 already ships an sshd-auth helper (it’s in /usr/lib/openssh/ in the lab), and in this lab its messages still arrive tagged sshd-session. That might be what you want, but it’s a broader rule than the list, and you should choose it on purpose.

  1. Write rules/ssh_invalid_user.yml that matches the Invalid user lines, tagged like the failed password rule. Convert it, run the hunt, then rerun coverage.py.
Solution
# rules/ssh_invalid_user.yml
title: SSH Login Attempt For Unknown User
id: 8d2f4a6c-5e7b-4c9d-a1b3-2e4f6a8c0d1e
status: experimental
description: sshd was asked to authenticate a user that does not exist.
tags:
    - attack.credential-access
    - attack.t1110.001
logsource:
    product: linux
    service: sshd
detection:
    selection:
        process:
            - sshd
            - sshd-session
        message|startswith: 'Invalid user '
    condition: selection
level: low

The hunt prints 2 results, admin and oracle. In coverage.py, credential-access goes up to 3 rules, and the total stays at 3 of 14 tactics. A fourth rule in a tactic you already cover doesn’t close any gap, which is exactly what the coverage report is for. Give the rule a fresh UUID of your own; id must be unique across your rule set.

  1. The Splunk query uses fixed five-minute bins. Without running Splunk, work out whether five failures at 07:58, 07:59, 08:00, 08:01 and 08:02 would alert there, and in the SQLite query.
Solution

In Splunk, the 07:55-08:00 bin holds two events and the 08:00-08:05 bin holds three, so neither reaches five and nothing fires. The SQLite query counts the events in the five minutes ending at each event; at 08:02 the window from 07:57 holds all five, so it fires. Same rule, different answers, which is why you test a converted rule on the platform that will actually run it.

Frequently Asked Questions

What is a Sigma rule?
Sigma is an open, vendor-neutral YAML format for describing log detections. A rule names the log source, lists field conditions under detection, and combines them with a condition. Tools such as sigma-cli convert one rule into the query language of Splunk, Elastic, SQLite and many other backends, so a detection is written once and reused across products.
Why does my Sigma rule match nothing?
The most common cause is a field value that is stricter than the logs. An exact match such as process: sshd fails if the program now logs as sshd-session, and a field name that your backend doesn't have fails silently too. Test every rule against a log sample that you know contains the activity, and treat zero hits on that sample as a bug in the rule.
How do Sigma correlation rules work?
A correlation rule refers to one or more base rules by name and adds a condition over a time window. The event_count type, for example, fires when at least N matching events share the same group-by fields, such as the source IP, within the timespan. Backends that support correlations compile the base rule and the window into a single query.
What ATT&CK technique is SSH brute force?
Repeated password guesses against one account map to T1110.001, Brute Force: Password Guessing, under the Credential Access tactic. Trying one password against many accounts is T1110.003, Password Spraying. In Sigma you record this with tags such as attack.credential-access and attack.t1110.001.
What is ATT&CK coverage, and why measure it?
Coverage means which ATT&CK tactics and techniques at least one of your detection rules is tagged with. Counting rules per tactic shows where you have no visibility at all, which is more useful than the total number of rules. It measures intent, not quality, so each rule still needs testing against real log samples.