38.7 账号安全 (撞库 / 暴力破解 / 防盗号 / 验证码)

一句话

账号安全的目标不是“密码足够复杂”,而是让撞库与爆破的成本高于收益,同时不让防御手段本身变成新的攻击面——锁死账号是 DoS,短信验证码是话费炸弹,MFA 才是撞库真正的终结者。

三种攻击,三种节奏

攻击 攻击者手上有什么 打谁 节奏特征 靠什么防
暴力破解 没有凭据 一个账号 高频、集中、可被限流 锁定/退避 + 强密码 + 验证码
撞库 别处泄露的“账号-密码”对 大量账号 低频、每账号一次、IP 分散 MFA + 设备指纹 + 泄露库比对
盗号(ATO) 钓鱼、木马、会话劫持 高价值账号 不猜密码,直接拿会话 会话保护 + 行为检测 + 二次验证

关键在于:撞库每个账号只试一次。所有“按账号”的阈值(5 次锁定、10 次限流)对它完全无效——它赌的是“你在别处用了同一个密码”。

数字一:爆破一个账号要多久

36 字符集(小写 + 数字),期望尝试次数 = 密码空间 ÷ 2:

密码长度 空间 10 次/秒(在线) 1000 次/秒(分布式)
6 位 2.18e9 3.5 年 12.6 天
8 位 2.82e12 4472 年 44.7 年
10 位 3.66e15 580 万年 6 万年

结论:

  • 8 位随机密码在线爆破不可行,但 6 位在分布式爆破下 13 天就破——短密码必须配合限流与验证码。
  • 真正危险的不是“在线猜”,而是离线拖库:拿到哈希后可以按每秒十亿次的速度跑,长度和算法(bcrypt/argon2)才是防线。
  • 因此“密码复杂度策略”主要防的是弱口令字典,不是算力;撞库绕过复杂度策略,因为它用的是真实存在的密码。

数字二:锁定策略是一种 DoS 武器

“连续 5 次失败锁定账号”看起来稳妥,但攻击者可以批量触发:

锁定 1,000 个账号 → 5,000 次错误尝试 → 100 请求/秒 → 50 秒
锁定 10,000 个账号 → 50,000 次错误尝试 → 8 分钟

8 分钟就能让一万个真实用户登不进来(而且他们会反复尝试、打爆客服)。更糟的是“永久锁定/必须人工解锁”——攻击者用 5 次请求换你一次工单。

策略 抗爆破 抗 DoS 体验 结论
永久锁定 强 极差 差 不要用
固定 30 分钟锁定 强 差 差 慎用
指数退避(1s/2s/4s…上限 1h) 强 好 可接受 推荐
验证码挑战(失败 N 次后) 强 好 中 推荐
只监控不阻断 弱 最好 好 仅适合低风险场景

🔑 锁定的正确形态是渐进式:失败次数越多、退避越长,但不把账号锁死;同时按“账号 + IP + 设备”三维计数,避免攻击者用少量账号反向锁死大量用户。

数字三:撞库为什么难防

100 万条泄露凭据、密码复用成功率 1.5%(保守估计)→ 15,000 个账号被接管。

分摊窗口 每小时尝试 每小时成功 检测难度
7 天 5,952 89 基线约 20 次/小时新设备成功 → 4.5 倍,可发现
30 天 1,389 21 约 1.04 倍,淹没在噪声里

按维度看检出率:

限流维度 攻击者分布 检出率
按账号(5 次/分钟) 每账号只试 1 次 0
按 IP(20 次/分钟) 1 万个代理 IP,每 IP 每小时 0.6 次 0
按设备指纹 每次换 UA/指纹 低
全局“新设备登录成功率” 需要基线 + 关联分析 中
MFA 成功率高但拿不到第二因子 高

MFA 覆盖率对结果的削减:

MFA 覆盖率 剩余被接管账号
0% 15,000
50% 7,500
90% 1,500
99% 150

🔑 撞库没有“阈值解”,只有“提高单次成功率的上限”:MFA 把“知道密码”变成“知道密码 + 持有设备”,一次撞库的收益从 1.5% 掉到接近 0。

验证码:按风险触发,而不是每次都要

类型 安全性 体验 成本 短板
无感(行为/设备指纹) 中 最好 低 可被养号绕过
滑块/点选 中 中 低 打码平台可破
图形字符 低 差 低 OCR 与打码平台
短信验证码 中 差 每条几分钱 SIM swap、拦截、轰炸
邮箱验证码 中 中 低 邮箱被盗则失效
TOTP(App) 高 中 0 需要用户安装
Passkey/WebAuthn 最高 好(可免密) 0 设备生态与兼容性

