慢霧:Balancer 第一次被黑詳細分析

買賣虛擬貨幣

By : yudan @慢霧安全團隊

前言

2020 年 6 月 28 日,自動化做市商服務提供者 Balancer 遭受攻擊,慢霧安全團隊在收到情報後對本次攻擊事件進行了全面的分析,下面就這次攻擊事件,為大家展開具體的技術分析。

知識儲備

自動做市商服務(AMM)

Balancor 是一個提供 AMM 服務的合約,也就是自動化做市商服務,自動化做市商服務提供者採用代幣池中的各種代幣之間的數量的比例確定代幣之間的價格,使用者可透過這種代幣之間的動態比例獲取代幣之間的價格,進而在合約中進行代幣之間的兌換。

通縮型代幣

通縮代幣模型是隨著時間的推移從市場上減少代幣的一種模型。可以透過多種方法將代幣從市場上減少,包括代幣回購和代幣建立者進行的代幣銷燬。本次攻擊的主角-STA 代幣就是一款通縮型代幣,它是透過在轉賬的時候燃燒轉賬使用者的餘額實現代幣的通縮。主要的實現程式碼如下(以 transfer 為例):

function transfer(address to, uint256 value) public returns (bool) {    require(value <= _balances[msg.sender]);    require(to != address(0));    // 代幣通縮邏輯    uint256 tokensToBurn = cut(value);    uint256 tokensToTransfer = value.sub(tokensToBurn);    _balances[msg.sender] = _balances[msg.sender].sub(value);    _balances[to] = _balances[to].add(tokensToTransfer);    // 進行代幣通縮    _totalSupply = _totalSupply.sub(tokensToBurn);    emit Transfer(msg.sender, to, tokensToTransfer);    emit Transfer(msg.sender, address(0), tokensToBurn);    returntrue;  }

瞭解以上兩點後,我們就可以開始進行詳細的技術分析。

技術細節

本次攻擊的交易如下:https://etherscan.io/tx/0x013be97768b702fe8eccef1a40544d5ecb3c1961ad5f87fee4d16fdc08c78106

透過代幣轉移概覽,我們可以看到,攻擊者(0x81d)多次向 Balancer 合約(0x0e5)傳送 WETH 兌換 STA 代幣。

為了知道更加具體的交易細節,我們使用 OKO 合約瀏覽工具對交易進行分析:https://oko.palkeo.com/0x013be97768b702fe8eccef1a40544d5ecb3c1961ad5f87fee4d16fdc08c78106/

透過分析交易內具體的細節,可以發現攻擊者頻繁呼叫了 swapExactAmountIn 這個函式(上圖列出的只是一部分,讀者可自行透過連線訪問檢視具體的結果)。接下來我們就 swapExactAmountIn 這個函式對程式碼展開分析,程式碼如下:

function swapExactAmountIn(        address tokenIn,        uint tokenAmountIn,        address tokenOut,        uint minAmountOut,        uint maxPrice)        external        _logs_        _lock_        returns (uint tokenAmountOut, uint spotPriceAfter){        require(_records[tokenIn].bound, "ERR_NOT_BOUND");        require(_records[tokenOut].bound, "ERR_NOT_BOUND");        require(_publicSwap, "ERR_SWAP_NOT_PUBLIC");        // 獲取兌換時轉入代幣和要轉出的代幣的餘額        Record storage inRecord = _records[address(tokenIn)];        Record storage outRecord = _records[address(tokenOut)];        require(tokenAmountIn <= bmul(inRecord.balance, MAX_IN_RATIO), "ERR_MAX_IN_RATIO");        uint spotPriceBefore = calcSpotPrice(                                    inRecord.balance,                                    inRecord.denorm,                                    outRecord.balance,                                    outRecord.denorm,                                    _swapFee                                );        require(spotPriceBefore <= maxPrice, "ERR_BAD_LIMIT_PRICE");        // 計算兌換代幣的數額        tokenAmountOut = calcOutGivenIn(                            inRecord.balance,                            inRecord.denorm,                            outRecord.balance,                            outRecord.denorm,                            tokenAmountIn,                            _swapFee                        );        require(tokenAmountOut >= minAmountOut, "ERR_LIMIT_OUT");        // 更新轉入和轉出代幣的餘額        inRecord.balance = badd(inRecord.balance, tokenAmountIn);        outRecord.balance = bsub(outRecord.balance, tokenAmountOut);        spotPriceAfter = calcSpotPrice(                                inRecord.balance,                                inRecord.denorm,                                outRecord.balance,                                outRecord.denorm,                                _swapFee                            );        require(spotPriceAfter >= spotPriceBefore, "ERR_MATH_APPROX");        require(spotPriceAfter <= maxPrice, "ERR_LIMIT_PRICE");        require(spotPriceBefore <= bdiv(tokenAmountIn, tokenAmountOut), "ERR_MATH_APPROX");        emit LOG_SWAP(msg.sender, tokenIn, tokenOut, tokenAmountIn, tokenAmountOut);        // 拉取使用者用於兌換的代幣和將使用者要兌換的代幣推送給使用者        _pullUnderlying(tokenIn, msg.sender, tokenAmountIn);        _pushUnderlying(tokenOut, msg.sender, tokenAmountOut);        return (tokenAmountOut, spotPriceAfter);    }

透過分析 swapExactAmountIn 函式,可以知道函式的主要的流程如下:

1、獲取進行兌換的兩種代幣的餘額

2、根據代幣的餘額計算價格,檢查交易前價格是否合理

3、計算目標兌換代幣的轉出數量

4、更新進行兌換的兩種代幣的餘額

5、計算兌換後的價格,並檢查價格是否合理

6、拉取使用者用於兌換的代幣,並將使用者需要兌換的目標代幣轉給使用者

透過分析該函式,我們並未發現太多異常,這是一個正常的兌換流程,但需要注意的是這裡獲取兩個代幣餘額的方式不是透過 balanceOf 的方式,而是用儲存於 _records 變數中的 balance 的值來獲取指定代幣的餘額,理解這點有利於分析攻擊者接下來的操作。既然這是個正常的流程, 那為什麼攻擊者需要頻繁呼叫這個函式呢?這裡就要引入這次的主角 - 通縮型代幣 STA。

根據上文分析,我們知道,STA 在轉賬的時候會將轉賬者的一部分餘額銷燬掉,達到通縮的目的,根據這一特性,Balancer 合約在向使用者支付 STA 代幣的時候,其中一部分的代幣會被燃燒掉,即使用者收到的 STA 代幣會比預期的要少,根據 STA 的轉賬程式碼,Balancer 的 STA 餘額會被正常的減少,但是使用者收到的 STA 餘額是燃燒過後的餘額,也就是說使用者在進行兌換的時候,兌換結果是虧的。舉個例子,攻擊者使用1個 WETH 兌換 STA,假設兌換出來的 STA 的數量為30000個,但是由於燃燒的原因,Balancer 發給攻擊者的 STA 只有27000個,也就是說,本來攻擊者應用1個 WETH 兌換出30000個 STA,但是最後只拿到了27000個,也就是這一次交易虧了。而對於 Balancer 的資金池而言,WETH 的數量確實增加了1個,同時也少掉了30000個 STA,對本身的兌換演算法並不受影響。根據這樣的邏輯,攻擊者每一次兌換 STA,都是在做虧本買賣。但是攻擊者又不傻,這買賣肯定是不能虧本的。那麼攻擊者最終的獲利手段究竟是什麼呢?我們需要根據交易細節接著分析。

在呼叫了24次 swapExactAmountIn 函式後,攻擊者已經將 Balancer 中的 STA 數量控制在一個低點,此時 STA 兌 WETH 的價格已經很高了,這時候攻擊者開始使用 swapExactAmountIn 函式,使用 STA 兌換 WETH。

按照正常的流程,即使現在 STA 兌換 WETH 的價格已經很高,但是由於攻擊者在使用 WETH 兌換 STA 的時候,由於燃燒機制,攻擊者是沒有拿到用於兌換的 WETH 等額價值的 STA,所以即使攻擊者使用兌換出來的全部 STA 去兌換 WETH,由於兌換過程中會導致 Balancer 中的 STA 餘額變大,導致 STA 兌 WETH 的價格降低,攻擊者最終還是虧的,攻擊者之所以能在 STA 換 WETH 的過程中獲利,原因在於交易鏈中呼叫的 gulp 函式,函式的程式碼具體如下:

function gulp(address token)        external        _logs_        _lock_{        require(_records[token].bound, "ERR_NOT_BOUND");        _records[token].balance = IERC20(token).balanceOf(address(this));    }

可以看到,gulp 函式主要是對 _records 變數中的 balance 進行修正。從上文可以知道,_records 中儲存的是對應幣種的餘額資訊,那麼呼叫 gulp 函式,實際上就是對相應的代幣的餘額進行修正。那麼攻擊者為什麼要呼叫這個函式呢?透過觀察上圖呼叫鏈中攻擊者在呼叫 swapExactAmountIn 傳入的 STA 的數量,可以發現傳入的數量為1,那麼根據 STA 的燃燒機制,在轉賬過程中,攻擊者實際上沒有向 Balancer 合約進行 STA 轉賬。

轉賬的 1 STA 在轉賬的過程中燃燒了。緊接著我們再次回顧 swapExactAmountIn 函式

function swapExactAmountIn(        address tokenIn,        uint tokenAmountIn,        address tokenOut,        uint minAmountOut,        uint maxPrice)        external        _logs_        _lock_        returns (uint tokenAmountOut, uint spotPriceAfter){        require(_records[tokenIn].bound, "ERR_NOT_BOUND");        require(_records[tokenOut].bound, "ERR_NOT_BOUND");        require(_publicSwap, "ERR_SWAP_NOT_PUBLIC");            Record storage inRecord = _records[address(tokenIn)];        Record storage outRecord = _records[address(tokenOut)];        require(tokenAmountIn <= bmul(inRecord.balance, MAX_IN_RATIO), "ERR_MAX_IN_RATIO");        uint spotPriceBefore = calcSpotPrice(                                    inRecord.balance,                                    inRecord.denorm,                                    outRecord.balance,                                    outRecord.denorm,                                    _swapFee                                );        require(spotPriceBefore <= maxPrice, "ERR_BAD_LIMIT_PRICE");        // 計算兌換的代幣數量,即使沒有收到 STA,此處依然進行了計算        tokenAmountOut = calcOutGivenIn(                            inRecord.balance,                            inRecord.denorm,                            outRecord.balance,                            outRecord.denorm,                            tokenAmountIn,                            _swapFee                        );        require(tokenAmountOut >= minAmountOut, "ERR_LIMIT_OUT");        // 更新轉入和轉出代幣的餘額        inRecord.balance = badd(inRecord.balance, tokenAmountIn);        outRecord.balance = bsub(outRecord.balance, tokenAmountOut);        spotPriceAfter = calcSpotPrice(                                inRecord.balance,                                inRecord.denorm,                                outRecord.balance,                                outRecord.denorm,                                _swapFee                            );        require(spotPriceAfter >= spotPriceBefore, "ERR_MATH_APPROX");        require(spotPriceAfter <= maxPrice, "ERR_LIMIT_PRICE");        require(spotPriceBefore <= bdiv(tokenAmountIn, tokenAmountOut), "ERR_MATH_APPROX");        emit LOG_SWAP(msg.sender, tokenIn, tokenOut, tokenAmountIn, tokenAmountOut);        // 拉取使用者用於兌換的代幣和將使用者要兌換的代幣推送給使用者        _pullUnderlying(tokenIn, msg.sender, tokenAmountIn);        _pushUnderlying(tokenOut, msg.sender, tokenAmountOut);        return (tokenAmountOut, spotPriceAfter);    }

雖然 Balancer 合約沒有收到 STA,但是由於兌換數量是直接取使用者傳入的值,所以即使沒有向 Balancer 轉 STA,由於 STA 兌換 WETH 價格很高,依然能換出大量的 WETH。這裡還有個問題,雖然 Balancer 合約沒有收到 STA,但是相關的入賬卻記錄在了 _records 中(42行)。這會導致一個問題,隨著兌換次數的增加,Balancer 池子中 STA 的記錄值會不斷增加,STA 兌換 WETH 的價格會逐步降低,這樣下去是無法持續獲利的,如何每次都維持最低的價格進行兌換呢?答案就在 gulp 函式中。

由於攻擊者在使用 STA 兌換 WETH 的時候,由於燃燒的原因,Balancer 合約實際並沒有收到 STA,導致雖然 swapExactAmountIn 使用 _records 記錄了相應的入賬,但是 Balancer 合約的 STA 代幣的真實餘額並沒有發生變化。當呼叫 gulp 函式的時候,由於 gulp 函式獲取到的是合約真正持有的 token balance,會導致覆蓋先前呼叫 swapExactAmountIn 函式時執行 inRecord.balance = badd(inRecord.balance, tokenAmountIn) 的值,那麼在下次兌換的時候,攻擊者就能消除因兌換導致 Balancer 池中 STA 代幣 _records 記錄值的增多而導致價格的降低帶來的影響,使攻擊者始終能以最高的價格兌換 WETH,從而進行獲利。

除了 WETH 之外,攻擊者使用了同樣的方法用 STA 兌換了池中的 WBTC,SNX 及 LINK 代幣,並透過 Uniswap 將相應的代幣套現,最終折回 WETH,歸還閃電貸的 10.4萬 WETH。到此攻擊完成。

完整攻擊過程如下

1、從 dYdX 進行貸款

2、不斷地呼叫 swapExactAmountIn 函式,將 Balancer 池中的 STA 數量降到低點,推高 STA 兌換 其他代幣的價格

3、使用 1 STA 兌換 WETH,並在每次兌換完成後(呼叫 swapExactAmountIn 函式)呼叫 gulp 函式,覆蓋 STA 的餘額,使 STA 兌換 WETH 的價格保持在高點

4、使用同樣的方法攻擊代幣池中的其他代幣

5、償還閃電貸貸款

6、獲利離場

修復建議

本次攻擊主要是因為通縮型代幣帶來的不相容性問題,當使用者在使用通縮型代幣進行兌換的時候,合約沒有有效的對接收到的通縮型代幣的餘額進行校驗,導致餘額記錄錯誤。從而產生套利。那麼修復方案也很簡單,即合約在處理兌換邏輯過程中,需要檢查進行兌換的兩種代幣在兌換過程中合約是否收到了相應的代幣,保證代幣的餘額正確記錄,不能只是依賴 ERC20 標準中關於轉賬的返回值,從而避免因代幣餘額記錄錯誤導致的問題。

免責聲明:

  1. 本文版權歸原作者所有,僅代表作者本人觀點,不代表鏈報觀點或立場。
  2. 如發現文章、圖片等侵權行爲,侵權責任將由作者本人承擔。
  3. 鏈報僅提供相關項目信息,不構成任何投資建議

推荐阅读

;