4. HMAC与数字签名:完整性验证与身份伪造检测

说到RBAC密钥越权检测,有个基础问题绕不开:你怎么知道消息是没被改过的?你怎么确定发消息的人就是声称的那个角色?

有人觉得,只要用了HTTPS就够了。嗯,我在项目里见过太多"外面安全里面裸奔"的情况——内部服务间调用根本不走TLS,全靠应用层自己扛。这时候,HMAC和数字签名就成了最后一道防线。

4.1 从哈希到HMAC:带上密钥的完整性校验

先聊哈希。SHA-256算出来的摘要,能证明数据没被篡改。但有个致命问题:谁都能算。

举个例子,攻击者把请求里的"role=admin"改成"role=user",再重新算一遍哈希附上去。接收方验哈希,发现对得上——因为攻击者也拿到了算法。单纯的哈希,防的是传输错误,防不了恶意篡改。

HMAC就是给哈希加了一把锁。

HMAC(Hash-based Message Authentication Code)在计算摘要时掺入了一个共享密钥。你想想看,没有密钥的人,就算知道算法,也算不出正确的MAC值。我在审计一个金融系统时发现,他们用HMAC-SHA256给内部API请求做签名,但密钥居然是"123456"写死在代码里……嗯,这个后面会讲怎么避免。

HMAC的计算过程(RFC 2104),核心就两步:

  1. 把密钥做两次异或填充(ipad/opad)
  2. 先算 (key ⊕ ipad) || message 的哈希,再算 (key ⊕ opad) || 上一步结果的哈希

说白了,就是两层哈希把密钥和安全地混进去了。简单,但有效。

# Python示例:用HMAC-SHA256给RBAC请求签名
import hmac
import hashlib
import base64

def sign_request(user_id, role, action, secret):
    message = f"{user_id}:{role}:{action}".encode('utf-8')
    sig = hmac.new(secret.encode('utf-8'), message, hashlib.sha256).digest()
    return base64.b64encode(sig).decode()

# 验证端
def verify_request(user_id, role, action, secret, received_sig):
    expected = sign_request(user_id, role, action, secret)
    # 注意:用hmac.compare_digest防时序攻击
    return hmac.compare_digest(expected, received_sig)
⚠️ 千万别自己写比较逻辑!
我曾经审计过一段代码,用 if expected == received_sig: 做验证。攻击者可以通过计时差异猜出签名。必须用 hmac.compare_digest 或类似的恒定时间比较函数。

4.2 数字签名:非对称场景下的身份锚定

HMAC有个天然缺陷:密钥必须共享。你和我都知道同一个secret。万一服务端被拖库,密钥泄露,攻击者就能伪造任意角色的请求。

数字签名用公私钥对解决了这个问题。私钥签名,公钥验签。私钥只有一方持有,服务器只需保存公钥。就算公钥被改,也伪造不了签名。

在RBAC场景里,我习惯这样设计:每个服务(或每个角色)生成一对RSA-2048或ECDSA密钥。服务A用私钥对请求内容签名,服务B拿服务A的公钥验签。这样即便B被攻破,攻击者也拿不到A的私钥,伪造不了A发来的越权请求。

# 用cryptography库演示数字签名(简化版)
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives import serialization

# 服务A:签名
def sign_message(private_key_pem, message):
    private_key = serialization.load_pem_private_key(private_key_pem, password=None)
    signature = private_key.sign(
        message.encode(),
        padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
        hashes.SHA256()
    )
    return signature.hex()

# 服务B:验签
def verify_message(public_key_pem, message, signature_hex):
    public_key = serialization.load_pem_public_key(public_key_pem)
    try:
        public_key.verify(
            bytes.fromhex(signature_hex),
            message.encode(),
            padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
            hashes.SHA256()
        )
        return True
    except Exception:
        return False

4.3 在RBAC越权检测中的具体应用

我在实际审计中总结了一套判断流程:

  1. 查签名覆盖范围——签名要覆盖关键字段:user_id、role、action、timestamp。漏掉一个就可能被篡改。
  2. 查密钥存储——千万不要硬编码。我在某云厂商的日志里见过用户代码里直接写secret,git历史都翻得到。
  3. 查重放防护——只有签名不够,必须要有timestamp + nonce,或者用序列号防重放。
  4. 查验证失败处理——签名失败不能只打日志然后放行。我见过一个项目,验签抛异常就直接返回200,因为开发图方便……
检测项 HMAC 数字签名
密钥管理复杂度 低(共享密钥) 中(需管理公私钥对)
抗伪造能力 高(密钥不泄露时) 极高(私钥不外泄)
典型越权场景 内部服务间调用 跨信任域、第三方调用
防重放推荐做法 timestamp+HMAC+nonce timestamp+签名+序列号
性能 快(对称哈希) 慢(非对称运算)

4.4 身份伪造检测:不只是验签名

拿到签名,验过了,就万事大吉?

不对。我踩过一个坑:有一次审计发现,某服务虽然验了签名,但签名里只包含action,没包含user_id。攻击者只要截获一个签名(比如"delete_user"的签名),就能把自己的请求里的user_id改成别人,然后用同一个签名去删除其他用户。

这就是签名截断重放攻击。签名必须绑定到具体的主体和资源,即:谁(user_id)对什么(resource_id)做了什么(action)。

🔑 防伪造核心原则
签名负载 = 用户身份 + 角色 + 操作 + 资源 + 时间戳
任何一项缺失,都可能被利用。

另外,在实际检测中我常用一个技巧:查看签名失败日志。如果你发现某个user_id的签名连续失败几次,很可能是有人在尝试伪造该用户的权限。可以联动告警,甚至临时锁定该账号。

4.5 本章知识体系总览

下面这张图总结了HMAC与数字签名在RBAC越权检测中的定位和关系:

RBAC密钥越权检测:HMAC与数字签名 RBAC请求 HMAC-SHA256共享密钥 RSA/ECDSA公私钥对 密钥存储 覆盖范围 防重放(time+nonce) 私钥保护 公钥信任 签名截断重放检测 核心:签名必须绑定身份+角色+操作+资源+时间戳

4.6 常见越权伪造套路与防御

我这些年审计过不少系统,几乎每个都会遇到下面几种伪造手法:

  • 签名剥离——攻击者把签名参数删掉,服务端如果没有强制验证,就直接通过了。解决办法:所有需要鉴权的接口,签名必须为必选参数,缺失即拒绝。
  • 签名继承——攻击者用自己的合法身份(比如"viewer"角色)得到签名,然后拼接到高权限请求上。我见过一个SaaS平台,同一个secret对所有角色共享。防御:签名要包含角色字段,密钥按角色级别隔离。
  • HMAC密钥猜解——弱密钥比如"password123"、"secret",或者密钥长度太短(少于32字节)。建议至少用256位随机密钥,定期轮换。
💡 审计小技巧
拿到一份代码后,我习惯先grep搜索"hmac"、"signature"、"verify"关键字,然后看密钥从哪里加载。如果看到 secret = "xxx" 或 load_from_env("SECRET", default="xxx"),基本就有问题。