69756 sc low staking setmigrationpermit unnecessary hasrole check on revocation blocks users from managing own permits

Submitted on Mar 16th 2026 at 17:27:00 UTC by @zbugs for Audit Comp | Folks Finance: Staking Contracts

  • Report ID: #69756

  • Report Type: Smart Contract

  • Report severity: Low

  • Target: https://github.com/Folks-Finance/folks-staking-contracts/blob/main/src/Staking.sol

  • Impacts:

    • Contract fails to deliver promised returns, but doesn't lose value

Description

Brief/Intro

The setMigrationPermit function in Staking.sol applies a hasRole(MIGRATOR_ROLE, _migrator) check unconditionally, blocking users from revoking their own migration permits once the migrator's role is revoked. No privileged action is required to trigger this bug: a regular user calls a public function to manage their own mapping entry, and the transaction reverts. The program's known issues list acknowledges that "migrationPermits may contain revoked migrators" as a data-level observation about stale state, but this finding identifies the root cause: a code-level bug where the hasRole check is applied to revocations (false), not just grants (true). There is no security or design rationale for blocking permit revocation. The check serves a valid purpose only when granting permits (preventing approval of non-migrator addresses), but not when revoking, since migratePositionsFrom already independently enforces onlyRole(MIGRATOR_ROLE). The result is that users are stripped of the ability to manage their own authorization state with no defensive alternative: locked stakes cannot be withdrawn (staking period not ended), and the revocation itself reverts. If the admin later re-grants MIGRATOR_ROLE to the same address for legitimate operational reasons (e.g., patching and redeploying a migrator), the irrevocable stale permit allows any third party to force-migrate the user's locked positions to a V2 contract without the user's current consent.

Vulnerability Details

The setMigrationPermit function [1] requires that the _migrator address currently holds the MIGRATOR_ROLE:

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 check does not distinguish between granting and revoking. When a migrator's role is revoked, setMigrationPermit(_migrator, false) reverts with MigratorNotFound, preventing the user from revoking their own permit. This is a user-callable function operating on the caller's own data. No elevated privileges are required to reach the bug: any user who previously granted a permit and now wants to revoke it will hit this revert.

The check is also redundant for access control. The migratePositionsFrom function [2] already enforces role-based access independently:

Because migratePositionsFrom checks onlyRole(MIGRATOR_ROLE) before reading the permit, a stale permit for a de-roled migrator is inert. The role check in setMigrationPermit adds no security value. Its only effect is to block users from revoking their own permits.

The downstream consequence turns this into a security issue when combined with locked stakes:

  1. User stakes tokens in a period with a long staking duration (tokens locked).

  2. User grants migration permit to a migrator address.

  3. Admin revokes MIGRATOR_ROLE from that address (security concern, contract rotation, etc.).

  4. User attempts to revoke their permit. Transaction reverts with MigratorNotFound. No privileged action was involved.

  5. User cannot withdraw locked stakes as an alternative defense (staking period has not ended).

  6. Admin later re-grants MIGRATOR_ROLE to the same address (legitimate operational action, e.g., after patching the migrator contract).

  7. The stale permit is active again. Any third party that can invoke the migrator contract triggers migration of the user's locked positions without the user's current consent.

The fix is a single conditional: only apply the role check when the user is granting permission, not when revoking:

Impact Details

This vulnerability affects any user who has previously granted a migration permit. The bug requires no elevated privileges, no special conditions, and no external dependencies to trigger. A regular user calls setMigrationPermit(migrator, false) and the transaction reverts.

For users with locked stakes, the consequences compound. Both defensive options are unavailable: withdrawal reverts (staking period not yet complete) and revocation reverts (MigratorNotFound). If MIGRATOR_ROLE is later re-granted to the same address for legitimate operational reasons, the stale permit becomes immediately exploitable. In the reference MigratorV1 implementation, the migrate(user) function is permissionless, meaning any third party can trigger forced migration of the user's locked principal and earned rewards to a V2 contract the user did not consent to interact with under the current circumstances.

References

[1] https://github.com/Folks-Finance/folks-staking-contracts/blob/3131a2d46b5afa76f606bf08adfd85452a47e2d8/src/Staking.sol#L77-L82

[2] https://github.com/Folks-Finance/folks-staking-contracts/blob/3131a2d46b5afa76f606bf08adfd85452a47e2d8/src/Staking.sol#L166-L172

Proof of Concept

1

Create a new file test/StalePermitPOC.t.sol with the following content:

2

Compilation and Execution

3

Test Output

Was this helpful?