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:

  1. The caller currently has MIGRATOR_ROLE.

  2. The stored approval migrationPermits[msg.sender][user] is still true.

That check is implemented in:

So the following sequence is possible:

  1. Governance grants MIGRATOR_ROLE to migrator A.

  2. Alice opts in with setMigrationPermit(A, true).

  3. Governance revokes MIGRATOR_ROLE from A.

  4. Alice tries to revoke with setMigrationPermit(A, false) and the call reverts with MigratorNotFound(A).

  5. Governance later re-grants MIGRATOR_ROLE to the same address A.

  6. migrationPermits[A][alice] is still true, so A can 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 MigratorV1 then 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-L81

  • migrationPermits storage: https://github.com/Folks-Finance/folks-staking-contracts/blob/3131a2d46b5afa76f606bf08adfd85452a47e2d8/src/Staking.sol#L36-L39

  • migratePositionsFrom() authorization check: https://github.com/Folks-Finance/folks-staking-contracts/blob/3131a2d46b5afa76f606bf08adfd85452a47e2d8/src/Staking.sol#L166-L209

  • Permissionless 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:

  1. Alice stakes.

  2. Alice permits the migrator.

  3. Governance revokes the migrator role.

  4. Alice cannot revoke her old approval anymore.

  5. Governance re-grants the same migrator role.

  6. A third party forces migration through the permissionless migrator.

Use forge to run the PoC:

Expected output:

Was this helpful?