返回 SC 笔记
SC Day 58

签名重放(Signature Replay) + EIP-712 + 中心化风险

### 1. ECDSA 签名基础

2026-05-28
第三阶段:安全审计
ecdsasignature-replayeip-712permitcentralization-risk

日期: 2026-05-28 方向: Solidity / Security 阶段: 第三阶段:安全审计 标签: #ecdsa #signature-replay #eip-712 #permit #centralization-risk


今日目标

  1. 理解 ECDSA 签名在 Solidity 中的使用和验证
  2. 掌握签名重放攻击的原理和防护方法
  3. 深入学习 EIP-712 结构化数据签名标准
  4. 实现 ERC2612 Permit 模式
  5. 识别和评估中心化风险

核心概念

1. ECDSA 签名基础

以太坊使用 ECDSA(椭圆曲线数字签名算法)进行消息签名和验证:

签名过程:
1. 对消息进行 keccak256 哈希 → messageHash
2. 用私钥对 messageHash 签名 → (v, r, s)
3. v = recovery id (27 或 28)
4. r, s = 签名的两个组成部分

验证过程:
1. ecrecover(messageHash, v, r, s) → 恢复出签名者地址
2. 对比恢复出的地址与期望的签名者地址
// Solidity 中的签名验证
function verify(
    address signer,
    bytes32 messageHash,
    uint8 v,
    bytes32 r,
    bytes32 s
) public pure returns (bool) {
    // 添加以太坊前缀(防止签名被用于发送交易)
    bytes32 ethSignedHash = keccak256(
        abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
    );
    address recovered = ecrecover(ethSignedHash, v, r, s);
    return recovered == signer && recovered != address(0);
}

ecrecover 的安全注意事项

// 危险!ecrecover 在签名无效时返回 address(0),不会 revert
address recovered = ecrecover(hash, v, r, s);
// 如果 recovered == address(0) 且 signer 碰巧也是 address(0)...
// 永远要检查 recovered != address(0)

// 推荐:使用 OpenZeppelin ECDSA 库
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

function verify(bytes32 hash, bytes memory signature) public view returns (bool) {
    // ECDSA.recover 在签名无效时直接 revert
    address recovered = ECDSA.recover(hash, signature);
    return recovered == expectedSigner;
}

2. 签名重放攻击(Signature Replay)

攻击类型

类型说明示例
同合约重放同一签名在同一合约上重复使用用一个签名多次提款
跨合约重放签名在另一个合约上使用合约A的签名在合约B上验证通过
跨链重放签名在另一条链上使用以太坊的签名在BSC上使用

同合约重放示例

// 漏洞合约:签名可以重复使用
contract VulnerableAirdrop {
    mapping(address => bool) public claimed;
    address public signer;

    // 漏洞:没有 nonce,同一签名可以在不同场景重用
    function claim(uint256 amount, bytes memory signature) external {
        bytes32 messageHash = keccak256(abi.encodePacked(
            msg.sender,
            amount
            // 缺少:nonce、合约地址、chainId
        ));

        bytes32 ethSignedHash = keccak256(abi.encodePacked(
            "\x19Ethereum Signed Message:\n32",
            messageHash
        ));

        address recovered = ECDSA.recover(ethSignedHash, signature);
        require(recovered == signer, "Invalid signature");

        // 即使有 claimed 检查,如果 amount 不同的签名可以重用
        require(!claimed[msg.sender], "Already claimed");
        claimed[msg.sender] = true;

        // 发送代币
    }
}

跨链重放示例

以太坊上的合约 A (chainId: 1)
BSC 上的合约 A' (chainId: 56) — 相同代码部署在不同链

如果签名中不包含 chainId:
1. 用户在以太坊上签名消息
2. 攻击者拿这个签名到 BSC 上使用
3. 验证通过!(因为签名不包含链 ID 信息)

3. EIP-712 结构化数据签名

EIP-712 定义了一个标准的结构化数据签名方案,解决了签名重放问题:

EIP-712 签名结构:

hashStruct = keccak256(
    abi.encode(
        TYPE_HASH,     // 结构体类型哈希
        struct.field1, // 结构体字段
        struct.field2,
        ...
    )
)

domainSeparator = keccak256(
    abi.encode(
        DOMAIN_TYPE_HASH,
        keccak256(bytes(name)),     // 合约名
        keccak256(bytes(version)),  // 版本号
        chainId,                    // 链 ID ← 防跨链重放
        address(this)               // 合约地址 ← 防跨合约重放
    )
)

最终哈希 = keccak256(
    abi.encodePacked(
        "\x19\x01",        // EIP-712 前缀
        domainSeparator,    // 域分隔符
        hashStruct          // 结构体哈希
    )
)

Domain Separator 的作用

