Platform: Code4rena
Start Date: 25/08/2022
Pot Size: $75,000 USDC
Total HM: 35
Participants: 147
Period: 7 days
Judge: 0xean
Total Solo HM: 15
Id: 156
League: ETH
Rank: 19/147
Findings: 6
Award: $1,415.91
π Selected for report: 1
π Solo Findings: 0
π Selected for report: rbserver
Also found by: GalloDaSballo, Guardian, Lambda, __141345__, cccz, csanuragjain, m9800, zzzitron
In the vote function of the OlympusGovernance contract, each user can only vote once for an activeProposal, which prevents the user from voting for the activeProposal even if new VOTES are obtained after voting. Consider the following scenario:
None
In the vote function, when the user's balance is greater than userVotesForProposal, the user is allowed to vote using the difference
#0 - fullyallocated
2022-09-04T02:50:41Z
Duplicate of #275
857.4359 DAI - $857.44
In the VoterRegistration contract, voter_admin can call the revokeVotesFrom function to revoke the user's votes.
function revokeVotesFrom(address wallet_, uint256 amount_) external onlyRole("voter_admin") { // Revoke the votes in the VOTES module VOTES.burnFrom(wallet_, amount_); }
But there is a way for users to prevent their votes from being revoked by voter_admin. In the OlympusGovernance contract, the user can call the vote function to vote for the activeProposal, and then call the reclaimVotes function to reclaim his votes. When the vote function is called, VOTES are sent to the OlympusGovernance contract and recorded using the userVotesForProposal variable. When the reclaimVotes function is called, the VOTES recorded in the userVotesForProposal variable are sent back to the user. This means that the user can store his VOTES tokens in userVotesForProposal. The revokeVotesFrom function cannot revoke the VOTES tokens recorded in userVotesForProposal and the reclaimVotes function can only be called by the user himself. If the user calls the reclaimVotes function and vote function in one transaction, then his VOTES token balance will always be 0 (thus avoiding revocation of votes by voter_admin) and he will be able to vote.
https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/policies/Governance.sol#L240-L262 https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/policies/Governance.sol#L295-L313 https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/policies/VoterRegistration.sol#L53-L56
None
Consider allowing to call the reclaimVotes function to reclaim any user's vote, thus avoiding the user storing his VOTOS tokens in userVotesForProposal
function reclaimVotes(uint256 proposalId_, address user_) external { uint256 userVotes = userVotesForProposal[proposalId_][user_]; if (userVotes == 0) { revert CannotReclaimZeroVotes(); } if (proposalId_ == activeProposal.proposalId) { revert CannotReclaimTokensForActiveVote(); } if (tokenClaimsForProposal[proposalId_][user_] == true) { revert VotingTokensAlreadyReclaimed(); } tokenClaimsForProposal[proposalId_][user_] = true; VOTES.transferFrom(address(this), user_, userVotes); }
#0 - fullyallocated
2022-09-01T23:06:13Z
this is true, we don't expect to use the voter admin in production, just to issue votes during internal testing period
#1 - 0xean
2022-09-20T13:32:15Z
downgrading to M severity as this does not lead to direct loss of user funds, but does highlight an issue with current contracts.
#2 - thereksfour
2022-09-22T13:43:19Z
Consider the following scenarios. There are currently three users, A, B and C, in the system.
#3 - 0xean
2022-09-22T13:51:39Z
Okay, at this point I still believe a M issue, voter_admin as a mitigation could reissue votes to User A and B. Additionally User C will eventually have to reclaim these votes in order to vote on the next proposal. I am going to stick with M on this one. Appreciate the additional clarity.
#4 - thereksfour
2022-09-22T13:56:20Z
You are right, thanks for your attention and hope this doesn't bother you
Okay, at this point I still believe a M issue, voter_admin as a mitigation could reissue votes to User A and B. Additionally User C will eventually have to reclaim these votes in order to vote on the next proposal. I am going to stick with M on this one. Appreciate the additional clarity.
π Selected for report: GalloDaSballo
347.2615 DAI - $347.26
In the beat function of the OlympusHeart contract, the user will receive reward tokens by spending gas to update the price.
function beat() external nonReentrant { if (!active) revert Heart_BeatStopped(); if (block.timestamp < lastBeat + frequency()) revert Heart_OutOfCycle(); // Update the moving average on the Price module PRICE.updateMovingAverage(); // Trigger price range update and market operations _operator.operate(); // Update the last beat timestamp lastBeat += frequency(); // Issue reward to sender _issueReward(msg.sender); emit Beat(block.timestamp); }
But if there are not enough reward tokens in the contract, _issueReward will fail and the price cannot be updated.
function _issueReward(address to_) internal { rewardToken.safeTransfer(to_, reward); emit RewardIssued(to_, reward); }
None
Consider limiting the number of reward tokens sent to the user to no more than the contract balance in the _issueReward function. And the contract can store the number of reward tokens for the user to claim later
#0 - Oighty
2022-09-07T21:32:52Z
See comment on #378.
#1 - 0xean
2022-09-19T14:37:16Z
closing as dupe of #378
11.0311 DAI - $11.03
On Price.sol, we are using latestRoundData, but there is no check if the return value indicates stale data.
(, int256 ohmEthPriceInt, , uint256 updatedAt, ) = _ohmEthPriceFeed.latestRoundData(); // Use a multiple of observation frequency to determine what is too old to use. // Price feeds will not provide an updated answer if the data doesn't change much. // This would be similar to if the feed just stopped updating; therefore, we need a cutoff. if (updatedAt < block.timestamp - 3 * uint256(observationFrequency)) revert Price_BadFeed(address(_ohmEthPriceFeed)); ohmEthPrice = uint256(ohmEthPriceInt); int256 reserveEthPriceInt; (, reserveEthPriceInt, , updatedAt, ) = _reserveEthPriceFeed.latestRoundData(); if (updatedAt < block.timestamp - uint256(observationFrequency)) revert Price_BadFeed(address(_reserveEthPriceFeed)); reserveEthPrice = uint256(reserveEthPriceInt);
This could lead to stale prices according to the Chainlink documentation:
https://docs.chain.link/docs/historical-price-data/#historical-rounds https://docs.chain.link/docs/faq/#how-can-i-check-if-the-answer-to-a-round-is-being-carried-over-from-a-previous-round
None
- (, int256 ohmEthPriceInt, , uint256 updatedAt, ) = _ohmEthPriceFeed.latestRoundData(); + (uint80 roundID, int256 ohmEthPriceInt, , uint256 updatedAt, uint80 answeredInRound) = _ohmEthPriceFeed.latestRoundData(); + require(answeredInRound >= roundID, "Stale price"); + require(ohmEthPriceInt > 0,"Chainlink answer reporting 0"); // Use a multiple of observation frequency to determine what is too old to use. // Price feeds will not provide an updated answer if the data doesn't change much. // This would be similar to if the feed just stopped updating; therefore, we need a cutoff. if (updatedAt < block.timestamp - 3 * uint256(observationFrequency)) revert Price_BadFeed(address(_ohmEthPriceFeed)); ohmEthPrice = uint256(ohmEthPriceInt); int256 reserveEthPriceInt; - (, reserveEthPriceInt, , updatedAt, ) = _reserveEthPriceFeed.latestRoundData(); + (uint80 roundID, int256 reserveEthPriceInt, , uint256 updatedAt, uint80 answeredInRound) = _reserveEthPriceFeed.latestRoundData(); + require(answeredInRound >= roundID, "Stale price"); + require(ohmEthPriceInt > 0,"Chainlink answer reporting 0"); if (updatedAt < block.timestamp - uint256(observationFrequency)) revert Price_BadFeed(address(_reserveEthPriceFeed)); reserveEthPrice = uint256(reserveEthPriceInt);
#0 - Oighty
2022-09-06T19:55:06Z
Duplicate. See comment on #441.
π Selected for report: zzzitron
Also found by: 0x040, 0x1f8b, 0x52, 0x85102, 0xDjango, 0xNazgul, 0xNineDec, 0xSky, 0xSmartContract, 0xkatana, 8olidity, Aymen0909, Bahurum, BipinSah, Bnke0x0, CRYP70, CertoraInc, Ch_301, Chandr, Chom, CodingNameKiki, Deivitto, DimSon, Diraco, ElKu, EthLedger, Funen, GalloDaSballo, Guardian, IllIllI, JansenC, Jeiwan, Lambda, LeoS, Margaret, MasterCookie, PPrieditis, PaludoX0, Picodes, PwnPatrol, RaymondFam, ReyAdmirado, Rohan16, Rolezn, Ruhum, Sm4rty, StevenL, The_GUILD, TomJ, Tomo, Trust, Waze, __141345__, ajtra, ak1, apostle0x01, aviggiano, bin2chen, bobirichman, brgltd, c3phas, cRat1st0s, carlitox477, cccz, ch13fd357r0y3r, cloudjunky, cryptphi, csanuragjain, d3e4, datapunk, delfin454000, devtooligan, dipp, djxploit, durianSausage, eierina, enckrish, erictee, fatherOfBlocks, gogo, grGred, hansfriese, hyh, ignacio, indijanc, itsmeSTYJ, ladboy233, lukris02, martin, medikko, mics, natzuu, ne0n, nxrblsrpr, okkothejawa, oyc_109, p_crypt0, pfapostol, prasantgupta52, rajatbeladiya, rbserver, reassor, ret2basic, robee, rokinot, rvierdiiev, shenwilly, sikorico, sorrynotsorry, tnevler, tonisives, w0Lfrum, yixxas
76.4605 DAI - $76.46
In the executeAction function of the Kernel contract, when the action is InstallModule or UpgradeModule, the ensureValidKeycode function is used to verify that Module(target_).KEYCODE() consists of uppercase letters. But after passing ensureValidKeycode verification, newModule_.KEYCODE() will be used again as keycode in _installModule and _upgradeModule functions. Users can bypass ensureValidKeycode() verification by controlling the return value of KEYCODE()
https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/Kernel.sol#L235-L243 https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/Kernel.sol#L265-L293
Consider getting the keycode only once with KEYCODE() and use the keycode later instead of KEYCODE()
In the OlympusHeart contract, heart_admin can call setRewardTokenAndAmount to set the reward tokens and reward amount. When the setRewardTokenAndAmount function is executed in the same block as the beat function, it may front run the user's beat, causing the user to receive less reward.
https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/policies/Heart.sol#L92-L114 https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/policies/Heart.sol#L140-L147
There is no restriction on configParams[0] in the constructor of Operator. configParams[0] will be assigned to cushionFactor directly afterwards. In the setCushionFactor function, the cushionFactor_ is limited to be greater than 100 and less than 10000.
function setCushionFactor(uint32 cushionFactor_) external onlyRole("operator_policy") { /// Confirm factor is within allowed values if (cushionFactor_ > 10000 || cushionFactor_ < 100) revert Operator_InvalidParams(); /// Set factor _config.cushionFactor = cushionFactor_; emit CushionFactorChanged(cushionFactor_); }
If an incorrect cushionFactor is set in the constructor of the Operator, this will work because there is no restriction. Later the _activate function will make the created market capacity too large or too small, and may even overflow to make the contract not work
uint256 marketCapacity = range.high.capacity.mulDiv( config_.cushionFactor, FACTOR_SCALE );
https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/policies/Operator.sol#L92-L134 https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/policies/Operator.sol#L516-L524 https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/policies/Operator.sol#L388-L391
In the constructor of the Operator, limit cushionFactor_ to be greater than 100 and less than 10000.
The observations array of the OlympusPrice contract is used to calculate the moving average price, which is usually used to prevent price manipulation. When the length of observations is too small (currently the minimum length is 1), price manipulation cannot be prevented.
https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/modules/PRICE.sol#L97-L100 https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/modules/PRICE.sol#L242-L249 https://github.com/code-423n4/2022-08-olympus/blob/2a0b515012b4a40076f6eac487f7816aafb8724a/src/modules/PRICE.sol#L268-L281
Consider adding a minimum length limit to observations
When the reward is unattractive (gas consumption is greater than reward tokens) and the price is stable, people have no incentive to call OlympusHeart's beat function to update the price. After a period of time, if the attacker launches a price manipulation attack, the attacker can control the moving average price by continuously calling the beat function. This is because when the beat function is called, lastBeat will not be updated to the current time, but will add a value.
Consider updating lastBeat to the current time in the beat function
π Selected for report: pfapostol
Also found by: 0x040, 0x1f8b, 0x85102, 0xDjango, 0xNazgul, 0xNineDec, 0xSmartContract, 0xkatana, Amithuddar, Aymen0909, Bnke0x0, CertoraInc, Chandr, CodingNameKiki, Deivitto, Dionysus, Diraco, ElKu, Fitraldys, Funen, GalloDaSballo, Guardian, IllIllI, JC, JansenC, Jeiwan, LeoS, Metatron, Noah3o6, RaymondFam, ReyAdmirado, Rohan16, RoiEvenHaim, Rolezn, Ruhum, Saintcode_, Shishigami, Sm4rty, SooYa, StevenL, Tagir2003, The_GUILD, TomJ, Tomo, Waze, __141345__, ajtra, apostle0x01, aviggiano, bobirichman, brgltd, c3phas, cRat1st0s, carlitox477, cccz, ch0bu, chrisdior4, d3e4, delfin454000, djxploit, durianSausage, erictee, exolorkistis, fatherOfBlocks, gogo, grGred, hyh, ignacio, jag, karanctf, kris, ladboy233, lukris02, m_Rassska, martin, medikko, natzuu, ne0n, newfork01, oyc_109, peiw, rbserver, ret2basic, robee, rokinot, rvierdiiev, sikorico, simon135, tnevler, zishansami
32.5838 DAI - $32.58
See @audit tag
_ohmEthPriceFeed = ohmEthPriceFeed_; uint8 ohmEthDecimals = _ohmEthPriceFeed.decimals(); // @audit gas: should use ohmEthPriceFeed_.decimals() _reserveEthPriceFeed = reserveEthPriceFeed_; uint8 reserveEthDecimals = _reserveEthPriceFeed.decimals(); // @audit gas: should use reserveEthPriceFeed_.decimals()
Solidity version 0.8+ comes with implicit overflow and underflow checks on unsigned integers. When an overflow or an underflow isnβt possible (as an example, when a comparison is made before the arithmetic operation), some gas can be saved by using an unchecked block: https://docs.soliditylang.org/en/v0.8.7/control-structures.html#checked-or-unchecked-arithmetic
135: if (currentPrice > earliestPrice) { 136: _movingAverage += (currentPrice - earliestPrice) / numObs; 137: } else { 138: _movingAverage -= (earliestPrice - currentPrice) / numObs; 139: }
See @audit tag
for (uint256 step; step < instructions.length; ) { kernel.executeAction(instructions[step].action, instructions[step].target); // @audit gas: should cache "kernel" (SLOAD IN LOOP) unchecked { ++step; } }
!= 0 costs less gas compared to > 0 for unsigned integers in require statements with the optimizer enabled (6 gas)
function vote(bool for_) external { uint256 userVotes = VOTES.balanceOf(msg.sender); if (activeProposal.proposalId == 0) { revert NoActiveProposalDetected(); } if (userVotesForProposal[activeProposal.proposalId][msg.sender] > 0) { // @audit gas: should use userVotesForProposal[activeProposal.proposalId][msg.sender] != 0 revert UserAlreadyVoted(); }
See @audit tag
for (uint256 j; j < len; ) { TRSRY.setApprovalFor(policy_, tokens_[j], 0); // @audit gas: should cache "TRSRY" (SLOAD IN LOOP) unchecked { ++j; } }