四个必须算的数字:

  • 位数:4 位验证码空间 1 万,限 5 次尝试,猜中概率 5e-4;6 位是 5e-6——差 200 倍。位数不够时,限流必须更严。
  • 轰炸成本:攻击者用脚本触发 10 万条短信,按 $0.04/条 = $4,000 的话费,账单算你的。必须按“手机号 + IP + 设备”三维限流,并加发送间隔与图形验证。
  • 有效期:5 分钟足够完成一次输入;越长越容易被截获重放。验证码必须一次性,用过即废。
  • 重放:验证码要和“这次会话/这个手机号/这个用途”绑定,不能一个码用于登录又用于改密。

什么时候该加验证码:登录失败 ≥3 次、新设备 + 异地、注册、找回密码、改绑手机号、提现。不该加:每次正常登录(赶走用户)、每次查看(无风险)。

防盗号:登录成功之后才是战场

拿到密码只是开始,攻击者随后会做四件事:

动作 攻击手法 防御
保持登录 会话劫持、token 窃取 HttpOnly/Secure/SameSite、短 TTL、绑定设备指纹、改密后 token_version+1 全局失效
改密/换绑 拿到会话后直接改 敏感操作二次验证(旧密码/短信/TOTP)、改绑冷静期、通知原手机号
转移资产 提现、下单、发券 大额二次确认、限额、风控名单、延迟到账
找回账号 社工客服、伪造身份 客服流程与权限、录音、人工复核、不可仅凭“身份证照片”

两个可量化的检测信号:

  • 不可能旅行:10 分钟内出现在 8000 公里外 → 速度 48,000 km/h,是客机巡航速度的 53 倍。阈值一般取 900–1000 km/h,超过就要求二次验证。
  • 新设备 + 首次成功:撞库最典型的特征,按“账号维度”统计“从未成功过的设备首次成功”,比按 IP 更稳。

分层防御:一张表

层 手段 挡住 挡不住
凭据 长度 + 弱口令库 + 泄露密码比对 弱密码、已泄露密码 唯一但被钓鱼的强密码
频率 指数退避、三维限流 爆破、脚本 慢速分布式撞库
挑战 风险触发验证码、MFA 自动化、撞库 实时钓鱼 OTP、SIM swap
会话 短 TTL、设备绑定、HttpOnly 会话劫持、XSS 窃取 终端被完全控制
行为 不可能旅行、新设备、操作序列 盗号后的异常 攻击者耐心模仿
流程 客服复核、冷静期、通知 社工、内部滥用 内部人员串通

可运行的账号安全账本

package main

import (
	"fmt"
	"math"
)

func human(sec float64) string {
	switch {
	case sec < 60:
		return fmt.Sprintf("%.0f 秒", sec)
	case sec < 3600:
		return fmt.Sprintf("%.0f 分钟", sec/60)
	case sec < 86400:
		return fmt.Sprintf("%.1f 小时", sec/3600)
	case sec < 86400*365:
		return fmt.Sprintf("%.1f 天", sec/86400)
	default:
		years := sec / 86400 / 365
		if years >= 10000 {
			return fmt.Sprintf("%.0f 万年", years/10000)
		}
		return fmt.Sprintf("%.1f 年", years)
	}
}

