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.
"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
T0: User calls setMigrationPermit(migrator, true)
→ migrationPermits[migrator][user] = true
→ User consents during a legitimate migration window, per protocol documentation
T1: Admin revokes MIGRATOR_ROLE from migrator (incident response — e.g. compromised key)
→ hasRole(MIGRATOR_ROLE, migrator) = false
→ migratePositionsFrom reverts for migrator (onlyRole blocks it)
→ Users told: "migration paused for maintenance"
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
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
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:
The protocol's own documentation presents T1+T3 (revoke then re-grant) as the standard incident-response/upgrade cycle
The user takes a documented protective action at T2 that the contract blocks
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
Recommended Fix
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?