There’s a pool offering rewards in tokens every 5 days for those who deposit their DVT tokens into it. There are 4 other participants who have already deposited some tokens and claimed their rewards.
We need to claim the most rewards for ourselves in the next round but we don't have any DVT tokens to deposit. There's also a lending pool offering free flash loans. Maybe we can use that to start our attack? Let's dive in.
Smart Contract Analysis
Let's start with the smaller contracts first.
RewardToken.sol: This looks like just another ERC20 token contract with a
MINTER_ROLE, a privileged role to mint new tokens and the
DEFAULT_ADMIN_ROLE. Both of them are assigned to the deployer, i.e.,
msg.sender. No issues here.
FlashLoanerPool.sol: This is just a lending pool that defines DVT as
liquidityToken and assigns it the right address in the constructor. It also has a
flashLoan() function that we can call to take loans for DVT tokens that can be used in our further attack. We also need to define a function in our attacker's contract called
receiveFlashLoan() that will handle tokens and repayment.
AccountingToken.sol: This is another ERC20 token contract but it's a snapshot. This means that its balances can be stored for a certain period for accounting. There are various privileged roles that control the minting and burning functions. Transfers are prohibited and are reverted since they are not needed. The function to take a snapshot has a
TheRewarderPool.sol: This is the main contract that we have to exploit. Let's take a look at the code in detail.
REWARDS_ROUND_MIN_DURATIONshows that a round lasts for 5 days.
deposit(): The deposit function is used to deposit DVT into the pool, the pool mints the same amount of accounting tokens (
rTKN) to the caller, calls
distributeRewards(), and then calls
transferFrom()to transfer the DVT from the caller to the pool. The caller should have already approved the pool for the amount of tokens for the
transferFrom()function to work.
distributeRewards(): This function is checking if it's a new reward round by adding the time of the last reward round and the 5 days delay. If it is, then it's recording a new snapshot for the new round. Then based on the caller's Accounting Token balance and the total number of Accounting Tokens in circulation (deposits by other users) at the last snapshot ID, it mints the Reward Tokens for the caller. This shows that the total rewards can be affected if other users deposit into the contract before our attacker because then the reward amount will be divided.
withdraw(): This function burns the Account Token and transfers the same amount of DVT tokens back to the caller.
_recordSnapshot(): It is just recording a new snapshot and increasing the round number.
_hasRetrievedReward(): This function is just to check if the user has already retrieved a reward or not for that round.
isNewRewardsRound(): It returns true if the round is a new reward round based on the last snapshot time + 5 days.
The exploitation looks quite simple. Here's what we need to do to get the most rewards from the pool:
flashLoanerPool.flashLoan()to take a loan for the maximum DVT tokens in the pool.
The flow will be redirected to our function
receiveFlashLoan()with the amount of tokens received from the loan.
We'll then approve the DVT for the
rewarderPoolso that they can be transferred.
rewarderPool.deposit()to deposit the DVT into the pool for which we'll get accounting tokens.
deposit()function will call the
distributeRewards()function. At this point, we must make sure that we are the only ones to deposit into the pool to claim the maximum reward.
This means we need to make sure that it's a new round, i.e., at least 5 days have passed since the last round. We'll be using
evm_increaseTimein our test case to achieve this.
There's no validation that checks for how long a user has deposited their DVT tokens which will allow us to deposit and withdraw almost immediately to claim the maximum rewards for that round.
Once we get the rewards minted to our contract, we are transferring them to the attacker's address on Line 59.
We are calling the
withdraw()on Line 60 to get back the DVT tokens by burning Accounting Tokens.
Then on Line 61, the loaned amount is sent back to the lending pool for the flash loan that we took to start the attack.
Here's how the test case will look:
We are deploying our attacker contract, increasing the time by 5 days so a new round can start and immediately calling the
exploit() to start the attack.
Run the script using
yarn run the-rewarder and the test case will pass.
- These type of reward-generating pools should incentivize users for how long they stake their tokens in the pool, rather than allowing them to withdraw immediately and calculating rewards based on that.