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: 51/127
Findings: 2
Award: $253.47
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
The current flow of offboarding is defined as below:
proposeOffboard()
supportOffboard()
offboard()
and offboard a Lending Term.This flow can be, however, disturbed, when we will re-onboard term.
Malicious user can immediately calls offboard()
on re-onboarded term and offboard it without voting.
Terms can be re-onboarded. This is confirmed in the comment section of LendingTermOnboarding.sol
:
File: LendingTermOnboarding.sol
// Check that the term is not already active // note that terms that have been offboarded in the past can be re-onboarded // and won't fail this check. This is intentional, because some terms might be offboarded // due to specific market conditions, and it might be desirable to re-onboard them // at a later date. bool isGauge = GuildToken(guildToken).isGauge(term);
Now, let's take a look at code at LendingTermOffboarding.sol
Whenever we call offboard()
- mappings canOffboard
, userPollVotes
and polls
are not being reset.
This means, that when we call offboard(term)
, userPollVotes[msg.sender][snapshotBlock][term]
and polls[snapshotBlock][term]
still contains users' votes and canOffboard
still points out that it reached the quorum.
offboard(term)
on term
. term
is being offboarded.offboard(term)
does not clear canOffboard[term]
, userPollVotes[msg.sender][snapshotBlock][term]
, polls[snapshotBlock][term]
mappings.term
.offboard(term)
did not clear canOffboard[term]
in step 1 - we are able to call offboard(term)
once again, on the re-onboarded term.This implies, that when term was offboarded and re-onboarded, it's possible to immediately offboard it again.
We don't need to propose to offboard (calling proposeOffboard()
) and vote for that term again, since canOffboard[term]
is already set to true
. We can immediately call offboard(term)
and offboard it.
Please notice, that above scenario assumes, that we do not call cleanup()
after calling offboard()
. After offboard()
, we re-onboard the term()
, without calling cleanup()
on it.
Since offbord()
function is external, everyone can call it. This means that any user can offboard re-onboarded term. Let's consider above scenario.
proposeOffboard(0xA)
supportOffboard(0xA)
until quorum is reached and canOffboard[0xA]
is set to true.offboard(0xA)
and offboards term 0xA.offboard(0xA)
(please notice, that he does not need to call proposeOffboard()
and supportOffboard()
). Since canOffboard[0xA]
was not reset (it points to true) - term 0xA is being offboarded.This scenario allows malicious user to immediately offboards re-onboarded terms. He does not need to propose them once again (proposeOffboard()
) or vote for them (supportOffboard()
.). He can just immediately call offboard()
on the re-onboarded term and that term will be offboarded again.
Manual code review
Fixing this issue would require to redesign the whole LendingTermOffboarding.sol
mechanism. The quickest solution would be to check again if quorum is met in the offboard()
function and reset votes whenever offboard()
is called. That way it won't be possible to call offboard()
twice without voting on the term again. When the same term will be re-onboarded its vote will be 0 and quorum won't be met - thus it won't be possible to call offboard()
again.
Other
#0 - c4-pre-sort
2024-01-04T16:27:48Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-01-04T16:28:04Z
0xSorryNotSorry marked the issue as duplicate of #1147
#2 - c4-judge
2024-01-25T18:49:25Z
Trumpero marked the issue as duplicate of #1141
#3 - c4-judge
2024-01-25T18:53:50Z
Trumpero marked the issue as satisfactory
🌟 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
211.2258 USDC - $211.23
Whenever we update/set a new value of some state variable, it's a good practice to make sure that, the value is being indeed updated.
In the current implementation of _setQuorum()
in GuildGovernor.sol
, even when the _quorum
won't be changed, function will still emit an QuorumUpdated()
event - which might be misleading to the end-user.
E.g. let's assume that _quorum = 123
. Calling _setQuorum(123)
won't update/set any new quorum, but QuorumUpdated()
will still be called.
Our recommendation is to add additional require
check, to verify, that provided values are indeed different than the current ones:
require(_quorum != newQuorum, "Nothing to update");
The same issue occurs in multiple of instances:
CoreRef.sol
: _setCore()
LendingTermOffboarding.sol
: setQuorum()
ProfitManager.sol
: setMinBorrow()
, setGaugeWeightTolerance()
, setProfitSharingConfig()
GuildVetoGovernor.sol
: _setQuorum()
, _updateTimelock()
ERC20MultiVotes.sol
: _setMaxDelegates()
GuildToken.sol
: setProfitManager()
SimplePSM.sol
: setRedemptionsPaused()
, setMintRatio()
, setRewardRatio()
address(0)
check in setCore()
in CoreRef.sol
function setCore( address newCore ) external onlyCoreRole(CoreRoles.GOVERNOR) { _setCore(newCore); } [...] function _setCore(address newCore) internal { address oldCore = address(_core); _core = Core(newCore); emit CoreUpdate(oldCore, newCore); }
There's no additional check which verifies, if newCore
address, passed by the user is not address(0)
.
Perform additional check: require(newCore != address(0), "newCore cannot be addr(0)")
.
address(0)
check in allowImplementation()
in LendingTermOnboarding.sol
File: LendingTermOnboarding.sol
function allowImplementation( [...] implementations[implementation] = allowed; [...]
There's no additional check which verifies, if implementation
address, passed by the user is not address(0)
.
Perform additional check: require(implementation != address(0), "newCore cannot be addr(0)")
.
address(0)
check in updateTimelock()
in GuildVetoGovernor.sol
function updateTimelock( address newTimelock ) external onlyCoreRole(CoreRoles.GOVERNOR) { _updateTimelock(newTimelock); } function _updateTimelock(address newTimelock) private { emit TimelockChange(timelock, newTimelock); timelock = newTimelock; }
There's no additional check which verifies, if newTimelock
address, passed by the user is not address(0)
.
Perform additional check: require(newTimelock != address(0), "newTimelock cannot be addr(0)")
.
function setMinBorrow( uint256 newValue ) external onlyCoreRole(CoreRoles.GOVERNOR) { _minBorrow = newValue; emit MinBorrowUpdate(block.timestamp, newValue); }
MinBorrowUpdate()
should emit both old (before the update) and new (after the update) values. The current implementation emits an event only with a new value.
The same issue occurs in multiple of instances:
ProfitManager.sol
: setGaugeWeightTolerance()
, setProfitSharingConfig()
.
GuildToken.sol
: setProfitManager()
SimplePSM.sol
: setMintRatio()
, setRewardRatio()
createRole()
in Core.sol
/// @notice creates a new role to be maintained /// @param role the new role id /// @param adminRole the admin role id for `role` /// @dev can also be used to update admin of existing role function createRole( bytes32 role, bytes32 adminRole ) external onlyRole(CoreRoles.GOVERNOR) { _setRoleAdmin(role, adminRole); }
As the NatSpec comment states, createRole()
: can also be used to update admin of existing role
.
This suggests, that the name of the function should be changed to: createOrUpdateRole()
.
renounceRole()
in Core.sol
should be overriden to disallow renouncing CoreRoles.GOVERNOR
roleSince Core
inherits from AccessControlEnumerable
, it implements additional AccessControl
functions. One of those functions is renounceRole()
.
CoreRoles.GOVERNOR
is the most important role in the protocol. Renouncing this role basically means that protocol won't be able to execute most of the operation.
Our recommendation is to override renounceRole()
so that it won't be possible to accidently renounce this role:
function renounceRole(bytes32 role, address callerConfirmation) public override { if (callerConfirmation == CoreRoles.GOVERNOR && role == CoreRoles.GOVERNOR ) revert("Cannot renounce CoreRoles.GOVERNOR!"); super.renounceRole(role, callerConfirmation); }
File: LendingTermOnboarding.sol
uint256 public constant MIN_DELAY_BETWEEN_PROPOSALS = 7 days;
The minimum delay between proposals of onboarding of a given term is represented as constant
variable: MIN_DELAY_BETWEEN_PROPOSALS
. This implies, that this value cannot be updated by anyone.
Our recommendation is to change this variable to a non-constant one and implement a function which allows to modify its value in the future.
allowImplementation()
in LendingTermOnboarding.sol
File: LendingTermOnboarding.sol
/// @notice Allow or disallow a given implemenation function allowImplementation( [...] implementations[implementation] = allowed; [...]
Function allowImplementation()
can either allow or disallow a given implementation, while its name suggests that it only allows new implementations. Our recommendation is to change the name of this function to: allowOrDisallowImplementation()
.
uint256 public constant MIN_STAKE = 1e18;
The minimum number of CREDIT to stake is represented as constant
variable: MIN_STAKE
. This implies, that this value cannot be updated by anyone.
Our recommendation is to change this variable to a non-constant one and implement a function which allows to modify its value in the future.
There's no need to pass block.timestamp
as event parameter, since this info can be get directly from off-chain (we can see when exactly event was emitted).
There are multiple of events which implements timestamp
or when
parameter and then pass block.timestamp
. These fields can be removed, since there's no need to pass them into events.
LendingTermOffboarding.sol: event OffboardSupport(uint256 indexed timestamp, [...] event Offboard(uint256 indexed timestamp, address indexed term); event Cleanup(uint256 indexed timestamp, address indexed term); LendingTermOnboarding.sol: event ImplementationAllowChanged(uint256 indexed when, [...] event TermCreated(uint256 indexed when, [...] ProfitManager.sol: event GaugePnL(address indexed gauge, uint256 indexed when, int256 pnl); event SurplusBufferUpdate(uint256 indexed when, uint256 newValue); event TermSurplusBufferUpdate(uint256 indexed when, [...] event CreditMultiplierUpdate(uint256 indexed when, uint256 newValue); event ProfitSharingConfigUpdate(uint256 indexed when, [...] event ClaimRewards(uint256 indexed when, [...] event MinBorrowUpdate(uint256 indexed when, uint256 newValue); event GaugeWeightToleranceUpdate(uint256 indexed when, uint256 newValue); ERC20RebaseDistributor.sol: event RebaseEnter(address indexed account, uint256 indexed timestamp); event RebaseExit(address indexed account, uint256 indexed timestamp); event RebaseDistribution(address indexed source, uint256 indexed timestamp, [...] event RebaseReward(address indexed account, uint256 indexed timestamp, [...] GuildToken.sol: event GaugeLoss(address indexed gauge, uint256 indexed when); event GaugeLossApply([...], uint256 when); event TransfersEnabled(uint256 block, uint256 timestamp); event ProfitManagerUpdated(uint256 timestamp, address newValue); AuctionHouse.sol: event AuctionStart(uint256 indexed when, [...] event AuctionEnd(uint256 indexed when, [...] LendingTerm.sol: event LoanOpen(uint256 indexed when, [...] event LoanCall(uint256 indexed when, bytes32 indexed loanId); event LoanClose( uint256 indexed when, [...] event LoanAddCollateral(uint256 indexed when,, [...] event LoanPartialRepay(uint256 indexed when, [...] SimplePSM.sol: event Mint(uint256 indexed when, [...] event RedemptionsPaused(uint256 indexed when, bool status); SurplusGuildMinter.sol: event Stake(uint256 indexed timestamp, [...] event Unstake(uint256 indexed timestamp, [...] event GuildReward(uint256 indexed timestamp, [...] event MintRatioUpdate(uint256 indexed timestamp, uint256 ratio); event RewardRatioUpdate(uint256 indexed timestamp, uint256 ratio);
Core.sol
_setRoleAdmin(CoreRoles.GOVERNOR, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GUARDIAN, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.CREDIT_MINTER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.RATE_LIMITED_CREDIT_MINTER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GUILD_MINTER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.RATE_LIMITED_GUILD_MINTER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GAUGE_ADD, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GAUGE_REMOVE, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GAUGE_PARAMETERS, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GAUGE_PNL_NOTIFIER, CoreRoles.GOVERNOR); _setRoleAdmin( CoreRoles.GUILD_GOVERNANCE_PARAMETERS, CoreRoles.GOVERNOR ); _setRoleAdmin( CoreRoles.GUILD_SURPLUS_BUFFER_WITHDRAW, CoreRoles.GOVERNOR ); _setRoleAdmin( CoreRoles.CREDIT_GOVERNANCE_PARAMETERS, CoreRoles.GOVERNOR ); _setRoleAdmin(CoreRoles.CREDIT_REBASE_PARAMETERS, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.TIMELOCK_PROPOSER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.TIMELOCK_EXECUTOR, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.TIMELOCK_CANCELLER, CoreRoles.GOVERNOR); [...]
constructor()
sets multiple of roles by calling _setRoleAdmin()
function. Since multiple of roles are being set at once, and the order of _setRoleAdmin()
does not matter, to increase the code readability, our recommendation is to set those roles in an alphabetical order.
E.g., in the current code-base, the very similar roles RATE_LIMITED_CREDIT_MINTER
, RATE_LIMITED_GUILD_MINTER
, are separated by _setRoleAdmin(CoreRoles.GUILD_MINTER, CoreRoles.GOVERNOR);
. Sorting those roles in an alphabetical order would allow to avoid that issue and make the code readability clearer:
_setRoleAdmin(CoreRoles.CREDIT_GOVERNANCE_PARAMETERS,CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.CREDIT_MINTER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.CREDIT_REBASE_PARAMETERS, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GAUGE_ADD, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GAUGE_PARAMETERS, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GAUGE_PNL_NOTIFIER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GAUGE_REMOVE, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GOVERNOR, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GUARDIAN, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GUILD_GOVERNANCE_PARAMETERS, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GUILD_MINTER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.GUILD_SURPLUS_BUFFER_WITHDRAW, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.RATE_LIMITED_CREDIT_MINTER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.RATE_LIMITED_GUILD_MINTER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.TIMELOCK_CANCELLER, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.TIMELOCK_EXECUTOR, CoreRoles.GOVERNOR); _setRoleAdmin(CoreRoles.TIMELOCK_PROPOSER, CoreRoles.GOVERNOR);
The same issue occurs in CoreRoles.sol
. Defined roles:
[...] bytes32 internal constant CREDIT_MINTER = keccak256("CREDIT_MINTER_ROLE"); /// @notice can mint CREDIT within rate limits & cap bytes32 internal constant RATE_LIMITED_CREDIT_MINTER = keccak256("RATE_LIMITED_CREDIT_MINTER_ROLE"); /// @notice can mint GUILD arbitrarily bytes32 internal constant GUILD_MINTER = keccak256("GUILD_MINTER_ROLE"); /// @notice can mint GUILD within rate limits & cap bytes32 internal constant RATE_LIMITED_GUILD_MINTER = keccak256("RATE_LIMITED_GUILD_MINTER_ROLE"); [...]
should be re-ordered alphabetically.
CoreRoles.sol
It's a good practice which increases the code-readability when keccak256
from constants are pre-calculated and inserted as comments.
E.g. below line:
/// @notice can manage add new gauges to the system bytes32 internal constant GAUGE_ADD = keccak256("GAUGE_ADD_ROLE");
can be changed to:
/// @notice can manage add new gauges to the system /// bytes32 internal constant GAUGE_ADD = 0xf213c52f17fbe7c12e60dedf809e77244577de25e041307d406900a545756831; bytes32 internal constant GAUGE_ADD = keccak256("GAUGE_ADD_ROLE");
| [`governance/GuildTimelockController.sol`](https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/GuildTimelockController.sol| 27 |
Markdown syntax does not contain closing )
, which means that the hyperlink is not properly rendered.
Above code should be changed to:
| [`governance/GuildTimelockController.sol`](https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/main/src/governance/GuildTimelockController.sol)| 27 |
File: LendingTermOnboarding.sol
// 31557601 comes from the constant LendingTerm.YEAR() + 1 params.maxDelayBetweenPartialRepay < 31557601, // periodic payment every [0, 1 year]
Use previously defined LendingTerm.YEAR() + 1
, instead of magic-number: 31557601
. Please notice, that @src/loan/LendingTerm.sol
is already imported in LendingTermOnboarding.sol
, which implies, we can use LendingTerm.YEAR()
straightforwardly in the code-base.
GuildVetoGovernor.sol
Some of the variables does not use camel-case syntax.
File: GuildVetoGovernor.sol 175: ProposalVote storage proposalvote = _proposalVotes[proposalId]; // should be: ProposalVote storage proposalVote = _proposalVotes[proposalId]; 202: ProposalVote storage proposalvote = _proposalVotes[proposalId]; // should be: ProposalVote storage proposalVote = _proposalVotes[proposalId]; 250: bytes32 queueid = _timelockIds[proposalId]; // should be: bytes32 queueId = _timelockIds[proposalId];
require
message should not be emptyUse descriptive messages which will explain why the function reverted.
require(signer != address(0));
LendingTerm.sol
does not properly explain YEAR
value/// @notice reference number of seconds in 1 year uint256 public constant YEAR = 31557600;
The comment suggests, that YEAR
constant should be 365 * 24 * 60 * 60
, which is 31536000
. However, the value of YEAR
is 31557600
, because it takes into a consideration a leap year. This means, that the calculations are: 365.25 * 24 * 60 * 60
, which indeed is 31557600
. The comment should be more descriptive about the YEAR
value (it should explain, that it takes into consideration a leap year).
// if the user has been slashed or isnt staking, there is nothing to do
isnt
should be either is not
or isn't
.
#0 - c4-pre-sort
2024-01-05T19:01:33Z
0xSorryNotSorry marked the issue as sufficient quality report
#1 - Trumpero
2024-01-30T22:13:47Z
QA-01: low QA-02: bot report duplication (L-14) -> invalid QA-03: bot report duplication (L-12) -> invalid QA-04: bot report duplication (L-12) -> invalid QA-05: bot report duplication (N-75) -> invalid R-01: NC R-02: R R-03: R R-04: NC R-05: R R-06: R NC-01: NC NC-02: NC NC-03: OOS NC-04: NC NC-05: NC NC-06: NC NC-07: bot report duplication (N-100) -> invalid NC-08: NC
#2 - Trumpero
2024-01-31T12:05:10Z
#3 - c4-judge
2024-01-31T12:05:13Z
Trumpero marked the issue as grade-b
#4 - c4-judge
2024-01-31T12:08:45Z
Trumpero marked the issue as grade-a