The Rewarder - Damn Vulnerable DeFi #05

The Rewarder - Damn Vulnerable DeFi #05

Analysis and PoC of Damn Vulnerable DeFi Level 05 - The Rewarder

Objectives

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 SNAPSHOT_ROLE.

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_DURATION shows 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 Exploit

The exploitation looks quite simple. Here's what we need to do to get the most rewards from the pool:

  • Call the 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 rewarderPool so that they can be transferred.

  • We'll call rewarderPool.deposit() to deposit the DVT into the pool for which we'll get accounting tokens.

  • TheRewarderPool's 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_increaseTime in 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.

Key Takeaways

  • 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.