func main() {
	// ① 在线爆破:36 字符集(小写+数字),期望尝试 = 空间 / 2
	fmt.Println("① 密码空间与爆破时间(36 字符集,期望尝试 = 空间/2)")
	for _, n := range []int{6, 8, 10} {
		space := math.Pow(36, float64(n))
		for _, rate := range []float64{10, 1000} {
			fmt.Printf("  %2d 位密码:空间 %.2e,%4.0f 次/秒 → %s\n",
				n, space, rate, human(space/2/rate))
		}
	}

	// ② 锁定策略被当成 DoS 武器
	const attemptsPerAccount = 5
	for _, accounts := range []int{1000, 10000} {
		req := float64(accounts * attemptsPerAccount)
		fmt.Printf("② 锁定 %d 个账号:需要 %.0f 次错误尝试,100 请求/秒 → %s\n",
			accounts, req, human(req/100))
	}

	// ③ 撞库:慢速、每账号只试一次,绕开所有“按账号”的阈值
	leaked, reuseRate := 1_000_000.0, 0.015
	days := 7.0
	success := leaked * reuseRate
	perHour := leaked / days / 24
	fmt.Printf("③ 撞库:%.0f 万条凭据、复用成功率 %.1f%% → %.0f 个账号被接管\n",
		leaked/10000, reuseRate*100, success)
	fmt.Printf("   分摊到 %.0f 天:每小时 %.0f 次尝试、%.0f 次成功;每个账号只试 1 次 → 按账号限流检出 0\n",
		days, perHour, perHour*reuseRate)
	fmt.Printf("   1 万个代理 IP 分摊:每 IP 每小时 %.2f 次 → 按 IP 限流也检出 0\n", perHour/10000)
	fmt.Printf("   分摊到 7 天:每小时 %.0f 次新设备成功 vs 基线 20 次(%.1f 倍)→ 全局异常可检出\n",
		perHour*reuseRate, perHour*reuseRate/20)
	slow := leaked / 30 / 24
	fmt.Printf("   分摊到 30 天:每小时 %.0f 次成功 vs 基线 20 次(%.2f 倍)→ 淹没在噪声里,只能靠“失败率/设备指纹”关联\n",
		slow*reuseRate, slow*reuseRate/20)

	// ④ 验证码:位数决定穷举难度
	for _, digits := range []int{4, 6} {
		space := math.Pow(10, float64(digits))
		fmt.Printf("④ %d 位验证码:空间 %.0f,限 5 次尝试 → 猜中概率 %.1e\n",
			digits, space, 5/space)
	}
	const smsCost = 0.04
	fmt.Printf("   短信轰炸:10 万条 × $%.2f = $%.0f;按手机号 + IP + 设备三维限流才能压住\n",
		smsCost, 100000*smsCost)

	// ⑤ 不可能旅行
	distKm, minutes := 8000.0, 10.0
	speed := distKm / (minutes / 60)
	fmt.Printf("⑤ 不可能旅行:%.0f km / %.0f 分钟 = %.0f km/h,是客机巡航速度的 %.0f 倍\n",
		distKm, minutes, speed, speed/900)

	// ⑥ MFA 覆盖率对撞库成功率的削减
	for _, coverage := range []float64{0.5, 0.9, 0.99} {
		fmt.Printf("⑥ MFA 覆盖率 %.0f%% → 撞库成功从 %.0f 降到 %.0f\n",
			coverage*100, success, success*(1-coverage))
	}
}

输出:

① 密码空间与爆破时间(36 字符集,期望尝试 = 空间/2)
   6 位密码:空间 2.18e+09,  10 次/秒 → 3.5 年
   6 位密码:空间 2.18e+09,1000 次/秒 → 12.6 天
   8 位密码:空间 2.82e+12,  10 次/秒 → 4472.8 年
   8 位密码:空间 2.82e+12,1000 次/秒 → 44.7 年
  10 位密码:空间 3.66e+15,  10 次/秒 → 580 万年
  10 位密码:空间 3.66e+15,1000 次/秒 → 6 万年
② 锁定 1000 个账号:需要 5000 次错误尝试,100 请求/秒 → 50 秒
② 锁定 10000 个账号:需要 50000 次错误尝试,100 请求/秒 → 8 分钟
③ 撞库:100 万条凭据、复用成功率 1.5% → 15000 个账号被接管
   分摊到 7 天:每小时 5952 次尝试、89 次成功;每个账号只试 1 次 → 按账号限流检出 0
   1 万个代理 IP 分摊:每 IP 每小时 0.60 次 → 按 IP 限流也检出 0
   分摊到 7 天:每小时 89 次新设备成功 vs 基线 20 次(4.5 倍)→ 全局异常可检出
   分摊到 30 天:每小时 21 次成功 vs 基线 20 次(1.04 倍)→ 淹没在噪声里,只能靠“失败率/设备指纹”关联
④ 4 位验证码:空间 10000,限 5 次尝试 → 猜中概率 5.0e-04
④ 6 位验证码:空间 1000000,限 5 次尝试 → 猜中概率 5.0e-06
   短信轰炸:10 万条 × $0.04 = $4000;按手机号 + IP + 设备三维限流才能压住
⑤ 不可能旅行:8000 km / 10 分钟 = 48000 km/h,是客机巡航速度的 53 倍
⑥ MFA 覆盖率 50% → 撞库成功从 15000 降到 7500
⑥ MFA 覆盖率 90% → 撞库成功从 15000 降到 1500
⑥ MFA 覆盖率 99% → 撞库成功从 15000 降到 150

参数怎么给

