68994 sc low users cannot revoke migration permits after migrator role is removed

Submitted on Mar 12th 2026 at 09:24:35 UTC by @godwinudo for Audit Comp | Folks Finance: Staking Contracts

  • Report ID: #68994

  • Report Type: Smart Contract

  • Report severity: Low

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

Description

Brief/Intro

The setMigrationPermit function validates that the target address currently holds the MIGRATOR_ROLE before allowing any change to the permit, including revocation. This means that if an admin removes the MIGRATOR_ROLE from a migrator address, users who previously granted that migrator permission can no longer revoke their own authorization. The permit remains true in storage permanently, and if the role is ever re-granted to the same address, the old permit becomes active again without the user's re-consent.

Vulnerability Details

The Staking.sol contract implements a migration system where users must explicitly authorize specific migrator contracts to move their staking positions. This authorization is managed through the setMigrationPermit function, which writes to the migrationPermits mapping:

mapping(address migrator => mapping(address user => bool isAuthorized)) public migrationPermits;

When a user calls setMigrationPermit, the function first checks whether the target 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);
}

The problem is that this role check applies to both granting (true) and revoking (false) the permit.

1

The admin grants MIGRATOR_ROLE to address MigratorA.

A user calls setMigrationPermit(MigratorA, true), setting migrationPermits[MigratorA][user] = true.

2

The admin later revokes MIGRATOR_ROLE from MigratorA.

This may happen because the migrator contract was found to have a flaw, or because the migration window has closed.

3

The user now wants to clean up their authorization and calls setMigrationPermit(MigratorA, false).

The function executes hasRole(MIGRATOR_ROLE, MigratorA), which returns false because the role was revoked.

4

The function reverts with MigratorNotFound(MigratorA).

At this point, the storage value migrationPermits[MigratorA][user] is permanently stuck at true.

The migrator can call migratePositionsFrom(user) and the permit check at line 173 passes:

The user never re-consented to this authorization. They may not even be aware that their old permit is active.

Impact Details

A user permanently loses the ability to revoke their migration authorization for a specific migrator address once that address's MIGRATOR_ROLE is removed. The permit remains true in storage with no way for anyone to clear it. If the same address later regains MIGRATOR_ROLE, it can migrate the user's positions without fresh consent.

Proof of Concept

1

The protocol admin calls grantRole(MIGRATOR_ROLE, MigratorA) to authorize a migrator contract.

2

Alice has active staking positions.

She calls setMigrationPermit(MigratorA, true). The function checks hasRole(MIGRATOR_ROLE, MigratorA) which returns true, so the transaction succeeds. Storage now holds migrationPermits[MigratorA][Alice] = true.

3

The admin calls revokeRole(MIGRATOR_ROLE, MigratorA) to disable it.

MigratorA can no longer call migratePositionsFrom because the onlyRole(MIGRATOR_ROLE) modifier blocks it.

4

Alice, being prudent, wants to revoke her permit.

She calls setMigrationPermit(MigratorA, false). The function hits hasRole(MIGRATOR_ROLE, MigratorA) returns false. The transaction reverts with MigratorNotFound(MigratorA). Alice cannot revoke her permit.

Was this helpful?