Lesson 20 / 22
Password Attacks
Separating three password-attack forms along the cost-and-threshold axis: across four hundred fictional accounts, brute force drops one hundred ninety, dictionary one hundred eighty, and spraying eighteen; account lockout stops two of the three forms and cuts the number of dropped accounts by eighty-seven percent; spraying, at three attempts per account, never triggers the lockout threshold at all; and the total legitimate-work cost of the two thresholds that see all three forms is four.
Published Updated
In this lesson
- Separates three password-attack forms by account count and attempts per account
- Measures which forms account lockout stops and which one it never sees at all
- Compares each threshold's count of forms seen against its legitimate-work cost in the same run
Contents
In the previous lesson’s chain, a field had no boundary, and the defense was about putting that boundary in place. In this lesson’s class, the boundary already exists: an identity attempt is either accepted or rejected, the number of attempts is counted, and it is tied to a threshold. The attacker is not trying to cross the boundary, but to stay under it.
Three forms are separated. Brute force tries a large number of candidates against a single account. Dictionary does the same thing with a shorter, more likely candidate sequence. Spraying reverses this: a large number of accounts, only a few candidates against each. All three use the same chain, but their behavior against the threshold differs, and this lesson counts that difference. No password, candidate list, or attempt script gets written; each account’s password is represented only by the number giving its rank in an ordered candidate sequence.
The Chain’s Six Steps
- Valid username set. Precondition: the account names are known or can be learned.
- Attempt channel open. Precondition: the endpoint accepts identity attempts.
- Attempt budget stays under the threshold. Precondition: no threshold counting at the scale used sees this rate.
- Correct match is found. Precondition: the password is within the candidate range the budget covers.
- Second factor is not requested. Precondition: no second verification factor is defined on the account.
- Session is established and used. Precondition: there is no check on session behavior.
The third step is the one this lesson measures, and it does not depend on a single threshold but on the threshold’s scale. The same threshold value stops one form when counted per account, and a different one when counted per source.
The fifth and sixth steps are kept outside this measurement, but they are part of the chain. If a second verification factor is defined, the chain breaks at the fifth step even when the fourth step completes: the correct password has been found, and it is useless. A check on session behavior breaks the sixth step; there, the chain has completed and the measure has shifted to detection. These two steps are the only defense left in cases where the third step can never be broken — and every number in this lesson is calculated as if they did not exist, because what is being measured is the threshold itself.
TA26: the account set is fictional; rank is the password’s position in the ordered
candidate sequence. TA27: the three thresholds count at three separate scales. TA28: the
three forms are separated by only two numbers — how many accounts it touches and how many
attempts it makes per account. TA29: duration is calculated at the maximum rate the rate
limit allows.
// pw/model.mjs — password-attack cost model. No password, dictionary, or attempt script at // all; each account's password is represented only by the number giving "its rank in an // ordered candidate sequence." export const ACCOUNT_COUNT = 400; // Self-written generator; seed is visible, gives the same result run to run. function generator(seed) { let s = seed >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; } export const BUCKET = [[24, 1, 5], [60, 6, 500], [96, 501, 50000], [40, 50001, 5000000]]; export function accounts(seed = 20260802) { const r = generator(seed); const h = []; for (const [count, lo, hi] of BUCKET) for (let i = 0; i < count; i++) h.push({ rank: lo + Math.floor(r() * (hi - lo + 1)) }); while (h.length < ACCOUNT_COUNT) h.push({ rank: Infinity }); return h; } // legit = fictional daily false-positive rate (blocking a legitimate user). export const THRESHOLD = [ { name: "account-lockout", scale: "account", limit: 5, legit: 30 }, { name: "source-rate-limit", scale: "source", limit: 60, legit: 4 }, { name: "global-rate-threshold", scale: "system", limit: 200, legit: 0 }, ]; export const FORM = [ { name: "brute-force", accountCount: 1, budget: 1000000 }, { name: "dictionary", accountCount: 1, budget: 50000 }, { name: "spraying", accountCount: ACCOUNT_COUNT, budget: 3 }, ]; export const duration = (attempt) => { const min = attempt / THRESHOLD[1].limit; return min < 1 ? `${(min * 60).toFixed(0)} sec` : min < 60 ? `${min.toFixed(0)} min` : min < 1440 ? `${(min / 60).toFixed(1)} hr` : `${(min / 1440).toFixed(1)} day`; }; export const write = (g, ...s) => console.log(s.map((v, i) => (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
// pw/measure.mjs — three forms are run under three thresholds: how many attempts per account, // at which attempt each threshold kicks in, how many accounts drop, and in how much time. // Randomness comes from the generator, the seed is visible; the same result comes out run to run. import { ACCOUNT_COUNT, accounts, THRESHOLD, FORM, duration, write } from "./model.mjs"; const H = accounts(); const bucket = (h) => h.rank <= 5 ? "first 5" : h.rank <= 500 ? "first 500" : h.rank <= 50000 ? "first 50,000" : h.rank <= 5000000 ? "first 5,000,000" : "out of budget"; const BUCKETS = ["first 5", "first 500", "first 50,000", "first 5,000,000", "out of budget"]; console.log(`${ACCOUNT_COUNT} fictional accounts, ${FORM.length} forms, ${THRESHOLD.length} thresholds\n`); const A = [-18, 11, 9]; console.log("1. distribution of accounts across the candidate sequence"); write(A, "bucket", "accounts", "share"); for (const k of BUCKETS) { const n = H.filter((h) => bucket(h) === k).length; write(A, k, n, `${((n / ACCOUNT_COUNT) * 100).toFixed(1)}%`); } // Does a threshold see a form: account scale counts per account, source and system scale // count over the total attempts. const sees = (b, e) => (e.scale === "account" ? b.budget : b.accountCount * b.budget) > e.limit; const B = [-13, 10, 15, 16, 16, 27]; console.log("\n2. threshold behavior per form"); write(B, "form", "accounts", "per account", "total attempts", "lockout", "first threshold seeing it"); for (const b of FORM) { const seeing = THRESHOLD.filter((e) => sees(b, e)); write(B, b.name, b.accountCount, b.budget, b.accountCount * b.budget, sees(b, THRESHOLD[0]) ? `at attempt ${THRESHOLD[0].limit}` : "never triggers", seeing.length ? seeing[0].name : "none"); } // How many accounts would drop if each form targeted every account in turn. const drop = (b, lockoutOn) => { const budget = lockoutOn ? Math.min(b.budget, THRESHOLD[0].limit) : b.budget; return H.filter((h) => h.rank <= budget).length; }; const C = [-13, 14, 15, 22, 8, 22]; console.log("\n3. how many of the targeted accounts drop"); write(C, "form", "no lockout", "with lockout", "lockout's difference", "diff", "duration per account"); for (const b of FORM) { const y = drop(b, false), v = drop(b, true); write(C, b.name, `${y}/${ACCOUNT_COUNT}`, `${v}/${ACCOUNT_COUNT}`, y - v, `${(((y - v) / Math.max(y, 1)) * 100).toFixed(0)}%`, duration(b.budget)); } console.log(`forms where lockout reduces the number of dropped accounts: ` + `${FORM.filter((b) => drop(b, false) > drop(b, true)).length}/${FORM.length}`); const p = FORM[2]; const D = [-24, 10, 10, 18]; console.log("\n4. which threshold sees spraying"); write(D, "threshold", "scale", "limit", "sees it?"); for (const e of THRESHOLD) write(D, e.name, e.scale, e.limit, sees(p, e) ? `at attempt ${e.limit}` : "does not see it"); console.log(`spraying makes ${p.budget} attempts per account; since the lockout threshold is ` + `${THRESHOLD[0].limit}, it never triggers on any account. Total ${p.accountCount * p.budget} attempts ` + `exceeds the rate threshold by ${(p.accountCount * p.budget / THRESHOLD[2].limit).toFixed(1)}x, taking ${duration(p.accountCount * p.budget)}`); const E = [-24, 9, 18, 36]; console.log("\n5. the legitimate-work cost of the thresholds"); write(E, "threshold", "legit", "forms seeing it", "forms it sees"); for (const e of THRESHOLD) { const g = FORM.filter((b) => sees(b, e)); write(E, e.name, e.legit, `${g.length}/${FORM.length}`, g.map((b) => b.name).join(",") || "-"); } console.log(`total legitimate-work cost ${THRESHOLD.reduce((t, e) => t + e.legit, 0)} fictional items/day; ` + `the total cost of the two thresholds that see all three forms ${THRESHOLD[1].legit + THRESHOLD[2].legit}`);
400 fictional accounts, 3 forms, 3 thresholds 1. distribution of accounts across the candidate sequence bucket accounts share first 5 24 6.0% first 500 60 15.0% first 50,000 96 24.0% first 5,000,000 40 10.0% out of budget 180 45.0% 2. threshold behavior per form form accounts per account total attempts lockout first threshold seeing it brute-force 1 1000000 1000000 at attempt 5 account-lockout dictionary 1 50000 50000 at attempt 5 account-lockout spraying 400 3 1200 never triggers source-rate-limit 3. how many of the targeted accounts drop form no lockout with lockout lockout's difference diff duration per account brute-force 190/400 24/400 166 87% 11.6 day dictionary 180/400 24/400 156 87% 13.9 hr spraying 18/400 18/400 0 0% 3 sec forms where lockout reduces the number of dropped accounts: 2/3 4. which threshold sees spraying threshold scale limit sees it? account-lockout account 5 does not see it source-rate-limit source 60 at attempt 60 global-rate-threshold system 200 at attempt 200 spraying makes 3 attempts per account; since the lockout threshold is 5, it never triggers on any account. Total 1200 attempts exceeds the rate threshold by 6.0x, taking 20 min 5. the legitimate-work cost of the thresholds threshold legit forms seeing it forms it sees account-lockout 30 2/3 brute-force,dictionary source-rate-limit 4 3/3 brute-force,dictionary,spraying global-rate-threshold 0 3/3 brute-force,dictionary,spraying total legitimate-work cost 34 fictional items/day; the total cost of the two thresholds that see all three forms 4
Two Numbers, Three Forms
The second output separates the three forms with only two numbers: how many accounts it touches, how many attempts it makes per account. Brute force is 1 account and 1,000,000 attempts; dictionary 1 account and 50,000; spraying 400 accounts and 3 attempts.
The total attempt count does not reverse the ranking, but it is misleading: spraying’s total is 1,200, which is 1.2 thousandths of brute force’s. Against that, brute force targets a single account, spraying all four hundred. Naming an attack form by its total attempt count does not say which threshold will see it.
The lockout column gives the difference directly. Brute force and dictionary both hit lockout on the fifth attempt; spraying never hits it at all. The reason is a single comparison: lockout counts per account, and spraying’s per-account attempt count is under the threshold. Lowering the threshold from 5 to 3 is not enough either; spraying reduces its budget to 2 and still stays under. A threshold that counts at the account scale, by definition, does not see a form that stays small at the account scale.
How Many Accounts Drop
The third output measures the case where each form targets every account in turn. With no lockout, brute force drops 190 of 400 accounts, dictionary 180, spraying 18. With lockout, all three forms’ per-account budget drops to 5: brute force and dictionary drop to 24 accounts, spraying stays at 18.
Lockout’s difference is 166 and 156 accounts in the first two forms — 87% of the number of dropped accounts. In spraying the difference is zero. These two numbers sitting side by side is this lesson’s result: the same threshold is the strongest item of the defense in two forms, and nothing at all in the third.
It is also notable that all three forms converge on nearly the same number under lockout: 24, 24, and 18. When the per-account budget drops to five, all the difference between the forms is erased, because the difference was coming from the budget itself. Lockout does not target an attack form, it targets the budget; that is why it has no effect at all on a form whose budget was already small.
The duration column gives the other side of the cost. At the maximum rate the rate limit allows, brute force takes 11.6 days for a single account, dictionary 13.9 hours, spraying 3 seconds. Spraying does not just avoid lockout; it drops eighteen accounts in three-millionths of the time brute force spends on one.
Duration itself is not a defense either — it is a budget constraint. Even if no threshold ever kicks in over an eleven-day run, the attempt has to stay sustainable over that whole time: the connection has to stay open, the source cannot get blocked, the account cannot get deleted. The rate limit’s real function is to grow this budget; it does not directly reduce the number of dropped accounts, it stretches the time needed to drop one, and opens up room during that time for detection to work.
The first output says where this difference comes from. 6% of accounts are within the first five candidates, 45% cannot be found in any budget. Raising the budget from 50,000 to 1,000,000 only moves the dropped-account count from 180 to 190 — twenty times the budget, ten accounts. The first few items in the candidate sequence give more than everything after them combined; that is the observation spraying rests on.
The Threshold’s Scale and Cost
The fourth output puts spraying to each of the three thresholds separately. The threshold that counts at the account scale does not see it. The threshold that counts at the source scale sees it at attempt 60, the one that counts at the system scale at attempt 200. The total of 1,200 attempts exceeds the rate threshold sixfold, and takes 20 minutes at the maximum rate.
This shows where the measure shifts to: against spraying, the chain might not break at the third step, but it is seen. In the link that cannot be broken, the measure shifts to detection, and detection’s unit is the attempt: how many attempts pass under the threshold, how many accounts get tried before the alarm fires.
The fifth output writes the cost. Account lockout’s legitimate-work cost is 30 fictional items a day, and it sees two of the three forms. The source rate limit’s cost is 4, and it sees all three. The rate threshold’s cost is 0, and it sees all three too — because it rejects no request at all, it only counts and reports.
The three thresholds’ total cost is 34, and the total cost of the two thresholds that see all three forms is 4. The two items with the widest scope carry one-eighth of the cost. This does not mean lockout is unnecessary: lockout is the single item that brings the dropped-account count from 190 down to 24. What it means is that scope and cost do not accumulate on the same item — and a defense set cannot be evaluated without writing the two down separately.
The threshold with zero cost has a separate place, and it should be named at this course’s scale of measurement: that threshold breaks no link at all. Because it rejects no request, it does not remove the third step’s precondition; it only counts. Writing it in a defense list as “the threshold that sees all three forms” is correct; writing it as “the threshold that stops all three forms” is not. The difference between these two sentences is exactly the distinction this course draws between a broken link and detection: an item that only counts does not shorten the chain, it only surfaces the point where the chain is seen.
Summary
- Three forms are separated by only two numbers: how many accounts it touches and how many attempts it makes per account. Brute force is 1 account/1,000,000 attempts, dictionary 1/50,000, spraying 400/3.
- Account lockout stops brute force and dictionary at the fifth attempt; it never sees spraying at all, because it counts at the account scale and spraying’s per-account budget is under the threshold.
- Lockout brings the dropped-account count down from 190 and 180 to 24 (an 87% reduction); in spraying the difference is 0.
- At the maximum rate, brute force takes 11.6 days for a single account, dictionary 13.9 hours, spraying 3 seconds for 18 accounts. Raising the budget from 50,000 to 1,000,000 raises the dropped-account count by only 10.
- The threshold that counts spraying at the source scale sees it at attempt 60, the one that counts at the system scale at attempt 200; the total of 1,200 attempts exceeds the rate threshold sixfold. In the link that cannot be broken, the measure shifts to detection.
- The three thresholds’ total legitimate-work cost is 34 fictional items/day; the total of the two thresholds that see all three forms is 4. Scope and cost do not accumulate on the same item, and the zero-cost threshold breaks no link at all — it only counts.
Next Step
In all six classes so far, the chain always started with a request from the attacker: a field was filled in, a link was sent, an attempt was made. In the next class, the first step is thrown by the victim, and what they do is ordinary — they open a page. The next lesson counts this class as a precondition chain: how many conditions have to hold at the same time, how many combinations remain once one of the conditions falls away, and which browser and network checks break how many conditions at once.
To keep your progress and take notes, Log in
My notes
Log in to take notes.