跳转到帖子

CTF 密码学实战:从 HMAC 签名串歧义定位密码重置越权

精选回复

发布于

背景

这类题常见于 CTF Web 与密码学交叉场景:服务端没有直接暴露密钥,也没有明显注入点,但密码重置链接使用自定义 HMAC 签名。问题往往不在 HMAC-SHA256 算法本身,而在签名前后的参数解析不一致。

本文以授权靶场中的密码重置接口为例。应用生成如下链接:

/reset?uid=1002&role=user&exp=1735689600&sig=...

网关验签时按查询参数首次出现的值计算签名,业务框架却按最后一次出现的值执行重置。攻击者不需要破解密钥,只需构造重复参数,即可能跨越普通用户与管理员之间的权限边界。

以下操作仅适用于 CTF、内部靶场或取得明确授权的渗透测试。生产验证应使用专门测试账号,避免实际修改高权限用户凭据。

问题定位

拿到样本链接后,先不要盲猜密钥。应分别确认四个问题:

  • 签名覆盖哪些字段,字段顺序是否固定;
  • URL 解码发生在验签前还是验签后;
  • 重复参数由网关、Web 框架和业务层分别取首值、末值还是数组;
  • 验签通过后,业务是否重新从原始查询串读取参数。

在测试账号上抓取一次合法重置请求,保留原始字节,不要只看代理工具解析后的参数表。可用 curl 发送重复参数,并用响应状态、重定向位置和审计日志判断各层行为:

curl -i --path-as-is \
  ' uid 是 1001,基本可以确认存在解析差异。若服务端可观测,建议同时记录原始 query string、验签后的规范化字符串和最终业务对象,三者必须逐项比对。

技术分析

假设网关采用如下逻辑:

pairs = parse_query_keep_first(raw_query)
message = pairs['uid'] + '|' + pairs['role'] + '|' + pairs['exp']
expected = HMAC_SHA256(secret, message)

业务层使用的框架却采用末值覆盖:

target_uid = request.args.get('uid')  # 某些框架或中间件配置下得到末值

于是查询串中同时存在 uid=1002 与 uid=1001 时,验签对象可能是 1002,实际执行对象却是 1001。这里没有发生密码学意义上的 HMAC 碰撞;漏洞根因是“认证对象”和“执行对象”不是同一个规范化数据结构。

还应测试编码层面的歧义,例如 uid、u%69d、分号分隔符、加号与空格、大小写字段名,以及 percent-decoding 次数。不过验证应逐项进行,避免一次混入多个变量后无法判断是哪一层产生差异。

操作步骤

  • 使用低权限测试账号申请一次合法重置链接,并记录服务端时间与过期时间。
  • 只增加一个同名参数,保持原签名和其他字段不变。
  • 观察验签结果、最终目标账号以及网关和应用日志中的参数值。
  • 交换重复参数顺序,判断验签层与业务层分别取首值还是末值。
  • 将目标改为另一个专用测试账号,验证是否真正跨越账号边界。
  • 修复后重复上述用例,预期结果应为请求在进入业务逻辑前被拒绝。

本地复现实验

下面的 Python 脚本只在本地演示解析差异。它使用固定测试密钥,不连接任何外部目标:

from urllib.parse import parse_qsl
import hmac
import hashlib

SECRET = b'ctf-lab-only-key'

def first_value_map(query):
    result = {}
    for key, value in parse_qsl(query, keep_blank_values=True):
        result.setdefault(key, value)
    return result

def last_value_map(query):
    result = {}
    for key, value in parse_qsl(query, keep_blank_values=True):
        result[key] = value
    return result

def sign(params):
    msg = f"{params['uid']}|{params['role']}|{params['exp']}".encode()
    return hmac.new(SECRET, msg, hashlib.sha256).hexdigest()

base = 'uid=1002&role=user&exp=1735689600'
sig = sign(first_value_map(base))
query = base + '&uid=1001&sig=' + sig

verified = first_value_map(query)
executed = last_value_map(query)
expected = sign(verified)

print('signature valid:', hmac.compare_digest(expected, verified['sig']))
print('verified uid:', verified['uid'])
print('executed uid:', executed['uid'])

预期输出中,签名校验为真,验签 uid 为 1002,业务执行 uid 为 1001。这就是需要修复的权限边界错位。

日志留痕与排查

不要只记录“验签失败”或“重置成功”。建议至少保留以下字段,并对敏感值做脱敏:

字段用途
request_id关联反向代理、应用和身份服务日志
raw_query_hash保留原始请求证据,避免直接落盘完整令牌
duplicate_keys记录发现的重复参数名
signed_subject验签数据结构中的账号标识
effective_subject业务最终使用的账号标识
signature_result验签结果与失败原因

