本關卡展示了在 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” 即可順利通關。