Let’s continue our journey of learning about vulnerable DeFi applications. The next exercise, the-rewarder, challenges us to cheat at getting all of the rewards in a stripped down liquidity pool app:
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. Luckily, these are really popular nowadays, so there's another pool offering them in free flash loans.In the upcoming round, you must claim all rewards for yourself.
The challenge consists of four different contracts with the following functionality:
- TheRewarderPool.sol accepts DamnValuableToken deposits and awards RewardTokens every 5 days. The contract uses AccountingToken for record keeping of deposited tokens.
- RewardToken.sol is a simple ERC-20 token with basic minting functionality. It is used as a reward for keeping DamnValuableToken deposited in TheRewarderPool.
- AccountingToken.sol is an ERC20Snapshot token. It is used to keep historical balances of DamnValuableToken deposited into TheRewarderPool and to calculate the amount of RewardToken to award users.
- DamnValuableToken.sol, also referred to as a Liquidity Token, is a simple ERC-20 token. It is used as a liquidity token which can be deposited into TheRewarderPool in order to earn RewardToken. It can be borrowed from the FlashLoanerPool.
- FlashLoanerPool.sol is a simple contract with a single method to supply flash loans of DamnValuableToken.
The interactions between all of the different contracts can be complex so it’s best to diagram all of the contract calls:
In the graph above, users can deposit Liquidity Tokens (DamnValuableToken) into the Rewarder Pool which in turn creates a balance snapshot and mints Reward Tokens each reward round. Users are awarded Reward Tokens based on the percentage of user owned vs. total deposited tokens. Below is the TheRewarderPool.sol source with some additional comments to help us better understand how the reward logic works:
What is interesting is that the rewards are calculated using historical snapshots taken every 5 days. This means that a user must have all their liquidity tokens deposited into TheRewarderPool contract before the next round in order to receive the reward. Let’s run a quick experiment in order to illustrate this point with the following code snippet:
The above will deposit some liquidity tokens into the rewarderPool prior to the new round, trigger the new round and observe collected how all of the token balances change. Here is the output:
Notice that the snapshot balance remains 0 until the beginning of the next round. Once the new round begins a snapshot is taken which allows correct reward token to be calculated. It is also interesting that the “live” token balance can be emptied while the snapshot amount will continue reporting the recorded amount.
The above logic presents an opportunity for a large flash loan deposit right at the beginning of the new round, but just before the distributeRewards() is triggered which would take the account snapshot. This would allow an attacker to collect the reward and withdraw deposited tokens to return them back to the flash loan pool all in the same transaction.
Let’s craft an attacker contract which will deposit and withdraw borrowed tokens within the same transaction:
Now we can populate therewarder.challenge.js to initiate the attack immediately after 5 days have passed, but before the new round has begun. However, our goal is to not only collect reward tokens, but to also prevent others from collecting any as well. Let’s take another look at the reward distribution logic:
The reward amount is calculated as a proportion to the total amount tracked by the accounting token. If we were to deposit a sufficiently large amount at the time the snapshot was taken it would make the remaining distribution amount tiny in comparison. Let’s estimate how much alice would be able to receive normally and if an attacker would deposit the available 1000000 DVT flash loan:
An interesting observation in the above experiment is that alice’s reward will actually be 0, because of a rounding error in the way Solidity does integer arithmetic in . The correct way to calculate the reward is to account for the decimal precision like it was done in .
At this point we have all of the pieces in-place to both execute the flash loan attack and take away all rewards from other users, below is a sample attacker payload you can add to the the-rewarder.challenge.js:
Running the above will perform the attack just at the beginning of a new round before any snapshots were taken:
Hurray! We passed the challenge :)