cryptic - `VotingEscrow` unsafe downcasting from `uint256` to `int128` may cause some users' tokens to be permanently locked and never have voting power #525
VotingEscrow unsafe downcasting from uint256 to int128 may cause some users' tokens to be permanently locked and never have voting power
Summary
Creating a lock by depositing LP tokens in VotingEscrow allows users to receive a veNFT which gives them voting power for gauges.
Due to unsafe downcast when depositing, some users may permanently lose LP tokens that can never be recovered, in addition to never being able to vote on gauges for rewards.
Vulnerability Detail
The following function is executed whenever locks are initiated:
function _deposit_for(
uint _tokenId,
@> uint _value,
uint unlock_time,
LockedBalance memory locked_balance,
DepositType deposit_type
) internal {
LockedBalance memory _locked = locked_balance;
uint supply_before = supply;
supply = supply_before + _value;
LockedBalance memory old_locked;
(old_locked.amount, old_locked.end) = (_locked.amount, _locked.end);
// Adding to existing lock, or if a lock is expired - creating a new one
@> _locked.amount += int128(int256(_value));
if (unlock_time != 0) {
_locked.end = unlock_time;
}
locked[_tokenId] = _locked;
// Possibilities:
// Both old_locked.end could be current or expired (>/< block.timestamp)
// value == 0 (extend lock) or value > 0 (add to lock or extend lock)
// _locked.end > block.timestamp (always)
_checkpoint(_tokenId, old_locked, _locked);
address from = msg.sender;
if (_value != 0 && deposit_type != DepositType.MERGE_TYPE && deposit_type != DepositType.SPLIT_TYPE) {
@> assert(IERC20(lpToken).transferFrom(from, address(this), _value));
}
emit Deposit(from, _tokenId, _value, _locked.end, deposit_type, block.timestamp);
emit Supply(supply_before, supply_before + _value);
}
_value sent by the user is downcasted from uint256 -> int256 -> int128. Note that type(uint256).max > type(int256).max and type(uint128).max > type(int128).max.
We can see that if _value is greater than type(int128).max, the value will overflow when downcasted to int128, and is truncated by wrapping around to a negative value. The locked.amount stored will not reflect the actual _value sent by the user, and will instead store a negative value for locked.amount.
Let's look at what happens for the following scenarios:
User attempts to withdraw funds after lock time passes from depositing value greater than type(int128).max
User attempts to vote on a gauge using the veNFT that was minted by locking these funds
Scenario 1: Let's look at what happens when the user attempts to withdraw after their lock time has passed. Recall that locked.amount (type int128) is storing a negative value due to the overflow from deposit.
function withdraw(uint _tokenId) external nonreentrant {
assert(_isApprovedOrOwner(msg.sender, _tokenId));
require(attachments[_tokenId] == 0 && !voted[_tokenId], "attached");
LockedBalance memory _locked = locked[_tokenId];
require(block.timestamp >= _locked.end, "The lock didn't expire");
@> uint value = uint(int256(_locked.amount));
locked[_tokenId] = LockedBalance(0,0);
uint supply_before = supply;
supply = supply_before - value;
// old_locked can have either expired <= timestamp or zero end
// _locked has only 0 end
// Both can have >= 0 amount
_checkpoint(_tokenId, _locked, LockedBalance(0,0));
@> assert(IERC20(lpToken).transfer(msg.sender, value));
// Burn the NFT
_burn(_tokenId);
emit Withdraw(msg.sender, _tokenId, value, block.timestamp);
emit Supply(supply_before, supply_before - value);
}
_locked.amount (which is negative), is casted to int256 and then uint256. When it is casted to int256, it will remain negative, but when casted to uint256, it will underflow to type(uint256).max. The function will attempt to transfer type(uint256).max number of tokens to the user instead of the original amount locked which was slightly greater than type(int128).max. It is very unlikely that the contract will have this many tokens (if it does, than the user will be able to steal them all), causing permanent DoS of withdrawal. The user will never be able to get back their locked tokens.
Scenario 2: Let's look at what happens when the user attempts to vote on gauges after locking (recall locked.amount holds a negative value)
Therefore the poolWeight will be 0 and due to the require(_poolWeight != 0); statement, the voting call will revert.
Thus, all withdrawals and voting is blocked if the user locks a value of more than type(int128).max, causing permanent loss of funds.
Note:
Although it is unlikely someone will hold LP tokens greater than type(int128).max (decimal: 170141183460469231731687303715884105727), if it does happen the impact is huge, as it will cause the user to lose all funds permanently and never be able to vote using the veNFT that was minted.
Proof of Concept
Coded PoC
Add the following to `test/Voter.t.sol` and run `forge test --mt testLockedFunds -vv`
```javascript
function testLockedFunds() public {
// set gauge, rewards, external bribe
voter.createGauge(address(pair), 0);
address gaugeAddress = voter.gauges(address(pair));
address[] memory rewards = new address[](2);
rewards[0] = address(USDC);
rewards[1] = address(FRAX);
ExternalBribe newExternalBribe = new ExternalBribe(
address(voter),
rewards
);
vm.expectEmit(true, true, false, true);
emit ExternalBribeSet(address(this), gaugeAddress, address(newExternalBribe));
voter.setExternalBribeFor(gaugeAddress, address(newExternalBribe));
Router.route[] memory routes = new Router.route[](1);
routes[0] = Router.route(address(USDC), address(FRAX), true);
assertEq(
router.getAmountsOut(USDC_1, routes)[1],
pair.getAmountOut(USDC_1, address(USDC))
);
uint256[] memory assertedOutput = router.getAmountsOut(USDC_1, routes);
USDC.approve(address(router), USDC_1);
router.swapExactTokensForTokens(
USDC_1,
assertedOutput[1],
routes,
address(owner),
block.timestamp
);
vm.startPrank(address(owner));
uint256 lockDuration = 5 * 7 * 24 * 3600; // 5 weeks
FLOW.mint(address(owner), 1e50);
DAI.mint(address(owner), 1e50);
FLOW.approve(address(router), 1e50);
DAI.approve(address(router), 1e50);
router.addLiquidity(address(FLOW), address(DAI), false, 1e50, 1e50, 0, 0, address(owner), block.timestamp); // unrealistic value but allows for simplicity when testing
uint256 lpAmount = flowDaiPair.balanceOf(address(owner));
console.log("Total amount LP before lock: ", lpAmount);
flowDaiPair.approve(address(escrow), lpAmount);
escrow.create_lock(lpAmount, lockDuration);
vm.roll(block.number + 1); // fwd 1 block because escrow.balanceOfNFT() returns 0 in same block
int128 amountLocked;
(amountLocked, ) = escrow.locked(1);
console.log("Amount locked: ", uint256(int256(amountLocked)));
console.log("NFT balance: ", escrow.balanceOfNFT(1));
uint256 lpAmountAfter = flowDaiPair.balanceOf(address(owner));
console.log("Total amount LP after lock: ", lpAmountAfter);
vm.stopPrank();
vm.warp(block.timestamp + 1 weeks);
// set values for voting
address[] memory pools = new address[](1);
pools[0] = address(pair);
uint256[] memory weights = new uint256[](1);
weights[0] = 5000;
vm.expectRevert();
voter.vote(1, pools, weights);
vm.warp(block.timestamp + 4 weeks); // unlock time
vm.expectRevert();
escrow.withdraw(1);
}
```
Console Output
Running 1 test for test/Voter.t.sol:VoterTest
[PASS] testLockedFunds() (gas: 7812561)
Logs:
Total amount LP before lock: 100000000000000000000000000000301999999999999999000
Amount locked: 115792089237316195423570985008687907853124301955207669823926529107935559679000
NFT balance: 0
Total amount LP after lock: 0
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.11ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
These values allow for simple testing. In the test, the user attempts to lock 100000000000000000000000000000301999999999999999000 LP tokens but it is downcasted to int128 which will be negative due to overflow, so it underflows to type(uint256).max when casted back to uint256, causing an incorrect value for amount locked. User can never withdraw nor vote on gauges.
Impact
Permanent loss of funds, denial of service, no voting power for amount deposited, tokens stuck forever in VotingEscrow.
cryptic
Medium
VotingEscrow
unsafe downcasting fromuint256
toint128
may cause some users' tokens to be permanently locked and never have voting powerSummary
Creating a lock by depositing LP tokens in
VotingEscrow
allows users to receive aveNFT
which gives them voting power for gauges.Due to unsafe downcast when depositing, some users may permanently lose LP tokens that can never be recovered, in addition to never being able to vote on gauges for rewards.
Vulnerability Detail
The following function is executed whenever locks are initiated:
VotingEscrow.sol#L777-L810
_value
sent by the user is downcasted from uint256 -> int256 -> int128. Note that type(uint256).max > type(int256).max and type(uint128).max > type(int128).max.We can see that if
_value
is greater thantype(int128).max
, the value will overflow when downcasted toint128
, and is truncated by wrapping around to a negative value. Thelocked.amount
stored will not reflect the actual_value
sent by the user, and will instead store a negative value forlocked.amount
.Let's look at what happens for the following scenarios:
type(int128).max
veNFT
that was minted by locking these fundsScenario 1: Let's look at what happens when the user attempts to withdraw after their lock time has passed. Recall that
locked.amount
(type int128) is storing a negative value due to the overflow from deposit.VotingEscrow.sol#L955-L979
_locked.amount
(which is negative), is casted toint256
and thenuint256
. When it is casted toint256
, it will remain negative, but when casted touint256
, it will underflow totype(uint256).max
. The function will attempt to transfertype(uint256).max
number of tokens to the user instead of the original amount locked which was slightly greater thantype(int128).max
. It is very unlikely that the contract will have this many tokens (if it does, than the user will be able to steal them all), causing permanent DoS of withdrawal. The user will never be able to get back their locked tokens.Scenario 2: Let's look at what happens when the user attempts to vote on gauges after locking (recall
locked.amount
holds a negative value)Voter.sol#L249-L285
VotingEscrow::balanceOfNFT
returns 0 if amount locked is negative:VotingEscrow.sol#L1017-L1028
Therefore the
poolWeight
will be 0 and due to therequire(_poolWeight != 0);
statement, the voting call will revert.Thus, all withdrawals and voting is blocked if the user locks a value of more than
type(int128).max
, causing permanent loss of funds.Proof of Concept
Coded PoC
Add the following to `test/Voter.t.sol` and run `forge test --mt testLockedFunds -vv` ```javascript function testLockedFunds() public { // set gauge, rewards, external bribe voter.createGauge(address(pair), 0); address gaugeAddress = voter.gauges(address(pair)); address[] memory rewards = new address[](2); rewards[0] = address(USDC); rewards[1] = address(FRAX); ExternalBribe newExternalBribe = new ExternalBribe( address(voter), rewards ); vm.expectEmit(true, true, false, true); emit ExternalBribeSet(address(this), gaugeAddress, address(newExternalBribe)); voter.setExternalBribeFor(gaugeAddress, address(newExternalBribe)); Router.route[] memory routes = new Router.route[](1); routes[0] = Router.route(address(USDC), address(FRAX), true); assertEq( router.getAmountsOut(USDC_1, routes)[1], pair.getAmountOut(USDC_1, address(USDC)) ); uint256[] memory assertedOutput = router.getAmountsOut(USDC_1, routes); USDC.approve(address(router), USDC_1); router.swapExactTokensForTokens( USDC_1, assertedOutput[1], routes, address(owner), block.timestamp ); vm.startPrank(address(owner)); uint256 lockDuration = 5 * 7 * 24 * 3600; // 5 weeks FLOW.mint(address(owner), 1e50); DAI.mint(address(owner), 1e50); FLOW.approve(address(router), 1e50); DAI.approve(address(router), 1e50); router.addLiquidity(address(FLOW), address(DAI), false, 1e50, 1e50, 0, 0, address(owner), block.timestamp); // unrealistic value but allows for simplicity when testing uint256 lpAmount = flowDaiPair.balanceOf(address(owner)); console.log("Total amount LP before lock: ", lpAmount); flowDaiPair.approve(address(escrow), lpAmount); escrow.create_lock(lpAmount, lockDuration); vm.roll(block.number + 1); // fwd 1 block because escrow.balanceOfNFT() returns 0 in same block int128 amountLocked; (amountLocked, ) = escrow.locked(1); console.log("Amount locked: ", uint256(int256(amountLocked))); console.log("NFT balance: ", escrow.balanceOfNFT(1)); uint256 lpAmountAfter = flowDaiPair.balanceOf(address(owner)); console.log("Total amount LP after lock: ", lpAmountAfter); vm.stopPrank(); vm.warp(block.timestamp + 1 weeks); // set values for voting address[] memory pools = new address[](1); pools[0] = address(pair); uint256[] memory weights = new uint256[](1); weights[0] = 5000; vm.expectRevert(); voter.vote(1, pools, weights); vm.warp(block.timestamp + 4 weeks); // unlock time vm.expectRevert(); escrow.withdraw(1); } ```
Console Output
These values allow for simple testing. In the test, the user attempts to lock
100000000000000000000000000000301999999999999999000
LP tokens but it is downcasted toint128
which will be negative due to overflow, so it underflows totype(uint256).max
when casted back touint256
, causing an incorrect value for amount locked. User can never withdraw nor vote on gauges.Impact
Permanent loss of funds, denial of service, no voting power for amount deposited, tokens stuck forever in
VotingEscrow
.Code Snippet
https://github.com/sherlock-audit/2024-06-velocimeter/blob/main/v4-contracts/contracts/VotingEscrow.sol#L777-L810
https://github.com/sherlock-audit/2024-06-velocimeter/blob/main/v4-contracts/contracts/VotingEscrow.sol#L955-L979
https://github.com/sherlock-audit/2024-06-velocimeter/blob/main/v4-contracts/contracts/Voter.sol#L249-L285
https://github.com/sherlock-audit/2024-06-velocimeter/blob/main/v4-contracts/contracts/VotingEscrow.sol#L1017-L1028
Tool used
Manual Review
Recommendation
Use SafeCastLibrary for casting as it's done on Velodrome V2
Duplicate of #225