在 Web3 的世界裡,許多專案為了節省使用者的 Gas 費,會採用「鏈下簽章、鏈上驗證」的機制。例如:專案方在自己的伺服器上審核你的資格,然後用專案方的私鑰簽署一張「允許該用戶領取 100 顆空投代幣」的數位憑證發給你。你再拿著這個憑證(簽章),去區塊鏈上呼叫智慧合約把代幣領出來。
靶機:毫無防備的空投合約
讓我們來看一個充滿漏洞的空投合約實作。假設專案方的公鑰地址是 signerAddress,使用者只要提交數量 amount 與對應的簽章 signature 就能領錢。
'''
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// 引入 OpenZeppelin 的 ECDSA 函式庫來處理簽章驗證
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract VulnerableAirdrop {
using ECDSA for bytes32;
address public signerAddress;
mapping(address => uint256) public balances;
constructor(address _signer) {
signerAddress = _signer;
}
// 領取空投的函數
function claimAirdrop(uint256 amount, bytes memory signature) public {
// 1. 將用戶地址與金額打包,並計算 Hash 值
bytes32 messageHash = keccak256(abi.encodePacked(msg.sender, amount));
// 2. 轉換成以太坊標準的簽章格式
bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();
// 3. 透過 ECDSA.recover 還原出簽署這個 Hash 的地址
// 致命弱點:只驗證了「是不是專案方簽的」,卻沒驗證「這張簽章是不是用過了」!
require(ethSignedMessageHash.recover(signature) == signerAddress, "Invalid signature");
// 4. 發放代幣
balances[msg.sender] += amount;
}
}
'''
駭客的攻擊劇本
最佳防禦實踐:Nonce、已使用紀錄與 EIP-712
要防禦重放攻擊,我們必須讓每一張簽章都具備「唯一性」與「時效性」。業界有三道常見的防護鎖:
加入 Nonce (流水號) 或記錄已使用的簽章
我們可以在合約裡建立一個 Mapping,把每一個成功驗證過的簽章(或 Nonce)標記為「已使用」。
'''
// 紀錄這個簽章是否已經被兌現過
mapping(bytes => bool) public usedSignatures;
function secureClaim(uint256 amount, bytes memory signature) public {
// 第一步就先檢查簽章是不是用過了!
require(!usedSignatures[signature], "Signature already used");
bytes32 messageHash = keccak256(abi.encodePacked(msg.sender, amount));
require(messageHash.toEthSignedMessageHash().recover(signature) == signerAddress, "Invalid signature");
// 標記為已使用 (符合 Checks-Effects-Interactions)
usedSignatures[signature] = true;
balances[msg.sender] += amount;
}
'''
跨鏈重放攻擊防禦:綁定 ChainID 與合約地址
如果你的專案同時部署在以太坊主網 (ChainID: 1) 與 Polygon (ChainID: 137),駭客可能會拿他在 Polygon 上取得的真實簽章,跑去以太坊主網上領錢!
為了解決這個問題,以太坊推出了 EIP-712 標準。我們在打包簽章資料時,必須強制把當前區塊鏈的 ChainID 以及當前智慧合約的地址一起 Hash 進去。這樣一來,這張簽章就只會在這條鏈、這個合約裡有效。
「偽造簽章」很難,因為要破解橢圓曲線密碼學的私鑰幾乎不可能;但「重放簽章」卻極其簡單,只要合約少寫了一行狀態檢查,駭客就能拿著你的真支票無限提款。