← 中文资源

简体中文版本

撞库攻击

撞库把其他站点泄露的账号口令对重放到你的登录接口:只要用户复用密码就能命中,以机器人规模在住宅代理背后展开。

阅读英文原文

概念定义

数十亿泄露凭据在流通。攻击者把它们喷向每个热门登录口,口令复用带来小比例但真实数量的账号。流量形似“分布式正常用户各失败一次”,恰好落在按账号锁定与按 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"))
}