时序攻击防御实战:恒定时间比较加固

适用场景

时序攻击防御针对的是「比较逻辑泄露信息」这一类隐蔽缺陷,适用于所有在服务端做字符串比对的认证与校验环节:登录口令验证、API Key 与签名校验、找回密码 Token 校验、一次性验证码比对、HMAC 签名验签、以及激活码、优惠码、邀请码等业务校验。这类接口的代码通常写作 if (input == secret),逻辑上完全正确,却在时间维度上暴露了秘密值的前缀。

与 XSS、注入等漏洞不同,时序攻击不产生异常请求特征,日志中看到的只是正常的登录或验签请求,常规 WAF 规则难以拦截。当目标处于同一数据中心、内网或就近 CDN 节点时,网络抖动可被大量采样平均掉,逐字节推断密钥在工程上是可行的。因此对持有高价值凭据的接口,必须从实现层面改为恒定时间比较。

前置条件

  • 可读取或修改认证、验签相关源码(PHP / Python / Java / Node.js / Go 任一栈);
  • 具备可复现的测试环境,能够对同一接口发送大量请求并统计响应耗时;
  • Python 3.8 以上(使用 time.perf_counterstatistics 做采样分析);
  • 服务端具备基本的限流能力(Nginx limit_req 或应用层令牌桶),用于降低采样效率;
  • 生产环境关闭详细错误回显,避免响应体差异先于时间差异泄露信息。

原理说明

字符串比较在多数语言中采用「逐字节比较、一旦不等立即返回」的短路实现。设正确密钥为 s3cr3t-key,攻击者提交 a 时第 1 个字节即失败,提交 s 时第 1 个字节通过、第 2 个字节失败,后者多执行了一次比较,耗时略高。单次差异通常在纳秒到微秒量级,远小于网络抖动,但通过以下手段可被放大:

  1. 大量采样:同一前缀重复数千次,取中位数或截尾均值,抑制抖动;
  2. 统计检验:对不同候选字节的耗时分布做 Welch t 检验,判断两组是否存在显著差异;
  3. 放大比较代价:若被比较的字符串很长(如签名、长 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);对不存在的账号执行等代价的占位哈希错误响应在文案、状态码、长度上完全统一入口层对验证接口限流,把采样成本抬到不现实的高度。这四条叠加后,比较逻辑不再承载任何可用于推断秘密值的时间信息,时序侧信道随之关闭。