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

溢位、下溢與那些會搬動金錢的取整漏洞

里程表會從 999999 跳回 000000。當代幣餘額也這麼跳時,一個算錯的減法就能無中生有印出一筆財富——而即使 Solidity 修好了那個問題,一個更不起眼的漏洞仍然存在:往錯誤方向取整的除法。

會跳回零的里程表

舊式汽車里程表的位數是固定的——比方說六位。開過 999,999 公里後,它不會顯示 1,000,000;因為它沒地方放最前面那個 1,於是默默地跳回 000,000。這個數字繞回去了。電腦的整數正是同一種儀表,只是用二進位,而跑在 EVM 上的 智慧合約 幾乎所有運算都用同一種儀表:256 位元的無號整數 `uint256`。它能容納從 0 到 2²⁵⁶ − 1 的值,那是一個 78 位數的數字——而且一個單位都不能多。

在大多數筆電上這很少有影響。但在區塊鏈上它影響巨大,因為儀表上的那個數字往往就是錢——你的餘額、要鑄造的數量、一個價格。溢位是結果太大、越過頂端繞回 0;下溢則是它的鏡像:在無號儀表上用 0 減 1,你得到的不是 −1(儀表沒有負號),而是 2²⁵⁶ − 1,它能容納的最大數字。一個算錯的減法就能把一個空錢包變成全世界最有錢的地址。

一個下溢實例:花太多錢反而印出財富

想像一個用舊版編譯器寫的天真代幣。它用一個位址到餘額的 `mapping`,並讓你轉帳:

// Solidity 0.7 — UNSAFE: arithmetic wraps silently, no revert
mapping(address => uint256) public balanceOf;

function transfer(address to, uint256 amount) external {
    // No check that you actually own `amount`.
    balanceOf[msg.sender] -= amount;   // <-- can underflow
    balanceOf[to]         += amount;
}
在 Solidity 0.8 之前,當你餘額不足時這個減法不會回退(revert)——它會繞回。

把數字走一遍。你的餘額是 100。你呼叫 `transfer(victim, 101)`。`balanceOf[msg.sender] -= amount` 這行算的是 100 − 101。用有號的紙筆算是 −1;但在無號的 `uint256` 儀表上它繞回到 2²⁵⁶ − 1 ≈ 1.16 × 10⁷⁷。你不但沒有因超額支出而破產——反而以兆兆倍之差,成了這個代幣有史以來最大的持有者,全只因為儀表沒辦法顯示負數。

這不是思想實驗。2018 年 4 月 22 日,BeautyChain(BEC)代幣就被這一類漏洞掏空——但走的是乘法裡的溢位,那更陰險,因為一道防護確實存在。它的 `batchTransfer` 一次付 `_value` 給多個收款人:

function batchTransfer(address[] _receivers, uint256 _value) public returns (bool) {
    uint cnt = _receivers.length;
    uint256 amount = uint256(cnt) * _value;          // <-- OVERFLOWS
    require(cnt > 0 && cnt <= 20);
    require(_value > 0 && balances[msg.sender] >= amount);  // passes!
    balances[msg.sender] = balances[msg.sender].sub(amount);
    for (uint i = 0; i < cnt; i++) {
        balances[_receivers[i]] = balances[_receivers[i]].add(_value);
    }
    return true;
}
BeautyChain 真實漏洞(CVE-2018-10299)的簡化版。注意:那個乘法是原始運算,沒有被防護。
  1. 攻擊者用兩個收款人(`cnt = 2`)與 `_value = 2²⁵⁵` 呼叫它。
  2. `amount = 2 × 2²⁵⁵ = 2²⁵⁶`,在儀表上正好是 0——它繞過頂端、落在零上。
  3. 於是 `require(balances[msg.sender] >= amount)` 變成 `>= 0`,恆為真——這道防護把攻擊者直接放行。
  4. 接著迴圈給每個收款人記入完整的 `2²⁵⁵` 代幣,從一個只付了 `amount = 0` 的發送者手中憑空變出天文數字的供給量。

教訓就藏在眼前:這份合約確實對 `.sub` 與 `.add` 用了 `SafeMath` 函式庫,但致命的 `cnt * _value` 卻是原始的 `*`。一道防護只能保護你真正經由它執行的運算。

SafeMath 時代:把缺少的檢查補上

