ECRECOVER 安全實務完整指南:以太坊簽名驗證的陷阱與最佳實踐

ecrecover 是以太坊智能合約中用於從 ECDSA 簽名恢覆公鑰的預編譯合約,是最危險的函數之一。本文從安全視角全面分析 ecrecover 的工作原理、已知漏洞パターン、典型攻擊案例,以及經過實戰驗證的安全編碼實踐。

ECRECOVER 安全實務完整指南:以太坊簽名驗證的陷阱與最佳實踐

概述

ecrecover 是以太坊智能合約中用於從ECDSA簽名恢覆公鑰的預編譯合約,是以太坊生態系統中最重要也最危險的函數之一。幾乎所有需要驗證簽名身份的智能合約都依賴於 ecrecover,包括 ERC-20 代幣的 permit 機制、多簽錢包、去中心化交易所訂單簽名、鏈上投票系統等。然而,ecrecover 的設計存在一個根本性缺陷:當簽名無效時,它會返回地址 0 而不是拋出錯誤。這個特性導致了大量安全漏洞,攻擊者利用「無效簽名等於零地址」的行為繞過身份驗證。

本文從安全視角全面分析 ecrecover 的工作原理、已知漏洞パターン、典型攻擊案例,以及經過實戰驗證的安全編碼實踐。讀者將理解為何簡單的 ecrecover 調用可能導致千萬美元損失,以及如何構建真正安全的簽名驗證系統。


第一部分:ECRECOVER 技術原理

1.1 ECDSA 簽名機制回顧

定義 1.1.1(ECDSA 簽名流程):

  1. 計算消息哈希:$z = \text{hash}(message)$
  2. 生成臨時私鑰:$k \in [1, n-1]$
  3. 計算臨時公鑰:$R = k \cdot G$
  4. 取 $r = R.x \mod n$
  5. 計算 $s = k^{-1}(z + r \cdot d) \mod n$
  6. 簽名為 $(r, s)$

簽名驗證:

給定公鑰 $Q$,消息哈希 $z$,簽名 $(r, s)$:

1. 確保 r, s ∈ [1, n-1]
2. 計算 w = s^{-1} mod n
3. 計算 u₁ = z·w mod n
4. 計算 u₂ = r·w mod n
5. 計算 P = u₁·G + u₂·Q
6. 驗證 P.x mod n = r

1.2 ecrecover 的工作原理

定義 1.2.1(ecrecover 預編譯合約):

ecrecover 是地址 0x0000000000000000000000000000000000000001 的預編譯合約。

函數簽名:

function ecrecover(
    bytes32 hash,
    uint8 v,
    bytes32 r,
    bytes32 s
) external pure returns (address);

參數解析:

參數類型說明
hashbytes32消息的 keccak256 哈希
vuint8recovery id (27 或 28)
rbytes32簽名的 r 值(橢圓曲線 x 座標)
sbytes32簽名 s 值
返回值address簽署者地址或 0x0

內部實現:

┌─────────────────────────────────────────────────────────────┐
│                 ecrecover 內部邏輯                          │
├─────────────────────────────────────────────────────────────┤
│                                                               │
│  1. 從 (r, s) 恢復候選公鑰 R                                │
│     - 需要遍歷兩個可能的 y 座標                              │
│     - v 值決定選擇哪個                                       │
│                                                               │
│  2. 驗證公鑰的有效性                                        │
│     - 確保公鑰在 secp256k1 曲線上                            │
│     - 確保公鑰不是無窮遠點                                    │
│                                                               │
│  3. 從公鑰計算以太坊地址                                    │
│     - address = last 20 bytes of keccak256(公鑰)            │
│                                                               │
│  4. ⚠️ 如果任何步驟失敗:                                    │
│     - 返回 address(0)                                        │
│     - 不拋出錯誤                                              │
│                                                               │
└─────────────────────────────────────────────────────────────┘

1.3 導致返回 0 的條件

定義 1.3.1(無效簽名條件):

ecrecover 在以下情況下返回 address(0)

  1. v 值無效
  1. r 值無效
  1. s 值無效
  1. 公鑰恢復失敗
  1. 座標不是二次剩餘

第二部分:經典漏洞模式與攻擊案例

2.1 漏洞模式 1:缺少零地址檢查

反模式代碼:

// ❌ 不安全的實現
contract Vulnerable1 {
    mapping(address => bool) public authorized;
    
    function authorize(
        bytes32 hash,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        address signer = ecrecover(hash, v, r, s);
        authorized[signer] = true;  // ⚠️ 如果 signer == 0,授權了地址 0
    }
}

攻擊向量:

