JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

存取控制失守:到底誰能呼叫這個函式

Parity 兩次事件沒有破解任何密碼學——只是某個函式忘了問一句「你有權限嗎?」。本篇教你缺修飾器、tx.origin 與未初始化代理三大陷阱,以及如何把每一扇門都鎖對。

一扇沒有鎖的門

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 有把關,但 setOwner 沒有——於是攻擊者先呼叫 setOwner(攻擊者),再「合法地」呼叫 withdraw()。

`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
}
每個特權進入點都帶著 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
    }
}
擁有者點了「領取獎勵」,Phish.attack() 隨之執行,而 tx.origin 檢查就放行了這場盜竊。

修法只有一個詞:用 `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
    }
}
initWallet 沒有「已初始化」的防護,而 kill 會呼叫 selfdestruct。這兩件事接下來都很要命。

第一次攻擊(2017 年 7 月)。 攻擊者透過代理,對已存有資金的現有錢包呼叫 `initWallet`。因為沒有任何機制阻止 `initWallet` 被第二次執行,攻擊者把自己重設為錢包的唯一擁有者,捲走約 153,000 ETH(約 3,000 萬美元)。隨後白帽駭客團體(White Hat Group)用一模一樣的手法,搶在其他人之前把剩下有風險的資金救了出來。

第二次事件(2017 年 11 月)。 修補後的函式庫仍有一個致命缺口:函式庫合約本身從未被初始化過。 一位人稱 *devops199* 的使用者直接對函式庫呼叫 `initWallet`,讓自己成為它的擁有者,接著呼叫 `kill`。一步步看看後果:

  1. 函式庫合約呼叫 initWallet(devops199)——它此前沒有擁有者,於是成功,devops199 成為函式庫的擁有者。
  2. kill(devops199) 通過 msg.sender == owner 的檢查並執行 selfdestruct,把函式庫的位元組碼從鏈上抹除。
  3. 每個 Parity 多重簽章錢包都把邏輯 delegatecall 到那個位址——但程式碼如今已不存在,於是 withdraw、transfer、每一個函式都會 revert。
  4. 結果:超過 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;
    }
}
一個布林閂鎖讓 initialize 變得冪等(只生效一次)——正是 initWallet 所缺的那道防護。

未初始化的代理與那筆 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 {}
}
initializer 加上 _disableInitializers() 封住了 Parity/Wormhole 的破口;而 _authorizeUpgrade 本身也由 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);
    }
}
把最小權限寫進程式:只有 MINTER_ROLE 能鑄造,也只有管理員能授予該角色。

兩條原則把一切串起來。第一,最小權限:每個函式都該要求最窄的、足以正當化呼叫它的角色,而強大的金鑰(管理員、升級者)應由一個 多重簽章錢包 加時間鎖把守,而不是放在單一的熱錢包裡。第二,預設拒絕(fail closed):任何特權動作的預設值都是拒絕,再由你逐一加上明確的授予——絕不能反過來。

  1. 把每一個會改變狀態的函式列出來,並大聲問:「誰被允許呼叫它?又是什麼擋住了其他所有人?」
  2. 為每個函式標明清楚的可見性,並加上它所需最窄的 存取檢查——onlyOwner、onlyRole 或自訂的防護。
  3. 為每個初始化函式加上防護以確保只執行一次,並在可升級的實作合約上呼叫 _disableInitializers()。
  4. 把管理員與升級權力放在「多重簽章 + 時間鎖」之後,並優先採用兩步驟的擁有權移轉。
  5. 驗證它:撰寫測試,斷言未授權的呼叫者會 revert,並在上主網前跑一輪 稽核/靜態分析,把缺失的修飾器揪出來。