Defensive Security · lesson 2 of 8
Intermediate
6- 2 Sigma Detection Rules: Catch SSH Brute Force and Map It to ATT&CK
- 3 Suricata on a Packet Capture: Write an IDS Rule That Fires
- 4 Alert Triage and Tuning: From 41 Alerts to the 3 That Matter
- 5 Incident Response: Preserve the Evidence, Then Contain
- 6 Building an Incident Timeline from Auth, Web and IDS Logs
- 7 Static File Analysis with EICAR and YARA: Hashes, file, strings and Rules
Also part of Defensive Security
6- + A Firing Alert Doesn't Mean Anyone Was Notified from Monitoring & Observability
- + MTTR Isn't a Guess — Compute It From Alertmanager's Own Timestamps from Monitoring & Observability
- + Logging Security Events Without Logging Secrets from Security Fundamentals
- + Watching the TCP Handshake with tcpdump from Networking
- + Host Firewalls with nftables: DROP Times Out, REJECT Refuses from Networking
- + Hunting in AWS CloudTrail Logs with Python from Cloud Security
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
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
- Change the correlation to
gte: 6and 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.
- Replace the
processlist withprocess|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.
- Write
rules/ssh_invalid_user.ymlthat matches theInvalid userlines, tagged like the failed password rule. Convert it, run the hunt, then reruncoverage.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.
- 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.