Lesson 08 / 16
Abuse Defense
Two configuration decisions in abuse defense: which key the limit is applied to (client, account, resource) and whether the response gives away whether a record exists; the attempts lockout stops and the legitimate members it locks out are counted in the same run.
Contents
The audit log asks afterward and answers well, but it stops nothing. Part of what reaches the loan system’s exposed endpoints neither forces a vulnerability nor breaks a rule; it is too much — attempts searching for an account’s password, requests polling member IDs in sequence, a client pulling the entire catalog. The mechanism that stops these is rate limiting, and its algorithm and placement were measured in earlier courses in this curriculum; both are taken as input here. This lesson measures two different decisions: who the limit is applied to, and what the application answers.
Who the Limit Is Applied To
AS18 — traffic has four components: forty members’ ordinary requests (twelve sharing the library branch’s common egress address), two hundred distributed attempts targeting a single account, three hundred identity probes from a single address, and two thousand record pulls from one authenticated account. The limit value and the algorithm are the same across all five schemes; the only thing that changes is which field the counter is bound to.
// key.mjs — what the limit applies to: client, account, target const LIMIT = 20; // requests per window; algorithm and placement are taken as input const requests = []; const add = (n, y) => { for (let i = 0; i < n; i++) requests.push(y(i)); }; // Good traffic: 40 members, each 1 login (unauthenticated) + 5 authenticated requests; 12 share the branch address. for (let u = 1; u <= 40; u++) { const address = u <= 12 ? 'A-branch' : `A-member-${u}`; add(1, () => ({ address, account: null, target: `U-${u}`, bad: false })); add(5, () => ({ address, account: `U-${u}`, target: `U-${u}`, bad: false })); } // Distributed brute force: 200 separate addresses, one account. add(200, (i) => ({ address: `A-distributed-${i}`, account: null, target: 'U-17', bad: true })); // Enumeration: one address, 300 separate targets. add(300, (i) => ({ address: 'A-scan', account: null, target: `U-${1000 + i}`, bad: true })); // Scraping: one authenticated account, 2000 separate resources. add(2000, (i) => ({ address: 'A-scrape', account: 'U-39', target: `K-${i}`, bad: true })); // A scheme binds a request to one or more keys and a limit for each key. const schemes = { 'client': (r) => [['a', r.address, LIMIT]], 'account': (r) => (r.account ? [['h', r.account, LIMIT]] : []), 'target': (r) => [['t', r.target, LIMIT]], 'combined': (r) => [['a', r.address, LIMIT], ['t', r.target, LIMIT], ...(r.account ? [['h', r.account, LIMIT]] : [])], 'layered': (r) => (r.account ? [['h', r.account, LIMIT]] : [['a', r.address, LIMIT], ['t', r.target, LIMIT]]), }; function measure(scheme, normalize = (d) => d, stream = requests) { const counter = new Map(); let stoppedBad = 0, passedBad = 0, falseReject = 0; for (const r of stream) { let drop = false; for (const [kind, raw, limit] of scheme(r)) { const key = `${kind}:${normalize(raw)}`; const n = (counter.get(key) ?? 0) + 1; counter.set(key, n); if (n > limit) drop = true; } if (r.bad) drop ? stoppedBad++ : passedBad++; else if (drop) falseReject++; } return { stoppedBad, passedBad, falseReject }; } const s = (x, n) => String(x).padStart(n); const bad = requests.filter((r) => r.bad).length; console.log(`good requests ${requests.length - bad}, bad requests ${bad}, limit ${LIMIT}`); console.log(`${'key'.padEnd(13)}${s('stopped', 12)}${s('passed', 7)}${s('false reject', 14)}`); for (const [name, scheme] of Object.entries(schemes)) { const r = measure(scheme); console.log(`${name.padEnd(13)}${s(r.stoppedBad, 12)}${s(r.passedBad, 7)}${s(r.falseReject, 14)}`); } // The same 200 attempts, if the target name is given in ten different spellings. const spellings = ['U-17', 'u-17', ' U-17', 'U-017', 'U-17 ', 'u-017', 'U_17', 'u_17', 'U-17\t', 'U-0017']; const spread = requests.map((r) => (r.bad && r.target === 'U-17' ? { ...r, target: spellings[requests.indexOf(r) % spellings.length] } : r)); const normalize = (d) => d.trim().replace(/_/g, '-').toUpperCase().replace(/^U-0+/, 'U-'); console.log('\nthe same 200 attempts, if the target name is given in ten different spellings (layered scheme):'); for (const [name, n] of [['raw key', (d) => d], ['normalized', normalize]]) { const r = measure(schemes.layered, n, spread); console.log(` ${name.padEnd(18)} stopped ${r.stoppedBad}, passed ${r.passedBad}, false reject ${r.falseReject}`); }
good requests 240, bad requests 2500, limit 20 key stopped passed false reject client 2260 240 52 account 1985 515 0 target 186 2314 0 combined 2451 49 52 layered 2446 54 0 the same 200 attempts, if the target name is given in ten different spellings (layered scheme): raw key stopped 2266, passed 234, false reject 0 normalized stopped 2446, passed 54, false reject 0
The first three rows say each key sees exactly one shape of abuse. The client key stops the probing and the scraping (2,260), never sees the distributed attempt, and drops fifty-two legitimate requests from the branch’s shared address. The account key’s false reject is zero but it never applies to the five hundred unauthenticated requests. The target key alone catches the distributed attempt, stopping only a hundred and eighty-six requests.
The last two rows are the decision itself. Applying all three keys at once stops 2,451 requests and still drops the branch’s fifty-two. The layered scheme stops five fewer requests (2,446) and brings the false reject to zero: an authenticated request binds to the narrowest key, the account; an unauthenticated one binds to both address and target. Same limit, same algorithm, same placement — the difference lies only in which field the counter looks at.
The final block’s last two lines are this decision’s silent flaw. If the key comes from an identifier carried in the request and is not normalized, two hundred attempts probing the same account under ten spellings spread across ten separate counters: stopped drops from 2,446 to 2,266, meaning a hundred and eighty attempts pass under the limit. There is no error anywhere; the counter counts correctly, the limit applies correctly, it just counts ten separate things.
The Response Itself
The limit stops too many requests. Enumeration’s problem is that too few requests can also be enough: if forty probes tell you which of forty identities exist, the information has already gotten out before the limit ever engages. What decides this is the shape of the response. The run below actually stands up three versions of the same endpoint and probes forty identities, alternating in order.
// response.mjs — does the response shape enable enumeration: signature difference and duration difference import { createServer } from 'node:http'; import { createHash } from 'node:crypto'; const ROUNDS = 60000; // fixed work the validator does: digest rounds const THRESHOLD = 5; // ms; fixed threshold for separating the two sets (see text) const doWork = () => { let d = 'x'; for (let i = 0; i < ROUNDS; i++) d = createHash('sha256').update(d).digest('hex'); }; const members = new Set(Array.from({ length: 20 }, (_, i) => `U-${i + 1}`)); const RECEIVED = '{"status":"request received"}'; const variants = { 'separate response': (v) => { if (v) doWork(); return v ? [202, '{"status":"code sent"}'] : [404, '{"error":"no record"}']; }, 'same response': (v) => { if (v) doWork(); return [202, RECEIVED]; }, 'same response + fixed work': () => { doWork(); return [202, RECEIVED]; }, }; const server = createServer((request, response) => { const [status, body] = variants[request.headers['x-variant']](members.has(request.headers['x-identity'])); response.writeHead(status, { 'content-type': 'application/json' }); response.end(body); }); server.listen(8952, '127.0.0.1'); const BASE = 'http://127.0.0.1:8952/password-reset'; // Attempt order alternates: existing and non-existing identities in turn, so drift affects both // sets at once and the duration difference comes only from the branch's own work. const identities = Array.from({ length: 20 }, (_, i) => [`U-${i + 1}`, `U-${i + 21}`]).flat(); const attempt = async (variant, identity) => { const t = performance.now(); const y = await fetch(BASE, { method: 'POST', headers: { 'x-variant': variant, 'x-identity': identity } }); const body = await y.text(); return { duration: performance.now() - t, signature: `${y.status}|${body}`, real: members.has(identity) }; }; for (let i = 0; i < 5; i++) await attempt('same response + fixed work', 'U-1'); // warm-up const s = (x, n) => String(x).padStart(n); console.log(`${'variant'.padEnd(27)}${s('sig real/fake', 14)}${s('sig disjoint', 13)}` + `${s('by duration', 14)}${s('digest rounds', 16)}`); for (const variant of Object.keys(variants)) { const measurements = []; for (const k of identities) measurements.push(await attempt(variant, k)); const sigs = (real) => new Set(measurements.filter((o) => o.real === real).map((o) => o.signature)); const [realSigs, fakeSigs] = [sigs(true), sigs(false)]; const disjoint = [...realSigs].every((x) => !fakeSigs.has(x)); const correct = measurements.filter((o) => (o.duration > THRESHOLD) === o.real).length; const work = variant === 'same response + fixed work' ? `${ROUNDS}/${ROUNDS}` : `${ROUNDS}/0`; console.log(`${variant.padEnd(27)}${s(`${realSigs.size}/${fakeSigs.size}`, 14)}${s(String(disjoint), 13)}` + `${s(`${correct}/40`, 14)}${s(work, 16)}`); } server.close(); console.log(`\nduration threshold ${THRESHOLD} ms is fixed; 20/40 is the random-guess level`); console.log(`digest-round count is independent of the run, durations are not`);
variant sig real/fake sig disjoint by duration digest rounds separate response 1/1 true 40/40 60000/0 same response 1/1 false 40/40 60000/0 same response + fixed work 1/1 false 20/40 60000/60000 duration threshold 5 ms is fixed; 20/40 is the random-guess level digest-round count is independent of the run, durations are not
The first version splits the response in two: 202 and “code sent” for an existing identity, 404 and “no record” for one that does not exist. The signature sets are disjoint, so all forty of the forty probes are correctly classified. This is not a vulnerability, it is a response-shape decision, and it is usually made to help the user.
The second version returns the same body and status code on both branches; the signature difference closes. The duration difference stays open, though, and all forty probes are again correctly classified, since the validator only runs on an existing identity. The run-independent quantity is the last column: sixty thousand digest rounds on one branch, zero on the other. Here the medians came to roughly 19.9 ms against 1.5 ms; the ratio is machine-dependent, its existence is not.
The third version does the work on both branches, and duration-based classification falls to the random-guess level (20/40). Equalizing the response alone is not enough; the work done must be equalized too. So the first two versions produce no error at all — all three run, all three return a correct answer, and the difference between them is measurable only by probing.
The False Reject of Lockout
The first defense that comes to mind against brute force is locking the account after a few consecutive errors. AS19 — the number of failed attempts in a login session comes from the distribution above, and thirty days for forty members is generated. AS20 — the attacker makes an attempt every three seconds, with a nine-hundred-second measurement window.
// lockout.mjs — the false-reject cost of lockout against brute force const SEED = 9137; let state = SEED; const rand = () => (state = (state * 1103515245 + 12345) % 2147483648) / 2147483648; const WINDOW = 900; // s: measurement window const PACE = 3; // s: seconds between two attacker attempts const THRESHOLD = 5; // consecutive failed attempts const LOCKOUT = 900; // s: lockout duration const MEMBERS = 40, DAYS = 30; // AS19: distribution of failed attempts in a login session (the legitimate member's own error) const DISTRIBUTION = [[0, 0.72], [1, 0.15], [2, 0.06], [3, 0.03], [4, 0.02], [6, 0.015], [8, 0.005]]; const sessions = []; for (let i = 0; i < MEMBERS * DAYS; i++) { let x = rand(), errors = 0; for (const [n, p] of DISTRIBUTION) { if ((x -= p) < 0) { errors = n; break; } } sessions.push(errors); } const falseReject = sessions.filter((h) => h >= THRESHOLD).length; // Backoff waits 2^(n-1) s before the nth attempt; how many attempts fit in the window. let backoff = 0; for (let t = 0, n = 1; t + 2 ** (n - 1) <= WINDOW; n++) { t += 2 ** (n - 1); backoff = n; } const policies = [ ['no limit', WINDOW / PACE, 0, 0, 0, 0], ['lockout, 15 min', THRESHOLD, falseReject, 0, MEMBERS, LOCKOUT], ['lockout, permanent', THRESHOLD, falseReject, falseReject, MEMBERS, -1], ['backoff', backoff, 0, 0, 0, 2 ** (backoff - 1)], ]; const s = (x, n) => String(x).padStart(n); console.log(`seed ${SEED}: ${sessions.length} login sessions (${MEMBERS} members x ${DAYS} days), window ${WINDOW} s`); console.log(`${'policy'.padEnd(19)}${s('attempts passed', 16)}${s('legit locked', 15)}` + `${s('staff unlock', 14)}${s('targeted lockouts', 18)}${s('legit member wait', 20)}`); for (const [name, passed, locked, unlock, targeted, wait] of policies) { console.log(`${name.padEnd(19)}${s(passed, 16)}${s(locked, 15)}${s(unlock, 14)}${s(targeted, 18)}` + `${s(wait === -1 ? 'staff unlocks' : `${wait} s`, 20)}`); } console.log(`\ntargeted lockout locks out ${MEMBERS} members with ${MEMBERS * THRESHOLD} requests`); console.log(`sessions with ${THRESHOLD}+ consecutive errors: ${falseReject}/${sessions.length}` + ` (${(100 * falseReject / sessions.length).toFixed(1)}%); all of them a legitimate member`);
seed 9137: 1200 login sessions (40 members x 30 days), window 900 s policy attempts passed legit locked staff unlock targeted lockouts legit member wait no limit 300 0 0 0 0 s lockout, 15 min 5 30 0 40 900 s lockout, permanent 5 30 30 40 staff unlocks backoff 9 0 0 0 256 s targeted lockout locks out 40 members with 200 requests sessions with 5+ consecutive errors: 30/1200 (2.5%); all of them a legitimate member
Lockout does its job: in nine hundred seconds, attempts passing drop from three hundred to five. Its cost shows up in two places at once. First, thirty legitimate member sessions a month — 2.5% of twelve hundred — belong to no attacker; under permanent lockout, all thirty become a staff task. Second, and heavier: lockout hands anyone who knows an account name a kill switch. Five attempts against each of forty accounts — two hundred requests — locks out all forty members, without knowing a single password.
Backoff allows nine attempts in the same window — four more than lockout, thirty-three times fewer than no limit — and locks out no legitimate member at all. The remaining cost is the last column: the real owner of a targeted account waits 256 seconds after the attacker burns through the attempts. So the delay produces a false reject too, but a finite one needing no staff work. The choice between the two defenses is not “which is more secure” but who a false reject lands on, and for how long.
Summary
- The limit’s algorithm and placement are this lesson’s input; the decision measured is which key the limit is applied to, and each key sees only one shape of abuse.
- The client key stopped 2,260 bad requests and dropped 52 legitimate ones over the shared address; the layered scheme stopped 2,446 and brought the false reject to zero — an authenticated request bound to the account, an unauthenticated one to the address and target.
- Without normalizing the key, two hundred attempts probing the same account under ten spellings spread across ten counters, and stopped requests dropped from 2,446 to 2,266; the counter works correctly, it just counts ten separate things.
- Giving a separate response to an existing versus a non-existing identity gave away all forty of the forty probes; equalizing the response closed the signature difference, but the duration difference stayed forty for forty, because the work happened on only one branch.
- Doing the work on both branches brought classification down to the random-guess level (20/40); the run-independent measure is equalizing the digest-round count on both branches.
- Lockout brought passing attempts from 300 to 5 and locked out 30 legitimate member sessions a month; two hundred requests can lock out all forty members. Backoff allowed nine attempts, locked no one out, and left the legitimate member a finite 256-second wait.
Next Step
Every defense built across this topic sits inside the application. The validator, the encoder, the header layer, the secret store, the audit log, and the counter — all run in the loan service’s own process, and all assume the request the application sees is really the request that arrived. In production this assumption does not hold on its own. The application does not stand alone: a reverse proxy meets the connection in front of it, a process manager keeps it up behind it, and both configurations change the request the application sees — client address, body size, headers, even how many process copies are running. Which address this lesson’s counters count depends on that too. The next lesson takes on the layer in front of the application and measures the same rule there: a wrong configuration raises no error, it shows up as a difference in the measure.
To keep your progress and take notes, Log in
My notes
Log in to take notes.