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: 19/127
Findings: 5
Award: $711.22
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Arz
Also found by: 0xStalin, 0xpiken, AlexCzm, Chinmay, HighDuty, Infect3d, JCN, Neon2835, Tendency, TheSchnilch, almurhasan, asui, c47ch3m4ll, critical-or-high, deliriusz, ether_sky, evmboi32, kaden, klau5, santipu_, sl1, zhaojohnson
46.8502 USDC - $46.85
ProfitManager is responsible for allocating and distributing rewards to all parties in the system, including GUILD holders, which participate in gauge support and earn rewards on payed interest. The contract has a function to claim rewards pending for given gauge, but it never check when the participant has allocated his funds to the given gauge. This can result in allocating weight after a profit for the term and getting the reward, which another staker deserves. Worse case is when GUILD transferability is enabled and a user, which owns 10% of the allocated tokens for the term can withdraw rewards for the other 90% of the profit by transferring his tokens to another address owned by him, allocating weight to this gauge and claiming reward. This would result stealing reward funds, which are meant for early gauge supporters
SurplusGuildMinter
functionality if a malicious user owns some GUILD tokens and perform the scenario from above. ProfitManager
won't have enough funds to send to SurplusGuildMinter
when calling getRewards
, which will block stake/unstake
(uint256 lastGaugeLoss, UserStake memory userStake, ) = getRewards( msg.sender, term );
1 -> 2 -> 3 -> potential revert
Coded PoC, which should be placed inside test/unit/governance/ProfitManager.t.sol
and executed with forge test --match-test testProfitDistributionRewardsStealing -vv
https://gist.github.com/NicolaMirchev/66f6fa840485e899164401b9c9386e73
Manual Review Foundry
Context
#0 - c4-pre-sort
2023-12-30T16:11:33Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-30T16:12:13Z
0xSorryNotSorry marked the issue as duplicate of #1167
#2 - c4-judge
2024-01-30T13:24:13Z
Trumpero marked the issue as not a duplicate
#3 - Trumpero
2024-01-30T13:26:59Z
Root cause of this issue is identified in #1194: a new account can still claim rewards in profitManager due to userGaugeProfitIndex
being set to 1e18 incorrectly.
#4 - c4-judge
2024-01-30T13:27:11Z
Trumpero marked the issue as duplicate of #1194
#5 - c4-judge
2024-01-30T13:27:16Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: JCN
Also found by: 0xadrii, 0xaltego, 0xdice91, 0xivas, 0xpiken, Akali, AlexCzm, Chinmay, DanielArmstrong, HighDuty, Infect3d, Inference, KupiaSec, PENGUN, SECURITISE, Stormreckson, SweetDream, TheSchnilch, Timeless, Varun_05, XDZIBECX, alexzoid, asui, beber89, btk, carrotsmuggler, cats, cccz, developerjordy, ether_sky, grearlake, imare, jasonxiale, kaden, klau5, santipu_, serial-coder, sl1, smiling_heretic, stackachu, wangxx2026, whitehat-boys
3.0466 USDC - $3.05
SurplusGuildMinter provide functionality to users to stake their CREDIT tokens and in return mint GUILD tokens and allocate corresponding weight to a given gauge. Before any operation getRewards() function is called to claim all standing rewards for the given user, before his stake/unstake. We can note something very suspicious inside first lines of getRewards function regarding checking if the user should be marked as slashed. We compare last loss for the given gauge with the default value of unit256, because the variable userStake is still empty:
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]; ...
We can see that we fetch userStake
variable from storage after checking lastGaugeLoss > uint256(userStake.lastGaugeLoss)
.
function unstake(address term, uint256 amount) external { // apply pending rewards (, UserStake memory userStake, bool slashed) = getRewards( msg.sender, term ); // if the user has been slashed, there is nothing to do if (slashed) return; ...
if (slashed) { emit Unstake(block.timestamp, term, uint256(userStake.credit)); userStake = UserStake({ stakeTime: uint48(0), lastGaugeLoss: uint48(0), profitIndex: uint160(0), credit: uint128(0), guild: uint128(0) }); updateState = true; } // store the updated stake, if needed if (updateState) { _stakes[user][term] = userStake; }
Run the following test inside test/unit/loan/SurplusGuildMinter.t.sol
with command forge test --match-test testUnstakeWithLoss -vv
https://gist.github.com/cholakovvv/e8514283ad7bd8bbbd2da87bd7bb0b1e
Manual Review Foundry
First assign UserStake memory userStake
to the storage variable for the given user are then compare it with lastGaugeLoss
userStake.lastGaugeLoss
somehow. Currently this field is never assigned to a value different from 0 in the whole contract, which leads to mistakes.Context
#0 - c4-pre-sort
2023-12-29T14:39:18Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-29T14:39:24Z
0xSorryNotSorry marked the issue as duplicate of #1164
#2 - c4-judge
2024-01-28T20:13:15Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: serial-coder
Also found by: 0xStalin, 0xbepresent, Cosine, DanielArmstrong, EV_om, HighDuty, Soul22, SpicyMeatball, ether_sky, evmboi32, gesha17, kaden, lsaudit, nonseodion, smiling_heretic
42.2419 USDC - $42.24
To offboard a term GUILD holders should agree on that. After a offboard is accepted, the gauge is removed from active gauges list and redemption inside SimplePSM
is paused until all loans are paid. To unpause the redemption, LendingTermOffboarding::cleanup should be called. But we can note that it is a valid scenario to re-onboard a term, before all conditions for cleanup
are met. But lets examine what would be the consequences from such an action.
When offboard
is called nOffboardingsInProgress
is incremented by 1 and SimplePSM
redemption will be paused as long as nOffboardingsInProgress > 0
But if a term is re-onboarded, before all his loans has been repaid, or nobody has called cleanup
function we can notice another concern:
2.1 To offboard a term we need canOffboard[term] to be true, which is set back to false inside cleanup
function, which has never been called
2.2 This means that after re-onboarding a single person can offboard it again by simply calling offboard
2.3 Which would lead to the worst impact, which is incrementing nOffboardingsInProgress
again for the same lending term. This means that now it is impossible to set nOffboardingsInProgress
back to 0, because cleanup
can be called only once for this term, which will decrement progress variable by only 1. The result is constantly paused SimplePSM
and blocked funds for stakers.
NOTE there is a way for community to vote on unpausing the PSM, but this would take a lot of time, during which all PSM functionalities (mint/redeem) would be blocked and even after it’s unblocking, when another term is off-boarded, we again enter in long pause, which is only changeable after long GOV vote and Timelock waiting period.
LendingTermOffboarding
and blocked functionality and funds of SimplePSM
I have provided instructions in the following gist on how and where to run the coded PoC.
Manual Review Foundry
canOffboard
for the term, which is being re-onboarded, when this happens. The implementation may be to implement a logic, which would check canOffboard
when a proposal execute
is called and based on that to remove it and decrement pending offboardings, or do nothingDoS
#0 - c4-pre-sort
2024-01-02T19:15:00Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-02T19:15:31Z
0xSorryNotSorry marked the issue as duplicate of #1147
#2 - c4-judge
2024-01-25T18:49:28Z
Trumpero marked the issue as duplicate of #1141
#3 - c4-judge
2024-01-25T18:53:56Z
Trumpero marked the issue as satisfactory
#4 - c4-judge
2024-01-31T13:45:16Z
Trumpero changed the severity to 2 (Med Risk)
🌟 Selected for report: Cosine
Also found by: Byteblockers, HighDuty, OMEN
598.2641 USDC - $598.26
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/AuctionHouse.sol#L118
In combination with USDT/USDC blacklist and block stuffing a malicious user can intentionally generate bad debt for successful term and slash all GUILD holders as a result.
Tokens like USDT and USDC have functions that allow them to blacklist an address. The consequence of this action is that a blacklisted user can no longer transfer or receive tokens, which will make the first phase of the auction always revert when trying to send the remaining collateral to the borrower, which will completely block the first phase.
After that when the midPoint passes, the malicious user can delay the second phase making use of block stuffing spaming the network with transactions for one or more blocks, to delay the debt repayment from active participants of the protocol. The result of this action would generate bad debt, which will slash all GUILD holders no matter if this is the best lending term out there and GUILD liquidity is worth a lot. This is huge loss of participants capital, without any real reason.
Coded PoC, which should be placed inside test/unit/loan/AuctionHouse.t.sol and executed with forge test --match-test testSlashGUILDTermBlacklistPlusBlockStuffing -vv
https://gist.github.com/NicolaMirchev/3a9d1cb926c6239493980c92136e5da8
Manual review
LendingTerm::onBid
so this function won’t be dependant on external ERC20 logic(blacklists)mapping(address -> uint256) collateralToBeRepayed
or other name and a function ``withdrawRepayedLoanCollatelwhich will send the pending reward to the
msg.sender` based on the mapping and decrement it.onBid
function may look like this:// send collateral to borrower if (collateralToBorrower != 0) { - IERC20(params.collateralToken).safeTransfer( - loans[loanId].borrower, - collateralToBorrower - ); + collateralToBeRepayed(loans[loanId].borrower) += collateralToBorrower; } ## Assessed type Token-Transfer
#0 - c4-pre-sort
2024-01-02T19:45:07Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-02T19:45:35Z
0xSorryNotSorry marked the issue as duplicate of #691
#2 - c4-pre-sort
2024-01-03T17:41:00Z
0xSorryNotSorry marked the issue as duplicate of #1245
#3 - c4-judge
2024-01-27T07:40:56Z
Trumpero changed the severity to QA (Quality Assurance)
#4 - c4-judge
2024-01-27T09:40:17Z
Trumpero marked the issue as grade-b
#5 - NicolaMirchev
2024-02-01T19:55:25Z
Hey, @Trumpero
About block stuffing:
About Blacklisted ERC20s
#6 - Trumpero
2024-02-08T16:05:07Z
@NicolaMirchev Agree that this issue should be a dup of #685, since it also mentions the block stuffing attack in phase 2 of auction.
#7 - c4-judge
2024-02-08T16:05:25Z
Trumpero removed the grade
#8 - thebrittfactor
2024-02-08T16:20:47Z
For transparency, the judge requested duplicate labels, severity and grade to be updated.
#9 - NicolaMirchev
2024-02-09T15:42:21Z
Hey, @Trumpero What about the second point of my argument? Imagine being a whale with millions staked in a stable lending term. You go to sleep, or just no in front of the protocol for only 30 mins. malicious borrower cannot be liquidated, because it is always failing. When it could finally happen, term is slashed...
🌟 Selected for report: SBSecurity
Also found by: 0xaltego, 0xbepresent, Aymen0909, Bauchibred, Cosine, EVDoc, EloiManuel, HighDuty, Sathish9098, Tendency, Timeless, ZanyBonzy, beber89, deliriusz, ether_sky, grearlake, hals, klau5, lsaudit, nadin, rvierdiiev, tsvetanovv
20.8157 USDC - $20.82
Lets examine how if
statement can be skipped and the for loop inside _decrementWeightUntilFree
would stuck, because of the i
increment inside if
statement.
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; } }
gaugeList
, which weight is 0, if
branch will be skippedincrementGauge
with 0 amount, which will add this gauge to a user's gauge list with the value of 0.transfer/transferFrom/burn
would be blocked, when userFreeWeight < weight
User allocating 0 amount to a gauge -> transfer/burn tokens, when amount is > all allocated weight for user -> stucked loop
Manual Review
Move i
increment outside the if
:
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; - } } } totalWeight -= totalFreed; + unchecked { + ++i; + } }
Token-Transfer
#0 - c4-pre-sort
2024-01-05T08:43:25Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-05T08:43:36Z
0xSorryNotSorry marked the issue as duplicate of #152
#2 - c4-judge
2024-01-28T19:06:03Z
Trumpero changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-01-28T19:08:43Z
Trumpero marked the issue as grade-b