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

預言機操縱與閃電貸攻擊

不用抵押就借走數百萬、扭曲一個淺薄資金池的價格、掏空一個信任它的協定,然後還款——全在同一筆交易裡完成。本篇拆解這個 DeFi 時代攻擊的確切原理,以及價格預言機如何防禦它。

一場不用本錢的搶案

2020 年 2 月 18 日,一名攻擊者開啟了單獨一筆以太坊交易,借走了數千顆他根本沒有的 ETH,用它把某個代幣在一個小型交易池裡的價格猛推到一邊,騙過 bZx 借貸協定、讓它相信那個假價格,捲走其中的價差,然後把借款還清——整件事在大約十三秒、也就是產出一個區塊所需的時間裡就結算完成。沒有抵押任何東西,沒有提供任何身分。那一週的兩起 bZx 事件約莫得手 100 萬美元,並向世界宣告了一個全新的攻擊類型:對可被操縱的價格預言機發動的閃電貸攻擊

你已經認識了重入、算術漏洞與失守的存取控制。那些是單一合約內部的瑕疵。這個攻擊不一樣,也更現代:每一份合約都完全按照所寫的方式運作。漏洞藏在一個假設裡——以為合約從另一份合約讀到的數字就是真正的市場價格。在 DeFi 裡,合約靠價格去放貸、清算與結算,一個錯誤的價格就是一張錯誤的資產負債表。本篇會從兩項材料——閃電貸與可被操縱的價格饋送——把攻擊一步步組起來,再展示真實協定實際採用的防禦手段。

閃電貸:在一筆交易裡借走數百萬

閃電貸是一種靠原子性而變得安全的無抵押借貸。一個資金池(Aave、Balancer、Uniswap、某個 ERC-3156 借出方)把它持有的任意金額交給你,然後回呼你的合約。你可以拿這筆錢做任何事,但在同一筆交易結束前,你必須歸還本金外加一小筆手續費。若你沒有歸還,借出方的檢查就會失敗,整筆交易回滾(revert)——EVM 會把每一處狀態變更都倒帶回去、彷彿從未發生,那筆貸款也包含在內。因此借出方毫無風險:要嘛被還清,要嘛這筆貸款從未存在。

// Simplified ERC-3156-style flash-loan flow (illustrative)
interface IFlashLender {
    function flashLoan(address borrower, address token, uint256 amount, bytes calldata data)
        external returns (bool);
}

