69218 sc low access control defect in setmigrationpermit leads to irrevocable stale migration permits
Submitted on Mar 13th 2026 at 16:32:35 UTC by @ArgusHunter for Audit Comp | Folks Finance: Staking Contracts
Report ID: #69218
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/Folks-Finance/folks-staking-contracts/blob/main/src/Staking.sol
Impacts:
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Brief/Intro
The setMigrationPermit function in Staking.sol applies an unconditional hasRole(MIGRATOR_ROLE, _migrator) check on line 78 that blocks both granting and revoking migration permits. When the admin revokes MIGRATOR_ROLE from a previously authorized migrator address, users who had granted that address a migration permit cannot revoke it — the call reverts with MigratorNotFound. The permit persists in storage indefinitely with no alternative clearing mechanism. This breaks the contract's documented user consent model: users permanently lose the ability to control their own migration authorization for that address.
Vulnerability Details
The setMigrationPermit function at src/Staking.sol:77-82:
// file: src/Staking.sol, lines 77-82
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 hasRole(MIGRATOR_ROLE, _migrator) check on line 78 does not distinguish between granting (_isMigrationPermitted = true) and revoking (_isMigrationPermitted = false). This means:
User grants permit: Alice calls
setMigrationPermit(M, true)whileMholdsMIGRATOR_ROLE. This writesmigrationPermits[M][alice] = trueat line 80.Admin revokes role: Admin calls
revokeRole(MIGRATOR_ROLE, M). ThemigrationPermitsmapping is not touched — the permit remainstruein storage atsrc/Staking.sol:38.User tries to revoke: Alice calls
setMigrationPermit(M, false). The call reverts at line 78 becausehasRole(MIGRATOR_ROLE, M)returnsfalse. Alice has no way to set her permit back tofalse.
There is no alternative path to clear permits:
The
migrationPermitsmapping (src/Staking.sol:38) is only written at line 80 insidesetMigrationPermit. No other function writes to it.The contract does not override
_revokeRoleto clear associated permits whenMIGRATOR_ROLEis removed.No admin function exists to clear permits on behalf of users.
migratePositionsFromdoes not clear the permit after a successful migration (src/Staking.sol:166-210).
This directly contradicts the design intent documented in src/interfaces/IMigratorV1.sol:9-11:
"Migrator require permission from user to transfer their position to the new staking contract. (migrator should not be able to migrate stakes if not approved by user)"
The "permission from user" becomes irrevocable under these conditions, violating the user consent model.
Escalation path: If the admin later re-grants MIGRATOR_ROLE to the same address M, the stale permit reactivates. M can then call migratePositionsFrom(alice) at src/Staking.sol:166, passing the migrationPermits check at line 172, and transfer Alice's unclaimed staked principal and accrued rewards via TOKEN.safeTransfer(msg.sender, ...) at line 206 — without Alice's current consent.
Impact Details
Primary impact: Users permanently lose the ability to revoke migration permits for any address whose MIGRATOR_ROLE has been revoked. This is a user-facing access control failure with no workaround. Every user who ever granted a permit to a migrator that later lost its role is affected. The user cannot clear the stale authorization through any on-chain action.
Escalation impact: Under the specific precondition that admin re-grants MIGRATOR_ROLE to the same address, the stale permit enables unauthorized migration. The migratePositionsFrom function transfers the user's full unclaimed staked principal (activeTotalStaked portion) and unclaimed rewards (activeTotalRewards portion) to the migrator contract at msg.sender. The user's userStakes positions are deleted from the source contract.
Preconditions for escalation:
User previously granted migration permit to address M (normal operation during any migration event)
Admin revoked
MIGRATOR_ROLEfrom M (expected after migration completes)Admin later re-grants
MIGRATOR_ROLEto the same address M (requires admin action — possible via contract reuse, CREATE2 address reuse, or admin error)
Severity justification (Immunefi v2.3): The core defect maps to Medium — Griefing (no profit motive required): users are denied control over their own authorization state with no on-chain remedy. The escalation path could reach High (theft of unclaimed yield) but requires privileged admin action as a precondition, which limits the realistic severity.
Note on known issue "Migration operational risks": This finding is a specific code-level defect (unconditional hasRole check preventing permit revocation), not a general operational concern about migration coordination. The fix is a one-line code change. Operational risks typically refer to process issues (timing, coordination); this is a logic bug that breaks a documented invariant.
References
Affected source files:
src/Staking.sol:77-82—setMigrationPermitwith unconditionalhasRolechecksrc/Staking.sol:38—migrationPermitsmapping declarationsrc/Staking.sol:166-210—migratePositionsFromthat reads stale permitssrc/interfaces/IMigratorV1.sol:9-11— design intent documentation
Recommended fix — gate the hasRole check on the grant path only:
This preserves validation when granting permits (only grant to current migrators) while unconditionally allowing users to revoke their own permissions — aligning with the documented design intent.
Proof of Concept
Proof of Concept
PoC Code
Execution
Output
Test 1 proves the core defect: after MIGRATOR_ROLE is revoked from migratorAddr, Alice's call to setMigrationPermit(migratorAddr, false) reverts with MigratorNotFound. The migrationPermits mapping retains true — Alice cannot revoke her permit through any on-chain action.
Test 2 proves the full exploitation path: after admin re-grants MIGRATOR_ROLE to the same address, the migrator successfully calls migratePositionsFrom(alice) and migrates her 10-token stake using the stale permit she tried (and failed) to revoke.
Was this helpful?