跳转到帖子

CTF 密码学实战:AES-GCM Nonce 重用的授权验证、日志取证与权限边界修复

精选回复

发布于

背景与授权场景

在一次内部 CTF 与授权红队演练中,测试人员通过预设的社会工程入口获得了一个低权限测试账号。该账号只能访问普通工单,但前端会收到一个由内部网关签发的加密会话令牌。令牌采用 AES-256-GCM,字段包括用户标识、租户、角色和过期时间。

检查流量后发现,同一密钥版本下的多个令牌出现了相同的 96 位 Nonce。问题来自某个容器实例重启后计数器归零,导致 AES-GCM 重复使用相同的“密钥 + Nonce”组合。这不是单纯的 CTF 解题技巧:如果令牌中存在固定 JSON 结构或已知明文,攻击者可能恢复其他令牌的对应内容,并进一步影响权限边界。

验证范围应限定在演练账号、测试密钥和已批准的流量样本。确认机密性失效后即可停止,不应在生产环境构造高权限令牌或提交伪造请求。

问题定位

AES-GCM 的密文部分本质上使用计数器模式。相同密钥和 Nonce 会生成相同密钥流,因此:

C1 = P1 XOR KS
C2 = P2 XOR KS
C1 XOR C2 = P1 XOR P2

如果 P1 是已知明文,则可以计算出 P2 的对应字节:

P2 = C1 XOR C2 XOR P1

GCM 的 AAD 不会改变密文使用的计数器密钥流,因此即使两个令牌的 AAD 不同,只要密钥与 Nonce 相同,密文部分仍然存在上述关系。Tag 不应参与这一步异或分析。

定位时需要同时确认以下条件:

  • 算法确实为 AES-GCM,而不是名称相似的封装格式。
  • 两个样本属于相同 issuer、tenant 和 key id。
  • Nonce 完全一致,Base64URL 解码后的长度通常为 12 字节。
  • 比较的是 ciphertext,不要把认证 Tag 拼入密文。
  • 已知明文与密文的序列化方式一致,包括空格、字段顺序和 UTF-8 编码。

从日志中筛查重复 Nonce

假设加密服务输出 JSON Lines 审计日志,至少包含 issuer、kid、alg、iv、request_id 和 node_id。IV/Nonce 本身不是秘密,可以记录;不建议记录完整明文或完整令牌。

jq -r '
  select(.alg == "A256GCM") |
  [.issuer, .kid, .iv] |
  @tsv
' encrypt-audit.jsonl |
sort |
uniq -c |
awk '$1 > 1 {print}'

发现重复项后,不要只按 iv 分组。不同密钥下重复 Nonce 不会产生相同密钥流,应以 issuer、kid、iv 作为最小关联键;多租户系统还应加入 tenant 或 key namespace。

进一步关联 request_id、node_id 和进程启动时间。如果重复仅集中在某个节点重启之后,通常意味着计数器未持久化、随机数种子处理错误,或多个副本使用了相同节点前缀。

CTF 样本的最小化验证

下面脚本读取两个 JWE 风格样本,只分析 iv 和 ciphertext。known.bin 保存第一个样本已确认的明文,例如由测试账号自行生成、字段和值均已知的令牌载荷。脚本不会尝试生成认证 Tag,也不会向服务发送数据。

#!/usr/bin/env python3
import base64
import json
import sys


def b64u(value):
    value += '=' * (-len(value) % 4)
    return base64.urlsafe_b64decode(value)


def load_sample(path):
    with open(path, 'r', encoding='utf-8') as f:
        obj = json.load(f)
    return {
        'kid': obj['kid'],
        'iv': b64u(obj['iv']),
        'ct': b64u(obj['ciphertext'])
    }


if len(sys.argv) != 4:
    raise SystemExit('usage: gcm_nonce_check.py sample1.json sample2.json known.bin')

a = load_sample(sys.argv[1])
b = load_sample(sys.argv[2])
known = open(sys.argv[3], 'rb').read()

if a['kid'] != b['kid']:
    raise SystemExit('different kid: cannot assume the same AES key')
if a['iv'] != b['iv']:
    raise SystemExit('nonce differs: this sample does not demonstrate reuse')

size = min(len(a['ct']), len(b['ct']), len(known))
recovered = bytes(
    a['ct'][i] ^ b['ct'][i] ^ known[i]
    for i in range(size)
)

print('confirmed: same kid and nonce')
print('recovered bytes:', size)
print('hex:', recovered.hex())
print('text:', recovered.decode('utf-8', errors='replace'))

执行方式:

python3 gcm_nonce_check.py low_priv.json target_sample.json known.bin

如果恢复结果出现稳定的 JSON 前缀,例如用户字段、租户字段或角色字段,即可证明机密性边界已经失效。不要因为部分字节不可读就直接否定漏洞,常见原因包括随机 padding、压缩、不同字段长度或序列化顺序变化。

权限边界分析

边界需要验证的实际影响
低权限用户到高权限用户低权限用户是否能观察到同一密钥域下其他主体的密文样本
租户隔离不同租户是否共用 kid 和 AES 密钥,Nonce 去重是否仅在租户内执行
服务节点多个节点是否可能分配相同计数器区间或相同节点标识
重启与回滚容器重启、虚拟机快照回滚后计数器是否倒退
解密流程应用是否在认证 Tag 校验完成前解析或使用明文

Nonce 重用不仅影响机密性。GCM 的认证部分也依赖同一组密钥材料,多次重用可能削弱消息完整性。授权验证报告中可以说明存在进一步伪造风险,但通常不需要制作可被业务接受的高权限令牌来证明影响。

日志留痕与取证建议