场景 策略
普通登录 指数退避(失败 3 次后 1s、2s、4s…上限 1h)+ 新设备二次验证
注册 图形/滑块 + 手机号/IP 限流 + 一次性验证码
找回密码 邮箱/短信二选一 + 风控评分;不要只凭“安全问题”
改密/换绑/提现 MFA 二次验证 + 冷静期 + 原手机号通知
高价值账号(管理员/资金) Passkey/硬件 key,禁止仅密码登录
内部客服后台 最小权限 + 全程审计 + 双人复核

反模式

反模式 后果
永久锁定账号 攻击者 5 次请求锁死一个用户,客服被打爆
只按账号限流 撞库每账号一次,检出率为 0
只按 IP 限流 代理池分摊后每 IP 每小时不到 1 次
验证码加在每次登录 用户流失,且打码平台照样破
4 位短信码不严限流 猜中概率 5e-4,10 万次尝试就能中 50 次
验证码可重复使用 截获后可重放
改密后不失效旧会话 攻击者保持登录
只凭身份证照片找回账号 社工与证件伪造直接接管
只防登录、不防操作 登录正常,提现/改绑被滥用
密码复杂度当唯一防线 撞库用的就是“符合复杂度”的真实密码

自检

Q1 每账号“连续 5 次失败锁定 30 分钟”,为什么对撞库完全无效?该改什么?

▸ 答案:撞库对每个账号只尝试 1 次(用别处泄露的真实密码),永远触发不了“连续 5 次失败”的阈值;把 100 万条尝试分摊到 7 天、1 万个代理 IP 后,每 IP 每小时不到 1 次,按 IP 的限流也看不见。真正有效的是提高单次尝试的成本:MFA(把成功率从 1.5% 压到接近 0)、设备指纹与可信设备、泄露密码库比对(命中即强制改密/二次验证),以及“新设备首次登录成功率”这类全局统计异常检测。

Q2 为什么“永久锁定 / 必须人工解锁”是危险设计?用数字说明。

▸ 答案:锁定是攻击者可以免费触发的副作用。5 次错误尝试就能锁死一个账号,1 万个账号只需要 5 万次请求——在 100 请求/秒下是 8 分钟。攻击者用极低成本制造“大面积无法登录 + 客服工单洪峰”,本质是用你的防御机制打你。正确做法是渐进退避(1s/2s/4s…上限 1h)、按三维计数、以及失败多次后改用验证码挑战,而不是把账号锁死。

Q3 6 位短信验证码安全吗?它的真正短板是什么?

▸ 答案:6 位空间 100 万,限 5 次尝试时猜中概率 5e-6,暴力猜解不是短板。短板在分发渠道:SIM swap(补卡)、短信拦截/木马、以及短信轰炸(10 万条 × $0.04 = $4,000 的话费)。另外运营商短信可能延迟,用户会重复点击“发送”,进一步放大成本。所以短信验证码应作为中等级别因子,高价值操作改用 TOTP 或 Passkey;并对发送做手机号 + IP + 设备三维限流、一次性使用、5 分钟过期。

Q4 MFA 覆盖率 90% 时,撞库的 15,000 个成功账号会降到多少?为什么不是 0?

▸ 答案:降到约 1,500(15,000 × 10%)。不是 0 的原因有三:① 剩下 10% 没开 MFA 的用户仍然可被接管;② 开了 MFA 的用户也可能被实时钓鱼(把 OTP 骗到手)或 SIM swap;③ “MFA 疲劳轰炸”(反复推送直到用户点同意)在推送式 MFA 上有效。所以还要补:号码匹配(number matching)、防疲劳限速、以及 Passkey 这类抗钓鱼因子。

Q5 “不可能旅行”的阈值怎么定?定得太严/太松分别有什么后果?

▸ 答案:阈值取“商用客机巡航速度”量级,约 900–1000 km/h;两地距离 ÷ 时间间隔 超过阈值就判定异常(例:10 分钟 8000 公里 = 48,000 km/h,是阈值的 53 倍)。定太严会误伤:VPN 出口跳变、运营商 NAT、用户真的坐飞机(登机前登录一次、落地后登录一次,时间差覆盖飞行时间则正常);定太松则形同虚设。工程上通常配合“可信设备/常用地点”降级处理:异常但设备可信 → 只要求二次验证,不直接封号。

跨篇相关:认证协议(Session / JWT / OAuth2 / OIDC)见 38.2 认证 (Session / JWT / OAuth2 / OIDC);授权与权限模型见 38.3 授权 (RBAC / ABAC / 权限模型);验证码与登录接口的注入/CSRF 风险见 38.6 常见攻击;登录态与令牌生命周期见 49.5 登录态与令牌生命周期;风控侧的规则与名单见 68.4 名单与画像(待补)。