攻擊者構造一個「魔術簽名」使 ecrecover 返回 0:

// 觸發 ecrecover 返回 0 的簽名
ecrecover(
    bytes32(0),           // hash
    uint8(0),            // v (無效!)
    bytes32(0),          // r
    bytes32(0)           // s
);  // 返回 address(0)

真實案例:

根據區塊鏈安全數據庫,2021-2023 年期間,至少有 12 個 DeFi 項目因缺少零地址檢查而遭受攻擊,累計損失超過 2.3 億美元。

2.2 漏洞模式 2:簽名可彎曲性攻擊

定義 2.2.1(簽名可彎曲性):

對於每個有效簽名 $(r, s)$,存在另一個有效簽名 $(r, -s \mod n)$。

// 兩個簽名都通過驗證
function testMalleability() {
    bytes32 r = 0x123...;
    bytes32 s1 = 0x456...;
    bytes32 s2 = uint256(0) - s1;  // n - s1
    
    address addr1 = ecrecover(hash, 27, r, s1);
    address addr2 = ecrecover(hash, 27, r, s2);
    
    assert(addr1 == addr2);  // 相同地址!
}

為什麼 s > n/2 無效:

EIP-2 修復後,$s$ 值必須小於曲線階的一半:

$$n/2 = 578960446186580977117854925043439539264187821395374521913028815 \

... / 2$$

這是因為 $s$ 和 $-s$ 會產生相同的公鑰,但 EIP-2 只接受較小的 $s$ 值。

攻擊場景:

// ❌ 易受攻擊的 ERC-20 轉账邏輯
contract VulnerableToken {
    mapping(bytes32 => bool) public processedHashes;
    
    function transferWithSignature(
        address to,
        uint256 amount,
        bytes32 hash,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        require(!processedHashes[hash], "Already processed");
        
        address from = ecrecover(hash, v, r, s);
        // 缺少 s 範圍檢查!
        
        _transfer(from, to, amount);
        processedHashes[hash] = true;
    }
}

// 攻擊者可以使用 (r, n-s) 再次提交同一筆轉账

2.3 漏洞模式 3:Nonce 重放攻擊

定義 2.3.1(Nonce 重放):

如果每次操作使用相同的 hash(例如相同的 nonce),攻擊者可以重放有效的簽名。

場景:

// ❌ 不安全的實現
contract Vulnerable3 {
    function claim(
        uint256 amount,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        bytes32 hash = keccak256(abi.encodePacked(amount));
        address signer = ecrecover(hash, v, r, s);
        
        require(signer == authorized, "Not authorized");
        // 沒有nonce檢查!
        
        msg.sender.transfer(amount);
    }
}

// 攻擊:監控內存池,找到 claim 交易,
// 在原交易確認前廣播相同 hash 的交易(更高 gas)

防禦措施:

// ✅ 安全的實現
contract SecureClaim {
    mapping(address => uint256) public nonces;
    
    function claim(
        uint256 amount,
        uint256 nonce,  // 增加 nonce
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        bytes32 hash = keccak256(abi.encodePacked(
            msg.sender,
            amount,
            nonces[msg.sender]
        ));
        
        address signer = ecrecover(hash, v, r, s);
        require(signer == authorized, "Not authorized");
        
        nonces[msg.sender]++;  // 增加 nonce
        msg.sender.transfer(amount);
    }
}

2.4 真實漏洞案例:The DAO 的教訓

事件背景:

2016 年 6 月 17 日,攻擊者利用 The DAO 合約中的「重入漏洞」盜取了價值約 360 萬 ETH 的代幣。

與 ecrecover 相關的問題:

// The DAO 的投票機制
function vote(
    uint256 proposalId,
    bool supports,
    uint8 v,
    bytes32 r,
    bytes32 s
) external {
    bytes32 hash = keccak256(abi.encodePacked(proposalId, supports));
    address voter = ecrecover(hash, v, r, s);
    
    // ❌ 沒有檢查 voter 是否已經投票
    // ❌ 沒有檢查 v 值有效性
    // ❌ 沒有檢查 s 範圍
    
    // 允許同一簽名被多次使用
    Vote(voter, proposalId, supports);
}

教訓:

  1. 簽名驗證只是整體安全的一環
  2. 業務邏輯中的狀態檢查同樣重要
  3. 需要完整的威脅模型

第三部分:安全編碼實踐

3.1 基礎安全檢查清單

定義 3.1.1(完整的簽名驗證流程):

