概念定义
数十亿泄露凭据在流通。攻击者把它们喷向每个热门登录口,口令复用带来小比例但真实数量的账号。流量形似“分布式正常用户各失败一次”,恰好落在按账号锁定与按 IP 限流的容忍区间。
工作机制
僵尸网络与代理池轮换源地址,尝试节奏压在阈值之下,目标来自组合列表的邮箱口令对。得手后的变现是邮件枢纽、支付滥用或转卖。防御是分层栈:设置与重置时都做泄露口令筛查、MFA、设备与行为信号、聚合计速检测。
使用场景
一切面向消费者的登录。泄露口令检查用 k-匿名性 API,明文不出域;MFA 尽量默认且优先防钓鱼因子;按队列监控登录成功率而非只看失败数。
常见误区
“密码复杂度规则防撞库”是错位——撞库利用的是复用不是弱口令,泄露筛查更重要。按 IP 限流对代理池近乎噪声;过激的账号锁定反而可被当成对已知邮箱的拒绝服务武器。重置流程同样是攻击面:撞库还会向重置令牌喷洒自动化尝试。
代码示例
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 } as const;
function deriveKey(password: string, salt: Buffer, length: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password.normalize("NFKC"), salt, length, { ...SCRYPT_PARAMS }, (error, key) => {
if (error) reject(error);
else resolve(key);
});
});
}
export async function hashPassword(password: string): Promise<string> {
if (typeof password !== "string" || password.length < 8) {
throw new RangeError("password must be at least 8 characters");
}
const salt = randomBytes(16);
const key = await deriveKey(password, salt, 64);
return [
"scrypt",
SCRYPT_PARAMS.N,
SCRYPT_PARAMS.r,
SCRYPT_PARAMS.p,
salt.toString("base64"),
key.toString("base64"),
].join("$");
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
if (typeof stored !== "string") return false;
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
const [, rawN, rawR, rawP, saltText, keyText] = parts;
const expected = Buffer.from(keyText!, "base64");
const salt = Buffer.from(saltText!, "base64");
if (expected.length === 0 || salt.length === 0) return false;
let actual: Buffer;
try {
actual = await new Promise<Buffer>((resolve, reject) => {
scrypt(
password.normalize("NFKC"),
salt,
expected.length,
{ N: Number(rawN), r: Number(rawR), p: Number(rawP), maxmem: 64 * 1024 * 1024 },
(error, key) => error ? reject(error) : resolve(key),
);
});
} catch {
return false;
}
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
package main
import (
"crypto/sha256"
"crypto/subtle"
"fmt"
)
func ConstantTimeEqual(a, b string) bool {
left := sha256.Sum256([]byte(a))
right := sha256.Sum256([]byte(b))
return subtle.ConstantTimeCompare(left[:], right[:]) == 1
}
func main() {
secret := "s3cr3t-token"
fmt.Println(ConstantTimeEqual(secret, "s3cr3t-token"))
fmt.Println(ConstantTimeEqual(secret, "s3cr3t-tokeX"))
fmt.Println(ConstantTimeEqual(secret, "short"))
}