DRAFT — internal staging preview. Not for distribution until owner-approved.
Zero to SOC starting…

Zero to SOC · Free open lesson series · Lesson 3 of 7

Lesson 3: Defeat the Counter

FreeNo signupRuns in your browserNo install

Lesson 3 of 7 · Detection arc

Time: ~45–60 minutes, one sitting.

You will be able to:

Prerequisites: Lessons 1–2 — you reuse Lesson 1's parser unchanged and re-run Lesson 1's per-IP counter to watch it fail. Python used: re, collections.defaultdict, datetime.strptime + timedelta, set comprehensions, json.dumps.

The free third lesson in Zero to SOC. It aims to be the clearest explanation of why

counting per source fails — and the one idea that fixes it — that exists anywhere. If

it isn't, it isn't done.

Start here: this lesson continues directly from

Lesson 1 and Lesson 2. You'll

reuse the same parser. If you haven't done those, do them first — each takes one sitting.


Where Lessons 1 and 2 left off

By the end of Lesson 2 you had a SIEM with three detections: a per-IP failure counter, a "did the brute-force succeed" correlation rule, and a first-seen-IP baseline. All three are real. All three share one assumption you never had to question — because in the data so far, it was always true.

Every rule grouped the events by source IP. Lesson 1 counted failures per IP. Lesson 2 correlated failures-then-success per IP. The attacker always came from one address, so "watch each address" worked.

An attacker only has to read your rule once to break it.

Lessons 1–6 are the detection arc; Lesson 7 opens the investigation arc.

LessonThe skill it adds
1. Build a SIEM from scratchingest → parse → detect → alert
2. Catch the breachcorrelate events; baseline what's normal
3. Defeat the counterpivot the group-by key← you are here
4. The patient attackerstretch the horizon; count occasions
5. The drip that landedchain detectors — rules that read alerts
6. The login that looked fineweigh weak signals; sum the risk
7. Follow the threadinvestigate: pivot, timeline, blast radius

The attack: spread out so no one IP looks bad

Put yourself on the attacker's side for a second. You know the defender counts failures per IP and alerts at 5. So you don't send 100 guesses from one machine. You send 2 guesses each from 50 machines — a botnet, a list of rented proxies, a few thousand compromised home routers. Same 100 guesses against the same account. But now no single IP ever reaches 5, so the per-IP counter never fires. Not once.

This is a distributed brute-force, and it is the normal shape of credential attacks on the real internet — not an exotic edge case. Its quiet cousin is low-and-slow: one IP, but one guess an hour, staying under any time window you set. Both beat counting-per-source the exact same way — by making each source look boring.

Here's the trap in one line: the attacker controls the source IP, so any rule keyed on the source is a rule the attacker can dilute. Lesson 3 is about finding a key they can't cheaply spread across.


The data

Same format as before. Mostly a normal morning — alice and bob log in, alice fumbles a password once like a human — and then, starting at 10:00, a distributed brute-force against root from eight different IPs, two failures each at most. Every attacker IP stays under Lesson 1's threshold of 5.

Make an auth.log with this content:

auth.log — edit freely, then create it

Fourteen lines. To the eye, the attack is obvious — root is getting hammered. But notice how it's spread: eight source IPs, none with more than two failures. Hold that thought. (All IPs are from ranges reserved for documentation, so they're safe as examples.)


Recap: your parser from Lesson 1

Unchanged. Paste it again (or keep your earlier file open) so we have events to work with.

Python — editable, runs in your browser

Run it: parsed 14 events.


First, watch Lesson 1's rule fail

Don't take my word that the per-IP counter misses this. Run it and see. Here is Lesson 1's detection, unchanged:

Python — editable, runs in your browser

Output:

Expected output
per-IP alerts: []
worst single IP: 2 failures

Zero alerts. The same rule that nailed the attacker in Lesson 1 is stone blind here, and it's not a tuning problem — lowering the threshold to 2 would alert on every user who ever fat-fingers a password twice. The rule isn't mis-set. It's asking the wrong question. It asks "is any single IP noisy?" and the attacker's whole design is to make sure the answer is no.


The new idea: change what you group by

Here's the move, and it's smaller than it sounds. Every rule so far grouped by ipwhere did this come from? But the attacker chooses where things come from. They can rent a thousand IPs for a few dollars. What they're actually doing — the thing they can't hide — is pounding on one account.

So group by the target instead of the source. Don't ask "is one IP failing a lot?" Ask **"is one account being failed against by a lot of different IPs?"** A single real user mistypes their password from one or two machines. An account taking failed logins from eight different IPs in five minutes is not a forgetful user — it's a coordinated attack, and the spread that hid it from the per-IP rule is exactly what gives it away here.

That's the whole lesson: pick a key the attacker doesn't control. They control the source. They do not control which account they're trying to break into — that's your user, not their resource. Group by it.


Detection: distinct sources per target

We count, per user, how many distinct IPs produced failed logins inside a time window. A real user: one, maybe two. An attack: many. We reuse the same when() time helper and the same sliding-window shape you wrote in Lesson 1 — only the key changed.

Python — editable, runs in your browser