// ✅ 安全實現模板
library ECDSA {
    function tryRecover(
        bytes32 hash,
        bytes memory signature
    ) internal pure returns (address, RecoverError) {
        // 1. 檢查簽名長度
        if (signature.length != 65) {
            return (address(0), RecoverError.InvalidSignatureLength);
        }
        
        // 2. 分割簽名分量
        bytes32 r;
        bytes32 s;
        uint8 v;
        assembly {
            r := mload(add(signature, 32))
            s := mload(add(signature, 64))
            v := byte(0, mload(add(signature, 96)))
        }
        
        // 3. 檢查 v 值
        if (v != 27 && v != 28) {
            return (address(0), RecoverError.InvalidSignatureV);
        }
        
        // 4. 檢查 s 值(EIP-2 可彎曲性修復)
        if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
            return (address(0), RecoverError.InvalidSignatureS);
        }
        
        // 5. 調用 ecrecover
        address signer = ecrecover(hash, v, r, s);
        
        // 6. 檢查零地址
        if (signer == address(0)) {
            return (address(0), RecoverError.InvalidSignature);
        }
        
        return (signer, RecoverError.NoError);
    }
}

3.2 OpenZeppelin ECDSA 庫

定義 3.2.1(官方推薦實現):

OpenZeppelin 的 ECDSA 庫是目前最被廣泛使用的安全簽名驗證工具:

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

library ECDSA {
    /**
     * @dev 嘗試從簽名和消息哈希恢復簽署者地址
     * @param hash 消息的 keccak256 哈希
     * @param signature 65 字節的簽名 (r, s, v)
     * @return signer 簽署者地址
     * @return errorValue 錯誤代碼
     */
    function tryRecover(
        bytes32 hash,
        bytes calldata signature
    ) internal pure returns (address, Errors) {
        // 實作見上方
    }
    
    /**
     * @dev 從簽名和消息哈希恢復簽署者地址
     * @dev 如果簽名無效,會 revert
     */
    function recover(
        bytes32 hash,
        bytes calldata signature
    ) internal pure returns (address) {
        (address recovered, Errors error) = tryRecover(hash, signature);
        _throwError(error);
        return recovered;
    }
}

contract MyContract {
    using ECDSA for bytes32;
    
    function verify(
        address signer,
        bytes32 hash,
        bytes calldata signature
    ) external pure returns (bool) {
        return ECDSA.recover(hash, signature) == signer;
    }
}

3.3 ERC-1271 智能合約簽名驗證

定義 3.3.1(ERC-1271 標準):

對於智能合約錢包(不是 EOA),無法使用 ecrecover。ERC-1271 定義了智能合約簽名驗證的標準接口:

/**
 * @dev ERC-1271 智能合約簽名驗證標準
 */
interface IERC1271 {
    /**
     * @dev 驗證智能合約的簽名
     * @param hash 消息哈希
     * @param signature 簽名
     * @return magicValue 如果簽名有效,返回此值
     */
    function isValidSignature(
        bytes32 hash,
        bytes calldata signature
    ) external view returns (bytes4 magicValue);
}

// Magic value
bytes4 constant internal MAGICVALUE = bytes4(keccak256("isValidSignature(bytes32,bytes)"));

完整實現:

contract ERC1271Wallet {
    address public owner;
    mapping(bytes32 => bool) public isValidSignature;
    
    constructor(address _owner) {
        owner = _owner;
    }
    
    // EOA 簽名驗證
    function isValidSignature(
        bytes32 hash,
        bytes calldata signature
    ) external view override returns (bytes4) {
        // 嘗試 ECDSA 恢復
        (address recovered, ) = ECDSA.tryRecover(hash, signature);
        
        if (recovered == owner) {
            return MAGICVALUE;
        }
        
        return bytes4(0);
    }
    
    // 智能合約錢包可以添加自定義邏輯
    function isValidContractSignature(
        bytes32 hash,
        bytes calldata signature
    ) internal view returns (bool) {
        // 實現多簽邏輯
    }
}

3.4 域名簽名(EIP-712)

定義 3.4.1(EIP-712 結構化哈希):

EIP-712 為人類可讀的簽名提供了安全的標準格式:

// EIP-712 類型定義
struct Person {
    string name;
    address wallet;
}

struct Mail {
    Person from;
    Person to;
    string contents;
}

// 類型哈希
bytes32 constant MAIL_TYPEHASH = keccak256(
    "Mail(Person from,Person to,string contents)Person(string name,address wallet)"
);

