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: 40/127
Findings: 3
Award: $336.05
🌟 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
The ProfitManager.claimGaugeRewards
doesn't update userGaugeProfitIndex
when deltaIndex == 0
.
Because of this users who stake/unstake at least once and then stake again are rewarded for the period they weren't staked.
Moreover if they can flashloan a big amount of Guild token, they can drain ProfitManager Credit balance since the rewards are distributed at a pro-rata share.
Let's take the following example:
incrementGauge
). claimGaugeRewards
is
called.
This call returns 0
since _userGaugeWeight == 0
; userGaugeWeight is incremented later by
super._incrementGaugeWeight
here.userGaugeProfitIndex
is not updated;
protocol accumulate interest and gaugeProfitIndex
is updated (these are the rewards alocated for guildSplit).
Alice unstake her entire weight from the gauge: decrementGauge
-> calls claimGaugeRewards
:
_userGaugeWeight != 0
;deltaIndex != 0
=> Alice gets her share of the accumulated interest.userGaugeProfitIndex[user][gauge] = _gaugeProfitIndex;
-> OKsuper._decrementGaugeWeight
is executed next and getUserGaugeWeight
is clearedtime passes, lendingTerm (gauge) accumulate more interests, gaugeProfitIndex
is updated accordingly;
Alice stakes again by calling incrementGauge
. The things from step (1) happens again:claimGaugeRewards
returns 0 because _userGaugeWeight == 0
=> userGaugeProfitIndex
is not set to _gaugeProfitIndex
-> NOK
Alice can unstake immediately and because userGaugeProfitIndex
wasn't updated when when she staked 2nd time, she is rewarded for the period she had 0 weight allocated on the gauge -> NOK
Protocol can get drained following next steps:
creditEarned = (_userGaugeWeight * deltaIndex) / 1e18;
Coded PoC:
Add following test in "../unit/governance/ProfitManager.t.sol" file and execute it with forge test --mt testStealGaugeRewards -vvv
function testStealGaugeRewards() public {// H4 // grant roles to test contract vm.startPrank(governor); core.grantRole(CoreRoles.GOVERNOR, address(this)); core.grantRole(CoreRoles.CREDIT_MINTER, address(this)); core.grantRole(CoreRoles.GUILD_MINTER, address(this)); core.grantRole(CoreRoles.GAUGE_ADD, address(this)); core.grantRole(CoreRoles.GAUGE_PARAMETERS, address(this)); core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(this)); vm.stopPrank(); uint256 guildAmount = 100e18;// `stake` amount (incrementGauge, decrementGauge weight amount) vm.prank(governor); profitManager.setProfitSharingConfig( 0.5e18, // surplusBufferSplit 0, // creditSplit 0.5e18, // guildSplit 0, // otherSplit address(0) // otherRecipient ); guild.setMaxGauges(1); guild.addGauge(1, gauge1); guild.mint(alice, guildAmount); guild.mint(bob, guildAmount); vm.prank(alice); guild.incrementGauge(gauge1, guildAmount); vm.prank(bob); guild.incrementGauge(gauge1, guildAmount); // simulate 40 profit on gauge1: // - 20 should go to surplusBuffer; // - 20 should be split equally between alice and bob credit.mint(address(profitManager), 40e18); profitManager.notifyPnL(gauge1, 40e18); assertEq(profitManager.claimRewards(alice), 10e18, "alice claimRewards1 == 10e18"); assertEq(profitManager.claimRewards(bob), 10e18, "bob claimRewards1 == 10e18"); assertEq(profitManager.userGaugeProfitIndex(alice,gauge1), 1.10e18, "alice userGaugeProfitIndex != 1.10 e18"); assertEq(profitManager.userGaugeProfitIndex(bob,gauge1), 1.10e18, "bob userGaugeProfitIndex != 1.10 e18"); // bob unstake vm.prank(bob); guild.decrementGauge(gauge1, guildAmount); assertEq(guild.getUserGaugeWeight(bob, gauge1), 0, "bob is still staking in gauge1"); // simulate a 16 credit profit split: // - 8 goes to surplus buffer; // - 8 goes to the guildSplit => 100% to alice since she's the only staker credit.mint(address(profitManager), 16e18); profitManager.notifyPnL(gauge1, 16e18); assertEq(profitManager.claimRewards(alice), 8e18, "alice second round of rewards"); // bob stakes again vm.prank(bob); guild.incrementGauge(gauge1, guildAmount); assertEq(profitManager.userGaugeProfitIndex(bob, gauge1), profitManager.gaugeProfitIndex(gauge1), "bob userGaugeProfitIndex was not updated"); assertEq(profitManager.claimRewards(bob), 0, "bob should get no rewards"); //bob should have 10 Credit from first claimRewards assertEq(credit.balanceOf(bob), 10e18, "bobs credit balance != 10"); }
Manual review
Removing this check from claimGaugeRewards
seems to solve the problem.
userGaugeProfitIndex
is updated when needed (delta != 0) and function will still return 0 creditEarned
(as it should when userGaugeWeight == 0).
if (_userGaugeWeight == 0) { return 0; }
However further analysis for potential side effects is required.
Other
#0 - c4-pre-sort
2024-01-01T12:43:30Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-01T12:43:45Z
0xSorryNotSorry marked the issue as duplicate of #1211
#2 - c4-judge
2024-01-29T03:57:03Z
Trumpero marked the issue as satisfactory
286.1479 USDC - $286.15
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/LendingTerm.sol#L228-L230 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/LendingTerm.sol#L751-L755 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/LendingTerm.sol#L758-L767
In case of a late partial repayment or offboarding lendingTerm loans can be call
ed.
LendingTerm.getLoanDebt
is used to calculate the outstanding borrowed amount of a loan. It includes the principal, interest, openFee. A creditMultiplier
correction is applied to reflect the updated loan value in case creditToken <-> underlyingToken ratio decreased after the loan was opened.
//getLoanDebt uint256 creditMultiplier = ProfitManager(refs.profitManager). creditMultiplier(); loanDebt = (loanDebt * loan.borrowCreditMultiplier) / creditMultiplier;
loans
mapping is updated with up-to-date loadDebt
amount.
//_call // update loan in state uint256 loanDebt = getLoanDebt(loanId); loans[loanId].callTime = block.timestamp; loans[loanId].callDebt = loanDebt;
The auction is started. auctions[loanId]
store same up-to-data loanDebt
(named callDebt now).
// save auction in state auctions[loanId] = Auction({ startTime: block.timestamp, endTime: 0, lendingTerm: msg.sender, collateralAmount: loan.collateralAmount, callDebt: callDebt });
Now auction is active and anyone can bid
on it.
AuctionHouse.getBidDetail
is used to get the collateralReceived
(by bidder) for creditAsked
.
As we can see updated callDebt
(updated at the moment the auction started) is returned for creditAsked
. (we ignore the fact less Credit is asked in second phase of the auction).
if (block.timestamp < _startTime + midPoint) { // ask for the full debt >> creditAsked = auctions[loanId].callDebt; // compute amount of collateral received uint256 elapsed = block.timestamp - _startTime; // [0, midPoint[ uint256 _collateralAmount = auctions[loanId].collateralAmount; // SLOAD collateralReceived = (_collateralAmount * elapsed) / midPoint; } // second phase of the auction, where less and less CREDIT is asked else if (block.timestamp < _startTime + auctionDuration) { // receive the full collateral collateralReceived = auctions[loanId].collateralAmount; // compute amount of CREDIT to ask uint256 PHASE_2_DURATION = auctionDuration - midPoint; uint256 elapsed = block.timestamp - _startTime - midPoint; // [0, PHASE_2_DURATION[ uint256 _callDebt = auctions[loanId].callDebt; // SLOAD >> creditAsked = _callDebt - (_callDebt * elapsed) / PHASE_2_DURATION; }
creditAsked
is passed down to onBid
callback.
principal
is calculated by applying the borrowCreditMultiplier / creditMultiplier
correction:uint256 creditMultiplier = ProfitManager(refs.profitManager) .creditMultiplier(); uint256 borrowAmount = loans[loanId].borrowAmount; uint256 principal = (borrowAmount * loans[loanId].borrowCreditMultiplier) / creditMultiplier;
creditFromBidder
is the creditAsked
== callDebt
== loanDebt
updated at the moment auction started. No new correction is applied.Let me explain what I mean by that and why it matters.
The auction can last many blocks, up to 30 minutes based on in the scope deployment script.
//GIP_0.sol AuctionHouse auctionHouse = new AuctionHouse( AddressLib.get("CORE"), 650, // midPoint = 10m50s 1800 // auctionDuration = 30m );
creditMultiplier
can have a correction down (due to bad debt accumulation) between (1) the loan was called (auction started) and (2) the auction was bid on.
On the time axis we have 3 creditMultiplier values of interest :
borrowCreditMultiplier
>= auctionStartedCreditMultiplier
>= bidOnAuctionCreditMultiplier
== creditMultiplier
Going back on onBid
callback :
-> principal
= borrowAmount * borrowCreditMultiplier/ bidOnAuctionCreditMultiplier
-> creditFromBidder
= borrowAmountPlusInterests * borrowCreditMultiplier / auctionStartedCreditMultiplier
In the first phase of auction more and more collateral is offered for the same amount of CREDIT to pay.
Let's suppose a bidder bids in this phase, offering creditFromBidder
for a x% ( x < 100%) of collateral.
But, in case creditMultiplier
decreased between startAuction
and bid
moment, principal
can be bigger than creditFromBidder
amount, forcing the code to enter else branch:
} else { pnl = int256(creditFromBidder) - int256(principal); principal = creditFromBidder; require( collateralToBorrower == 0, "LendingTerm: invalid collateral movement" );
Because collateralToBorrower
must be 0 even if auction is in first half (and collateral is split between bidder and borrower), the bid transaction reverts:
Manual review
Consider applying same correction to both principal
and creditFromBidder
:
LendingTerm._call : loans[loanId].callDebt => save rawLoanDebt (principal +interest + openFee) (but do not apply creditMultiplier correction)
AuctionHouse.getBidDetail : getBidDetail => apply creditM correction to rawLoanDebt to calculate creditAsked:
creditAsked
= rawLoanDebt * borrowCreditMultiplier/ bidOnAuctionCreditMultiplier
when onBid
callback is called the creditAsked
updated amount is passed and compared with principal
which has same correction applied.
Error
#0 - 0xSorryNotSorry
2024-01-01T14:37:33Z
Looks like the same root cause referred here: #1156 I'll leave this as primary since the issue is better pronounced.
#1 - c4-pre-sort
2024-01-01T14:37:37Z
0xSorryNotSorry marked the issue as sufficient quality report
#2 - c4-pre-sort
2024-01-01T14:37:42Z
0xSorryNotSorry marked the issue as primary issue
#3 - eswak
2024-01-17T15:16:54Z
Very clear, thanks for the quality of the report. Confirming
#4 - c4-sponsor
2024-01-17T15:16:57Z
eswak (sponsor) confirmed
#5 - c4-judge
2024-01-29T19:47:42Z
Trumpero marked the issue as satisfactory
#6 - c4-judge
2024-01-31T13:03:42Z
Trumpero marked the issue as selected for report
#7 - c4-judge
2024-02-07T12:21:05Z
Trumpero marked issue #476 as primary and marked this issue as a duplicate of 476
🌟 Selected for report: JCN
Also found by: 0xadrii, 0xaltego, 0xdice91, 0xivas, 0xpiken, Akali, AlexCzm, Chinmay, DanielArmstrong, HighDuty, Infect3d, Inference, KupiaSec, PENGUN, SECURITISE, Stormreckson, SweetDream, TheSchnilch, Timeless, Varun_05, XDZIBECX, alexzoid, asui, beber89, btk, carrotsmuggler, cats, cccz, developerjordy, ether_sky, grearlake, imare, jasonxiale, kaden, klau5, santipu_, serial-coder, sl1, smiling_heretic, stackachu, wangxx2026, whitehat-boys
3.0466 USDC - $3.05
After a first slashing event all subsequent SGM staking are considered slashed. Stakers loose principal staked and any potential Guild token rewards.
Inside SurplusGuildMinter.getRewards()
, lastGaugeLoss
timestamp is compared with the default, uninitialized userStake.lastGaugeLoss
.
function getRewards( address user, address term ) public returns ( uint256 lastGaugeLoss, // GuildToken.lastGaugeLoss(term) UserStake memory userStake, // stake state after execution of getRewards() bool slashed // true if the user has been slashed ) { bool updateState; lastGaugeLoss = GuildToken(guild).lastGaugeLoss(term); if (lastGaugeLoss > uint256(userStake.lastGaugeLoss)) { slashed = true; } ...
Variable slashed
is not updated afterwards and users guild reward is deleted and their staked balance cleared as well.
Coded PoC: Add folowing test in SurplusGuildMinter.t.sol file
function testSubsequentStakersAreSlashed_AfterBadDebtLossEvent() public { address alice = makeAddr("alice"); address bob = makeAddr("bob"); credit.mint(alice, 100e18); credit.mint(bob, 100e18); vm.startPrank(alice); credit.approve(address(sgm), 100e18); sgm.stake(term, 100e18); assertEq(credit.balanceOf(alice), 0, "alice should have no credit"); assertEq(profitManager.surplusBuffer(), 0, "should have no surplus"); assertEq(profitManager.termSurplusBuffer(term), 100e18, "should have term surplus"); assertEq(guild.balanceOf(address(sgm)), 200e18, "sgm guild balance");// because of mintRation = 2:1 assertEq(guild.getGaugeWeight(term), 250e18, "term gauge weight");// 200 + 50 from setUp assertEq(sgm.getUserStake(alice, term).credit, 100e18,"alice stake amount"); vm.stopPrank(); vm.prank(governor); profitManager.setProfitSharingConfig( 0.25e18, // surplusBufferSplit 0.25e18, // creditSplit 0.5e18, // guildSplit 0, // otherSplit address(0) // otherRecipient ); // add bad debt profitManager.notifyPnL(term, -30e18); assertEq(profitManager.surplusBuffer(), 100e18 - 30e18); // 70e18 assertEq(profitManager.termSurplusBuffer(term), 0); // cannot stake if there was just a loss // next block vm.warp(block.timestamp + 13); vm.roll(block.number + 1); // apply loss to alice sgm.getRewards(alice, term); // alice got no rewards because term accrued no interest; // alice got slashed assertEq(credit.balanceOf(alice), 0, "alice credit rewards after slashing"); assertEq(sgm.getUserStake(alice, term).credit, 0, "alice staked credit after slashing"); assertEq(guild.balanceOf(alice), 0, "alice guild rewards after slashing"); // bob stakes vm.startPrank(bob); credit.approve(address(sgm), 100e18); sgm.stake(term, 100e18); assertEq(credit.balanceOf(bob), 0, "bob should have no credit"); assertEq(guild.balanceOf(bob), 0, "bob should have no guild"); assertEq(profitManager.surplusBuffer(), 70e18, "surplusBuffer amount");// from previous slashing event assertEq(profitManager.termSurplusBuffer(term), 100e18, "termSurplusBuffer amount");// bob staked amount assertEq(guild.balanceOf(address(sgm)), 200e18 + 200e18, "sgm guild balance"); assertEq(guild.getGaugeWeight(term), 250e18 + 200e18, "term gauge weight");// 200 + 50 from setUp + 200 from alice assertEq(sgm.getUserStake(bob, term).credit, 100e18,"bob stake amount"); vm.stopPrank(); // accumulate profit profitManager.notifyPnL(term, 45e18); // (, , bool isBobSlashed) = sgm.getRewards(bob, term); assertEq(isBobSlashed, false, "bob should not be slashed"); assertEq(credit.balanceOf(bob), 10e18, "bob credit rewards after PnL"); assertEq(sgm.getUserStake(bob, term).credit, 100e18, "bob staked credit after PnL"); assertGt(guild.balanceOf(bob), 0, "bob guild rewards after PnL"); vm.prank(bob); sgm.unstake(term, 100e18); assertEq(credit.balanceOf(bob), 110e18, "bob credit balance after unstake");// initial staking amount + rewards assertEq(guild.balanceOf(bob), 0, "bob guild balance after unstake"); } ``` ## Tools Used Manual review ## Recommended Mitigation Steps Cache user `_stakes` first and use it afterwards: ```solidity bool updateState; lastGaugeLoss = GuildToken(guild).lastGaugeLoss(term); + userStake = _stakes[user][term]; if (lastGaugeLoss > uint256(userStake.lastGaugeLoss)) { slashed = true; } // if the user is not staking, do nothing - userStake = _stakes[user][term]; ``` ## Assessed type Error
#0 - 0xSorryNotSorry
2023-12-29T14:25:32Z
The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#1 - c4-pre-sort
2023-12-29T14:25:36Z
0xSorryNotSorry marked the issue as high quality report
#2 - c4-pre-sort
2023-12-29T14:26:43Z
0xSorryNotSorry marked the issue as duplicate of #1164
#3 - c4-judge
2024-01-28T20:13:50Z
Trumpero marked the issue as satisfactory