Platform: Code4rena
Start Date: 11/12/2023
Pot Size: $90,500 USDC
Total HM: 29
Participants: 127
Period: 17 days
Judge: TrungOre
Total Solo HM: 4
Id: 310
League: ETH
Rank: 84/127
Findings: 2
Award: $67.67
π Selected for report: 0
π Solo Findings: 0
π Selected for report: Arz
Also found by: 0xStalin, 0xpiken, AlexCzm, Chinmay, HighDuty, Infect3d, JCN, Neon2835, Tendency, TheSchnilch, almurhasan, asui, c47ch3m4ll, critical-or-high, deliriusz, ether_sky, evmboi32, kaden, klau5, santipu_, sl1, zhaojohnson
46.8502 USDC - $46.85
Gauge rewards in ECG are similar to Compound - there is a global ever-increasing profit index, and one profit index per user, synchronized by applying the delta between gaugeProfitIndex
and userGaugeProfitIndex
.
When user profit index is not initialized, it's set to 1e18. This is an issue when the ProfitManager operates for some time and the gaugeProfitIndex grew to a new value. Then we have following situation:
gaugeProfitIndex = 1.2e18
User just joined, userGaugeProfitIndex is set to 1e18
. Hence He's elligible to all rewards since gauge started operating.
The problem is even worse if the Guild token transfer are enabled. Then a malicious actor can use sybil accounts and claim all the rewards of other users to themselves.
All rewards are stolen by sybil accounts, able to claim all rewards since the protocol launch.
gaugeProfitIndex
to grow.GuildToken.incrementGauge()
. From a new account. This will internally call ProfitManager.claimGaugeRewards()
.Manual analysis
In case that the user doesn't have index assigned, assign it to current gauge profit index and return from the function early:
function claimGaugeRewards( address user, address gauge ) public returns (uint256 creditEarned) { uint256 _userGaugeWeight = uint256( GuildToken(guild).getUserGaugeWeight(user, gauge) ); if (_userGaugeWeight == 0) { return 0; } uint256 _gaugeProfitIndex = gaugeProfitIndex[gauge]; uint256 _userGaugeProfitIndex = userGaugeProfitIndex[user][gauge]; if (_gaugeProfitIndex == 0) { _gaugeProfitIndex = 1e18; } if (_userGaugeProfitIndex == 0) { - _userGaugeProfitIndex = 1e18; + userGaugeProfitIndex[user][gauge] = _gaugeProfitIndex; + return 0;
Math
#0 - c4-pre-sort
2024-01-01T12:19:52Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-01T12:20:00Z
0xSorryNotSorry marked the issue as primary issue
#2 - eswak
2024-01-16T14:21:13Z
#1194 (marked as duplicate to this issue) contains a test that can be run to confirm
Disputing severity (think it's more fit for medium because there is no loss of user funds, just rewards)
#3 - c4-sponsor
2024-01-16T14:21:19Z
eswak (sponsor) confirmed
#4 - c4-sponsor
2024-01-16T14:21:23Z
eswak marked the issue as disagree with severity
#5 - c4-judge
2024-01-19T10:42:04Z
Trumpero marked the issue as satisfactory
#6 - c4-judge
2024-01-19T20:10:31Z
Trumpero marked issue #1194 as primary and marked this issue as a duplicate of 1194
#7 - Trumpero
2024-01-19T20:18:20Z
I still consider this issue a high severity because the rewards in ProfitManager are matured yield, which should be distributed to the intended users, and the loss of matured yield is considered a high-severity issue based on C4's criteria. @eswak
π Selected for report: SBSecurity
Also found by: 0xaltego, 0xbepresent, Aymen0909, Bauchibred, Cosine, EVDoc, EloiManuel, HighDuty, Sathish9098, Tendency, Timeless, ZanyBonzy, beber89, deliriusz, ether_sky, grearlake, hals, klau5, lsaudit, nadin, rvierdiiev, tsvetanovv
20.8157 USDC - $20.82
When a loss is recorded in the system, it's shared across all gauge voters, and in order to perform any action on them, GuildToken.applyGaugeLoss() has to be called. However, this function will run indefinitely when it meets getUserGaugeWeight[user][gauge]
that is 0. This is easy to achieve by increasing voting weight by 0 by user. Then, the loop will run indefinitely, because of mismatched ++i position. This makes the position loss unappliable:
for ( uint256 i = 0; i < size && (userFreeWeight + userFreed) < weight; ) { address gauge = gaugeList[i]; uint256 userGaugeWeight = getUserGaugeWeight[user][gauge]; if (userGaugeWeight != 0) { userFreed += userGaugeWeight; _decrementGaugeWeight(user, gauge, userGaugeWeight); // If the gauge is live (not deprecated), include its weight in the total to remove if (!_deprecatedGauges.contains(gauge)) { totalTypeWeight[gaugeType[gauge]] -= userGaugeWeight; totalFreed += userGaugeWeight; } unchecked { ++i; } } }
User GUILD tokens which are menat to be burned after the protocol, leaving "poisonous" GUILD tokens in a gauge forever.
Manual analysis
The fix is very straight forward:
for ( uint256 i = 0; i < size && (userFreeWeight + userFreed) < weight; ) { address gauge = gaugeList[i]; uint256 userGaugeWeight = getUserGaugeWeight[user][gauge]; if (userGaugeWeight != 0) { userFreed += userGaugeWeight; _decrementGaugeWeight(user, gauge, userGaugeWeight); // If the gauge is live (not deprecated), include its weight in the total to remove if (!_deprecatedGauges.contains(gauge)) { totalTypeWeight[gaugeType[gauge]] -= userGaugeWeight; totalFreed += userGaugeWeight; } + } unchecked { ++i; } - } }
Generally, micro optimizations are an enemy to the security and should be verified very carefully.
Error
#0 - c4-pre-sort
2024-01-03T19:17:39Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-03T19:18:03Z
0xSorryNotSorry marked the issue as duplicate of #152
#2 - c4-judge
2024-01-28T19:06:03Z
Trumpero changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-01-28T19:09:30Z
Trumpero marked the issue as grade-b