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: 26/127
Findings: 4
Award: $529.68
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: evmboi32
Also found by: 0xAlix2, 0xadrii, 3docSec, Jorgect, KingNFT, Soul22, SpicyMeatball, Tendency, c47ch3m4ll, critical-or-high, kaden, stackachu
237.7229 USDC - $237.72
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/tokens/ERC20RebaseDistributor.sol#L560 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/tokens/ERC20RebaseDistributor.sol#L654
Sending tokens to yourself while being in the rebase mode allows users to double their rebasing reward, essentially stealing other users rewards and messing their balances.
The problem is that we read rebasing states of the sender and the receiver before the transfer,
function transfer( address to, uint256 amount ) public virtual override returns (bool) { // if `from` is rebasing, materialize the tokens from rebase to ensure // proper behavior in `ERC20.transfer()`. RebasingState memory rebasingStateFrom = rebasingState[msg.sender]; RebasingState memory rebasingStateTo = rebasingState[to];
thus in case of self transfer rebasingStateTo
value won't be affected and will be used in further calculations.
if (rebasingStateTo.isRebasing == 1) { // compute rebased balance uint256 rawToBalanceAfter = ERC20.balanceOf(to); uint256 toBalanceAfter = _shares2balance( rebasingStateTo.nShares, _rebasingSharePrice, amount, rawToBalanceAfter );
Check this test case for ERC20RebaseDistributor.t.sol
function testSelfie() public { address bob = makeAddr("Bob"); // premint token.mint(alice, 100 ether); token.mint(bob, 100 ether); token.mint(address(this), 10000 ether); vm.prank(alice); token.enterRebase(); vm.prank(bob); token.enterRebase(); // distribute 100 tokens // Alice and Bob should receive 50 tokens each, right? token.distribute(100 ether); vm.warp(block.timestamp + 30 days); vm.startPrank(alice); token.transfer(alice, 50 ether); console.log("ALICE BAL: ", token.balanceOf(alice)); // Alice gets all the rebasing reward token.exitRebase(); assertEq(token.balanceOf(alice), 200 ether); vm.stopPrank(); // although Bob's balance is 150 // he can't do anything with his tokens console.log("BOB BAL: ", token.balanceOf(bob)); vm.prank(bob); vm.expectRevert(); token.exitRebase(); }
Foundry
Read RebasingState memory rebasingStateTo = rebasingState[to];
from the storage after the transfer OR disable transfers if from == to
Token-Transfer
#0 - c4-pre-sort
2024-01-02T19:43:07Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-02T19:43:47Z
0xSorryNotSorry marked the issue as duplicate of #991
#2 - c4-judge
2024-01-29T06:16:31Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: carrotsmuggler
Also found by: 3docSec, EV_om, PENGUN, SpicyMeatball, TheSchnilch, ether_sky, falconhoof, santipu_
157.0084 USDC - $157.01
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/ProfitManager.sol#L172-L176 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/LendingTerm.sol#L379-L380
User can mint credit tokens in the SimplePSM contract
function mint( address to, uint256 amountIn ) external whenNotPaused returns (uint256 amountOut) { amountOut = getMintAmountOut(amountIn); pegTokenBalance += amountIn; ERC20(pegToken).safeTransferFrom(msg.sender, address(this), amountIn); CreditToken(credit).mint(to, amountOut); emit Mint(block.timestamp, to, amountIn, amountOut); }
and then burn them making totalBorrowedCredit
to underflow
function totalBorrowedCredit() external view returns (uint256) { return CreditToken(credit).targetTotalSupply() - SimplePSM(psm).redeemableCredit(); }
Since we use totalBorrowedCredit
function in borrowing operations users won't be able to use protocol terms .
The protocol uses totalBorrowedCredit
value to assess debt across multiple terms, it is basically amount of credit tokens that we can redeem for collateral in SimplePSM subtracted from the credit total supply. We can inflate redeemableCredit
while simultaneously decreasing targetTotalSupply
by minting tokens in SimplePSM and burning them. If we manage to burn more tokens that were borrowed, redeemableCredit
will be greater than targetTotalSupply
and totalBorrowedCredit
will underflow.
Test case for LendingTerm.t.sol
, set psm as MINTER in setUp
core.grantRole(CoreRoles.CREDIT_MINTER, address(psm));
function testCantBorrow() public { address mal = makeAddr("Mal"); // prepare & borrow uint256 borrowAmount = 1_000e18; uint256 collateralAmount = 1_000e18; collateral.mint(address(this), collateralAmount); collateral.mint(mal, collateralAmount + 1); collateral.approve(address(term), type(uint256).max); bytes32 loanId = term.borrow(borrowAmount, collateralAmount); vm.startPrank(mal); // mint tokens in PSM and burn them, to stop borrowing operations we need to burn // totalBorrowedCredit + 1 = 1001 collateral.approve(address(psm), collateralAmount + 1); psm.mint(mal, borrowAmount + 1); credit.burn(borrowAmount + 1); vm.stopPrank(); // no more borrows // revert with underflow vm.warp(block.timestamp + 1); collateral.mint(address(this), collateralAmount); loanId = term.borrow(borrowAmount, collateralAmount); } ``` ## Tools Used Foundry ## Recommended Mitigation Steps Add a BURNER_ROLE so only authorized addresses can burn credit tokens. ## Assessed type DoS
#0 - c4-pre-sort
2023-12-30T14:13:26Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-30T14:13:35Z
0xSorryNotSorry marked the issue as duplicate of #1170
#2 - c4-judge
2024-01-28T23:36:06Z
Trumpero changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-01-28T23:37:47Z
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
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/LendingTermOffboarding.sol#L154 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/LendingTermOffboarding.sol#L197
There can be a situation where an offboarded term has been re-onboarded without a cleanup
, that term can be offboarded instantly, without a 7 day vote.
In order to offboard a term users need to start a 7 day voting in the LendingTermOffboarding.sol
, after it succeeded we set canOffboard[term] = true
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/LendingTermOffboarding.sol#L139
and anyone can call offboard
function to remove the gauge from the active list
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/LendingTermOffboarding.sol#L153
function offboard(address term) external whenNotPaused { require(canOffboard[term], "LendingTermOffboarding: quorum not met"); // 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); }
Later this gauge can be re-onboarded with the LendingTermOnboarding.sol
, unfortunately canOffboard
flag isn't reset for the re-onboarded term and it can be offboarded again by anyone without a vote. To set this flag to false we need to execute a cleanup
function
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/LendingTermOffboarding.sol#L197
but this requirement indicates that it is not necessary to call this function if we plan to re-onboard the term
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/LendingTermOffboarding.sol#L181-L184
It is worth noting that the nOffboardingsInProgress
counter is also not updated, which can create problems with following requirement in the future
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/LendingTermOffboarding.sol#L191-L195
Check this test case for LendingTermOffboarding.t.sol
function testInstantOffboardAfterReonboard() public { // prepare (1) 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)); vm.stopPrank(); // re-onboard guild.addGauge(1, address(term)); // offboard without voting offboarder.offboard(address(term)); }
Foundry
Set canOffboard[term]
to false and decrement nOffboardingsInProgress
if the gauge was previously removed
Governance
#0 - c4-pre-sort
2024-01-04T13:13:30Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-04T13:13:42Z
0xSorryNotSorry marked the issue as duplicate of #1147
#2 - c4-judge
2024-01-25T18:49:31Z
Trumpero marked the issue as duplicate of #1141
#3 - c4-judge
2024-01-25T18:54:10Z
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_
92.7119 USDC - $92.71
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/tokens/GuildToken.sol#L224-L231 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/LendingTerm.sol#L287-L292
Incorrect debtCeiling
value will prevent users from withdrawing Guild tokens out of the gauge.
If there is a loan in the gauge and user wants to decrement the weight, we need to check it's ceiling first, https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/tokens/GuildToken.sol#L224-L231
// check if gauge is currently using its allocated debt ceiling. // To decrement gauge weight, guild holders might have to call loans if the debt ceiling is used. uint256 issuance = LendingTerm(gauge).issuance(); if (issuance != 0) { uint256 debtCeilingAfterDecrement = LendingTerm(gauge).debtCeiling(-int256(weight)); require( issuance <= debtCeilingAfterDecrement, "GuildToken: debt ceiling used" ); }
ensuring 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 only 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 user is unable to withdraw tokens from the gauge even it it's the only gauge of it's type and ceiling should be "unlimited"
modify debtCeiling
to act like a LendingTerm
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/test/unit/tokens/GuildToken.t.sol#L27
function debtCeiling(int256 gaugeWeightDelta) public view returns (uint256) { uint256 gaugeWeight = token.getGaugeWeight(address(this)); gaugeWeight = uint256(int256(gaugeWeight) + gaugeWeightDelta); uint256 gaugeType = token.gaugeType(address(this)); uint256 totalWeight = token.totalTypeWeight(gaugeType); if (gaugeWeight == 0) { return 0; // no gauge vote, 0 debt ceiling } else if (gaugeWeight == totalWeight) { // one gauge, unlimited debt ceiling // return unlimited return 1_000_000 ether; } uint256 _issuance = issuance; // cached SLOAD uint256 totalBorrowedCredit = credit.totalSupply(); uint256 gaugeWeightTolerance = 1e18; if (totalBorrowedCredit == 0 && gaugeWeight != 0) { // first-ever CREDIT mint on a non-zero gauge weight term // does not check the relative debt ceilings return 1_000_000 ether; } uint256 toleratedGaugeWeight = (gaugeWeight * gaugeWeightTolerance) / 1e18; uint256 debtCeilingBefore = (totalBorrowedCredit * toleratedGaugeWeight) / totalWeight; if (_issuance >= debtCeilingBefore) { return debtCeilingBefore; // no more borrows allowed } uint256 remainingDebtCeiling = debtCeilingBefore - _issuance; // always >0 if (toleratedGaugeWeight >= totalWeight) { // if the gauge weight is above 100% when we include tolerance, // the gauge relative debt ceilings are not constraining. return 1_000_000 ether; } uint256 otherGaugesWeight = totalWeight - toleratedGaugeWeight; // always >0 uint256 maxBorrow = (remainingDebtCeiling * totalWeight) / otherGaugesWeight; uint256 _debtCeiling = _issuance + maxBorrow; // return min(creditMinterBuffer, hardCap, debtCeiling) if (1_000_000 ether < _debtCeiling) { return 1_000_000 ether; } return _debtCeiling; }
run the test
function testDebtCeilingOneGauge() public { // grant roles to test contract vm.startPrank(governor); core.grantRole(CoreRoles.CREDIT_MINTER, address(this)); core.grantRole(CoreRoles.GUILD_MINTER, address(this)); core.grantRole(CoreRoles.GAUGE_ADD, address(this)); core.grantRole(CoreRoles.GAUGE_REMOVE, address(this)); core.grantRole(CoreRoles.GAUGE_PARAMETERS, address(this)); vm.stopPrank(); // setup token.mint(alice, 100e18); token.setMaxGauges(3); token.addGauge(1, address(this)); vm.startPrank(alice); token.incrementGauge(address(this), 10e18); vm.stopPrank(); credit.mint(alice, 100e18); issuance = 100e18; vm.prank(alice); // will revert with "GuildToken: debt ceiling used" token.decrementGauge(address(this), 8e18); }
Foundry
Apply delta to totalWeight
as well
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/LendingTerm.sol#L270
function debtCeiling( int256 gaugeWeightDelta ) public view returns (uint256) { address _guildToken = refs.guildToken; // cached SLOAD uint256 gaugeWeight = GuildToken(_guildToken).getGaugeWeight( address(this) ); gaugeWeight = uint256(int256(gaugeWeight) + gaugeWeightDelta); uint256 gaugeType = GuildToken(_guildToken).gaugeType(address(this)); uint256 totalWeight = GuildToken(_guildToken).totalTypeWeight( gaugeType ); + totalWeight = uint256(int256(totalWeight) + gaugeWeightDelta);
Math
#0 - c4-pre-sort
2023-12-30T15:05:47Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-12-30T15:05:58Z
0xSorryNotSorry marked the issue as duplicate of #880
#2 - c4-judge
2024-01-28T19:17:14Z
Trumpero marked the issue as satisfactory
#3 - c4-judge
2024-01-28T19:26:11Z
Trumpero marked the issue as selected for report
#4 - Trumpero
2024-02-01T15:46:38Z
I chose this to be the primary issue since it has better context than #880, and it contains a PoC test to prove