适用场景
时序攻击防御针对的是「比较逻辑泄露信息」这一类隐蔽缺陷,适用于所有在服务端做字符串比对的认证与校验环节:登录口令验证、API Key 与签名校验、找回密码 Token 校验、一次性验证码比对、HMAC 签名验签、以及激活码、优惠码、邀请码等业务校验。这类接口的代码通常写作 if (input == secret),逻辑上完全正确,却在时间维度上暴露了秘密值的前缀。
与 XSS、注入等漏洞不同,时序攻击不产生异常请求特征,日志中看到的只是正常的登录或验签请求,常规 WAF 规则难以拦截。当目标处于同一数据中心、内网或就近 CDN 节点时,网络抖动可被大量采样平均掉,逐字节推断密钥在工程上是可行的。因此对持有高价值凭据的接口,必须从实现层面改为恒定时间比较。
前置条件
- 可读取或修改认证、验签相关源码(PHP / Python / Java / Node.js / Go 任一栈);
- 具备可复现的测试环境,能够对同一接口发送大量请求并统计响应耗时;
- Python 3.8 以上(使用
time.perf_counter与statistics做采样分析); - 服务端具备基本的限流能力(Nginx
limit_req或应用层令牌桶),用于降低采样效率; - 生产环境关闭详细错误回显,避免响应体差异先于时间差异泄露信息。
原理说明
字符串比较在多数语言中采用「逐字节比较、一旦不等立即返回」的短路实现。设正确密钥为 s3cr3t-key,攻击者提交 a 时第 1 个字节即失败,提交 s 时第 1 个字节通过、第 2 个字节失败,后者多执行了一次比较,耗时略高。单次差异通常在纳秒到微秒量级,远小于网络抖动,但通过以下手段可被放大:
- 大量采样:同一前缀重复数千次,取中位数或截尾均值,抑制抖动;
- 统计检验:对不同候选字节的耗时分布做 Welch t 检验,判断两组是否存在显著差异;
- 放大比较代价:若被比较的字符串很长(如签名、长 Token),差异随长度线性放大,更易观测。
防御原则只有一条:比较过程的执行时间必须与「是否匹配」以及与「匹配到第几个字节」无关。工程实现上即使用语言内置的恒定时间比较函数,并对「用户不存在」与「用户存在但密码错误」两类分支做等代价处理。
操作步骤
第一步:用采样脚本定位可疑比较点
先做一次自检,量化本地比较函数的时间差异,确认攻击面真实存在:
import statistics, time
SECRET = "s3cr3t-key-2026"
def naive_compare(a, b):
if len(a) != len(b):
return False
for i in range(len(a)):
if a[i] != b[i]: # 短路返回,泄露首个不匹配位置
return False
return True
def bench(guess, rounds=200000):
t = []
for _ in range(rounds):
s = time.perf_counter_ns()
naive_compare(guess, SECRET)
t.append(time.perf_counter_ns() - s)
return statistics.median(t)
print("全错 :", bench("a")) # 第 1 字节即失败
print("前缀 1 :", bench("s"))
print("前缀 3 :", bench("s3c"))
print("前缀 6 :", bench("s3cr3t")) # 越接近越慢
print("全对 :", bench(SECRET))
若耗时随前缀长度递增呈单调趋势,则说明该实现存在时序泄露。真实攻击中可对远端接口重复此逻辑,用请求往返时间代替本地计时。
第二步:替换为恒定时间比较函数
各语言均有官方提供的恒定时间比较实现,直接替换即可,不要自行编写循环:
# --- Python ---
import hmac, secrets
if hmac.compare_digest(user_token, stored_token):
grant_access()
# hmac.compare_digest 不会因首个不同字节而提前返回;
# secrets.compare_digest 与之等价,二者互为别名。
# --- PHP ---
if (hash_equals($storedToken, $inputToken)) {
grant_access();
}
# 注意:hash_equals 要求两个参数均为字符串且长度可比,
# 不可用于用户可控长度差异极大的场景(需先做哈希再比较)。
# --- Java ---
import java.security.MessageDigest;
if (MessageDigest.isEqual(provided.getBytes(StandardCharsets.UTF_8),
secret.getBytes(StandardCharsets.UTF_8))) {
grantAccess();
}
# --- Node.js ---
import { timingSafeEqual } from "node:crypto";
const a = Buffer.from(provided), b = Buffer.from(secret);
const ok = a.length === b.length && timingSafeEqual(a, b);
# --- Go ---
import "crypto/subtle"
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) == 1 {
grantAccess()
}
三个必须注意的细节:一是 Node.js 的 timingSafeEqual 在长度不同时会直接抛出异常,需先做长度判断,但长度判断本身也会泄露长度信息,因此更稳妥的做法是先对输入做定长哈希(如 SHA-256)再比较哈希值;二是 Java 的 MessageDigest.isEqual 在 6 及以上版本已实现恒定时间语义,旧版本需自行封装;三是恒定时间比较解决的是「比较环节」的泄露,若上游已在别处泄露长度或存在结构差异,需一并处理。
第三步:统一各类分支的执行代价
很多时序泄露并不来自字符串比较,而来自「用户不存在」与「密码错误」两条分支的耗时差异。攻击者可据此枚举有效账号,或判断 Token 前缀是否正确:
# 反面示例:用户不存在时直接返回,跳过了哈希计算
def login_bad(username, password):
row = db.query("SELECT hash FROM users WHERE name=%s", username)
if not row:
return None # 分支 A:极快返回
return check_password(password, row.hash) # 分支 B:执行 bcrypt,慢得多
# 正确示例:对不存在的用户也执行一次等代价的哈希计算
DUMMY_HASH = "$2b$12$" + "x" * 53 # 与服务端 cost 一致的占位哈希
def login_ok(username, password):
row = db.query("SELECT hash FROM users WHERE name=%s", username)
stored = row.hash if row else DUMMY_HASH
matched = bcrypt.checkpw(password.encode(), stored.encode())
return username if (row and matched) else None
除哈希分支外,还应统一以下三类处理的代价:
- 响应体与状态码一致:无论何种失败原因,统一返回
401与同一段文案,不区分「账号不存在」与「密码错误」; - 日志写入路径一致:失败日志的同步写盘顺序不应因分支不同而差异明显,必要时统一改为异步队列;
- 是否触发二次校验一致:如短信验证、图形验证码的触发条件不应成为账号存在性的旁路通道。
第四步:验证码与 Token 校验改用「存哈希、做强比较」
对于 6 位数字验证码这类短空间凭据,恒定时间比较只是一部分,更重要的是限制尝试次数并避免明文比对:
# 存储侧:只存验证码的 HMAC 摘要,带用户维度盐值
code_hash = hmac.new(server_secret, f"{user_id}:{code}".encode(), "sha256").hexdigest()
# 校验侧:错误次数与比较结果解耦,恒定时间比较 + 原子自增计数
ok = hmac.compare_digest(code_hash, hmac.new(
server_secret, f"{user_id}:{input_code}".encode(), "sha256").hexdigest())
attempts = redis.incr(f"otp:try:{user_id}")
redis.expire(f"otp:try:{user_id}", 600)
if attempts > 5:
raise TooManyAttempts() # 与 ok 是否为真无关,恒定触发
if not ok:
raise InvalidCode()
验证码有效窗口建议不超过 5 分钟,校验成功后立刻删除服务端存储值,并绑定一次性 nonce 防止重放。
第五步:入口层限流,压低攻击者的采样效率
http {
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=verify:10m rate=20r/m;
server {
location = /api/login {
limit_req zone=login burst=3 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8080;
}
location = /api/verify {
limit_req zone=verify burst=10 nodelay;
proxy_pass http://127.0.0.1:8080;
}
}
}
时序攻击的可利用性直接取决于采样次数。把关键验证接口限制到每分钟个位数请求,配合账号维度锁定与 IP 维度限速,可把统计检验所需的样本量推到不现实的水平。
配置验证
import statistics, time, urllib.request
URL = "https://www.example.com/api/verify"
HEAD = {"Content-Type": "application/json"}
def probe(token, rounds=300):
t = []
for _ in range(rounds):
body = ('{"token":"%s"}' % token).encode()
s = time.perf_counter_ns()
try:
urllib.request.urlopen(urllib.request.Request(URL, body, HEAD), timeout=10).read()
except Exception:
pass
t.append(time.perf_counter_ns() - s)
return statistics.median(t) / 1e6, statistics.stdev(t) / 1e6
for guess in ["a", "s", "s3c", "s3cr3t", "s3cr3t-key-2026"]:
med, sd = probe(guess)
print(f"{guess:<18} 中位数={med:.3f}ms 标准差={sd:.3f}ms")
整改后的期望结果是:不同前缀的中位数差异落在标准差范围内,不再呈现单调递增趋势。若仍有明显台阶,需回查是否还有别的分支(如数据库索引命中差异、缓存命中差异、日志级别差异)导致耗时不同。建议把该脚本固化为回归用例,每次修改认证模块后重跑一次。
此外应顺带核对两点:一是比较失败与成功时响应体长度是否一致(长度差异比时间差异更易利用);二是 429 限流与 401 失败的响应是否结构一致,避免把限流阈值当成枚举刻度。
常见问题(FAQ)
Q1:密码已经是 bcrypt 哈希,比较哈希值时还需要恒定时间比较吗?
需要。bcrypt 只保证「从明文到哈希」的过程计算代价高且带随机盐,并不改变「哈希串如何比对」的实现语义。如果比对环节仍是逐字节短路比较,攻击者虽然无法从时间反推明文口令,却可能反推出存储哈希的字节前缀;在 HMAC 验签、API Key 比对、一次性 Token 校验这类以秘密值本身作为凭据的场景中,前缀泄露即等同于凭据部分泄露,风险更直接。统一使用恒定时间比较函数是最省事的做法。
Q2:网络抖动这么大,公网上真的能利用时序差异吗?
可行性取决于三个条件:样本量、差异幅度、与目标的网络距离。当目标部署在与攻击者同一可用区、同一机房或就近 CDN 节点时,往返抖动可降到几十微秒量级,而长 Token 或长签名比较的差异可达数微秒,此时数千到数万次采样配合中位数与 t 检验即可稳定区分;若跨越公网远距离访问,抖动上升到毫秒级,单字节差异难以观测,但攻击者仍可通过「放大被比较字符串长度」「先本地复现同类实现」等方式提升成功率。结论是:不要以「公网噪声大」为由降低修复优先级,尤其是签名验签与 API Key 校验接口。
Q3:改成恒定时间比较后,为什么测出来仍有微小差异?
恒定时间比较只约束比较环节本身,无法消除上游差异。常见残余来源包括:数据库按主键命中与否的查找差异、Redis 命中与未命的网络往返差异、对象分配与 GC 时机、日志落盘同步开销、以及 CDN 与负载均衡的路由抖动。排查方法是在被测代码路径上打点计时,定位真正的耗时占比,再针对性补齐;若残余差异已明显小于网络抖动且不随输入前缀单调变化,则视为可接受。
总结
时序攻击防御的落地要点可归纳为四条:比较一律使用语言内置的恒定时间函数(Python 的 hmac.compare_digest、PHP 的 hash_equals、Java 的 MessageDigest.isEqual、Node.js 的 timingSafeEqual、Go 的 subtle.ConstantTimeCompare);对不存在的账号执行等代价的占位哈希;错误响应在文案、状态码、长度上完全统一;入口层对验证接口限流,把采样成本抬到不现实的高度。这四条叠加后,比较逻辑不再承载任何可用于推断秘密值的时间信息,时序侧信道随之关闭。