一扇沒有鎖的門
2017 年 7 月 19 日,一名攻擊者把三個 Parity 多重簽章錢包掏空,盜走約 153,000 ETH——以當時幣價約合 3,000 萬美元。四個月後的 11 月,一位好奇的使用者在把玩「修補後」的程式碼時,意外把超過 513,000 ETH——逾 1.5 億美元——永遠凍結,任誰都動不了。這兩起事件都沒有破解任何密碼學:沒有私鑰被偷,沒有雜湊被反推。它們是同一個再平凡不過的失誤:一個本該問「你有權做這件事嗎?」的函式,根本……沒問。
這個問題——到底誰被允許呼叫這個函式?——就是 存取控制,而搞錯它是智慧合約中最常見、也最昂貴的漏洞類型之一。在這一階你已經見過 重入攻擊 與 算術漏洞,那些講的是程式碼如何執行;存取控制講的是誰被准許去執行。在一個程式碼公開、地球上任何人都能呼叫任何函式的系統裡,每個特權動作都需要一把鎖,而每把鎖都必須裝在對的門上。
缺失的修飾器
最單純的存取控制漏洞,往往就藏在最顯眼的地方:一個會更改合約擁有者、鑄造代幣或搬動金錢的函式,卻對呼叫者完全不做檢查。以下就是一個任何人都能奪走的金庫。
// VULNERABLE — anyone can become owner, then drain the vault
contract Vault {
address public owner;
constructor() { owner = msg.sender; }
function setOwner(address newOwner) public { // <-- no access check!
owner = newOwner;
}
function withdraw() public {
require(msg.sender == owner, "not owner");
payable(owner).transfer(address(this).balance);
}
}`withdraw` 看起來很安全——它檢查了 `msg.sender == owner`。但這道把關毫無價值,因為 `setOwner` 讓任何人都能先把 `owner` 改掉。修法是:把每個會改變狀態的特權函式,都關進一道明確的檢查之後。與其自己土法煉鋼,不如繼承一個身經百戰的基底,例如 OpenZeppelin 的 `Ownable`,它直接給你一個 `onlyOwner` 修飾器 與安全的 `transferOwnership`。
import "@openzeppelin/contracts/access/Ownable.sol";
// FIXED — ownership can only move via Ownable's onlyOwner-guarded path
contract Vault is Ownable {
constructor() Ownable(msg.sender) {}
function withdraw() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
// transferOwnership(newOwner) comes from Ownable and is itself onlyOwner
}tx.origin:那個能被你騙的代理人
Solidity 提供兩種方式問「誰呼叫了我」,而把它們搞混是經典陷阱。`msg.sender` 是緊鄰的呼叫者——隔你一步的那個合約或帳戶。`tx.origin` 則是最初發起整筆交易的那個外部帳戶(EOA),不管中間經過了多少合約。用 `tx.origin` 來授權看似等價,實則讓攻擊者得以「借用」受害者的身分。
想像一個用 `tx.origin == owner` 來檢查的錢包。攻擊者部署了一個看起來像免費空投的合約,誘使擁有者去呼叫它。當擁有者一呼叫,攻擊者的合約就回頭呼叫受害錢包——而因為這筆交易是擁有者發起的,`tx.origin` 仍然是擁有者。檢查通過,攻擊者把錢包掏空。這就是 tx.origin 釣魚攻擊。
// VULNERABLE wallet — authorizes by tx.origin
contract Wallet {
address public owner = msg.sender;
function transferTo(address payable to, uint256 amount) public {
require(tx.origin == owner, "not owner"); // <-- BUG
to.transfer(amount);
}
}
// Attacker's bait: the owner is tricked into calling attack()
contract Phish {
Wallet immutable victim;
address payable immutable attacker;
constructor(Wallet _victim) {
victim = _victim;
attacker = payable(msg.sender);
}
function attack() external { // looks like "claim reward"
victim.transferTo(attacker, address(victim).balance);
// msg.sender here is Phish, but tx.origin is still the owner -> passes
}
}修法只有一個詞:用 `msg.sender` 來授權,永遠別用 `tx.origin`。`require(msg.sender == owner)` 會擋下 `Phish`,因為緊鄰的呼叫者是攻擊者的合約,而不是擁有者。`tx.origin` 有少數合理用途,但拿來做授權就是錯的。
Parity 大災難:只能初始化一次,否則全盤皆輸
現在我們可以好好解剖 Parity 事件了,因為它把缺失的存取控制和你在 Solidity 那一階尾聲學到的 代理模式 結合在一起。為了省 gas,每個 Parity 多重簽章錢包都是一個薄薄的代理:它持有資金,卻把所有邏輯都 delegatecall 到同一個共用的函式庫合約。關鍵在於,`delegatecall` 是在 錢包自己的儲存空間裡 執行函式庫的程式碼——所以函式庫的 `initWallet` 設定的其實是那個錢包的擁有者。
// Simplified Parity WalletLibrary — delegatecalled by every wallet
contract WalletLibrary {
address public owner;
// meant to run ONCE at wallet creation — but nothing enforces that
function initWallet(address _owner) public {
owner = _owner; // <-- re-ownable by anyone, anytime
}
function kill(address payable to) public {
require(msg.sender == owner, "not owner");
selfdestruct(to); // removes this contract's code
}
}第一次攻擊(2017 年 7 月)。 攻擊者透過代理,對已存有資金的現有錢包呼叫 `initWallet`。因為沒有任何機制阻止 `initWallet` 被第二次執行,攻擊者把自己重設為錢包的唯一擁有者,捲走約 153,000 ETH(約 3,000 萬美元)。隨後白帽駭客團體(White Hat Group)用一模一樣的手法,搶在其他人之前把剩下有風險的資金救了出來。
第二次事件(2017 年 11 月)。 修補後的函式庫仍有一個致命缺口:函式庫合約本身從未被初始化過。 一位人稱 *devops199* 的使用者直接對函式庫呼叫 `initWallet`,讓自己成為它的擁有者,接著呼叫 `kill`。一步步看看後果:
- 對函式庫合約呼叫 initWallet(devops199)——它此前沒有擁有者,於是成功,devops199 成為函式庫的擁有者。
- kill(devops199) 通過 msg.sender == owner 的檢查並執行 selfdestruct,把函式庫的位元組碼從鏈上抹除。
- 每個 Parity 多重簽章錢包都把邏輯 delegatecall 到那個位址——但程式碼如今已不存在,於是 withdraw、transfer、每一個函式都會 revert。
- 結果:超過 513,000 ETH——以當時幣價逾 1.5 億美元,依後來更高的幣價常被引用為近 3 億美元——遭永久凍結。沒有東西被偷,只是被「磚化」了,而且沒有任何升級途徑可逃。
兩次的根本原因是同一行的遺漏:一個沒有防護的特權初始化函式,裝在一段「本該只初始化一次」的程式碼上。修法是加上一道 初始化防護——並且在實作合約/函式庫上,乾脆把初始化整個停用。
contract WalletLogic {
address public owner;
bool private initialized;
function initialize(address _owner) external {
require(!initialized, "already initialized");
initialized = true; // can never run a second time
owner = _owner;
}
}未初始化的代理與那筆 1,000 萬美元賞金
Parity 的凍結並非孤例;未初始化的實作合約至今仍是可升級合約中最致命的錯誤之一。在現代的 UUPS 與透明代理 模式裡,代理持有儲存空間與資金,並把邏輯 delegatecall 到一個獨立的 實作(implementation) 合約。實作照理只該透過代理執行——但它本身也是一個已部署的合約,若它的 `initialize()` 被留著能呼叫,攻擊者就能去初始化那個實作、成為它的擁有者,並(在 UUPS 中)呼叫它的升級掛鉤對它 `selfdestruct`。於是所有指向它的代理都會像 Parity 一樣被磚化。
這並非紙上談兵。2022 年 2 月,一位白帽駭客在 Wormhole 跨鏈橋上回報的正是此事:它的核心實作合約未被初始化,任何人都能去初始化再把它自毀,凍結整座橋。Wormhole 為這份回報支付了 1,000 萬美元 賞金——史上數一數二高——而漏洞在被實際利用前就已修補。
OpenZeppelin 的可升級合約把修法直接寫進了程式。`initializer` 修飾器讓 `initialize` 在代理上至多只執行一次;而在實作合約建構子裡呼叫 `_disableInitializers()`,會永久鎖死實作,讓它根本無法被初始化。
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract Vault is Initializable, OwnableUpgradeable, UUPSUpgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // implementation can NEVER be initialized
}
function initialize(address admin) public initializer {
__Ownable_init(admin); // runs once, on the proxy only
__UUPSUpgradeable_init();
}
// who may upgrade the logic — the most privileged action of all
function _authorizeUpgrade(address) internal override onlyOwner {}
}設計站得住腳的存取控制
逐一修補漏洞是被動的;專業合約會在一開始就設計好自己的權限模型。當不同角色需要不同權力時——鑄造者、暫停者、升級者——就別停在單一的 `owner`,改用 以角色為基礎的存取控制,這樣其中一個角色被攻陷時,也不會把一切都拱手讓人。OpenZeppelin 的 `AccessControl` 為每個角色配一個 `bytes32` 識別碼,並設有一個可授予與撤銷它的管理角色。
import "@openzeppelin/contracts/access/AccessControl.sol";
contract Token is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin); // can manage all roles
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
_mint(to, amount);
}
}兩條原則把一切串起來。第一,最小權限:每個函式都該要求最窄的、足以正當化呼叫它的角色,而強大的金鑰(管理員、升級者)應由一個 多重簽章錢包 加時間鎖把守,而不是放在單一的熱錢包裡。第二,預設拒絕(fail closed):任何特權動作的預設值都是拒絕,再由你逐一加上明確的授予——絕不能反過來。