检测规则可以关注:重置接口出现重复关键参数、signed_subject 与 effective_subject 不一致、同一 request_id 经多层解析后参数数量变化。不要在日志中保存完整 sig、重置令牌或明文新密码。

修复建议

  • 关键字段只允许出现一次。发现 uid、role、exp 或 sig 重复时直接返回 400,不做首值或末值兼容。
  • 只解析一次。验签与业务执行必须引用同一个不可变参数对象,禁止验签后重新读取原始请求。
  • 采用明确的规范化格式。优先对结构化载荷签名,例如固定字段的 JSON 或经过严格定义的二进制编码,而不是临时拼接字符串。
  • 将用途、主体和时效纳入签名,例如 purpose=password_reset、subject、issued_at、expires_at,并在服务端校验用途。
  • 使用 hmac.compare_digest 或等价常量时间比较函数,避免直接使用字符串相等比较。
  • 重置令牌应单次使用,并在服务端保存消费状态;高权限账号可增加二次确认或独立审批。
  • 网关与应用使用相同的 URL 解析规则,并通过回归测试覆盖重复键、空值、编码差异和超时令牌。

安全实现的核心不是换一个更强的摘要算法,而是先拒绝歧义,再对唯一、确定的业务对象签名:

pairs = strict_parse_query(raw_query)
reject_duplicate_keys(pairs, ['uid', 'role', 'exp', 'sig'])
payload = build_immutable_payload(pairs)
verify_hmac(payload)
reset_password(payload.uid)

总结

在 CTF 密码学题和真实授权测试中,看到 HMAC 不应立即转向爆破或碰撞。更有效的排查路径是检查规范化、编码顺序、重复参数和跨组件解析差异。验签对象与执行对象必须来自同一份无歧义数据;否则密码算法即使完全正确,仍可能被业务层的解析差异绕过。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意
靶场实验与攻防复盘示意图
靶场实验、复盘记录与攻防路径示意

这类问题的关键通常不在 HMAC 本身,而在“签名前的消息如何编码”。如果服务端直接拼接可变长字段,例如:

msg = username + email + timestamp
sig = HMAC(secret, msg)

那么不同参数可能映射到同一个字节串,例如 ("ab", "c") 与 ("a", "bc")。已有合法签名就可能被复用于另一组重置参数,最终造成账号绑定对象变化。定位时建议先抓取两次仅修改一个字段的请求,将 URL 解码、Base64 解码后的原始签名输入逐字节比较;同时检查参数解析是否存在重复键、空字段、大小写归一化和二次解码差异。

可以先写一个小脚本枚举等价拼接,确认是否确实存在碰撞,而不是误判为密钥或算法问题:

from itertools import product

target = b"[email protected]"
users = [b"a", b"ad", b"admin"]
emails = [b"[email protected]", b"example.com", b""]
times = [b"1700000000", b"com1700000000"]

for u, e, t in product(users, emails, times):
    if u + e + t == target:
        print(u, e, t)

修复不要只加一个分隔符,因为字段内容如果允许出现该字符,仍可能产生歧义。更稳妥的是长度前缀编码,或者使用字段顺序、字符集和序列化规则都固定的规范格式:

import hmac
import hashlib
import struct

def field(value: str) -> bytes:
    data = value.encode("utf-8")
    return struct.pack("!I", len(data)) + data

def reset_mac(key, user_id, expires, nonce):
    message = (
        field("password-reset-v1") +
        field(str(user_id)) +
        field(str(expires)) +
        field(nonce)
    )
    return hmac.new(key, message, hashlib.sha256).hexdigest()

def verify(expected, supplied):
    return hmac.compare_digest(expected, supplied)

重置令牌还应绑定稳定的内部 user_id,不要只绑定可变的用户名或邮箱;同时纳入用途标识、过期时间和一次性随机数。服务端校验成功后原子地消费 nonce,避免同一签名重放。排查日志时重点检索“同一签名对应多组参数”“同一 nonce 多次成功”“签发账号与最终重置账号不一致”,这几项通常能直接确认是否已经被利用。

另外要确保验签和执行业务使用的是同一份解析结果:不要验签时读取原始查询串,重置时再从框架参数对象取值。重复参数如 user=a&user=admin 在网关、框架和业务代码中可能分别采用首值或末值,也会形成类似的签名边界绕过。

创建帐户或登录后发表意见

最近浏览 0

  • 没有会员查看此页面。

SiteMap HACKHAT © 2026 All rights reserved.