多年來,標準防禦是 OpenZeppelin 的 `SafeMath`,一個 Solidity 函式庫,把每個運算都裹上一道檢查,溢位時回退而不是繞回。看懂訣竅後,它的 `sub` 與 `add` 幾乎平凡無奇:

library SafeMath {
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c >= a, "SafeMath: addition overflow");  // if it wrapped, c < a
        return c;
    }
    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b <= a, "SafeMath: subtraction underflow"); // refuse to go below 0
        return a - b;
    }
    function mul(uint256 a, uint256 b) internal pure returns (uint256) {
        if (a == 0) return 0;
        uint256 c = a * b;
        require(c / a == b, "SafeMath: multiplication overflow"); // undo and compare
        return c;
    }
}
偵測訣竅:先做運算,再檢查結果在數學上是否與輸入一致。

每道檢查都編碼了一個在沒有繞回時必然成立的性質。加法:真正的和絕不會小於任一運算元,所以 `c >= a` 失敗就代表它繞回了。減法:只有在 `b <= a` 時你才能減去 `b`。乘法:若 `a × b` 誠實無誤,那麼除回 `a` 必然得到 `b`。於是你會到處寫 `balance = balance.sub(amount)` 而不是 `balance -= amount`。它有效——但冗長、容易在某一行漏掉(BeautyChain 已證明),而且每次運算都多花一些 gas

Solidity 0.8:預設就檢查——以及新的自傷陷阱

2020 年 12 月,Solidity 0.8.0 終結了漏寫檢查的時代。從那時起,整數上每一個 `+`、`-`、`*` 都由編譯器檢查:溢位或下溢時交易會帶著 `Panic(0x11)` 錯誤回退,而不是默默繞回。BeautyChain 那個漏洞若用 0.8 編譯,會直接在 `cnt * _value` 處回退。對常見情況而言,SafeMath 變成了不必要的樣板程式碼。

但這些檢查會花一點 gas,所以 0.8 也加了一個逃生口:`unchecked { ... }` 區塊,裡面的運算會用舊方式繞回。這是貨真價實的優化——也是貨真價實的新自傷陷阱。在你已證明不可能繞回之處使用它。

// Solidity ^0.8.0
uint8 x = 255;
x = x + 1;     // REVERTS with Panic(0x11) — does NOT wrap to 0

// A legitimate `unchecked`: the loop index is bounded by the array length,
// so ++i cannot reach 2^256. Skipping the check saves gas every iteration.
function sumAll(uint256[] calldata xs) external pure returns (uint256 total) {
    for (uint256 i = 0; i < xs.length;) {
        total += xs[i];          // still CHECKED — a real sum could overflow
        unchecked { ++i; }       // safe to skip the check here
    }
}
預設是安全的;`unchecked` 是一個你必須逐行說明理由的退出選項。

Solidity 沒修好的更隱微漏洞:除法往下取整

受檢算術能擋下繞回,卻救不了一個安靜得多的問題,因為這一個並不是 EVM 的漏洞——它是你忘了納入考量的正確行為。EVM 沒有小數。整數除法永遠捨去餘數、往零的方向取整:`7 / 2` 是 `3`,不是 `3.5`。沒有任何回退。消失的那一半就這麼不見了,而在數百萬次運算累積下,那些消失的碎屑會湊成真金白銀——有時還湊進攻擊者的口袋。

一個具體的揩油。某協議用 `fee = amount * 3 / 1000` 收 0.3% 的手續費。當 `amount = 100` 時,那是 `300 / 1000 = 0`。手續費取整成。攻擊者把一筆大交易拆成數千筆 `amount = 100` 的小額兌換,就完全不付手續費——本質上就是同一招,讓小額帳戶在整個 DeFi 裡躲掉逐筆收費。而運算的順序極其要緊:

// These are NOT equal in integer math:
uint256 a = 100; uint256 b = 1000; uint256 c = 3;

uint256 wrong = a / b * c;   // (100 / 1000) * 3 = 0 * 3   = 0
uint256 right = a * c / b;   // (100 * 3)  / 1000 = 300 / 1000 = 0  (still 0 here)

// With a = 100_000:
// a / b * c = 100 * 3            = 300
// a * c / b = 300_000 / 1000     = 300   (same, but loses no precision early)

