高危签名与重放静态扫描查不出
签名重放
Signature Replay
同一份签名被重复使用:跨链重放(在 A 链的签名拿到 B 链用)、跨合约重放(同一个签名被另一个合约接受)、或同链重复重放(没记 nonce,同一笔授权执行多次)。
根因
签名本身只是「某私钥对某段数据的背书」,它不包含「在哪条链、给哪个合约、第几次使用」的信息,除非你在被签名的摘要里显式写进去。EIP-712 的 domain separator 与 nonce 就是为了解决这两件事而生的。
攻击是怎么一步步发生的
- 1监控者
在内存池看到一笔带签名的提款交易,从 calldata 里拿到 signature
- 2攻击者
把同一个 (user, amount, signature) 原样再发一次
- 3合约
因为没有 nonce,第二次校验同样通过,同一个签名被用了两次
- 4变体
或者在链上分叉 / 另一个同样代码的合约上重放,同样有效
有漏洞的版本
不要照抄soliditySolidity25 行
contract ReplayableVault {
mapping(address => uint256) public balances;
// 被签名的结构里只有「谁收、收多少」
// 没有 chainId、没有合约地址、没有 nonce
function withdrawWithSig(
address user,
uint256 amount,
bytes memory signature
) external {
bytes32 digest = keccak256(abi.encodePacked(user, amount));
// 注意:这里缺少 `\x19Ethereum Signed Message:\n32` 前缀,
// 也没有对 (user, amount) 做防碰撞编码,本身还有哈希歧义问题
require(recoverSigner(digest, signature) == user, "bad signature");
balances[user] -= amount;
payable(user).transfer(amount);
}
function recoverSigner(bytes32 digest, bytes memory sig) internal pure returns (address) {
(bytes32 r, bytes32 s, uint8 v) = splitSignature(sig);
return ecrecover(digest, v, r, s);
}
}- 签名里没有 chainId → 同一次签名可以在分叉链 / 测试网上重复使用
- 没有保存已用签名 / nonce → 同一个签名可以在这条链上反复调用
- 没有合约地址 → 另一个部署了同样代码的合约也能接受这个签名
abi.encodePacked拼接多个动态类型会产生哈希碰撞(不同参数拼出同一个字节串)ecrecover对 malleable signature 不敏感,同一个签名的 s 取补会得到同一个地址
修复版本
推荐写法soliditySolidity36 行
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SafeVault is EIP712 {
// EIP-2612 风格的结构化类型定义
bytes32 private constant WITHDRAW_TYPEHASH =
keccak256("Withdraw(address user,uint256 amount,uint256 nonce,uint256 deadline)");
mapping(address => uint256) public nonces;
constructor() EIP712("SafeVault", "1") {}
function withdrawWithSig(
address user,
uint256 amount,
uint256 deadline,
bytes calldata signature
) external {
require(block.timestamp <= deadline, "signature expired");
uint256 nonce = nonces[user]++;
bytes32 structHash = keccak256(
abi.encode(WITHDRAW_TYPEHASH, user, amount, nonce, deadline)
);
// domainSeparator 里包含 chainId 与合约地址,
// 因此这个签名只在「这条链的这个合约」上有效
bytes32 digest = _hashTypedDataV4(structHash);
// ECDSA.recover 会拒绝 malleable 签名与非法 v 值
address signer = ECDSA.recover(digest, signature);
require(signer == user, "bad signature");
// ...转账
}
}- EIP-712 的 domainSeparator 由
{name, version, chainId, verifyingContract}哈希而成 —— 三件事一次解决 - nonce 用「先取后自增」的写法,避免并发重放
- deadline 让签名具备时效,泄漏后也不至于永久有效
- 用
abi.encode而非abi.encodePacked做结构化哈希,避免碰撞 - 用 OpenZeppelin 的
ECDSA.recover而不是裸ecrecover(它会拒绝 s 值在高位的 malleable 签名)
怎么防
- 用 EIP-712 结构化签名,domain separator 必须含 chainId 与 verifyingContract
- 给每个签名加 nonce 并在使用后作废
- 加 deadline,让签名过期
- 用 abi.encode 而非 abi.encodePacked 计算结构化哈希
- 用 OpenZeppelin ECDSA 而不是裸 ecrecover(防 malleability 与 v 值边界)
- 涉及跨链的场景:把目标链 chainId 也纳入签名内容
真实案例
2022多个 Permit(EIP-2612)实现单起数十万至数百万美元
domain separator 缺失或未随分叉更新,导致签名被跨链重放
2022Poly Network 等跨链桥数亿美元级
跨链消息的签名校验缺陷,攻击者伪造有效签名
2023多个 Permit2 集成方因集成方式不当导致的授权被复用
Permit 签名需要与 owner / spender / value / nonce / deadline 全部绑定
审计这个合约时要问的问题
- 1.被签名的数据里包含 chainId 和合约地址吗?
- 2.同一个签名能用第二次吗?用什么阻止?
- 3.签名有有效期吗?泄漏后能被无限期使用吗?
- 4.恢复地址用的是 ecrecover 还是经过校验的封装?
- 5.结构化哈希用的是 encode 还是 encodePacked?
这些问题也是审计检查清单里对应的条目——那里可以把每一条的检查结果记下来,最后导出成报告。
静态扫描能发现它吗
不能。源码扫描引擎里没有能覆盖这一条的规则。 原因不是规则写得不够多,而是这类漏洞的代码语法完全正常: 权限检查写了、外部调用顺序对了、整数运算也没问题, 危险来自运行时的状态组合。
这正是「扫一遍没发现问题」不能当作安全结论的原因。 本站的合约体检会把这一类明确标成「工具完全查不出来」的检查项,而不是给你一个绿灯。