Domain Separator 包含:
├── name: "MyToken"        → 区分不同合约
├── version: "1"           → 区分不同版本
├── chainId: 1             → 防止跨链重放
└── verifyingContract: 0x... → 防止跨合约重放

只要任一字段不同,签名就无法在其他上下文使用

4. 中心化风险

中心化风险是 DeFi 审计中经常被忽视但极其重要的问题。

常见中心化风险

风险说明影响
单一 owner合约由一个 EOA 控制私钥泄露 = 全部资金损失
无限铸造Owner 可以无限 mint通胀攻击
暂停功能Owner 可以暂停合约用户资金冻结
黑名单Owner 可以冻结任意地址审查风险
升级权限Owner 可以升级合约逻辑可能改为恶意逻辑
手续费调整Owner 可以设置任意手续费可能设为100%
预言机操控Owner 可以修改预言机地址价格操纵

中心化风险评估框架

风险等级评估:

[Critical] Owner 可以直接提走用户资金
[High]     Owner 可以冻结用户资金 / 无限铸造
[Medium]   Owner 可以暂停功能 / 修改关键参数
[Low]      Owner 可以修改非关键参数(如手续费率在合理范围内)
[Info]     存在 owner 角色但权限有限

缓解措施:
├── Timelock: 管理操作有时间延迟(用户可以在执行前撤离)
├── Multisig: 多签控制(如 3/5 签名)
├── DAO: 去中心化治理
├── 参数边界: 手续费最大值、mint 速率限制
└── 逐步去中心化: 初期有 admin,后续移交给 DAO

代码实战

实现 ERC2612 Permit(EIP-712 签名授权)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/interfaces/IERC20Permit.sol";

/**
 * @title MomoToken with ERC2612 Permit
 * @notice ERC20 + permit: 用签名代替 approve,实现一步授权+转账
 */
contract MomoToken is ERC20, EIP712, IERC20Permit {
    using ECDSA for bytes32;

    // EIP-712 类型哈希
    bytes32 public constant PERMIT_TYPEHASH = keccak256(
        "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
    );

    // 每个地址的 nonce,防止签名重放
    mapping(address => uint256) private _nonces;

    constructor() ERC20("Momo Token", "MOMO") EIP712("Momo Token", "1") {
        _mint(msg.sender, 1000000 * 1e18);
    }

    /**
     * @notice 使用签名进行授权(代替 approve)
     * @param owner Token 持有者
     * @param spender 被授权者
     * @param value 授权数量
     * @param deadline 签名过期时间
     * @param v 签名参数
     * @param r 签名参数
     * @param s 签名参数
     */
    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external override {
        // 检查1: 签名是否过期
        require(block.timestamp <= deadline, "Permit: expired deadline");

        // 构造 EIP-712 结构化哈希
        bytes32 structHash = keccak256(
            abi.encode(
                PERMIT_TYPEHASH,
                owner,
                spender,
                value,
                _useNonce(owner), // 使用并递增 nonce(防重放)
                deadline
            )
        );

        // 计算完整的 EIP-712 哈希
        bytes32 hash = _hashTypedDataV4(structHash);
        // _hashTypedDataV4 内部:
        // keccak256("\x19\x01" || domainSeparator || structHash)
        // domainSeparator 包含 name, version, chainId, address(this)

        // 恢复签名者地址
        address signer = ECDSA.recover(hash, v, r, s);

        // 检查2: 签名者是否是 owner
        require(signer == owner, "Permit: invalid signature");

        // 执行授权
        _approve(owner, spender, value);
    }

    /**
     * @notice 获取地址的当前 nonce
     */
    function nonces(address owner) external view override returns (uint256) {
        return _nonces[owner];
    }

    /**
     * @notice 获取 domain separator
     */
    function DOMAIN_SEPARATOR() external view override returns (bytes32) {
        return _domainSeparatorV4();
    }

    /**
     * @dev 使用 nonce 并递增
     */
    function _useNonce(address owner) internal returns (uint256 current) {
        current = _nonces[owner];
        _nonces[owner] = current + 1;
    }
}

前端签名示例

// 前端使用 ethers.js v6 构造 Permit 签名
async function createPermitSignature(
    signer,      // ethers.js Signer
    tokenAddress,
    spender,
    value,
    deadline
) {
    const token = new ethers.Contract(tokenAddress, TOKEN_ABI, signer);
    const ownerAddress = await signer.getAddress();
    const nonce = await token.nonces(ownerAddress);

    // EIP-712 domain
    const domain = {
        name: "Momo Token",
        version: "1",
        chainId: (await signer.provider.getNetwork()).chainId,
        verifyingContract: tokenAddress,
    };

    // EIP-712 types
    const types = {
        Permit: [
            { name: "owner", type: "address" },
            { name: "spender", type: "address" },
            { name: "value", type: "uint256" },
            { name: "nonce", type: "uint256" },
            { name: "deadline", type: "uint256" },
        ],
    };

    // EIP-712 message
    const message = {
        owner: ownerAddress,
        spender: spender,
        value: value,
        nonce: nonce,
        deadline: deadline,
    };

    // 签名
    const signature = await signer.signTypedData(domain, types, message);
    const { v, r, s } = ethers.Signature.from(signature);

    return { v, r, s };
}