// RULE: multiply before you divide, so the big intermediate keeps the precision.
// Beware: a * c can itself overflow — use a full-precision mulDiv for large values.
先除再乘會先丟掉精度;先乘再除則能保住精度(但要留意中間值溢位)。

由此產生兩個專業習慣。第一,放大刻度:金融協議以 18 位小數的定點數承載數值(所以 1.0 存成 `1e18`),讓取整在咬人之前有 18 位的緩衝。第二,永遠往協議有利的方向取整、絕不往使用者有利的方向:計算要發出去多少時往取整;計算要收進來多少時(一筆債務、一筆必繳的存款)往取整。你要守護的不變量是:合約絕不能被誘導以每次一個 wei 的方式付出超過它收進的金額。

當取整成為武器:ERC-4626 膨脹攻擊

往下取整不只是緩慢的漏洞;攻擊者還能操縱它。教科書案例是對 ERC-4626 金庫首位存款人膨脹攻擊——一種標準的收益金庫,你存入資產、領到份額(shares),份額再用 `assets = shares × totalAssets / totalSupply` 換回資產。新份額用鏡像公式鑄造,而它往下取整

// shares minted for a deposit, rounding DOWN:
function deposit(uint256 assets) external returns (uint256 shares) {
    shares = totalSupply == 0
        ? assets                                   // first depositor: 1:1
        : assets * totalSupply / totalAssets();    // <-- rounds down, can hit 0
    require(shares > 0, "zero shares");            // <-- the missing guard
    _mint(msg.sender, shares);
    token.transferFrom(msg.sender, address(this), assets);
}
若 `assets * totalSupply / totalAssets` 取整成 0,存款人就可能付了資產卻領不到份額。
  1. 金庫是空的。攻擊者存入 1 wei 的資產,正好領到 1 份額。此時 totalSupply = 1。
  2. 攻擊者接著捐贈——直接轉帳進金庫、繞過 `deposit`——10,000 顆代幣。此時 totalAssets ≈ 10,000e18,但 totalSupply 仍是 1。一份額現在『值』整個資金池。
  3. 受害者存入 10,000 顆代幣。份額 = 10,000e18 × 1 / (10,000e18 + 1),往下取整成 0。受害者的資產此刻已進了金庫,卻持有零份額。
  4. 攻擊者贖回他那唯一一份額——世上僅存的一份——帶著整個資金池、連同受害者的存款揚長而去。(許多金庫加了 `require(shares > 0)`,那只是把竊盜變成阻斷服務:受害者的存款被回退,但攻擊者仍已毒化了份額價格。)

防禦如今已成標準,而每一種都是一個小小的不變量。虛擬份額/資產(OpenZeppelin 帶 decimals offset 的 4626)把金庫當成它總是額外持有、比方說 1e3 份虛擬份額與 1 份虛擬資產,使取整永遠無法被真實存款人操縱到零。死份額:部署者先做一筆存款並把那些份額燒到黑洞位址,讓資金池永遠不會空到讓攻擊者奪取。內部記帳用一個變數追蹤餘額、而不是去讀 `token.balanceOf(this)`,能徹底瓦解那個捐贈步驟。整篇指南的主線:算術安全不是一行 `SafeMath` 匯入——它是一種紀律,在每一次除法都要問:這往哪邊取整,而誰從那些碎屑中得利?

稽核員實際上怎麼抓出這些漏洞

從頭到尾讀程式碼能找到明顯的 `unchecked` 與漏掉的 `require(shares > 0)`,卻很少能找到只在某個古怪輸入下才咬人的取整。為此,專業人員會陳述一個不變量——一個必須恆成立的性質——再用 模糊測試去攻擊它:工具把數千個隨機輸入丟給函式,尋找任何會打破該性質的輸入。在這裡,不變量可能是『使用者贖回的價值永遠不能超過他存入的價值。』

// Foundry fuzz test (Solidity). `amount` is randomized over the whole uint range,
// shrinking automatically toward the smallest counterexample on failure.
function testFuzz_NoFreeValue(uint256 amount) public {
    amount = bound(amount, 1, 1_000_000e18);
    uint256 shares = vault.deposit(amount);
    uint256 out    = vault.redeem(shares);
    assertLe(out, amount, "INVARIANT BROKEN: redeemed more than deposited");
}
一個模糊測試器試圖證偽的性質;任何一個讓它失敗的 `amount` 就是一項發現。