69031 sc low user cannot revoke permission from migrator if it does not have migrator role

Submitted on Mar 12th 2026 at 12:41:30 UTC by @Audittens for Audit Comp | Folks Finance: Staking Contracts

  • Report ID: #69031

  • Report Type: Smart Contract

  • Report severity: Low

  • Target: https://github.com/Folks-Finance/folks-staking-contracts/blob/main/src/Staking.sol

  • Impacts:

    • Contract fails to deliver promised returns, but doesn't lose value

    • Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief/Intro

Function Staking::setMigrationPermit requires _migrator to have MIGRATOR_ROLE even when a user attempts to revoke permission. This violates the promise: "The permission can be revoked at any time by calling setMigrationPermit(migratorAddress, false)".

Moreover, this behavior enables a malicious admin to steal users' funds.

Vulnerability Details

Function Staking::setMigrationPermit(address _migrator, bool _isMigrationPermitted) allows users to grant (_isMigrationPermitted = true) or revoke (_isMigrationPermitted = false) permission to migrate their positions using _migrator. However, in both cases it requires hasRole(MIGRATOR_ROLE, _migrator).

Consider the following scenario:

  1. Admin grants MIGRATOR_ROLE to Migrator.

  2. User grants migration permission to Migrator.

  3. Admin revokes MIGRATOR_ROLE from Migrator.

  4. User attempts to revoke permission from Migrator but cannot — this violates the promise: "The permission can be revoked at any time by calling setMigrationPermit(migratorAddress, false)".

Additionally, this issue can allow a malicious admin to steal users' funds. Consider the following scenario:

  1. Admin deploys an upgradeable Migrator contract with a good implementation and the ability to upgrade the implementation with a 30-day timelock.

  2. Admin grants MIGRATOR_ROLE to the Migrator.

  3. User grants permission to the Migrator, trusting the timelock: if the admin upgrades the Migrator, the user expects to have 30 days to revoke permission.

  4. Admin revokes MIGRATOR_ROLE from the Migrator and schedules an upgrade to a malicious implementation that steals users' funds.

  5. The user observes this upgrade but cannot revoke permission, because the Migrator no longer has MIGRATOR_ROLE.

  6. After 30 days, the admin atomically grants MIGRATOR_ROLE back to the Migrator, completes the upgrade, and steals the user's funds via the malicious Migrator implementation.

Impact Details

There are two impacts:

1. [Critical] Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

A malicious admin can steal users' funds.

Note that this attack requires a malicious admin, but it is still in scope, because by design the admin only has the privilege to:

  • authorize potential migrators

but does not have privileges to:

  • prevent users from revoking their permissions, or

  • steal users' funds.

Relevant program rules:

Default Out of Scope and rules Impacts caused by attacks requiring access to privileged addresses (including, but not limited to: governance and strategist contracts) without additional modifications to the privileges attributed

What addresses would you consider any bug report requiring their involvement to be out of scope, as long as they operate within the privileges attributed to them? Default admin, manager, pauser, migrator.

2. [Low] Contract fails to deliver promised returns, but doesn't lose value

The contract violates the documented guarantee: "The permission can be revoked at any time by calling setMigrationPermit(migratorAddress, false)".

Importantly, this issue does not require a malicious admin.

References

  • https://github.com/Folks-Finance/folks-staking-contracts/blob/3131a2d/src/Staking.sol#L78

  • https://github.com/Folks-Finance/folks-staking-contracts/blob/3131a2d/README.md?plain=1#L140C72-L140C169

Proof of Concept

The following codes simulate the scenarios described in the Vulnerability Details section.

Save them to files src/test/PoC_1_AdminStealsFunds.t.sol and src/test/PoC_2_UnsetPermitFails.t.sol respectively. Then run: forge test --mc PoC* -vv. This will produce the following logs:

PoC for impact 1 (Critical)

PoC for impact 2 (Low)

Was this helpful?