69330 sc low revoked migrators leave non revocable stale permits that reactivate on role re grant
Submitted on Mar 14th 2026 at 08:40:46 UTC by @two for Audit Comp | Folks Finance: Staking Contracts
Report ID: #69330
Report Type: Smart Contract
Report severity: Low
Target: https://github.com/Folks-Finance/folks-staking-contracts/blob/main/src/interfaces/IMigratorV1.sol
Impacts:
Contract fails to deliver promised returns, but doesn't lose value
Description
Brief/Intro
setMigrationPermit() only allows a user to update consent for an address that currently holds MIGRATOR_ROLE. Because revoking the role does not clear stored approvals, a user who previously opted in cannot later revoke that approval while the migrator is disabled. If governance later re-grants MIGRATOR_ROLE to the same migrator address, the old approval becomes live again immediately. In the provided example implementation, MigratorV1.migrate(user) is permissionless, so any third party can force migration without renewed user consent.
Vulnerability Details
The root cause is that migration consent is keyed only by migrator address and survives role revocation, while the revoke path is blocked by the current-role check.
The user-facing consent function is:
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);
}Because of the hasRole(MIGRATOR_ROLE, _migrator) gate, once governance revokes the role from a migrator contract, users can no longer call setMigrationPermit(migrator, false) to clean up old approvals. However, the approval is still stored in:
Later, migration only checks:
The caller currently has
MIGRATOR_ROLE.The stored approval
migrationPermits[msg.sender][user]is stilltrue.
That check is implemented in:
So the following sequence is possible:
Governance grants
MIGRATOR_ROLEto migratorA.Alice opts in with
setMigrationPermit(A, true).Governance revokes
MIGRATOR_ROLEfromA.Alice tries to revoke with
setMigrationPermit(A, false)and the call reverts withMigratorNotFound(A).Governance later re-grants
MIGRATOR_ROLEto the same addressA.migrationPermits[A][alice]is stilltrue, soAcan again migrate Alice’s positions.
The practical trigger is stronger because the provided example migrator is permissionless:
There is no access control on migrate(user), so after a role re-grant any external account can trigger the migration path on behalf of the user.
Impact Details
Selected impact: Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
This issue breaks the documented consent model for migration. A user can opt in, governance can revoke the migrator, and the user still cannot revoke that approval during the disabled period. If governance later reuses the same migrator address, the user is silently opted back in without taking any new action.
In the current codebase, the direct consequence is unauthorized forced migration rather than direct theft:
The old staking contract will transfer the user’s remaining stake principal and reward entitlement to the migrator once
migratePositionsFrom(user)succeeds.The example
MigratorV1then forwards those assets and positions into the destination contract.Because
MigratorV1.migrate(user)is externally callable by anyone, a third party can execute the migration as soon as the role is re-granted.
This is best classified as griefing because the user loses control over migration timing and consent state, even though the PoC does not require the attacker to profit directly or steal principal in the source contract.
References
setMigrationPermit()gate: https://github.com/Folks-Finance/folks-staking-contracts/blob/3131a2d46b5afa76f606bf08adfd85452a47e2d8/src/Staking.sol#L77-L81migrationPermitsstorage: https://github.com/Folks-Finance/folks-staking-contracts/blob/3131a2d46b5afa76f606bf08adfd85452a47e2d8/src/Staking.sol#L36-L39migratePositionsFrom()authorization check: https://github.com/Folks-Finance/folks-staking-contracts/blob/3131a2d46b5afa76f606bf08adfd85452a47e2d8/src/Staking.sol#L166-L209Permissionless example migrator entrypoint: https://github.com/Folks-Finance/folks-staking-contracts/blob/3131a2d46b5afa76f606bf08adfd85452a47e2d8/src/test/MigratorV1.sol#L44-L60
README statement that users can revoke permission "at any time": https://github.com/Folks-Finance/folks-staking-contracts/blob/3131a2d46b5afa76f606bf08adfd85452a47e2d8/README.md#L140
Proof of Concept
The following Foundry test demonstrates the full PoC sequence:
Alice stakes.
Alice permits the migrator.
Governance revokes the migrator role.
Alice cannot revoke her old approval anymore.
Governance re-grants the same migrator role.
A third party forces migration through the permissionless migrator.
Use forge to run the PoC:
Expected output:
Was this helpful?