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: 23/125
Findings: 4
Award: $187.16
🌟 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
143.0396 USDC - $143.04
https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/LendingLedger.sol#L129-L143 https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/LendingLedger.sol#L152-L182
Anytime a user deposits during an epoch (i.e a week) he immediately is eligible to earn from the rewards for that lending market that week. This occurs because when the lending pool calls sync_ledger
, LendingLedger adds the deposit to the users deposit for that week. It updates the lenders balance using these lines and the market's deposits here. It calculates the current epoch and uses it to update these balances.
This allows a user to deposit even 1 second before an epoch ends and still claim the rewards for that epoch.
function testDepositAndClaimEndOfEpoch() public { uint248 amountPerEpoch = 1 ether; uint256 fromEpoch = WEEK * 5; uint256 toEpoch = WEEK * 10; address lendingMarket = vm.addr(5201314); vm.prank(goverance); ledger.whiteListLendingMarket(lendingMarket, true); payable(ledger).transfer(1000 ether); vm.prank(goverance); ledger.setRewards(fromEpoch, toEpoch, amountPerEpoch); // first attack vm.warp(WEEK * 6 - 1); // 1 second before week 5 ends address lender = users[1]; int256 delta = 1.1 ether; vm.prank(lendingMarket); ledger.sync_ledger(lender, delta); // user deposits in lending pool vm.prank(lender); vm.warp(WEEK * 6); // now in week 6 uint256 balanceBefore = address(lender).balance; ledger.claim(lendingMarket, fromEpoch, fromEpoch); uint256 balanceAfter = address(lender).balance; assertTrue(balanceAfter - balanceBefore == 1 ether); // user claimed 1 ether vm.startPrank(lendingMarket); ledger.sync_ledger(lender, -delta); // user withdraws // second attack vm.warp(WEEK * 7 - 1); // 1 second before week 6 ends ledger.sync_ledger(lender, delta); // user deposits in lending pool vm.stopPrank(); vm.prank(lender); vm.warp(WEEK * 7); // now in week 6 balanceBefore = address(lender).balance; ledger.claim(lendingMarket, WEEK*6, WEEK*6); // user claims 1 ether balanceAfter = address(lender).balance; assertTrue(balanceAfter - balanceBefore == 1 ether); vm.startPrank(lendingMarket); ledger.sync_ledger(lender, -delta); // user withdraws }
Foundry, Vscode
Let the deposits during an epoch count towards the rewards earned in the next epoch.
function sync_ledger(address _lender, int256 _delta) external { address lendingMarket = msg.sender; require(lendingMarketWhitelist[lendingMarket], "Market not whitelisted"); _checkpoint_lender(lendingMarket, _lender, type(uint256).max); - uint256 currEpoch = (block.timestamp / WEEK) * WEEK; + uint256 nextEpoch = ((block.timestamp + WEEK) / WEEK) * WEEK; int256 updatedLenderBalance = int256(lendingMarketBalances[lendingMarket][_lender][nextEpoch]) + _delta; require(updatedLenderBalance >= 0, "Lender balance underflow"); // Sanity check performed here, but the market should ensure that this never happens lendingMarketBalances[lendingMarket][_lender][nextEpoch] = uint256(updatedLenderBalance); _checkpoint_market(lendingMarket, type(uint256).max); int256 updatedMarketBalance = int256(lendingMarketTotalBalance[lendingMarket][nextEpoch]) + _delta; require(updatedMarketBalance >= 0, "Market balance underflow"); // Sanity check performed here, but the market should ensure that this never happens lendingMarketTotalBalance[lendingMarket][nextEpoch] = uint256(updatedMarketBalance); }
Math
#0 - c4-pre-sort
2023-08-12T15:02:46Z
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:02:57Z
alcueca marked the issue as satisfactory
🌟 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
https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/VotingEscrow.sol#L356-L387 https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/GaugeController.sol#L211-L278
Users should be able to have only one concurrent vote on a pool in GaugeController. When a user votes the weight of his vote is calculated using his _user_weight
parameter and the slope and end time of his balance lock are used to calculate the bias for that pool.
But nothing stops him from delegating his balance to another voter who can then use it to vote in GaugeController. This means that many delegators can vote individually on a pool and also delegate their balance to another voter who uses all their balances to vote on the same pool. The time of delegation does not matter i.e before or after individual votes. This effectively allows them to have multiple votes on a pool. It can also be carried out by an individual user through multiple iterations of the following proof of concept.
Alice creates an attacker contract to:
VsCode
Depending on what the team actually wants, two different steps can be taken.
delegatee
of a msg.sender
by calling the locked
function on VotingEscrow. GaugeController can revert if delegatee != msg.sender
Other
#0 - c4-pre-sort
2023-08-11T13:31:51Z
141345 marked the issue as duplicate of #45
#1 - c4-pre-sort
2023-08-13T13:17:13Z
141345 marked the issue as duplicate of #99
#2 - c4-pre-sort
2023-08-13T17:11:40Z
141345 marked the issue as duplicate of #178
#3 - c4-pre-sort
2023-08-13T17:24:38Z
141345 marked the issue as not a duplicate
#4 - c4-pre-sort
2023-08-13T17:24:59Z
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:53:22Z
alcueca marked the issue as partial-50
🌟 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
https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/VotingEscrow.sol#L383-L384 https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/VotingEscrow.sol#L330-L331
Before a voter can withdraw, the end time of his locked balance must have passed and he must be the delegate for his balance.
For a voter who does not delegate and his lock time has passed, he can easily withdraw. But if the voter has delegated and the locked balance end time has passed, he satisfies the first condition but would fail the second condition because he is not the current delegatee
. When he tries to redelegate, he is met with these conditions in the delegate function.
require(toLocked.end > block.timestamp, "Delegatee lock expired"); require(toLocked.end >= fromLocked.end, "Only delegate to longer lock");
He cannot redelegate back to himself without calling increaseAmount
to increase the amount of CANTO he has deposited and resetting his lock time for another 5 years.
Hence for a user that delegates to withdraw, he'll have to increase the amount he deposited, redelegate to himself and then wait for 5 years before he can withdraw his deposit.
delegatee
for her balance.delegate
because her lock time is over so she calls increaseAmount
which resets her lock time to another 5 years.Manual Analysis
Allow users to be able to delegate back to themselves regardless of lock time.
- require(toLocked.amount > 0, "Delegatee has no lock"); - require(toLocked.end > block.timestamp, "Delegatee lock expired"); - require(toLocked.end >= fromLocked.end, "Only delegate to longer lock"); --- + if(_addr != msg.sender){ + require(toLocked.amount > 0, "Delegatee has no lock"); // this check has already been done for msg.sender + require(toLocked.end > block.timestamp, "Delegatee lock expired"); + require(toLocked.end >= fromLocked.end, "Only delegate to longer lock"); + }
Invalid Validation
#0 - c4-pre-sort
2023-08-11T11:59:40Z
141345 marked the issue as duplicate of #223
#1 - c4-pre-sort
2023-08-13T11:54:11Z
141345 marked the issue as not a duplicate
#2 - c4-pre-sort
2023-08-13T11:55:03Z
141345 marked the issue as duplicate of #178
#3 - c4-judge
2023-08-24T07:14:09Z
alcueca marked the issue as duplicate of #82
#4 - c4-judge
2023-08-24T07:20:31Z
alcueca changed the severity to 3 (High Risk)
#5 - c4-judge
2023-08-24T07:20:40Z
alcueca changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-08-24T07:27:51Z
alcueca marked the issue as partial-50
#7 - c4-judge
2023-08-29T06:37:36Z
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
A voter cannot redelegate his deposit except the new delegatee
has a higher lock time than the current delegatee.
require(toLocked.end >= fromLocked.end, "Only delegate to longer lock");
This is a good design choice for the benefit of the protocol. But for a user that wants to redelegate, undelegate he may be prevented by the current delegatee.
The current delegatee can achieve this by frontrunning any transaction that wants to redelegate an amount currently delegated to him. The frontrun transaction will increase his lock time, which will make the incoming transaction invalid. Although this can be avoided if the transaction is sent through a private RPC URL or if the new delegate's lock time can be increased and the delegation happens in the same block. Depending on the technical level of the voter, this might be feasible to perform.
Ultimately, the lock time of the new delegatee has to be increased to redelegate or undelegate which may not be favorable for him.
Vscode
The current check seems more like a design choice to make sure voters remain in the contract longer than their lock time. Replacing it with the check below will help achieve the same objective with no detriment to the voter.
require(toLocked.end >= locked_.end, "Cannot delegate to shorter lock");
Invalid Validation
#0 - c4-pre-sort
2023-08-12T07:45:32Z
141345 marked the issue as duplicate of #116
#1 - c4-pre-sort
2023-08-13T14:35:09Z
141345 marked the issue as duplicate of #82
#2 - c4-judge
2023-08-24T07:20:31Z
alcueca changed the severity to 3 (High Risk)
#3 - c4-judge
2023-08-24T07:20:40Z
alcueca changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-08-24T07:27:03Z
alcueca marked the issue as partial-50
#5 - c4-pre-sort
2023-08-24T08:21:46Z
141345 marked the issue as not a duplicate
#6 - c4-pre-sort
2023-08-24T08:24:18Z
141345 marked the issue as duplicate of #375
#7 - c4-judge
2023-08-24T21:12:09Z
alcueca marked the issue as partial-50
#8 - c4-judge
2023-08-29T06:37:09Z
alcueca marked the issue as duplicate of #182
#9 - 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
It has already been established that if the Voting Escrow is not interacted with by the user in 5 years, Voting Weight would be broken. This seems like what the protocol has already agreed on as a tradeoff because it's very unlikely for the contract not to be called at all after 5 years. Regardless, if there's a possible fix it should be applied to avoid this tradeoff completely.
The voter weight is affected because after 5 years the loop which runs for 255 times would not calculate lastPoint
correctly. This makes the voter's weight to be applied to the wrong epoch (week). The voters will be able to withdraw but will not be able to vote using the VotingEscrow. If voting is still needed they would have to migrate to another contract. This can be fixed, check recommended mitigation steps.
Vscode
checkPoint
can be called continuously, to make sure lastPoint is updated correctly. To prevent vote weights from being broken, we can modify the lines 223 to lines 234 to this:
if (_addr != address(0)) { // If last point was in this block, the slope change has been applied already // But in such case we have 0 slope(s) require(lastPoint.blk == block.number, "lastPoint not completely updated"); lastPoint.slope = lastPoint.slope + userNewPoint.slope - userOldPoint.slope; lastPoint.bias = lastPoint.bias + userNewPoint.bias - userOldPoint.bias; if (lastPoint.slope < 0) { lastPoint.slope = 0; } if (lastPoint.bias < 0) { lastPoint.bias = 0; } }
The fix comes with the tradeoff of allowing users only perform actions when lastPoint is up to date after 5 years. But this is easily remedied, since checkPoint
can be called continuously by anyone to update lastPoint correctly.
Other
#0 - c4-pre-sort
2023-08-13T07:07:53Z
141345 marked the issue as duplicate of #129
#1 - c4-pre-sort
2023-08-13T15:16:54Z
141345 marked the issue as duplicate of #384
#2 - c4-judge
2023-08-25T07:15:37Z
alcueca marked the issue as not a duplicate
#3 - alcueca
2023-08-25T07:16:13Z
This would be valid analysis. I'm downgrading it to QA grade-a, and asking for a sponsor review.
#4 - c4-judge
2023-08-25T07:16:20Z
alcueca changed the severity to QA (Quality Assurance)
#5 - c4-judge
2023-08-25T07:16:25Z
alcueca marked the issue as grade-a
#6 - alcueca
2023-08-25T07:16:28Z
@OpenCoreCH
#7 - OpenCoreCH
2023-08-25T08:46:56Z
Yeah definitely a valuable suggestion. Not sure if I will implement that because the weights will only be broken when no user at all interacted with the contract for 5 years. In our case this implies that no user will have an active lock and withdrawing will still be possible if some users did not do that yet. Because of that, the "at least one call every 5 years" assumption is pretty reasonable in my opinion.