contract Attacker {
    function attack() external {
        // ask for 1,000,000 USDC with zero collateral
        lender.flashLoan(address(this), USDC, 1_000_000e6, "");
    }

    // the lender calls THIS back, in the middle of the same transaction:
    function onFlashLoan(address, address token, uint256 amount, uint256 fee, bytes calldata)
        external returns (bytes32)
    {
        // ... do the whole attack here, with a million dollars in hand ...
        // finally: hand back principal + fee, or the WHOLE tx reverts
        IERC20(token).approve(msg.sender, amount + fee);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}
借出方先把錢給你,再回呼你。結束前還清,否則一切都當作沒發生過。

閃電貸本身並不邪惡——它支撐了正當的套利、抵押品互換與自我清算。但它抹除了過去限制市場操縱的那一件事:需要本錢。在閃電貸出現之前,要推動一個價格得有一大筆資金。如今只需要一份聰明的合約。攻擊者唯一真正的成本,是那筆手續費(常見為 0.05~0.09%,有時甚至為零)加上 gas。

為什麼鏈上的即時價格這麼容易被扭曲

第二項材料,是一個天真的價格饋送。最簡單、最便宜的鏈上預言機,就是直接從自動做市商讀取即時價格:在一個 Uniswap-v2 資金池裡,某個代幣的價格不過就是兩種儲備的比值。這感覺很客觀——它是真實、即時、鏈上的市場資料——但它同時也正好是攻擊者能操控的那個數字,因為恆定乘積公式 x · y = k 讓任何人都能單純藉著對資金池下單,去推動這個比值。

我們用數字算一遍。取一個淺薄的資金池——流動性很淺、正是攻擊者最愛的目標——裡頭有 1,000 TOKEN100,000 USDC。乘積 k = 100,000,000,即時價格為 100,000 / 1,000 = 每顆 TOKEN 100 USDC。現在攻擊者把 100,000 USDC 換進這個池子(為清楚起見先忽略 0.3% 手續費):

  1. USDC 儲備變成 100,000 + 100,000 = 200,000。為了維持 k 不變,TOKEN 儲備必須降到 k / 200,000 = 500
  2. 因此攻擊者從池中取出 1,000 − 500 = 500 TOKEN
  3. 預言機現在回報的新即時價格為 200,000 / 500 = 每顆 TOKEN 400 USDC——一次換手就乾淨俐落地翻了 4 倍

那個巨大的波動,正是一筆大單撞上淺薄流動性所造成的價格影響。但讓操縱幾乎免費的關鍵洞見在於:這個波動是可逆的。如果攻擊者把那 500 TOKEN 立刻換回去,儲備就會回到 1,000 TOKEN/100,000 USDC,他也拿回那 100,000 USDC,只損失了換手的手續費。把一個恆定乘積價格先推高、再推回,是一趟只花手續費與滑價、而非花掉那個帳面金額的來回旅程。攻擊者只是在他的交易需要的那短短幾微秒裡,把一個荒謬的價格「租」下來,用完再還回去。

組合起來:完整的攻擊

現在把兩項材料串起來。假設有一個借貸市場用那個淺薄資金池的即時價格來為抵押品估值,並讓你借出抵押品價值的 75%。攻擊者不拿自己任何東西冒險,在一筆閃電貸交易裡執行以下流程:

  1. 透過閃電貸借入 100,000 USDC(無抵押)。
  2. 拉抬:把那 100,000 USDC 換進 TOKEN/USDC 池,取得 500 TOKEN,並把預言機價格從 100 推到 400。
  3. 利用:把那 500 TOKEN 存進借貸市場作為抵押品。它讀到即時價格 400,記入 500 × 400 = 20 萬美元的抵押品,然後讓你借出其中的 75%=150,000 USDC真實資產。
  4. 還款並消失:從你剛借到的 150,000 USDC 中拿出 100,000 USDC 加手續費,還清閃電貸。帶著約 50,000 USDC 揚長而去,把那 500 TOKEN(誠實價值 5 萬美元)丟在協定裡。

把帳算清楚。攻擊者進場與離場時都沒有自己的本錢,卻帶走了 約 50,000 美元。借貸協定如今持有 500 TOKEN——實際只值 5 萬美元——卻對應著一筆永遠不會被償還的 15 萬美元貸款:10 萬美元的壞帳。(消失的那 5 萬美元,外漏給了資金池的流動性提供者,以及把價格修正回去的套利者。)協定的帳目從不曾在自己的算式上出錯——它錯在一顆 TOKEN 到底值多少,而這唯一一個錯誤的輸入,級聯演變成了資不抵債。

// The VULNERABLE oracle: a lending market priced off one pool's spot reserves
interface IUniswapV2Pair {
    // token0 = TOKEN, token1 = USDC
    function getReserves() external view returns (uint112 r0, uint112 r1, uint32);
}

contract VulnerableLend {
    IUniswapV2Pair public pool;

    // USDC per TOKEN, as 18-decimal fixed point -- the instantaneous spot price
    function tokenPrice() public view returns (uint256) {
        (uint112 r0, uint112 r1, ) = pool.getReserves();
        return uint256(r1) * 1e18 / uint256(r0);   // <-- attacker controls this
    }

    function borrowable(uint256 tokenCollateral) external view returns (uint256) {
        uint256 valueUSDC = tokenCollateral * tokenPrice() / 1e18;
        return valueUSDC * 75 / 100;               // 75% LTV against a slider
    }
}
整個漏洞利用都繫於 tokenPrice() 信任了單一資金池的即時儲備。

這套模板把一個又一個協定掏空。bZx(2020 年 2 月)在 Kyber/Uniswap 上操縱 sUSD 的價格,借走遠超過其抵押品應得的 ETH。Harvest Finance(2020 年 10 月)閃電操縱了一個 Curve 池內的 USDC/USDT 平衡——Harvest 用它來為自家金庫份額估值——劫走約 2,400 萬美元。這個模式永遠是同一個形狀:閃電貸提供規模、淺薄的場地提供謊言,而一個信任了即時價格的合約提供贓款。

守住價格來源:TWAP 與去中心化預言機

解法絕不是「更小心地信任即時價格」。而是去讀取一個攻擊者無法在一筆交易內推動的價格。有兩大類防禦做到了這一點。

(1)時間加權平均價格(TWAP)。 不取瞬時比值,而是對一段時間窗內的價格取平均。Uniswap v2 儲存了一個 `price0CumulativeLast` 累加器,每個區塊都把 價格 × 秒數 累加進去;使用方在時間 t₁ 記錄它一次、在 t₂ 再記錄一次,該窗內的 TWAP 就是累加器的差除以經過的秒數。由於閃電貸造成的尖峰只持續一筆交易的瞬間,它幾乎撼動不了一段橫跨數分鐘的平均值。要推動一個 30 分鐘的 TWAP,攻擊者必須跨越許多區塊把價格頂在高位——每個區塊都付出滑價,同時套利者與一般交易者奮力把它拖回去。操縱不再免費,反而開始變得會把人拖垮。

(2)去中心化預言機網路。 像 Chainlink 這樣的服務根本不去讀單一資金池。一組獨立的節點營運者各自在鏈下抓取一個橫跨眾多深度市場(中心化與去中心化交易所)彙整而成的價格,網路就一個中位數達成一致、簽署它,並把它發布到鏈上。由於那個價格反映的是整個市場的流動性,一筆抽乾某個淺薄池子的閃電貸根本登記不上去。使用方讀取最新的已簽署數值,並且——這點至關重要——檢查它是否新鮮且合理:

// The FIX: read a decentralized feed, and reject stale or absurd values
interface AggregatorV3Interface {
    function latestRoundData() external view returns (
        uint80 roundId, int256 answer, uint256 startedAt,
        uint256 updatedAt, uint80 answeredInRound);
    function decimals() external view returns (uint8);
}

contract SaferLend {
    AggregatorV3Interface public feed;          // e.g. a TOKEN/USD price feed
    uint256 public constant MAX_STALENESS = 3600; // reject if older than the heartbeat

    function tokenPrice() public view returns (uint256) {
        (uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound)
            = feed.latestRoundData();
        require(answer > 0, "bad price");                                // not zero/negative
        require(answeredInRound >= roundId, "stale round");              // fully reported
        require(block.timestamp - updatedAt <= MAX_STALENESS, "stale");  // recent enough
        return uint256(answer) * 1e18 / (10 ** feed.decimals());
    }
}
光是讀取饋送還不夠——你必須強制檢查新鮮度與正值。

去中心化饋送也帶著自己的取捨。你如今信任的是預言機網路的節點集合及其彙整方式;更新只在心跳到來、或價格越過某個偏離門檻時才送達,所以鏈上的數值是刻意地稍稍過期的;而饋送本身也成了高價值的攻擊目標。更尖銳的誠實底線是:預言機只能轉述存在的市場的價格。在 Mango Markets 攻擊(2022 年 10 月,約 1.17 億美元)中,攻擊者在那些餵給預言機的場地上把交投清淡的 MNGO 代幣拉抬起來,於是預言機忠實地回報了一個真實——卻被操縱過——的價格,協定便讓他憑著被吹脹的抵押品去借款。當一項資產只在淺薄的場地交易時,沒有任何預言機設計能完全救你;解方是:不要拿一項你無法穩健取得其價格的資產去放貸。

稽核員會檢查什麼

預言機操縱如今是 DeFi 中獲利最豐的單一漏洞類型,因此檢視一個協定如何得知價格,是稽核員做的第一件事。檢查清單如下:

  1. 追蹤每一個價格。 對於每一個驅動借款、鑄造、兌換或清算的數字,都要問:它從哪裡來,一筆交易能不能推動它?一個原始的 `getReserves()` 或 `balanceOf()` 比值就是一面紅旗。
  2. 假設資金無限。 把每個函式都當成呼叫者握有無上限的閃電貸來做威脅建模。若是只要在一個區塊內持有巨額餘額就能讓他們獲利,那它就可被利用。
  3. 偏好彙整過或時間平均的價格。 採用帶有新鮮度檢查的去中心化饋送,或一個窗口夠長的 TWAP;更好的做法是兩者交叉比對,一旦偏離超過某個界限就回滾。
  4. 限制爆炸半徑。 每區塊的借款/提領上限、偏離熔斷機制,以及不要拿正被借出的那項資產本身去為抵押品定價,都能縮小一次錯誤報價所能造成的破壞。

請注意,這些沒有一項是關於編碼錯誤;在一場閃電貸預言機攻擊裡,每一份合約都運行得完美無瑕。這正是本階最難的這個漏洞所給的教訓:在 DeFi 中,安全是一種經濟性質,而不只是程式碼性質。 下一篇會退一步,探問專業稽核員如何有系統地把這一切——重入、算術、存取控制與預言機漏洞——找出來:用測試、靜態分析、模糊測試與威脅建模,搶在一個握著閃電貸的攻擊者替你示範之前。