69926 sc low users cannot revoke migration permits after migrator role is removed enabling fund migration without re consent

Submitted on Mar 17th 2026 at 12:30:50 UTC by @dmitriia for Audit Comp | Folks Finance: Staking Contracts

  • Report ID: #69926

  • Report Type: Smart Contract

  • Report severity: Low

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

  • Impacts:

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief/Intro

setMigrationPermit() enforces hasRole(MIGRATOR_ROLE, _migrator) for both granting and revoking permits. Once the admin revokes MIGRATOR_ROLE from an address, users who previously granted that address a permit can no longer call setMigrationPermit(_migrator, false) because the role check reverts. The stale permit persists in storage. If MIGRATOR_ROLE is later re-granted to the same address, all stale permits become active again and migration proceeds without the user's re-consent.

Vulnerability Details

The migration mechanism requires two independent authorizations:

  1. Admin grants MIGRATOR_ROLE to a contract address.

  2. User calls setMigrationPermit(migrator, true) to authorize that address.

When the admin revokes MIGRATOR_ROLE, both authorizations should become independently manageable. Instead, the hasRole guard in setMigrationPermit() blocks all calls — including revocations — creating a one-way lock on the user's permit.

Attack path:

  1. Admin grants MIGRATOR_ROLE to address M for a legitimate V1 → V2 migration window.

  2. Alice calls setMigrationPermit(M, true) during the window.

  3. Migration window closes; admin revokes MIGRATOR_ROLE from M.

  4. Alice attempts setMigrationPermit(M, false) → reverts with MigratorNotFound(M).

  5. Time passes. Admin re-grants MIGRATOR_ROLE to M for a new migration cycle.

  6. M calls migratePositionsFrom(alice) — succeeds because the stale permit is still true.

  7. Alice's unclaimed principal and rewards are transferred to M without Alice ever re-consenting.

Root cause is that the hasRole check does not distinguish between granting (true) and revoking (false). I.e. revoking a permit is a user-protective action that is advised to be available unconditionally and it's now not.

As a mitigation consider controlling the role check with the _isMigrationPermitted flag:

Staking.sol#L77-L82

Impact Details

Likelihood — Low. Requires the admin to revoke and later re-grant MIGRATOR_ROLE to the same address. This is plausible during multi-phase migration rollouts, address reuse, or misconfiguration.

Impact — High. When the path completes, all unclaimed principal and accrued rewards for every user with a stale permit are transferred to the migrator address without any user interaction or additional consent, i.e. the user has no on-chain mechanism to prevent this.

Severity — Low. Low likelihood combined with direct loss of user funds.

References

setMigrationPermit() unconditionally checks hasRole(), blocking permit revocation after role removal:

Staking.sol#L77-L82

migratePositionsFrom() reads the stale permit and proceeds to transfer all unclaimed funds:

Staking.sol#L166-L210

The permit mapping is never cleared by migratePositionsFrom() or any other function, so it survives across role grant/revoke cycles:

Staking.sol#L38

Proof of Concept

Was this helpful?