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: 80/127
Findings: 1
Award: $85.84
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: serial-coder
Also found by: 0x70C9, 0xAlix2, 0xmystery, 0xpiken, Arz, DanielArmstrong, Shubham, deth, rbserver, rvierdiiev, xeros
85.8444 USDC - $85.84
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/LendingTermOffboarding.sol#L175-L199 https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/LendingTermOffboarding.sol#L116-L148
The identified issue lies in supportOffboard()
of the LendingTermOffboarding
smart contract, which lacks mechanisms to prevent GUILD holders from voting on a lending term that has already been offboarded and cleaned up. This contradicts the function NatSpec of offboard()
that is known to be saying,
/// This will prevent new loans from being open, and will prevent GUILD holders to vote for the term.
As can been seen in the code logic of supportOffboard()
, any user who has not voted on an unexpired poll can support the poll even though (_weight + userWeight >= quorum)
has been fulfilled:
/// @notice Support a poll to offboard a given LendingTerm. 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; if (_weight + userWeight >= quorum) { canOffboard[term] = true; } emit OffboardSupport( block.timestamp, term, snapshotBlock, msg.sender, userWeight ); }
This oversight could lead to the reactivation of the canOffboard[term]
flag for terms that have completed the offboarding process where an exploit could be crafted to nullify the mutext logic of cleanup()
. The primary impact of this issue is unpausing redemptions in SimplePSM.sol
pre-maturely while other terms that have been offboarded are yet to have their loans closed.
Here's a scenario:
proposeOffboard()
, and then supportOffboard()
till quorum
is met to make canOffboard[term] = true
:if (_weight + userWeight >= quorum) { canOffboard[term] = true; }
offboard(()
is separately called to post-increment nOffboardingsInProgress
to 3 where the first call will have SimplePSM.redeem() paused:// pause psm redemptions if ( nOffboardingsInProgress++ == 0 && !SimplePSM(psm).redemptionsPaused() ) { SimplePSM(psm).setRedemptionsPaused(true); }
cleanup()
to pre-decrement nOffboardingsInProgress
to 2, but understandably not meeting the following condition to unpause redemptions in SimplePSM.sol yet. And, at this point, canOffboard[term]
is set to false
for term 1:// unpause psm redemptions if ( --nOffboardingsInProgress == 0 && SimplePSM(psm).redemptionsPaused() ) { SimplePSM(psm).setRedemptionsPaused(false); } canOffboard[term] = false;
supportOffboard()
to turn on canOffboard[term]
to true
again and then calling cleanup()
to get through the first require check, supposedly a mutex lock for a term that has already been cleaned up:/// @notice Cleanup roles of a LendingTerm. /// This is only callable after a term has been offboarded and all its loans have been closed. /// @param term LendingTerm to cleanup. 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); } canOffboard[term] = false; emit Cleanup(block.timestamp, term); }
core().revokeRole()
also get through as they only return bool
, i.e. false
in this case and don't revert on a role that has already been revoked:/** * @dev Attempts to revoke `role` to `account` and returns a boolean indicating if `role` was revoked. * * Internal function without access restriction. * * May emit a {RoleRevoked} event. */ function _revokeRole(bytes32 role, address account) internal virtual returns (bool) { if (hasRole(role, account)) { _roles[role].hasRole[account] = false; emit RoleRevoked(role, account, _msgSender()); return true; } else { return false; } }
nOffboardingsInProgress
is pre-decremented to 1, but steps 4. and 5. can be repeated by another voter who has not voted to eventually unpause psm redemptions against the intended design while terms 2 and 3 are yet to be cleaned up:https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/loan/SimplePSM.sol#L132-L154
/// @notice redeem `amountIn` CREDIT for `amountOut` underlying tokens and send to address `to` /// @dev see getRedeemAmountOut() to pre-calculate amount out function redeem( address to, uint256 amountIn ) external returns (uint256 amountOut) { require(!redemptionsPaused, "SimplePSM: redemptions paused"); amountOut = getRedeemAmountOut(amountIn); CreditToken(credit).burnFrom(msg.sender, amountIn); pegTokenBalance -= amountOut; ERC20(pegToken).safeTransfer(to, amountOut); emit Redeem(block.timestamp, to, amountIn, amountOut); } /// @notice set `redemptionsPaused` /// governor-only, to allow full governance to update the psm mechanisms, /// or automated processes to pause redemptions under certain conditions. function setRedemptionsPaused( bool paused ) external onlyCoreRole(CoreRoles.GOVERNOR) { redemptionsPaused = paused; emit RedemptionsPaused(block.timestamp, paused); }
Manual
Consider the following refactoring steps. The primary goal is to ensure that once a lending term is offboarded and cleaned up, it cannot be voted on again in supportOffboard()
:
enum OffboardingStatus { Active, Offboarded, CleanedUp } mapping(address => OffboardingStatus) public termStatus;
supportOffboard()
Logic
Amend supportOffboard()
to check the term's offboarding status. If a term has been cleaned up, it should not accept further votes. (Note: OffboardingStatus.Active
is the default status when termStatus[term]
remains uninitialized/updated.)function supportOffboard(uint256 snapshotBlock, address term) external whenNotPaused { require(termStatus[term] == OffboardingStatus.Active, "Term already offboarded or cleaned up"); // existing logic... }
offboard()
Logic
In offboard()
, set the status to Offboarded
once a term is offboarded. (Note: This refactoring is optional since GuildToken(guildToken).removeGauge(term)
is laready in place to prevent offboarding. However, it will will prevent GUILD holders to vote for the term again as commented by the function NatSpec:function offboard(address term) external whenNotPaused { // existing offboarding logic... termStatus[term] = OffboardingStatus.Offboarded; }
cleanup()
Logic
In cleanup()
, set the status to CleanedUp
after successful cleanup:function cleanup(address term) external whenNotPaused { // existing cleanup checks and logic... termStatus[term] = OffboardingStatus.CleanedUp; }
Access Control
#0 - c4-pre-sort
2024-01-04T10:13:35Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-04T10:13:51Z
0xSorryNotSorry marked the issue as duplicate of #1141
#2 - c4-judge
2024-01-25T18:54:04Z
Trumpero marked the issue as satisfactory
#3 - Trumpero
2024-02-07T17:07:46Z
dup of #1141 due to the same reason: supportOffboard can still be called after offboarding.
#4 - c4-judge
2024-02-07T17:07:58Z
Trumpero marked the issue as not a duplicate
#5 - c4-judge
2024-02-07T17:08:10Z
Trumpero marked the issue as duplicate of #1141