Platform: Code4rena
Start Date: 11/12/2023
Pot Size: $90,500 USDC
Total HM: 29
Participants: 127
Period: 17 days
Judge: TrungOre
Total Solo HM: 4
Id: 310
League: ETH
Rank: 6/127
Findings: 6
Award: $4,104.36
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: SBSecurity
Also found by: 0x_6a70, 0xanmol, 0xbepresent, 0xfave, Arz, Byteblockers, CaeraDenoir, EV_om, EllipticPoint, Infect3d, JCN, Mike_Bello90, SECURITISE, Soul22, almurhasan, c47ch3m4ll, carrotsmuggler, cccz, critical-or-high, ether_sky, evmboi32, grearlake, kaden, rbserver, smiling_heretic, whitehat-boys, zhaojie
6.8173 USDC - $6.82
Liquidators will be able to flash loan mint and stake before liquidating the borrower, extracting maximal potential value. While advantageous for liquidators, this significantly reduces gauge stakers' profits without changing the associated risks.
To extract the maximal possible value, bidders (liquidators) will mint with PSM and stake in the gauge they are liquidating. This is because, upon liquidation, onBid calls ProfitManager's notifyPnL, distributing part of the interest to gauge voters. This process is achievable in a single transaction, incentivizing liquidators to do it for every profitable (positive PnL) liquidation.
Example:
Prerequisites | Values |
---|---|
Borrower coll | 10,000 USDC |
Borrower loan | 7,000 USDC |
Borrower fees (start + interest) | 1,000 USDC |
Gauge weight | 10,000 |
PM split - buffer/credit/gauge | 20% / 40% / 40% |
After Alice bids on Bob's loan, calculations are performed, and ProfitManager's notifyPnL is called with 1,000 USDC to split. PM allocates 400 USDC to the gauge. However, Alice holds 90k out of 100k weight (90%), entitling her to 90% of the gauge's profit (360 USDC).
Alice profits 360 USDC from the FL (460 USDC in total) + the gauge tokens that SGM mints as rewardsRatio (360 with rewardRatio
of 1), while the remaining gauge stakers split the remaining 40 USDC. This scenario disincentivizes staking for a given gauge, as liquidation becomes a safer and more profitable alternative.
Gist - https://gist.github.com/0x3b33/cf4349253c7762ab4c3d099ecadbea95 Add to - 2023-12-ethereumcreditguild/test/unit/loan/<name>.sol Run it with - forge test --match-test test_flashLoanExtraProfit
Manual review
Implementing a dripping mechanism similar to that used with credit tokens (here) may be the most effective solution, albeit making gauges more complex. Alternatively, pausing mint could be considered, but this might only make it more challenging as liquidators can still use flash loans to acquire credits through other means.
Error
#0 - c4-pre-sort
2024-01-05T17:04:58Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-05T17:06:13Z
0xSorryNotSorry marked the issue as duplicate of #994
#2 - c4-judge
2024-01-25T18:14:07Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: SBSecurity
Also found by: 0x_6a70, 0xanmol, 0xbepresent, 0xfave, Arz, Byteblockers, CaeraDenoir, EV_om, EllipticPoint, Infect3d, JCN, Mike_Bello90, SECURITISE, Soul22, almurhasan, c47ch3m4ll, carrotsmuggler, cccz, critical-or-high, ether_sky, evmboi32, grearlake, kaden, rbserver, smiling_heretic, whitehat-boys, zhaojie
6.8173 USDC - $6.82
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/LendingTerm.sol#L562
Due to the gauge profit distribution mechanism, every borrower would be able to claim back a certain percentage of their repayment. Similarly, individuals with significant capital (whales) can exploit this situation to make substantial profits without assuming any risk.
When a borrower makes a payment through either partialRepay or repay, notifyPnL is called. This function sends the profit (interest + start fee) to the Profit Manager, which then distributes the profit to the buffer, credit token holders, gauge voters, and other special addresses. The issue arises from the instant distribution of profits to gauge voters.
if (amountForGuild != 0) { uint256 _gaugeWeight = uint256(GuildToken(guild).getGaugeWeight(gauge)); if (_gaugeWeight != 0) { uint256 _gaugeProfitIndex = gaugeProfitIndex[gauge]; if (_gaugeProfitIndex == 0) { _gaugeProfitIndex = 1e18; } gaugeProfitIndex[gauge] = _gaugeProfitIndex + (amountForGuild * 1e18) / _gaugeWeight; } }
This implies that anyone with capital can claim some of these profits by simply depositing capital before a borrower initiates payment. Afterwards, they can claim the rewards using getRewards and unstake to avoid the risk of slashing.
Example
This operation can be executed by one whale simultaneously in every market and every term, as the money is only needed to sandwich.
Note that the borrower can perform the same operation, and they even have the ability to flash-loan and stake a huge weight, and thus extract a big amount of their repayment back.
Gist - https://gist.github.com/0x3b33/7ca9b8a4861c96e0b97ad35c4abf5ff9 Add in - security/2023-12-ethereumcreditguild/test/unit/loan/<name>.sol Run it with:
Manual review
I suggest implementing a mechanism for gauges to drip in a manner similar to how credit does.
Error
#0 - c4-pre-sort
2023-12-30T16:16:00Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-30T16:17:39Z
0xSorryNotSorry marked the issue as duplicate of #994
#2 - c4-judge
2024-01-25T18:10:22Z
Trumpero changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-01-25T18:14:12Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: neocrao
Also found by: 0xStalin, Aymen0909, Byteblockers, Chinmay, The-Seraphs, TheSchnilch, Timenov, Varun_05, ether_sky, kaden, mojito_auditor, mussucal, nonseodion, rbserver, santipu_, thank_you, twcctop
30.4141 USDC - $30.41
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/LendingTerm.sol#L270
The debtCeiling function may return incorrect values. The vulnerability surpasses this requirement, potentially causing borrowers to reduce the debt ceiling below the issuance.
if (issuance != 0) { uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight)); require( issuance <= debtCeilingAfterDecrement, "GuildToken: debt ceiling exceeded" ); }
The debtCeiling final minimum check may be inaccurate, as it doesn't return the actual minimum value. If _hardCap
< creditMinterBuffer
, it will still return creditMinterBuffer
because creditMinterBuffer
is compared first to _debtCeiling
.
if (creditMinterBuffer < _debtCeiling && creditMinterBuffer < _hardCap) { return creditMinterBuffer; } if (_hardCap < _debtCeiling) { return _hardCap; } return _debtCeiling;
Example:
Prerequisites | Values |
---|---|
Hard cap | 70k |
Issuance | 70k |
Total borrowed credit | 100k |
Gauges | 80% / 20% - 80k / 20k weight |
Total weight | 100k |
Gauge weight tolerance | 60% |
Credit minter buffer | 100k |
With the current parameters, debtCeiling will return 100,000 instead of 70,000 (which is the hardCap
). These parameters are not uncommon, as they are expected for small markets with not much use. A big buffer
suggests that loans are rarely taken and a small hardcap
indicates volatility.
Below is the math we need to do to reach the final return value. You can follow along with the code.
function debtCeiling(int256 gaugeWeightDelta) public view returns (uint256) { ... if (creditMinterBuffer < _debtCeiling && creditMinterBuffer < _hardCap) { return creditMinterBuffer; } if (_hardCap < _debtCeiling) { return _hardCap; } return _debtCeiling; }
Manual review
Improve the final min check.
function debtCeiling(int256 gaugeWeightDelta) public view returns (uint256) { ... - if (creditMinterBuffer < _debtCeiling) { + if (creditMinterBuffer < _debtCeiling && creditMinterBuffer < _hardCap) { return creditMinterBuffer; } if (_hardCap < _debtCeiling) { return _hardCap; } return _debtCeiling; }
Error
#0 - c4-pre-sort
2023-12-30T15:17:44Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-30T15:18:04Z
0xSorryNotSorry marked the issue as duplicate of #878
#2 - c4-judge
2024-01-25T18:19:49Z
Trumpero changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-01-30T13:32:47Z
This previously downgraded issue has been upgraded by Trumpero
#4 - c4-judge
2024-01-30T13:33:18Z
Trumpero marked the issue as not a duplicate
#5 - c4-judge
2024-01-30T13:33:29Z
Trumpero marked the issue as duplicate of #708
#6 - c4-judge
2024-01-30T17:48:15Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: Cosine
Also found by: Byteblockers, HighDuty, OMEN
598.2641 USDC - $598.26
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/AuctionHouse.sol#L144-L152 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/test/proposals/gips/GIP_0.sol#L175-L179
Whales (accounts with significant cryptocurrency holdings) can exploit the current auction mechanics to guarantee profits through block stuffing.
This is particularly feasible due to the short duration of auctions, allowing these entities to manipulate auction outcomes in their favor.
The protocol uses a Dutch auction format with two phases:
Bidders are disincentivized to participate in the first phase, as it generally results in a net loss unless there are force majeure market conditions.
Whales can use a block stuffing attack to win large auctions and acquire collateral at significantly reduced prices.
Example scenario:
GIP_0.sol
deployment script), i.e. 88 blocks on Mainnet. The decay rate is thus 100% / 88 ~ 1.14%The attack cost is:
Thus, Bob has made a profit of 500,000 USDC.
This strategy, while requiring substantial funds, is a feasible and potentially lucrative attack vector.
The severity is set to Medium given its low likelihood but high impact.
Manual review
To mitigate this vulnerability, it is recommended to extend the auction duration. Longer auctions would increase the cost and complexity of block stuffing attacks, reducing the likelihood of such exploits.
Other
#0 - c4-pre-sort
2024-01-04T18:31:37Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-04T18:32:03Z
0xSorryNotSorry marked the issue as duplicate of #685
#2 - c4-judge
2024-01-27T07:43:50Z
Trumpero changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-01-27T07:43:54Z
Trumpero marked the issue as grade-b
#4 - c4-judge
2024-01-31T06:01:52Z
This previously downgraded issue has been upgraded by Trumpero
#5 - c4-judge
2024-01-31T06:39:11Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: 0xpiken
Also found by: Byteblockers
1477.1954 USDC - $1,477.20
If market conditions change, some markets might consider deprecating specific gauges. However, this action could trigger a bank run on the gauge, leading to permanent losses for some lenders, as their credit tokens would be slashed if a borrower leaves with bad debt.
When offBoarding a gauge, PSM is paused, preventing the so-called "bank runs." Nevertheless, these bank runs are still likely to occur in the case of the removed gauge, where voters will attempt to exit before any bad debt accrues, otherwise, they face potential slashing. This will be feasible for some but not for all, as voters cannot decrease the gauge weight below its issuance. This limitation is enforced by the GuildToken's _decrementGaugeWeight, which checks if the issuance of the term is below the debtCeiling.
uint256 issuance = LendingTerm(gauge).issuance(); if (issuance != 0) { uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight)); require( issuance <= debtCeilingAfterDecrement, "GuildToken: debt ceiling used" ); }
If there are still existing borrowers (whose auctions haven't finished), lenders are not allowed to leave. If one of these borrowers causes bad debt, all lenders will be slashed. The pausing mechanism that aims to stop these bank runs is not working correctly, as they are still likely to happen. In this scenario, the first lenders will win (avoiding credit slashing), while the last will lose (being slashed).
Example:
In this example Alice was fast enough to leave, but due to the issuance check she still gets slashed.
Manual review.
One option is to keep PSM paused and skip this if, if a gauge is removed. This approach will halt the bank run from the gauge and still use the creditMultiplier
as a tool for splitting bad debt.
+ if (isGauge(gauge)) { // This will stop deprecated gauges from entering uint256 issuance = LendingTerm(gauge).issuance(); if (issuance != 0) { uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight)); require( issuance <= debtCeilingAfterDecrement, "GuildToken: debt ceiling used" ); } + }
Error
#0 - c4-pre-sort
2024-01-05T17:02:34Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-05T17:03:02Z
0xSorryNotSorry marked the issue as duplicate of #651
#2 - c4-judge
2024-01-29T21:53:35Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: SpicyMeatball
Also found by: Byteblockers, JCN, TheSchnilch, cccz, ether_sky, kaden, klau5, mojito_auditor, niroh, nocoder, rbserver, santipu_
71.3169 USDC - $71.32
Gauges weight will still take effect in markets where there is only one term. This is not desired, as described by the developers, if there is only one gauge, its weight won't matter.This will make voters for this gauge unable to decrease its weight, even though they should be able to do so.
debtCeiling
should not go below issuance because if there is just one term, then 100% of the borrows can happen there regardless of the weight (10 or 1e27, it doesn't matter, still 100%, so loans should be unrestricted).
The function _decrementGaugeWeight, called by decrementGauge, checks if the new decrease in gauge weight will lower the debt ceiling below the issuance.
uint256 issuance = LendingTerm(gauge).issuance(); if (issuance != 0) { uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight)); require(issuance <= debtCeilingAfterDecrement, "GuildToken: debt ceiling used"); }
In debtCeiling, we get the gauge weight and add the gaugeWeightDelta
(decrease the weight in this case).
uint256 gaugeWeight = GuildToken(_guildToken).getGaugeWeight(address(this)); gaugeWeight = uint256(int256(gaugeWeight) + gaugeWeightDelta);
However, because totalTypeWeight
is updated after debtCeiling finishes execution, the math above makes it so
gaugeWeight != totalTypeWeight
even though there is only one gauge.
This, in turn, will pass by the first else if (which is made for occasions where there is only one gauge) and continue execution below. It will most likely stop at the if below, where it will return debtCeilingBefore
, which will cause _decrementGaugeWeight to revert. This is because debtCeilingBefore
is already smaller than issuance.
uint256 toleratedGaugeWeight = (gaugeWeight * gaugeWeightTolerance) / 1e18; uint256 debtCeilingBefore = (totalBorrowedCredit * toleratedGaugeWeight) / totalWeight; if (_issuance >= debtCeilingBefore) { return debtCeilingBefore; }
The above is likely, as we have mentioned that gaugeWeight != totalTypeWeight
, which will make this calculation push totalBorrowedCredit
downwards (i.e., it will make it smaller since totalWeight > toleratedGaugeWeight
).
uint256 debtCeilingBefore = (totalBorrowedCredit * toleratedGaugeWeight) / totalWeight;
Prerequisites | Values |
---|---|
Issuance | 20,000 |
Total borrowed credit | 21,000 (issuance + 1,000 interest) |
Total weight | 20,000 |
Gauge weight | 20,000 |
Gauge weight tolerance | 60% |
Current reduction | 5,000 |
With the example above, our current user is trying to unstake 5,000 weight from the gauge. As it's the only gauge in the market, debtCeiling should return hardCap
or creditMinterBuffer
(whichever is the smaller one). Let's check the math and find out.
totalWeight
is 20,000 as it's reduced in decrementGauge after _decrementGaugeWeight finishes.
This will skip else if since gaugeWeight != totalWeight
.
toleratedGaugeWeight will be 18,000.
debtCeilingBefore
.if (_issuance >= debtCeilingBefore) { return debtCeilingBefore; }
debtCeilingBefore < issuance
.uint256 issuance = LendingTerm(gauge).issuance(); if (issuance != 0) { uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight)); require(issuance <= debtCeilingAfterDecrement, "GuildToken: debt ceiling used"); }
Note that, of course, the user can make 5 separate TX and withdraw those 5k, 1k at a time (this is possible as gaugeWeight * 1.2 > totalWeight
and entering this if). However, it will be unnecessary, and the solution I have recommended will fix the issue without asking the user to redo his TX.
Manual review.
Add a check here, before updating the gauge weight, to see if there is only one gauge.
uint256 gaugeWeight = GuildToken(_guildToken).getGaugeWeight(address(this)); + uint256 totalWeight = GuildToken(_guildToken).totalTypeWeight(gaugeType); + if (gaugeWeight == totalWeight) { + return _hardCap < creditMinterBuffer ? _hardCap : creditMinterBuffer; + } gaugeWeight = uint256(int256(gaugeWeight) + gaugeWeightDelta); uint256 gaugeType = GuildToken(_guildToken).gaugeType(address(this)); - uint256 totalWeight = GuildToken(_guildToken).totalTypeWeight(gaugeType);
Error
#0 - c4-pre-sort
2024-01-05T15:12:42Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-05T15:12:48Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-sponsor
2024-01-09T10:10:20Z
eswak (sponsor) confirmed
#3 - eswak
2024-01-09T10:10:41Z
Confirming, thanks for the quality of the report 😄
#4 - Trumpero
2024-01-30T11:16:32Z
I believe this issue shares the same root cause with #586 since unapplying deltaGaugeWeight to totalTypeWeight lead it be unequal with gaugeWeight in case the gauge type has only 1 term
#5 - c4-judge
2024-01-30T11:16:45Z
Trumpero marked the issue as duplicate of #586
#6 - c4-judge
2024-01-30T11:16:50Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: Byteblockers
Also found by: kaden
1920.354 USDC - $1,920.35
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.
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:
2. debtCeiling()
function's calculation method:
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.
Manual review.
Unify the debtCeiling
calculation method which is used across the protocol.
Error
#0 - c4-pre-sort
2023-12-30T15:14:13Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-30T15:14:27Z
0xSorryNotSorry marked the issue as duplicate of #878
#2 - c4-judge
2024-01-25T18:19:49Z
Trumpero changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-01-30T13:32:47Z
This previously downgraded issue has been upgraded by Trumpero
#4 - c4-judge
2024-01-30T16:48:55Z
Trumpero marked the issue as not a duplicate
#5 - c4-judge
2024-01-30T16:49:00Z
Trumpero marked the issue as primary issue
#6 - c4-sponsor
2024-01-31T12:31:58Z
eswak (sponsor) confirmed
#7 - c4-judge
2024-01-31T12:36:37Z
Trumpero marked the issue as satisfactory
#8 - c4-judge
2024-01-31T13:40:09Z
Trumpero marked the issue as selected for report