深入探討重入攻擊如何盜走Curve池7000萬美元
jk
2023-08-07 00:58
本文约2222字,阅读全文需要约9分钟
有關重入攻擊的技術科普。

原文《A Deep Dive Into How Curve Pool’s $ 70 Million Reentrancy Exploit Was Possible原文《

原文《

原文《

》,作者:Ann,由Odaily jk 編譯。

這次漏洞被稱為重入錯誤,它是在Vyper 編程語言的某些版本上出現的,特別是v 0.2.15、v 0.2.16 和v 0.3.0 。因此,使用這些特定版本的Vyper 的所有項目都可能成為攻擊的目標。

一級標題

一級標題

一級標題

什麼是重入(reentrancy)?

  • 為了理解這次漏洞為什麼會發生,我們首先需要了解什麼是重入以及它是如何工作的。

  • 如果一個函數在執行過程中可以被中斷,並且在其之前的調用完成執行之前可以安全地再次被調用(“重新進入”),則稱該函數為可重入的。可重入函數在硬件中斷處理、遞歸等應用中都有使用。

  • 為了使一個函數變得可重入,它需要滿足以下條件:

它不能使用全局和靜態數據。這只是一種約定,沒有硬性的限制,但如果使用全局數據的函數被中斷和重新啟動,它可能會丟失信息。

i = 5 
def non_reentrant_function():
  return i** 5 
def reentrant_function(number:int):
  return number** 5 

它不應修改自己的代碼。無論函數何時被中斷,都應該能夠以相同的方式執行。這可以管理,但通常不建議這樣做。

  • 它不應該調用其他非重入函數。重入不應與線程安全混淆,儘管它們緊密相關。一個函數可以是線程安全的,但仍然不是可重入的。為了避免混淆,重入只涉及到一個線程的執行。這是在沒有多任務操作系統存在的時代的一個概念。

  • 這裡有一個實際的例子:

  • 函數non_reentrant_function:

這個函數沒有參數。

  • 它直接返回全局變量i 的五次方。

  • 所以當你調用這個函數時,它總是返回5** 5 ,即3125 。

  • 函數reentrant_function:

值得注意的是,許多智能合約函數都不是可重入的,因為它們訪問如錢包餘額之類的全局信息。

一級標題

一級標題

一級標題

什麼是鎖(Lock)?

@nonreentrant('lock')
def func():
  assert not self.locked, "locked"
  self.locked = True
  # Do stuff
  # Release the lock after finishing doing stuff
  raw_call(msg.sender, b"", value= 0)
  self.locked = False
  # More code here

鎖本質上是一種線程同步機制,某個進程可以聲稱或“鎖定”另一個進程。

最簡單的鎖類型被稱為二進制信號量。這種鎖為被鎖定的數據提供獨佔訪問。還有更複雜的鎖類型,可以提供對讀數據的共享訪問。在編程中誤用鎖可能導致死鎖或活鎖,進程持續互相阻塞,狀態不斷改變但沒有進展。

編程語言在後台使用鎖來優雅地管理和共享多個子程序之間的狀態更改。但是,某些語言,如C# 和Vyper 允許在代碼中直接使用鎖。

在上面的例子中,我們希望確保如果msg.sender(合同呼叫者)是另一個合同,它不會在執行時調用代碼。如果在raw_call()下面還有更多的代碼,而沒有鎖,msg.sender 可能會在我們的函數執行完畢之前調用上面的所有代碼。

因此,在Vyper 中,nonreentrant('lock')裝飾器是一種控制對函數的訪問的機制,以防止調用者在它們完成運行之前反复執行智能合約函數。

讓我們看看真正受到重入攻擊的合約。注意@nonreentrant('lock')修飾符嗎?通常情況下,這應該可以防止重入,但實際上並未能防止。攻擊者能夠在函數返回結果之前反複調用remove_liquidity()。

@nonreentrant('lock')
def remove_liquidity(
    _burn_amount: uint 256,
    _min_amounts: uint 256 [N_COINS],
    _receiver: address = msg.sender
) -> uint 256 [N_COINS]:
    """
    @notice Withdraw coins from the pool
    @dev Withdrawal amounts are based on current deposit ratios
    @param _burn_amount Quantity of LP tokens to burn in the withdrawal
    @param _min_amounts Minimum amounts of underlying coins to receive
    @param _receiver Address that receives the withdrawn coins
    @return List of amounts of coins that were withdrawn
    """
    total_supply: uint 256 = self.totalSupply
    amounts: uint 256 [N_COINS] = empty(uint 256 [N_COINS])
    for i in range(N_COINS):
        old_balance: uint 256 = self.balances[i]
        value: uint 256 = old_balance * _burn_amount / total_supply
        assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
        self.balances[i] = old_balance - value
        amounts[i] = value
        if i == 0:
            raw_call(_receiver, b"", value=value)
        else:
            response: Bytes[ 32 ] = raw_call(
                self.coins[ 1 ],
                concat(
                    method_id("transfer(address, uint 256)"),
                    convert(_receiver, bytes 32),
                    convert(value, bytes 32),
                ),
                max_outsize= 32,
            )
            if len(response) > 0:
                assert convert(response, bool)
    total_supply -= _burn_amount
    self.balanceOf[msg.sender] -= _burn_amount
    self.totalSupply = total_supply
    log Transfer(msg.sender, ZERO_ADDRESS, _burn_amount)
    log RemoveLiquidity(msg.sender, amounts, empty(uint 256 [N_COINS]), total_supply)
    return amounts

一級標題

一級標題

一級標題

這是如何被利用的?

到目前為止,我們知道重入攻擊是一種反複調用智能合約中的某個函數的方法。但這是如何導致資金被盜和在Curve 攻擊中損失7000 萬美元的呢?

  • 注意智能合約末尾的self.balanceOf[msg.sender] -= _burn_amount 嗎?這告訴智能合約池中msg.sender 的流動性,減去燃燒費。接下來的代碼行為message.sender 調用transfer()。

  • 因此,一個惡意合約可以在金額更新之前不斷地調用提現,幾乎讓他們可以選擇提取池中的所有流動性。

  • 這樣的攻擊通常的流程是這樣的:

  • 易受攻擊的合約有10 個eth。

  • 攻擊者調用存款並存入1 個eth。

  • 攻擊者調用提現1 個eth,此時提現函數執行一些檢查:

  • 攻擊者的賬戶中是否有1 個eth?是的。

將1 個eth 轉移到惡意合約。注意:合約的餘額尚未更改,因為該函數仍在執行。

攻擊者再次調用提現1 個eth。 (重新入場)

jk
作者文库