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:
Admin grants
MIGRATOR_ROLEto a contract address.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:
Admin grants
MIGRATOR_ROLEto addressMfor a legitimate V1 → V2 migration window.Alice calls
setMigrationPermit(M, true)during the window.Migration window closes; admin revokes
MIGRATOR_ROLEfromM.Alice attempts
setMigrationPermit(M, false)→ reverts withMigratorNotFound(M).Time passes. Admin re-grants
MIGRATOR_ROLEtoMfor a new migration cycle.McallsmigratePositionsFrom(alice)— succeeds because the stale permit is stilltrue.Alice's unclaimed principal and rewards are transferred to
Mwithout 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:
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:
migratePositionsFrom() reads the stale permit and proceeds to transfer all unclaimed funds:
The permit mapping is never cleared by migratePositionsFrom() or any other function, so it survives across role grant/revoke cycles:
Proof of Concept
Was this helpful?