// 使用示例:一步完成 approve + swap
async function permitAndSwap(tokenAddress, dexAddress, amount) {
    const deadline = Math.floor(Date.now() / 1000) + 3600; // 1小时后过期

    // 第一步:链下签名(不消耗 Gas)
    const { v, r, s } = await createPermitSignature(
        signer, tokenAddress, dexAddress, amount, deadline
    );

    // 第二步:一笔交易完成 permit + swap
    const dex = new ethers.Contract(dexAddress, DEX_ABI, signer);
    const tx = await dex.permitAndSwap(
        tokenAddress, amount, deadline, v, r, s
    );
    await tx.wait();
}

签名重放攻击漏洞和修复

// ===== 漏洞版本 =====
contract VulnerableMultisig {
    address[] public owners;
    uint256 public threshold;

    // 漏洞:没有 nonce,同一签名可以反复使用
    function execute(
        address to,
        uint256 value,
        bytes memory data,
        bytes[] memory signatures
    ) external {
        bytes32 txHash = keccak256(abi.encodePacked(to, value, data));
        // 缺少:nonce、address(this)、chainId

        uint256 validSigs = 0;
        address lastSigner = address(0);

        for (uint256 i = 0; i < signatures.length; i++) {
            address signer = ECDSA.recover(
                ECDSA.toEthSignedMessageHash(txHash),
                signatures[i]
            );
            require(signer > lastSigner, "Duplicate signer");
            require(isOwner(signer), "Not owner");
            lastSigner = signer;
            validSigs++;
        }

        require(validSigs >= threshold, "Not enough signatures");
        (bool success,) = to.call{value: value}(data);
        require(success, "Execution failed");
        // 同一组签名可以反复调用 execute!
    }
}

// ===== 修复版本 =====
contract SecureMultisig is EIP712 {
    address[] public owners;
    uint256 public threshold;
    uint256 public nonce; // 添加 nonce

    bytes32 public constant EXECUTE_TYPEHASH = keccak256(
        "Execute(address to,uint256 value,bytes data,uint256 nonce)"
    );

    constructor()
        EIP712("SecureMultisig", "1")
    {}

    function execute(
        address to,
        uint256 value,
        bytes memory data,
        bytes[] memory signatures
    ) external {
        // 使用 EIP-712 标准哈希(包含 chainId、合约地址)
        bytes32 structHash = keccak256(abi.encode(
            EXECUTE_TYPEHASH,
            to,
            value,
            keccak256(data),
            nonce  // 包含 nonce
        ));
        bytes32 digest = _hashTypedDataV4(structHash);

        uint256 validSigs = 0;
        address lastSigner = address(0);

        for (uint256 i = 0; i < signatures.length; i++) {
            address signer = ECDSA.recover(digest, signatures[i]);
            require(signer > lastSigner, "Duplicate/unsorted signer");
            require(isOwner(signer), "Not owner");
            lastSigner = signer;
            validSigs++;
        }

        require(validSigs >= threshold, "Not enough signatures");

        nonce++; // 递增 nonce,防止重放

        (bool success,) = to.call{value: value}(data);
        require(success, "Execution failed");
    }

    function isOwner(address addr) public view returns (bool) {
        for (uint256 i = 0; i < owners.length; i++) {
            if (owners[i] == addr) return true;
        }
        return false;
    }
}

中心化风险示例

