CTF Vault

這一關的核心旨在揭示 Solidity 中 private 修飾符的安全盲區。許多開發者誤以為將變數設為私有即可隱藏資訊,但實際上這只能限制其他合約的呼叫,完全無法改變區塊鏈底層資料全網公開的本質。這道題目帶給開發者最重要的資安教訓是,絕對不可將密碼、私鑰等任何敏感資料以明文形式寫入智能合約中,所有上鏈的資料都應視為完全公開。

題目位置:https://ethernaut.openzeppelin.com/level/8


題目說明

這關會給你一個名為 Vault 的智能合約實例(你可以把它想像成一個數位保險箱)。你的唯一目標是:解鎖這個保險箱。 具體來說,就是想辦法將合約裡面的狀態變數 locked 從預設的 true(上鎖)變成 false(解鎖)。

合約內容如下:

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

contract Vault {
    bool public locked;
    bytes32 private password;

    constructor(bytes32 _password) {
        locked = true;
        password = _password;
    }

    function unlock(bytes32 _password) public {
        if (password == _password) {
            locked = false;
        }
    }
}

代碼解說

1. 狀態變數 (State Variables)

這是合約用來儲存資料的地方,資料會永久記錄在區塊鏈上。

  • bool public locked;:宣告一個布林值(只有 true 或 false),用來代表保險箱的開關狀態。設定為 public,所以任何人都可以隨時查看它現在是不是鎖著的。
  • bytes32 private password;:宣告一個 32 bytes 的變數來存放密碼。作者刻意加上 private(私有)。

2. 建構子 (Constructor)

  • constructor(bytes32 _password):這段程式碼只會在合約「誕生(部署到區塊鏈上)」的那一瞬間執行一次,之後就永遠不能再被呼叫。
  • 部署這個合約的人,會在部署時傳入一組自己設定的密碼(_password)。
  • 合約一建立,就會立刻把 locked 設為 true(上鎖),並把部署者傳入的密碼存進底層的 password 變數中。

3. 解鎖函數 (Unlock Function)

  • function unlock(bytes32 _password) public:這是一個公開的方法,任何人都可以呼叫它,並嘗試輸入密碼來開鎖。
  • if (password == _password):合約會拿你剛剛輸入的密碼(_password),去跟當初存在區塊鏈底層的密碼(password)做比對。
  • locked = false;:如果兩者完全一樣,條件成立,保險箱狀態就會被改成 false(解鎖)。這就是你剛才成功觸發的最後一步。

漏洞分析

當你將變數設為 private,這只是 Solidity 編譯器層級的保護機制。它唯一的作用是:禁止其他智能合約透過合約程式碼直接呼叫並讀取這個變數。 例如,合約 B 無法寫出 合約A.password() 這樣的程式碼來獲取密碼,編譯器在編譯階段就會直接報錯阻止。

以太坊是一個公開的帳本(Public Ledger)。所有的交易紀錄、智能合約的程式碼,以及合約當下的所有狀態(State Variables),都必須公開同步並儲存在每一個以太坊節點的硬碟中。 因為資料就存在所有節點的公開資料庫裡,任何人都可以繞過智能合約的程式碼限制,直接向區塊鏈節點查詢「特定記憶體位置(Storage Slot)」裡存放的原始資料。

以太坊智能合約的儲存槽(Storage Slot)是依序排列的,每個槽的容量為 32 bytes(256 bits):

  • Slot 0: 儲存了第一個變數 locked。
  • Slot 1: 儲存了第二個變數 password。即使它被標示為 private,這些位元組依然實實在在地寫入並儲存在區塊鏈的第 1 號儲存槽中。

解答步驟

1.讀取密碼

只需要打開瀏覽器的開發者控制台,使用 Web3.js 提供的區塊鏈底層 API getStorageAt,直接讀取合約地址上的第 1 號儲存槽:

> web3.eth.getStorageAt(instance, 1).then(web3.utils.toAscii)
...omit...
A very strong secret password :)
...omit...

> web3.eth.getStorageAt(instance, 1, console.log)
...omit...
0x412076657279207374726f6e67207365637265742070617373776f7264203a29
...omit...

補充說明:其實 web3.eth.getStorageAt 只是 Web3.js 幫你包裝好的語法。在以太坊節點底層的 JSON-RPC 協議中,本來就有一個公開的標準方法叫做 eth_getStorageAt。去翻閱 Web3.js 的 API 手冊找尋對應的功能,就可以找到。

2.將密碼填入

> await contract.unlock("0x412076657279207374726f6e67207365637265742070617373776f7264203a29") 

3.檢查密碼是否正確

執行以下指令,如果是 false 表示成功

> await contract.locked()
false  

補充說明:雖然原始碼裡沒有看到 function locked(),但其實 Solidity 編譯器自動幫你加上去了。在 Solidity 語言中,有一個非常重要的特性:只要你將狀態變數宣告為 public,編譯器就會在背後自動為這個變數生成一個同名的「讀取函數(Getter Function)」。

換句話說, 當合約中有 bool public locked; ,編譯器在編譯成位元糕(Bytecode)時,會偷偷幫合約加上類似以下的程式碼

function locked() public view returns (bool) {
    return locked;
}