W3
高危签名与重放静态扫描查不出

签名重放

Signature Replay

同一份签名被重复使用:跨链重放(在 A 链的签名拿到 B 链用)、跨合约重放(同一个签名被另一个合约接受)、或同链重复重放(没记 nonce,同一笔授权执行多次)。

根因

签名本身只是「某私钥对某段数据的背书」,它不包含「在哪条链、给哪个合约、第几次使用」的信息,除非你在被签名的摘要里显式写进去。EIP-712 的 domain separator 与 nonce 就是为了解决这两件事而生的。

攻击是怎么一步步发生的

  1. 1
    监控者

    在内存池看到一笔带签名的提款交易,从 calldata 里拿到 signature

  2. 2
    攻击者

    把同一个 (user, amount, signature) 原样再发一次

  3. 3
    合约

    因为没有 nonce,第二次校验同样通过,同一个签名被用了两次

  4. 4
    变体

    或者在链上分叉 / 另一个同样代码的合约上重放,同样有效

有漏洞的版本

不要照抄solidity
Solidity25
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 取补会得到同一个地址

修复版本

推荐写法solidity
Solidity36
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?

这些问题也是审计检查清单里对应的条目——那里可以把每一条的检查结果记下来,最后导出成报告。

静态扫描能发现它吗

不能。源码扫描引擎里没有能覆盖这一条的规则。 原因不是规则写得不够多,而是这类漏洞的代码语法完全正常: 权限检查写了、外部调用顺序对了、整数运算也没问题, 危险来自运行时的状态组合。

这正是「扫一遍没发现问题」不能当作安全结论的原因。 本站的合约体检会把这一类明确标成「工具完全查不出来」的检查项,而不是给你一个绿灯。

延伸阅读

下一步:这一条没有任何自动规则覆盖,扫描结果里永远看不到它——这正是Rug Pull 扫描器页面里那句「没命中不等于安全」的具体含义。 审计检查清单可以把它落成一条可勾选、可写备注的记录,最后导出 Markdown 报告;想先弄懂这一类漏洞背后的通用思路,去参考手册看对应的语言与标准条目。