What would you like to learn?

Try PowerShell, networks, XSS, or Step 138

Browse the full curriculum →

Networks

Step 165. Password Spraying and Credential Stuffing — Attacks That Walk Sideways Past the Lock

Step 165Estimated practice · 3 hours

Unit guide: This chapter opens the Attacker’s Extended Toolkit unit (Steps 165–171) — password spraying, wireless, social engineering, malware dissection, and OSINT.

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.


Review and practice notes

20 September 2026 · Text/code review and corrections to identified issues; this is not a claim that every exercise was rerun.

Log newlines are restored and attempted passwords are no longer written to the log. The missing localhost-only request helper is supplied. Restart the server before each comparison to reset its in-memory lockout state. An IP-only limit cannot reliably distinguish distributed attempts from users sharing a network. Use only the fictional accounts in this lesson.

Save this helper before running the comparison scripts.

# Save as lab_login165.py. Fixed localhost target; fictional lab data only.
import json
import urllib.request
import urllib.error

def try_login(username, password):
    data = json.dumps({"username": username, "password": password}).encode("utf-8")
    request = urllib.request.Request("http://127.0.0.1:9091", data=data,
                                     headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(request, timeout=3) as response:
            return response.read().decode("utf-8")
    except urllib.error.HTTPError as error:
        return error.read().decode("utf-8")

Technical references: OWASP Authentication Cheat Sheet

OWASP: Logging Cheat Sheet

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

The following describes the controlled experiment inputs. Because passwords are not logged, server logs alone cannot conclusively distinguish spraying from stuffing. Compare the known inputs and run order with timestamps, accounts and outcomes.

  • 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

Section 3-5 compares the known run order with logs. Operational detection also needs context such as source, device and attempt frequency. This minimal log does not record source or device information.


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} -> {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. ② The fifth failure returns HTTP 401 while locking the account; subsequent requests return HTTP 423 (Locked). ③ The log records time, account and verdict, never attempted passwords.

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)

from lab_login165 import try_login
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 localhost-only urllib POST helper supplied above
    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 -> lockout triggered
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)

from lab_login165 import try_login
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)

from lab_login165 import try_login
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 (illustrative output format, with passwords removed from the earlier lab record; your timestamps will differ):

16:57:44 alice -> fail
16:57:44 alice -> fail
16:57:44 alice -> fail
16:57:44 alice -> fail
16:57:44 alice -> fail -> lockout triggered
16:57:44 alice -> locked account
16:57:47 alice -> fail
16:57:47 bob -> success
16:57:47 carol -> fail
16:57:47 dave -> fail
16:57:47 erin -> fail
16:57:47 frank -> fail
16:57:47 grace -> fail
16:57:47 henry -> fail
16:57:49 alice -> fail
16:57:49 carol -> fail
16:57:49 dave -> success
16:57:49 grace -> fail
16:57:49 henry -> fail

How to read it: the repeated account and lockout in the :44 block are visible in the log. We know the :47 and :49 labels from our controlled run order. A password-free log alone does not prove which of these two techniques occurred, and account ordering is not a reliable general discriminator.

  • 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

  1. Complete 3-1’s server, run the three attack scripts in order, and record the full outputs (restart the server between runs)
  2. Find the three blocks in auth165.log and annotate each with one line saying "what attack this is"
  3. Write a rule detecting spray attacks as a log condition (e.g., "N failures on different accounts from the same source within 60 seconds")
  4. 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 use enough repeated fictional localhost trials to reach that threshold; one 8-account run is insufficient
  5. 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. Explain why a password-free log cannot conclusively distinguish spraying from stuffing, and what additional lab records you can compare.

Exercise 4. In 3-6’s table, only MFA gets 〇 against all three attacks. Explain why MFA stops even stuffing.


Answers & completion criteria · expand/collapse

5. Model Answers & Completion Criteria

Mission Model Answer

This is aggregate-counter pseudocode, not a standalone Python program. Implement the source definition, response termination and a time window separately.

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 the known lab inputs and run order, distinguished from what the log itself shows? ③ After adding the aggregate counter, does blocking occur at the configured failure threshold (a threshold of 10 cannot be reached with only 8 attempts)? ④ 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. The log contains timestamps, accounts and outcomes but cannot show whether passwords were identical. Account ordering is not proof either. In this controlled lab, compare the fictional input lists and known run order. Operational detection combines several contextual signals without logging passwords.

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: this log has no password column, and account ordering alone cannot establish the technique.
Fix: compare the fictional input lists and known run order with the log. Do not add attempted passwords to logs to distinguish the techniques.

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.

ONE STEP FURTHER

Finished this lesson?

Check the completion criteria, then mark your progress.

Something wrong with this page or a link? Let us know.
Next