// 高中心化风险的合约
contract CentralizedToken {
    address public owner;
    mapping(address => uint256) public balances;
    bool public paused;
    mapping(address => bool) public blacklisted;

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    // [CRITICAL] Owner 可以直接修改任何人的余额
    function setBalance(address user, uint256 amount) external onlyOwner {
        balances[user] = amount; // 可以清零任何人的余额!
    }

    // [HIGH] Owner 可以无限铸造
    function mint(address to, uint256 amount) external onlyOwner {
        balances[to] += amount; // 无上限!
    }

    // [HIGH] Owner 可以冻结任何地址
    function blacklist(address user) external onlyOwner {
        blacklisted[user] = true;
    }

    // [MEDIUM] Owner 可以暂停所有操作
    function pause() external onlyOwner {
        paused = true; // 所有用户资金被冻结
    }

    function transfer(address to, uint256 amount) external {
        require(!paused, "Paused");
        require(!blacklisted[msg.sender], "Blacklisted");
        require(balances[msg.sender] >= amount, "Insufficient");
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

// 缓解中心化风险的改进版本
contract DecentralizedToken {
    address public owner;
    address public pendingOwner;
    uint256 public constant MAX_SUPPLY = 1e9 * 1e18;
    uint256 public totalSupply;

    // 时间锁:关键操作需要延迟执行
    uint256 public constant TIMELOCK_DELAY = 48 hours;
    mapping(bytes32 => uint256) public timelocked;

    // 铸造有上限
    function mint(address to, uint256 amount) external onlyOwner {
        require(totalSupply + amount <= MAX_SUPPLY, "Exceeds max supply");
        totalSupply += amount;
        // ...
    }

    // 关键参数修改需要时间锁
    function queueAction(bytes32 actionHash) external onlyOwner {
        timelocked[actionHash] = block.timestamp + TIMELOCK_DELAY;
    }

    function executeAction(bytes32 actionHash) external onlyOwner {
        require(timelocked[actionHash] != 0, "Not queued");
        require(block.timestamp >= timelocked[actionHash], "Timelock not expired");
        delete timelocked[actionHash];
        // 执行操作
    }

    // 两步 owner 转移(防止转到错误地址)
    function transferOwnership(address newOwner) external onlyOwner {
        pendingOwner = newOwner;
    }

    function acceptOwnership() external {
        require(msg.sender == pendingOwner, "Not pending owner");
        owner = pendingOwner;
        pendingOwner = address(0);
    }
}

关键要点总结

签名安全检查清单

检查项重要性说明
nonce 管理必须每次签名使用唯一 nonce
deadline/expiry必须签名应有过期时间
chainId必须包含在 domain separator 中
合约地址必须包含在 domain separator 中
ecrecover != address(0)必须验证签名有效性
EIP-712 标准推荐使用结构化签名而非原始哈希
签名可展性保护推荐使用 OpenZeppelin ECDSA

EIP-712 域分隔符组成

domainSeparator = hash(
    typeHash,           // "EIP712Domain(string name,...)"
    nameHash,           // 合约名称哈希
    versionHash,        // 版本哈希
    chainId,            // 链 ID → 防跨链重放
    verifyingContract   // 合约地址 → 防跨合约重放
)

中心化风险缓解矩阵

缓解措施效果成本
Timelock用户有时间撤离低(部署合约)
Multisig分散权力低(Gnosis Safe)
参数边界限制极端操作低(代码限制)
DAO 治理完全去中心化高(代币+治理系统)
透明度社区监督低(开源+文档)

常见误区

误区 1:"签名只用一次就安全了"

不一定。如果签名没有绑定 nonce 或合约地址,攻击者可以在其他合约或其他链上使用同一签名。EIP-712 的 domain separator 确保签名与特定上下文绑定。

误区 2:"EIP-712 签名可以被其他人发送"

正确!EIP-712 Permit 的一个特性是签名可以被任何人提交到链上——不需要是签名者本人。这允许"gasless"交易:用户签名,第三方(relayer)支付 Gas 提交。

误区 3:"有 Timelock 就去中心化了"

Timelock 只是缓解措施,不是去中心化。Owner 仍然可以执行恶意操作——只是给了用户反应时间。真正的去中心化需要移除 owner 角色或转为 DAO 治理。

误区 4:"ecrecover 是安全的"

原始 ecrecover 有签名可展性(signature malleability)问题——同一个签名可以有多种等价表示。始终使用 OpenZeppelin 的 ECDSA.recover,它强制 s 值在低半区间。


面试关联

Q: 什么是签名重放攻击?EIP-712 如何防护?

简短回答:签名重放是将有效签名在非预期的上下文中重复使用。EIP-712 通过 domain separator(包含 chainId、合约地址)和 nonce 防护。

详细回答

  • 同合约重放:nonce 递增后旧签名失效
  • 跨合约重放:domain separator 包含 verifyingContract
  • 跨链重放:domain separator 包含 chainId
  • 签名过期:deadline 参数确保签名有时效性
  • EIP-712 还提供了结构化数据展示,用户在钱包中可以看到签名的具体内容

Q: 如何评估一个 DeFi 协议的中心化风险?

回答(检查框架):

  1. Owner 权限范围:能做什么?能提走资金?能暂停?能升级?
  2. Owner 类型:EOA?多签?DAO?Timelock?
  3. 关键参数可修改性:手续费率?抵押率?预言机地址?
  4. 应急机制:有暂停功能?是否合理?
  5. 去中心化路径:是否有移交给 DAO 的计划?

Q: ERC2612 Permit 相比传统 approve 有什么优势?

回答

  1. 节省 Gas:从两笔交易(approve + transferFrom)变为一笔
  2. Gasless 交易:签名在链下完成,提交可由 relayer 代付 Gas
  3. 用户体验:一次签名+一次交易,而非两次交易
  4. 安全性:有过期时间(deadline),避免无限授权长期暴露

参考资源