69275 sc low protocol s explicit revoke at any time promise broken users cannot revoke migration consent during incident window

Submitted on Mar 13th 2026 at 22:22:41 UTC by @lyxesxyz for Audit Comp | Folks Finance: Staking Contracts

  • Report ID: #69275

  • 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

The project README makes two explicit security guarantees:

"User-controlled migration: Users grant migration permission per-migrator explicitly. No migration can happen without the user's active approval."

"The permission can be revoked at any time by calling setMigrationPermit(migratorAddress, false)."

Both guarantees are violated by a single code path. setMigrationPermit() applies hasRole(MIGRATOR_ROLE, _migrator) unconditionally - for both granting and revoking consent. When an admin revokes a migrator's role as an incident response, users cannot revoke their own permits because the role check that blocks the attacker simultaneously blocks the victim's self-protection. If the role is subsequently re-granted to the same address — a routine operation for upgradeable proxy deployments or post-incident key rotation — all stale permits activate immediately, allowing the migrator to move the funds of previously consenting user's staked principal and unclaimed rewards with no re-consent required at all.

Vulnerability Details

Root cause — Staking.sol:78:

function setMigrationPermit(address _migrator, bool _isMigrationPermitted) external {
    if (!hasRole(MIGRATOR_ROLE, _migrator)) revert MigratorNotFound(_migrator);
    // this fires for _isMigrationPermitted = false as well as true
    migrationPermits[_migrator][msg.sender] = _isMigrationPermitted;

The role check is logically correct for grants (a user should not create a permit for a non-migrator). For revocations, consent withdrawal is a unilateral user right. It must not depend on the counterparty's current role status. The implementation makes revocation contingent on the migrator still holding the role. The actual moment when a user would most likely need to revoke (during an incident) is the only moment when revocation is impossible.

README guarantee
Code behaviour

"The permission can be revoked at any time"

Revocation reverts with MigratorNotFound when migrator's role is revoked

"No migration can happen without the user's active approval"

Migration executes on stale permits granted during a previous trust window, without re-consent

Secondary root cause — migratePositionsFrom (line 172):

Combined with the blocked revocation, a permit granted once at T0 can never be cleared by the user if the migrator's role is revoked between T0 and any future re-grant.

Exploit

1

T0: User calls setMigrationPermit(migrator, true)

migrationPermits[migrator][user] = true → User consents during a legitimate migration window, per protocol documentation

2

T1: Admin revokes MIGRATOR_ROLE from migrator (incident response — e.g. compromised key)

hasRole(MIGRATOR_ROLE, migrator) = falsemigratePositionsFrom reverts for migrator (onlyRole blocks it) → Users told: "migration paused for maintenance"

3

T2: User calls setMigrationPermit(migrator, false) to self-protect

→ REVERTS: MigratorNotFound(migrator) → Protocol's documented "revoke at any time" guarantee is broken → migrationPermits[migrator][user] remains true in storage → No admin function exists to clear a specific user's permit

4

T3: Admin re-grants MIGRATOR_ROLE (patch deployed at same proxy address, or new implementation at same address via upgradeable proxy pattern)

hasRole(MIGRATOR_ROLE, migrator) = true

5

T4: Migrator calls migratePositionsFrom(user) for every consenting user

→ permit check passes (stale permit, never cleared) → role check passes (re-granted) → Σ(amount - claimedAmount) + Σ(reward - claimedReward) transferred to migrator → UserStake entries deleted from V1 → User calls withdraw()StakeNotFound — no recovery in V1

Admin trust issue: The attack requires admin to re-grant the role (T3). However:

  1. The protocol's own documentation presents T1+T3 (revoke then re-grant) as the standard incident-response/upgrade cycle

  2. The user takes a documented protective action at T2 that the contract blocks

  3. The protocol explicitly promises the user can revoke "at any time" - breaking this promise removes the admin trust argument, since the exploit path works despite the user's attempt at protective action

  • No user action available: The user tried to invoke the documented protection mechanism and was blocked by the contract itself

  • No admin mitigation: No function exists to clear a specific user's permit on their behalf

  • Scope: All users who granted permits during any prior migration announcement window

Apply the role check only to grant operations. Revocations must be unconditionally available to the user, fulfilling the documented guarantee:

To additionally honour "one permit = one migration" (closing the stale permit path entirely):

Proof of Concept

Was this helpful?