// 域分隔符
bytes32 constant EIP712DOMAIN_HASH = keccak256(
    "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);

contract EIP712Example {
    bytes32 public domainSeparator;
    
    constructor() {
        domainSeparator = keccak256(abi.encode(
            EIP712DOMAIN_HASH,
            keccak256(bytes("MyApp")),
            keccak256(bytes("1")),
            block.chainid,
            address(this)
        ));
    }
    
    function verify(
        Mail memory mail,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public view returns (bool) {
        // 計算結構化哈希
        bytes32 digest = keccak256(abi.encodePacked(
            "\x19\x01",
            domainSeparator,
            hash(mail)
        ));
        
        address signer = ecrecover(digest, v, r, s);
        return signer == mail.from.wallet;
    }
    
    function hash(Mail memory mail) internal pure returns (bytes32) {
        return keccak256(abi.encode(
            MAIL_TYPEHASH,
            hash(mail.from),
            hash(mail.to),
            keccak256(bytes(mail.contents))
        ));
    }
    
    function hash(Person memory person) internal pure returns (bytes32) {
        return keccak256(abi.encode(
            keccak256(bytes("Person(string name,address wallet)")),
            keccak256(bytes(person.name)),
            person.wallet
        ));
    }
}

第四部分:高級安全模式

4.1 時間鎖定的簽名

定義 4.1.1(時間鎖機制):

在高價值操作中,引入時間鎖可以提供最後的安全屏障:

contract TimeLockedWithdraw {
    mapping(address => uint256) public pendingAmount;
    mapping(address => uint256) public unlockTime;
    mapping(bytes32 => bool) public executed;
    
    event WithdrawalRequested(
        address indexed user,
        uint256 amount,
        uint256 unlockTime
    );
    
    function requestWithdrawal(
        uint256 amount,
        uint256 v,
        bytes32 r,
        bytes32 s
    ) external {
        // 1. 驗證簽名
        bytes32 hash = keccak256(abi.encodePacked(
            "\x19\x01",
            domainSeparator,
            keccak256(abi.encode(
                keccak256("RequestWithdrawal(uint256 amount,uint256 nonce)"),
                amount,
                nonces[msg.sender]
            ))
        ));
        
        address signer = ecrecover(hash, v, r, s);
        require(signer == authorized, "Invalid signature");
        
        // 2. 設置時間鎖
        pendingAmount[msg.sender] = amount;
        unlockTime[msg.sender] = block.timestamp + 2 days;
        nonces[msg.sender]++;
        
        emit WithdrawalRequested(
            msg.sender, 
            amount, 
            unlockTime[msg.sender]
        );
    }
    
    function executeWithdrawal() external {
        require(
            block.timestamp >= unlockTime[msg.sender],
            "Not yet unlocked"
        );
        require(pendingAmount[msg.sender] > 0, "No pending withdrawal");
        
        uint256 amount = pendingAmount[msg.sender];
        pendingAmount[msg.sender] = 0;
        
        payable(msg.sender).transfer(amount);
    }
}

4.2 多重簽名驗證

定義 4.2.1(門限簽名):

contract MultiSigVerify {
    uint256 public constant THRESHOLD = 3;
    uint256 public constant TOTAL_SIGNERS = 5;
    
    mapping(bytes32 => uint256) public signedCount;
    mapping(bytes32 => mapping(address => bool)) public hasSigned;
    
    function verifyMultiSig(
        bytes32 actionHash,
        uint8[] memory v,
        bytes32[] memory r,
        bytes32[] memory s
    ) public view returns (bool) {
        require(
            v.length >= THRESHOLD,
            "Not enough signatures"
        );
        require(
            v.length == r.length && r.length == s.length,
            "Array length mismatch"
        );
        
        address lastSigner = address(0);
        
        for (uint256 i = 0; i < v.length; i++) {
            address signer = ecrecover(actionHash, v[i], r[i], s[i]);
            
            // 1. 檢查簽名者是否有效
            require(isValidSigner[signer], "Invalid signer");
            
            // 2. 檢查簽名者不重複
            require(!hasSigned[actionHash][signer], "Duplicate signature");
            
            // 3. 檢查簽名者順序(防止重排攻擊)
            require(signer > lastSigner, "Wrong order");
            
            hasSigned[actionHash][signer] = true;
            lastSigner = signer;
        }
        
        return signedCount[actionHash] >= THRESHOLD;
    }
}

4.3 防盜竊的簽名模式

定義 4.3.1(燃燒Nonce機制):

contract BurnNonceSignature {
    mapping(address => uint256) public burnNonces;
    
    /**
     * @dev 使用燃燒 nonce 的簽名驗證
     * 每次簽名需要消耗一個 nonce
     */
    function verifyBurnNonce(
        uint256 action,
        uint256 burnNonce,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external returns (bool) {
        // 1. 驗證 burnNonce
        require(
            burnNonce == burnNonces[msg.sender],
            "Invalid burn nonce"
        );
        
        // 2. 構造包含 burnNonce 的哈希
        bytes32 hash = keccak256(abi.encodePacked(
            action,
            msg.sender,
            burnNonce,
            address(this)
        ));
        
        // 3. 恢復簽名者
        address signer = ecrecover(
            keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)),
            v,
            r,
            s
        );
        
        // 4. 驗證簽名者
        require(signer == owner, "Invalid signer");
        
        // 5. 燃燒 nonce
        burnNonces[msg.sender]++;
        
        return true;
    }
}

第五部分:測試與審計清單

5.1 單元測試清單

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

import "forge-std/Test.sol";
import "../src/SignatureVerifier.sol";

contract SignatureVerifierTest is Test {
    SignatureVerifier public verifier;
    
    function setUp() public {
        verifier = new SignatureVerifier();
    }
    
    function testValidSignature() public {
        // 測試正常簽名驗證
    }
    
    function testZeroAddressPrevention() public {
        // 測試無效簽名返回零地址
        (address recovered, ) = ECDSA.tryRecover(
            bytes32(0),
            bytes("")  // 無效長度
        );
        assertTrue(recovered == address(0));
    }
    
    function testInvalidV() public {
        // 測試無效 v 值
        bytes32 hash = keccak256("test");
        // v = 0, 1, 2, 3 都應該失敗(只有 27, 28 有效)
    }
    
    function testInvalidS() public {
        // 測試超出範圍的 s 值
        bytes32 hash = keccak256("test");
        // s > n/2 應該被拒絕
    }
    
    function testSignatureMalleability() public {
        // 測試 s 和 n-s 只能有一個有效
    }
    
    function testNonceReplay() public {
        // 測試相同 nonce 的簽名不能重放
    }
    
    function testWrongHash() public {
        // 測試使用錯誤哈希的簽名被拒絕
    }
}

5.2 安全審計檢查清單

┌─────────────────────────────────────────────────────────────┐
│              簽名驗證安全審計清單                            │
├─────────────────────────────────────────────────────────────┤
│                                                               │
│  □ 1. 零地址檢查                                             │
│     □ 調用 ecrecover 後檢查結果 != address(0)               │
│                                                               │
│  □ 2. 簽名格式檢查                                           │
│     □ 簽名長度 = 65 字節                                      │
│     □ v 值 ∈ {27, 28}                                        │
│     □ r 值 ≠ 0 且 r < n                                      │
│     □ s 值 ≠ 0 且 s ≤ n/2 (EIP-2)                           │
│                                                               │
│  □ 3. Nonce 管理                                             │
│     □ 使用遞增 nonce                                          │
│     □ 每個操作有唯一的識別符                                  │
│     □ 已使用的簽名標記為已處理                                 │
│                                                               │
│  □ 4. 哈希構造                                               │
│     □ 哈希包含足夠的上下文                                    │
│     □ 包含發送者地址                                          │
│     □ 包含唯一識別符                                          │
│     □ 使用 EIP-712 進行結構化數據                            │
│                                                               │
│  □ 5. 重放攻擊防禦                                           │
│     □ 存儲已處理的消息哈希                                    │
│     □ 設置合理的有效期                                        │
│     □ 考慮時間鎖機制                                          │
│                                                               │
│  □ 6. 業務邏輯                                               │
│     □ 簽名驗證後檢查業務規則                                  │
│     □ 狀態變更前驗證所有前置條件                              │
│                                                               │
└─────────────────────────────────────────────────────────────┘

結論

ecrecover 是以太坊生態系統中最基礎也最危險的函數之一。其「無效簽名返回零地址」的特性是無數安全漏洞的根本原因。

核心安全要點:

  1. 永遠檢查返回值:在調用 ecrecover 後立即檢查結果是否為 address(0)
  1. 驗證簽名格式:確保 v、r、s 值都在有效範圍內,特別是 s ≤ n/2
  1. 使用防重放機制:每個簽名應該有唯一的識別符(nonce、hash)
  1. 採用成熟庫:使用 OpenZeppelin 的 ECDSA 庫,避免重造輪子
  1. 考慮 ERC-1271:對於智能合約錢包,需要支持 ERC-1271 接口
  1. 使用 EIP-712:結構化數據哈希比原始消息哈希更安全

遵循這些最佳實踐,可以顯著降低因 ecrecover 誤用導致的安全風險。


參考文獻

EIP 標準

安全資源

漏洞數據庫


本網站內容僅供教育與資訊目的,不構成任何投資建議或推薦。

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。

目前尚無評論,成為第一個發表評論的人吧!