Emit the alerts — critical, because a coordinated attack on one account is an incident, not weather:

Python — editable, runs in your browser

Run it:

Expected output
{"user": "root", "distinct_ips": 8, "failures": 10, "window_start": "Jun 22 10:00:05", "rule": "distributed_bruteforce", "severity": "critical"}

There it is — the attack that was invisible a minute ago. Same fourteen log lines, same parser, same sliding-window idea you already knew. The only thing that changed is the dictionary key: from by_ip to by_user. That one change is the difference between blind and not.

And it still keeps quiet about alice, who failed once from her usual address — one IP, nowhere near the distinct-source threshold. Breach vs. butterfingers, again: the rule fires on the coordinated thing and ignores the human one.


Now generate the attack yourself

Same loop as every lesson: the fastest way to trust a detector is to be the attacker and watch it fire. This time you're running a distributed attack — many sources, few guesses each — so make it look like one. Hit a different account (admin) from six brand-new IPs:

Python — editable, runs in your browser

Re-run the parser block so events is fresh, then the detection again. Now it catches both distributed attacks:

Expected output
{"user": "root", "distinct_ips": 8, "failures": 10, "window_start": "Jun 22 10:00:05", "rule": "distributed_bruteforce", "severity": "critical"}
{"user": "admin", "distinct_ips": 6, "failures": 6, "window_start": "Jun 22 12:00:05", "rule": "distributed_bruteforce", "severity": "critical"}

Notice your admin attack sent only one failure per IP — the kind of thing no per-source rule on earth would ever flag — and your detector caught it anyway, because six different sources ganging up on one account is the signal, no matter how quiet each one is. Run Lesson 1's per-IP counter over this same log if you want the contrast: still zero alerts.


Make it real: where this rule breaks

A third working detection. Still not a product. Here's what a real analyst pokes at next — and notice the pattern is identical to Lessons 1 and 2: the rule catches the obvious case, the real world supplies an edge case, you add context.

Shared accounts cause false positives. A service account like root, deploy, or jenkins legitimately gets used from many machines — a CI fleet, a load balancer, an ops team. A handful of mistyped logins from different hosts can look distributed when nobody's attacking. Real systems handle this by learning a per-account baseline (Lesson 2's idea, one level up): how many distinct sources is normal for this user? Five new IPs means nothing for jenkins and means everything for alice.

Low-and-slow still slips under the window. We catch distributed because the spread is in space (many IPs at once). The patient attacker spreads in time instead — one guess an hour from one IP, never enough inside any 10-minute window. Catching that needs memory across a long horizon: counting an account's failures over days, not minutes, and noticing a slow drip that never stops. That's a longer-window version of exactly what you just built — and it's where the next lesson goes.

Two keys are better than one. You don't replace the per-IP rule with the per-user rule — you run both. The concentrated attacker trips the source counter; the distributed one trips the target counter. Mature detection is a panel of rules watching the same events through different keys, because every single key has a blind spot some attacker is built to exploit.

That move — when one grouping goes blind, add the grouping that doesn't — is detection engineering. You've now done it three times.


Check yourself

Before moving on — answer from your head, then verify against your code:

  1. The alert on the original 14-line log says distinct_ips: 8 but failures: 10. Where do the two extra failures come from?
  2. Why group by user instead of IP — what property makes the target account the better key?
  3. Which attack still beats this rule and Lesson 1's burst rule at the same time?
Answers
  1. Two of the attacker's IPs come back for a second guess: 203.0.113.10 fails again at 10:05:00 and 203.0.113.12 at 10:05:39. Ten failed events, eight unique sources — the rule counts the set {x["ip"] for x in window}, not the events.
  2. The attacker controls the source — a thousand rented IPs cost a few dollars, so any source-keyed count can be diluted until no single source looks bad. They do not control which account they're breaking into; that's your user, not their resource. Key the rule on the thing they can't cheaply spread across.
  3. Low-and-slow from a single IP — one guess an hour. One source, so distinct_ips never climbs above 1 here; never five failures inside any 60-second window, so Lesson 1's burst rule stays dark too. Spread in time instead of space — that's exactly where Lesson 4 goes.

What you just did, and what's next

You learned the idea that quietly runs underneath most real detection content: the group-by key is a choice, and the wrong choice is a blind spot. You watched a working rule from Lesson 1 go completely dark against a distributed attack, found the one account the attacker couldn't hide behind, and caught the thing by changing a single dictionary key. Then you ran the attack yourself and watched it fire. That is detection engineering — not memorizing rules, but knowing why a rule sees what it sees and what it's structurally blind to.

This is the Zero to SOC flagship deepening on the same path it started: ingest, parse, detect, correlate, baseline — now pivot the key. From here the course keeps going the same way:

scenarios at your lab so no two students get the same exercise.

— a portfolio artifact, not a multiple-choice score.

If these three lessons were clear, that's the whole course: hard things explained plainly, built by your own hands, no magic.


Free to use and share. This series is open-source on purpose — it's the clearest way we know to show what the flagship is. If it helped, that's the pitch.

← Previous lesson Next lesson →