Platform: Code4rena
Start Date: 07/08/2023
Pot Size: $36,500 USDC
Total HM: 11
Participants: 125
Period: 3 days
Judge: alcueca
Total Solo HM: 4
Id: 274
League: ETH
Rank: 29/125
Findings: 5
Award: $137.24
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: SpicyMeatball
Also found by: 0xComfyCat, GREY-HAWK-REACH, Yanchuan, cducrest, immeas, kaden, mert_eren, nonseodion, pep7siup, popular00, ppetrov
71.5198 USDC - $71.52
Users can expose themselves to only a small fraction of the risk involved with being deposited while still receiving 100% of the rewards, or even doubling their rewards for a given amount of liquidity.
When a deposit is made to a lending market, LendingLedger.sync_ledger
is called by the market, syncing their balance at the current epoch.
// sync_ledger() uint256 currEpoch = (block.timestamp / WEEK) * WEEK; int256 updatedLenderBalance = int256(lendingMarketBalances[lendingMarket][_lender][currEpoch]) + _delta; require(updatedLenderBalance >= 0, "Lender balance underflow"); // Sanity check performed here, but the market should ensure that this never happens lendingMarketBalances[lendingMarket][_lender][currEpoch] = uint256(updatedLenderBalance);
When users later claim
, it pays them out if they had a position during that epoch according to lendingMarketBalances
.
// claim() for (uint256 i = claimStart; i <= claimEnd; i += WEEK) { uint256 userBalance = lendingMarketBalances[_market][lender][i]; uint256 marketBalance = lendingMarketTotalBalance[_market][i]; RewardInformation memory ri = rewardInformation[i]; require(ri.set, "Reward not set yet"); // Can only claim for epochs where rewards are set, even if it is set to 0 uint256 marketWeight = gaugeController.gauge_relative_weight_write(_market, i); // Normalized to 1e18 cantoToSend += (marketWeight * userBalance * ri.amount) / (1e18 * marketBalance); // (marketWeight / 1e18) * (userBalance / marketBalance) * ri.amount; }
This means that users receive the full epoch reward even if they had only deposited at the very end of the epoch.
This allows malicious users to deposit at the end of epochs and withdraw at the start, later receiving the same amount of rewards as if they held their deposit the entire time. They can repeat this for every epoch, only being exposed to the associated risk for a couple blocks per epoch, while receiving the same amount of rewards as someone being exposed to risk for the full duration.
Furthermore, they could alternate their liquidity between markets such that they get the full reward of having their full amount of liquidity deposited on both markets, effectively doubling their intended reward for the amount of liquidity at risk.
As a result of attackers doubling their rewards and the simplicity of this attack, it's likely to become commonplace, leading to unknowing users being at a significant disadvantage.
It's recommended that deposits only set the user's balance for the next epoch while withdrawals still set the user's balance for the current epoch. For example,
// sync_ledger() uint256 currEpoch = (block.timestamp / WEEK) * WEEK; uint256 nextEpoch = ((block.timestamp + WEEK) / WEEK) * WEEK; int256 updatedLenderBalance if (_delta > 0) { // deposit, apply to next epoch updatedLenderBalance = int256(lendingMarketBalances[lendingMarket][_lender][nextEpoch]) + _delta; } else { // withdrawal, apply to current epoch updatedLenderBalance = int256(lendingMarketBalances[lendingMarket][_lender][currEpoch]) + _delta; } require(updatedLenderBalance >= 0, "Lender balance underflow"); // Sanity check performed here, but the market should ensure that this never happens lendingMarketBalances[lendingMarket][_lender][currEpoch] = uint256(updatedLenderBalance);
Other
#0 - c4-pre-sort
2023-08-12T15:05:39Z
141345 marked the issue as duplicate of #71
#1 - c4-judge
2023-08-25T11:00:07Z
alcueca changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-08-25T11:01:29Z
alcueca changed the severity to 3 (High Risk)
#3 - c4-judge
2023-08-25T11:03:05Z
alcueca marked the issue as partial-50
🌟 Selected for report: 0x73696d616f
Also found by: 0xCiphky, 0xComfyCat, 0xDetermination, GREY-HAWK-REACH, QiuhaoLi, SpicyMeatball, Team_Rocket, Tricko, Yanchuan, deadrxsezzz, immeas, kaden, lanrebayode77, ltyu, mert_eren, nonseodion, oakcobalt, popular00, th13vn
18.4722 USDC - $18.47
Users can reuse their voting power by voting, then delegating to an account they control, and voting again, repeating this process to have virtually infinite voting power.
When a user creates a lock, it _checkpoint
s the slope and lock end, which is later used in GaugeController.vote_for_gauge_weights
to determine the user's bias and increase the gauge weight and sum accordingly.
// vote_for_gauge_weights() ( , /*int128 bias*/ int128 slope_, /*uint256 ts*/ ) = ve.getLastUserPoint(msg.sender); ... uint256 lock_end = ve.lockEnd(msg.sender); ... VotedSlope memory new_slope = VotedSlope({ slope: (slope * _user_weight) / 10_000, end: lock_end, power: _user_weight });
We can see in VotingEscrow._checkpoint
, that the user Point
is calculated using the delegated amount.
// _checkpoint() if (_newLocked.end > block.timestamp && _newLocked.delegated > 0) { userNewPoint.slope = _newLocked.delegated / int128(int256(LOCKTIME)); userNewPoint.bias = userNewPoint.slope * int128(int256(_newLocked.end - block.timestamp)); } ... userPointHistory[_addr][uEpoch + 1] = userNewPoint;
The problem with all this is that there is nothing to invalidate a users prior votes once they delegate. As a result, they can delegate to an account they control, _checkpoint
ing that accounts' Point
, which is then used when they vote with vote_for_gauge_weights
from the delegated account, thereby effectively doubling their voting power.
Furthermore, the user can then repeat this process by delegating a new account they control and voting yet again, ad infinitum.
It's not clear how we can solve this problem without removing delegation logic unless we were to disable delegation for users who have an existing voting weight or who have previously delegated. It is otherwise very difficult to keep track of the share of voting weight of every user throughout every delegated vote.
Other
#0 - c4-pre-sort
2023-08-11T13:31:48Z
141345 marked the issue as duplicate of #45
#1 - c4-pre-sort
2023-08-13T13:17:03Z
141345 marked the issue as duplicate of #99
#2 - c4-pre-sort
2023-08-13T17:09:22Z
141345 marked the issue as duplicate of #178
#3 - c4-pre-sort
2023-08-13T17:38:12Z
141345 marked the issue as not a duplicate
#4 - c4-pre-sort
2023-08-13T17:38:28Z
141345 marked the issue as duplicate of #86
#5 - c4-judge
2023-08-25T10:51:22Z
alcueca changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-08-25T10:51:34Z
alcueca changed the severity to 3 (High Risk)
#7 - c4-judge
2023-08-25T10:54:17Z
alcueca marked the issue as partial-50
🌟 Selected for report: ltyu
Also found by: 0xDING99YA, 3docSec, KmanOfficial, MrPotatoMagic, RED-LOTUS-REACH, Tendency, Yuki, bart1e, bin2chen, carrotsmuggler, cducrest, kaden, mert_eren, pep7siup, popular00, qpzm, seerether, zhaojie
21.6049 USDC - $21.60
If a user's lock expires while delegated to another user, they cannot undelegate their tokens and therefore cannot withdraw, permanently locking their tokens in the contract.
Consider a circumstance in which a user has delegated to another user. Their lock has expired and they intend to withdraw their locked tokens.
In VotingEscrow.withdraw
, you cannot withdraw your lock if you're currently delegating to another account.
// withdraw() require(locked_.delegatee == msg.sender, "Lock delegated");
But in delegate
, you can't set a new delegate as an expired account.
// delegate() require(toLocked.end > block.timestamp, "Delegatee lock expired");
This logic makes sense in the case that you are actually delegating to another account, but the logic is the same here regardless of whether you're delegating or undelegating.
As a result, in the above example, the user cannot withdraw their position even though it has reached the end time.
Normally, users can extend their positions using increaseAmount
, which resets the lock period, but this will revert because you can't execute it on expired locks.
// increaseAmount() require(locked_.end > block.timestamp, "Lock expired");
Since we cannot undelegate with an expired lock and we cannot extend the end time of the lock in any way, the users funds are permanently locked.
It's recommended that we allow undelegation of expired locks. e.g.
// delegate() require(toLocked == locked_ || toLocked.end > block.timestamp, "Delegatee lock expired");
DoS
#0 - c4-pre-sort
2023-08-11T11:55:02Z
141345 marked the issue as duplicate of #223
#1 - c4-pre-sort
2023-08-13T12:00:46Z
141345 marked the issue as duplicate of #112
#2 - c4-judge
2023-08-24T07:16:15Z
alcueca marked the issue as duplicate of #82
#3 - c4-judge
2023-08-24T07:20:40Z
alcueca changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-08-24T07:26:53Z
alcueca marked the issue as partial-50
#5 - c4-pre-sort
2023-08-24T08:20:16Z
141345 marked the issue as not a duplicate
#6 - c4-pre-sort
2023-08-24T08:20:23Z
141345 marked the issue as not a duplicate
#7 - c4-pre-sort
2023-08-24T08:23:06Z
141345 marked the issue as duplicate of #211
#8 - c4-judge
2023-08-24T21:15:48Z
alcueca marked the issue as partial-50
#9 - c4-judge
2023-08-26T21:24:29Z
alcueca changed the severity to 3 (High Risk)
🌟 Selected for report: ADM
Also found by: 0xDING99YA, 3docSec, BenRai, Jorgect, Kow, MrPotatoMagic, QiuhaoLi, RandomUser, SpicyMeatball, Tendency, Topmark, Watermelon, Yanchuan, Yuki, bart1e, cducrest, kaden, lsaudit, nemveer, nonseodion
15.8333 USDC - $15.83
Any time a user delegates to another user, they will have to at some point reset their lock time to undelegate. As a result, delegatees can, either intentionally or unintentionally, increase their lock time to prevent delegators from undelegating, preventing withdrawal.
In VotingEscrow.delegate
, users may only delegate to locks with a later end time.
// delegate() require(toLocked.end >= fromLocked.end, "Only delegate to longer lock");
The problem with this is that the same logic applies when undelegating, which must be done to withdraw.
// withdraw() require(locked_.delegatee == msg.sender, "Lock delegated");
Since the lock we're delegating to must be longer than the lock we're delegating from, and we also have to undelegate from that lock with matching logic as if we're delegating, we must at some point reset our lock time. Because the lock can only reset to an end date of 5 years from the current time, and the user may delegate at any point throughout the lifecycle of their lock, this logic could easily force the user to lock their tokens for almost double the initially expected lock time.
Consider for example, a circumstance in which a user delegates their lock until shortly before it expires before going to undelegate their lock and suddenly realizes that they must extend their lock period by 5 years.
Furthermore, it's possible that the delegatee extends their lock period, either for their own sake or to prevent the delegator from undelegating, causing the user to have their lock extended indefinitely.
Mitigating this issue is difficult because allowing a user to undelegate with a closer end time means that users can execute an attack by which they delegate to another, newer account which they control so that they can prevent voting decay. At this point I'm not clear on any solutions other than removing delegation functionality or somehow including delegator voting decay into the delegated amount such that they can't perform the attack to artificially prevent voting decay.
DoS
#0 - c4-pre-sort
2023-08-12T07:46:26Z
141345 marked the issue as duplicate of #116
#1 - c4-pre-sort
2023-08-13T14:35:10Z
141345 marked the issue as duplicate of #82
#2 - c4-judge
2023-08-24T07:20:39Z
alcueca changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-08-24T07:24:58Z
alcueca marked the issue as satisfactory
#4 - c4-judge
2023-08-24T07:25:02Z
alcueca marked the issue as partial-50
#5 - c4-pre-sort
2023-08-24T08:21:03Z
141345 marked the issue as not a duplicate
#6 - c4-pre-sort
2023-08-24T08:21:08Z
141345 marked the issue as primary issue
#7 - c4-judge
2023-08-24T21:11:58Z
alcueca marked the issue as partial-50
#8 - c4-judge
2023-08-24T21:14:31Z
alcueca marked issue #245 as primary and marked this issue as a duplicate of 245
#9 - c4-judge
2023-08-29T06:37:10Z
alcueca marked the issue as duplicate of #182
#10 - c4-judge
2023-08-29T06:37:36Z
alcueca changed the severity to 3 (High Risk)
🌟 Selected for report: RED-LOTUS-REACH
Also found by: 0x3b, 0x4non, 0xCiphky, 0xDING99YA, 0xDetermination, 0xE1, 0xG0P1, 0xStalin, 0xWaitress, 0xbrett8571, 0xhacksmithh, 0xkazim, 0xmuxyz, 0xweb3boy, 14si2o_Flint, AlexCzm, Alhakista, Bube, Bughunter101, Deekshith99, Eeyore, Giorgio, HChang26, InAllHonesty, JP_Courses, KmanOfficial, MatricksDeCoder, Mike_Bello90, MrPotatoMagic, Naubit, QiuhaoLi, RHaO-sec, Raihan, Rolezn, SUPERMAN_I4G, Shubham, Silverskrrrt, Strausses, T1MOH, Topmark, Tripathi, Watermelon, _eperezok, aakansha, auditsea, audityourcontracts, ayden, carlos__alegre, castle_chain, cducrest, ch0bu, d23e, deadrxsezzz, deth, devival, erebus, fatherOfBlocks, halden, hassan-truscova, hpsb, hunter_w3b, imkapadia, immeas, jat, kaden, kaveyjoe, klau5, koxuan, kutugu, ladboy233, lanrebayode77, leasowillow, lsaudit, markus_ether, matrix_0wl, merlin, nemveer, ni8mare, nonseodion, oakcobalt, owadez, p_crypt0, pipidu83, piyushshukla, popular00, ppetrov, rjs, sandy, sl1, supervrijdag, tay054, thekmj, wahedtalash77, windhustler, zhaojie
9.8204 USDC - $9.82
_get_sum
call at end of GaugeController.vote_for_gauge_weight
is a noop_get_sum
only runs a loop iteration if time_sum
<= block.timestamp.
// _get_sum() uint256 t = time_sum; Point memory pt = points_sum[t]; for (uint256 i; i < 500; ++i) { if (t > block.timestamp) break;
And it the return value is unused.
// vote_for_gauge_weight _get_sum(); // @audit: unused return value
Since we executed _get_sum
earlier during the function execution, we set time_sum
as > block.timestamp.
// _get_sum if (t > block.timestamp) time_sum = t;
As a result, calling _get_sum
here is a noop.
LOCKTIME
is 1825 days, which amounts to 260.7142857142857 epochs.
// VotingEscrow.sol uint256 public constant LOCKTIME = 1825 days;
Creating a lock sets your unlock time as the _floorToWeek
value of 1825 days from the current timestamp.
// createLock uint256 unlock_time = _floorToWeek(block.timestamp + LOCKTIME); // Locktime is rounded down to weeks
As a result, creating a lock in the last ~30% of an epoch results in an extra epoch considering to the first ~70%.
Increasing the locktime to 1827 days would be cleanly divisible by epochs, resulting in the same amount of epochs regardless of lock timing. This is also still well aligned with 5 years when you consider leap years, since any 5 year period has at least one leap year, if not two, 5 years would be at least 1826 days, if not 1827 depending on timing.
#0 - c4-judge
2023-08-22T13:31:48Z
alcueca marked the issue as grade-a