加密服务应保留足以定位重复 Nonce、但不会泄露敏感明文的审计字段:

  • 算法、key id、key epoch 和密钥用途。
  • 完整 Nonce,或使用独立审计密钥计算的 Nonce 指纹。
  • issuer、tenant、service、node_id 和 process_start_id。
  • request_id、调用方身份、生成时间和加密结果。
  • 明文长度与密文长度,不记录明文本身。

检测规则应在写入时检查同一 kid 下的 Nonce 唯一性,而不是只依赖离线扫描。若存储使用关系数据库,可为 key_id 与 nonce 建立唯一约束;发生冲突时必须中止本次加密并告警,不能继续返回密文。

CREATE UNIQUE INDEX uniq_gcm_key_nonce
ON encryption_nonce_audit (key_id, nonce_hex);

应重点检索以下异常:节点重启后短时间内大量冲突、计数器突然下降、多个节点使用同一前缀、旧 key epoch 在轮换后仍持续签发,以及同一 request_id 被重复处理。

修复建议

  • 优先使用成熟密码库的 AEAD 接口,不自行拼接 CTR、GHASH 或 Tag。
  • 每个 AES-GCM 密钥下必须保证 Nonce 唯一。随机生成 96 位 Nonce 时使用操作系统 CSPRNG,并设置单密钥使用量上限与碰撞监控。
  • 高并发分布式服务可采用“唯一节点前缀 + 持久化单调计数器”,但必须确保节点前缀不重复、计数器不会因重启或快照回滚而倒退。
  • 密钥轮换时更换 key id 和 key epoch,旧密钥只允许解密,不再用于新加密。
  • 解密端必须先完成 Tag 验证,再解析 JSON、执行权限判断或写入缓存。流式 API 返回的未认证明文不能提前进入业务逻辑。
  • 将租户隔离落实到密钥域。不同租户不应只依赖载荷中的 tenant 字段进行逻辑隔离。
  • 漏洞处置后重新签发受影响时间窗口内的会话令牌,并评估日志中是否存在跨账号密文暴露。

复测标准

修复后的复测不应只确认“Nonce 看起来随机”。建议覆盖并发、重启、扩容、故障转移和快照恢复场景,在同一 key epoch 下持续采集加密事件,确认不存在重复值;同时验证旧密钥不能继续签发、Tag 错误时业务不会读取任何载荷字段。

这类题目的关键不在于跑出一段可读明文,而是把密码学缺陷映射到实际权限边界:谁能取得样本、哪些主体共用密钥、重复从何处产生、日志能否追溯,以及修复后如何证明唯一性约束真正生效。

网络流量与边界分析示意图
网络请求、日志与边界流量分析示意
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

这个题里建议把“GCM 完整性验证”和“业务授权”分开看。Nonce 重用导致的不只是明文关系泄露,认证能力也应视为失效;即使标签校验通过,也不能等同于调用者有权访问目标资源。

复现时先按 (key_id, nonce) 查重,而不是只按 nonce。日志至少保留时间、密钥版本、96-bit nonce、接口、租户/主体和校验结果,但不要记录密钥、明文或完整令牌。JSON 日志可以先这样筛:

jq -r '
  select(.crypto.alg == "AES-256-GCM")
  | [.crypto.key_id, .crypto.nonce_b64] | @tsv
' app.jsonl \
| sort | uniq -c | awk '$1 > 1'

发现重复后,再关联实例 ID、进程启动时间和部署事件。常见根因并不是随机数库本身,而是容器快照恢复、fork 后复制状态、计数器回滚,或者多实例共用同一个“启动前缀 + 本地计数器”。判断复用影响时可用已知样本验证:

# 同一 key 和 nonce 下,GCM 使用相同的 CTR 密钥流
# 因而 C1 xor C2 == P1 xor P2
assert xor(c1, c2) == xor(p1, p2)

解密链路应当先完成标签验证,再解析任何 claim;租户、资源、操作和协议版本最好放入 AAD,避免密文被跨接口或跨租户搬用:

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
import json

def decrypt_request(key, nonce, ciphertext_and_tag, tenant, resource, method):
    if len(nonce) != 12:
        raise ValueError("invalid GCM nonce length")

    aad = f"v1|{tenant}|{resource}|{method}".encode()
    try:
        plaintext = AESGCM(key).decrypt(nonce, ciphertext_and_tag, aad)
    except InvalidTag:
        raise PermissionError("authentication failed")

    # 只能在标签验证成功后解析
    claims = json.loads(plaintext)
    if claims.get("tenant") != tenant:
        raise PermissionError("tenant mismatch")
    if resource not in claims.get("resources", []):
        raise PermissionError("resource denied")
    return claims

Nonce 生成建议固定为 12 字节。随机方案可直接使用系统 CSPRNG;如果必须使用计数器,则要给每个实例分配不会重复的前缀,并保证计数器持久化、原子递增且不会因快照回滚:

import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

key = AESGCM.generate_key(bit_length=256)
aes = AESGCM(key)

nonce = os.urandom(12)
aad = b"v1|tenant-42|invoice-1001|read"
ciphertext_and_tag = aes.encrypt(nonce, b'{"scope":"invoice:read"}', aad)

修复时仅替换 Nonce 生成逻辑还不够。已经出现复用的密钥应按泄露处理:立即停止签发、轮换对应 key_id、使旧令牌失效,并回查复用窗口内的敏感操作。服务端还应限制允许的算法和密钥集合,不能让请求载荷里的 kid 任意选择密钥。

如果是 CTF 取证,最好把证据链整理成“重复的 key_id/nonce → 对应请求 → 部署或重启事件 → 越权结果”,并只在题目提供的流量和环境内验证。这样既能证明密码学缺陷,也能说明权限边界为什么会被突破。

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

最近浏览 0

  • 没有会员查看此页面。