近期 Curve 的 Vyper 編譯語言漏洞導致超過 7000 萬美元被盜,此次的漏洞被稱為「重入攻擊」,本文將深入介紹重入攻擊。本文源自 CyberPunkMetalHead 於 Medium 所著文章 《A Deep Dive Into How Curve Pool’s $70 Million Reentrancy Exploit Was Possible》,由 星球日報Odaily 編譯、整理。
(前情提要:導致Curve被駭的Vyper:第二智能合約語言,出了什麼問題? )
(背景補充:Curve遭受重創,證明DeFi充滿風險? )
近期的 Curve 池漏洞與我們在過去幾年裡看到的大多數加密貨幣駭客事件有所不同,因為與之前的許多漏洞不同,這一次並不直接與智慧合約本身的漏洞有關,而是與它所使用的語言的底層編譯器有關。
在這裡,我們談論的是 Vyper:一個面向智慧合約的、具有Pythonic風格的編程語言,旨在與以太坊虛擬機(EVM)交互。我對此次漏洞的背後原因非常感興趣,所以我決定深入研究。
隨著這次漏洞的發展,每天的新聞頭條都在報告新的數字。現在看來,情況終於得到了控制,但在此之前已經有超過 7000 萬美元被盜。根據 LlamaRisk 的事後評估,截止到今天,有幾個 DeFi 項目的池子也被駭客攻破,包括PEGD 的 pETH/ETH: 1100 萬美元;Metronome 的 msETH/ETH: 340 萬美元;Alchemix 的 alETH/ETH: 2260萬美元;和Curve DAO: 大約2470萬美元。
這次漏洞被稱為重入錯誤,它是在 Vyper 編程語言的某些版本上出現的,特別是 v0.2.15、v0.2.16 和 v0.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:
- 這個函數有一個參數 number,是整型。
- 它返回參數 number 的五次方。
- 這意味著你可以給這個函數傳入任何整數,並得到這個數的五次方作為返回值。例如,如果你傳入 2,它會返回 2 的 5 次方 ,即 32。
值得注意的是,許多智慧合約函數都不是可重入的,因為它們訪問如錢包餘額之類的全局信息。
什麽是鎖(Lock)?
鎖本質上是一種線程同步機制,某個進程可以聲稱或「鎖定」另一個進程。
最簡單的鎖類型被稱為二進制信號量。這種鎖為被鎖定的數據提供獨占訪問。還有更複雜的鎖類型,可以提供對讀數據的共享訪問。在編程中誤用鎖可能導致死鎖或活鎖,進程持續互相阻塞,狀態不斷改變但沒有進展。
編程語言在後台使用鎖來優雅地管理和共享多個子程式之間的狀態更改。但是,某些語言,如 C# 和 Vyper 允許在程式碼中直接使用鎖。
@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
在上面的例子中,我們希望確保如果 msg.sender(合約呼叫者)是另一個合約,它不會在執行時調用程式碼。如果在 raw_call() 下面還有更多的程式碼,而沒有鎖,msg.sender 可能會在我們的函數執行完畢之前調用上面的所有程式碼。
因此,在 Vyper 中,nonreentrant(‘lock’) 裝飾器是一種控制對函數的訪問的機制,以防止調用者在它們完成運行之前反覆執行智慧合約函數。
在許多 DeFi 駭客事件中,通常都是合約開發者沒有預見到的智慧合約錯誤,一個聰明但惡意的利用者發現了某些函數或數據暴露的方式中的弱點。但這次的情況獨特之處在於,Curve 的智慧合約以及所有其他成為攻擊受害者的池和項目在程式碼本身中都沒有已知的漏洞。合約是穩固的。
nonreentrant(‘lock’)是存在的。
由於 Vyper 語言在處理重入鎖的方式上出現了問題,導致了這個問題的發生。所以,合約創建者可能部署了看似合理的程式碼,但由於編譯器沒有正確處理鎖,使得攻擊者能夠利用這個有缺陷的鎖進行利用,導致合約行為出現意料之外的結果。
讓我們看看真正受到重入攻擊的合約。注意 @nonreentrant(‘lock’) 修飾符嗎?通常情況下,這應該可以防止重入,但實際上並未能防止。攻擊者能夠在函數返回結果之前反覆調用 remove_liquidity()。
@nonreentrant(‘lock’)
def remove_liquidity(
_burn_amount: uint256,
_min_amounts: uint256[N_COINS],
_receiver: address = msg.sender
) -> uint256[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: uint256 = self.totalSupply
amounts: uint256[N_COINS] = empty(uint256[N_COINS])
for i in range(N_COINS):
old_balance: uint256 = self.balances[i]
value: uint256 = 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,uint256)”),
convert(_receiver, bytes32),
convert(value, bytes32),
),
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(uint256[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。(重新入場)
- 攻擊者的帳戶中是否有 1 個 eth?是的。
這將重複,直到池中沒有更多的流動性。
Vyper 語言中的這個問題已經被修復,在 0.3.0 版本之後不再存在。如果您是開發人員,或使用 Vyper 的 Web3 組織,請確保立即更新您的版本。
📍相關報導📍
Curve駭客還款6000ETH!囂張諷:一群笨蛋快倒閉很可憐,不是怕被抓..