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:
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 internalVirtualStakingRewards
contract doesn't have any access control on its publicgetReward()
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:
Code Snippet
Unlike the other functions, there is no
onlyOperator
modifier, and the user acted upon is not required to bemsg.sender
: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 viaVotingEscrowTruf.claimReward()