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: 3/147
Findings: 7
Award: $3,500.39
π Selected for report: 2
π Solo Findings: 1
π 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/main/src/modules/TRSRY.sol#L64-L71
setApprovalFor() is used to grant permission for some address to withdraw X amount of funds. withdrawReserves() can later be directly called to withdraw the approved amount. However, if previous approval was X and setApprovalFor(address, token, Y) is called, a total of X+Y can be withdrawn , although the intention is for a maximum of Y to be withdrawn. This is because attacker can frontrun the setApprovalFor(address, token, Y) call with a withdrawReserves(address, token, X) call and therefore empty the existing approval before it is replenished. This kind of attack is well known, for example ERC20 increaseAllowance() function is used instead of approve() for this reason.
Attacker may withdraw X+Y amount instead of X or Y from the treasury.
1. setApprovalFor(attacker, token, X) called 2. setApprovalFor(attacker, token, Y) transaction is in mempool 3. Attacker front-runs with withdrawReserves(address, token, X) transaction, which is executed first 4. setApprovalFor(attacker, token, Y) is executed 5. withdrawReserves(address, token, Y) is called 6. Attacker withdraws a total of X+Y tokens
Refactor the affected code to increaseApprovalFor(Y), which checks existing approval, and increases it by Y amount
#0 - 0xean
2022-09-16T19:28:43Z
This looks to be valid based on my current understanding of the attack vector. It opens up the possibility of a direct loss of funds up to the amount of the previous approval amount. Would like the sponsor to weigh in as well.
#1 - 0xean
2022-09-16T21:03:05Z
Looks like the sponsor has confirmed this in #410
Will use that as the main ticket, marking others as duplicates.
π Selected for report: __141345__
250.0283 DAI - $250.03
When a proposal has over ENDORSEMENT_THRESHOLD % of votes, it may be activated using activateProposal() call. The current proposal must have been active for at least GRACE_PERIOD. To vote, users call vote(for_) with YES or NO for the current proposal, which sends their vote tokens to Governance and marks their vote in the vote table.
The vulnerability is that a proposal submitter can activate his proposal and override the current proposal, just before the vote() transaction is executed. By doing this, the victim casts his vote on the wrong proposal. It is not possible to claim back the misplaced vote tokens.
The main exploit scenario is that attacker waits for a whale to cast a YES vote on some proposal, and then frontrun to register it as a YES for his own proposal. Any manipulation of governance votes is always considered maximum severity because it undermines the core principal of majority rule.
1. User submits proposal P1 - submitProposal(instructions, title, URI) 2. User receives endorsements above ENDORSEMENT_THRESHOLD - endorseProposal(P1) 3. User activates his proposal - activateProposal(P1) 4. Attacker submits a proposal P2 - submitProposal(instructions2, title2, URI2) 5. Attacker receives endorsements above ENDORSEMENT_THRESHOLD - endorseProposal(P2) 6. Over GRACE_PERIOD of time (1 week) has passed since #3, and P1 has not been executed 7. Victim whale casts YES vote for P1 8. Attacker frontruns with activateProposal(P2) 9. Victim's YES vote is registered for P2 10. Attacker might be able to execute his proposal using the whale's votes - executeProposal()
Vote() function MUST have a proposal_id parameter, just like any other function in the Governance policy.
#0 - bahurum
2022-09-02T12:54:41Z
Duplicate of #273
#1 - fullyallocated
2022-09-04T01:57:23Z
Related to #273
This is a pretty unique edge case. It's possible this will happen but the warden is wrong in that they cannot reclaim tokens. Tokens will always be reclaimable, but they the vote is manipulated to a certain extent.
#2 - 0xean
2022-09-16T23:11:41Z
dupe of #273
π Selected for report: okkothejawa
347.2615 DAI - $347.26
Heart's beat() function is extremely important as it keeps the protocol updated and reactive to market conditions (prices + RBS). It calls updateMovingAverage() to update the ETH/RESERVE moving average price, which in turn calls getCurrentPrice() to get the current price. If the oracle data is found to be stale, the function reverts, which makes sense to some degree as we could not determine a current price. However, as is documented in line 160, the oracle might not update if the data didn't change much, which is why it is good enough that the last update time is 3 times the observationFrequency. However, this relaxation of the update time is only used in the _ohmEthPriceFeed oracle and not in _reserveEthPriceFeed ( line #169). Therefore, if the reserve asset's price did not change much, which is likely with stablecoins, getCurrentPrice() reverts and the RBS is not reactive.
It is likely that at different points in the protocol's lifetime the heart beat() function will stop working, causing the RBS system to fail and the moving average to be completely outdated. Note that the fact reserve/ETH price did not change much does not indicate at all that the OHM/reserve moving average did not change as well.
1. observationFrequency = 4 hours 2. reserveEthPriceFeed tracks USDC price 3. USDC price did not change for 4 hours 4. Protocol keeper calls beat() but transaction is reverted. 5. Protocol does not track prices and RBS system is stale. This is a financial risk as cushion bonds will not be activated and more importantly, walls are not taken down if the price breached them upwards or downwards causing the protocol to lose money on incorrect trading.
Use the same 3X factor of observationFrequency to validate the last update time of the reserveEthPriceFeed oracle.
#0 - Oighty
2022-09-06T19:06:45Z
Duplicate of #391. This will be updated.
#1 - 0xean
2022-09-19T13:19:53Z
closing as dupe
347.2615 DAI - $347.26
https://github.com/code-423n4/2022-08-olympus/blob/main/src/modules/TRSRY.sol#L64-L102
Treasury allocates approvals in the withdrawApproval mapping which is set via setApprovalFor(). In both withdrawReserves() and in getLoan(), _checkApproval() is used to verify user has enough approval and subtracts the withdraw / loan amount. Therefore, there is no differentiation in validation between loan approval and withdraw approval. Policies which will use getLoan() (currently none) can simply withdraw the tokens without bookkeeping it as a loan.
A separate mapping called loanApproval should be implemented, and setLoanApprovalFor() will set it, getLoan() will reduce loanApproval balance.
#0 - ind-igo
2022-09-07T17:06:09Z
Confirmed. Good suggestion. Would put as low risk though.
#1 - 0xean
2022-09-18T22:08:07Z
Currently thinking M is appropriate for this issue, but will circle back on it.
#2 - 0xean
2022-09-19T18:40:04Z
See #293 for a possible vector in which this could lead to loss of funds. Going to leave as M
π Selected for report: rvierdiiev
Also found by: Jeiwan, Lambda, Trust, datapunk, devtooligan, itsmeSTYJ, zzzitron
113.9192 DAI - $113.92
Heart beat() should be called around every observationFrequency (named F) time for the RBS to function. In order for beat() to succeed, at least F seconds have to pass since lastBeat. However, since lastBeat is always increased by F and not actual seconds that have passed, the lastBeat will eventually go out of sync. The effect is that after some time of operation, or if beat() for some reason was not called, it is possible to call beat() multiple times in quick succession. Since beat() updates the moving average using currentPrice(), attacker is able to skew the MA , putting too much weight on the current price.
The RBS system may malfunction and very bizzare behavior can occur. For example, if there is a trend of downward movement in the last 5 minutes and beat is called several times, the operator may regenerate the high wall incorrectly. Another possible impact - operator might deactivate a cushion bond because it detects from the wrong MA that the price is no longer in the danger zone. Most severe impact - the zero slippage walls will defend the wrong price range. This means if the price strays up or down much less than the intended spread, the walls may take a big hit.
Attached a diagram in the references below.
https://imgur.com/a/k2PVncz - Diagram of proper RBS functioning and the MA attack https://docs.google.com/document/d/1AdPex_lMpSC_3U8UEU4hiSZIT1O1FekoCujRtYEJ0ig - documentation of the RBS system
Add a check in beat() function:
if (block.timestamp > lastBeat + FREQ_PRECISION_THRESHOLD * frequency()) revert Heart_OutOfCycle(); // Too far out of sync, require admin to reset the heart
lastBeat should be tracking block.timestamp to prevent the clock from going more and more out of sync.
#0 - Oighty
2022-09-07T21:21:50Z
See comment on #79 and #405
#1 - 0xean
2022-09-19T13:26:02Z
closing as dupe of #79
π Selected for report: Trust
1905.4132 DAI - $1,905.41
https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/policies/Operator.sol#L188-L191 https://github.com/code-423n4/2022-08-olympus/blob/main/src/policies/Operator.sol#L272 https://github.com/code-423n4/2022-08-olympus/blob/b5e139d732eb4c07102f149fb9426d356af617aa/src/policies/Operator.sol#L346
The Walls of the RBS mechanism offer zero slippage swaps at the high and low of the moving average spread. The capacity to be swapped at these prices is usually very large, so it must make sure to only be enabled when the prices are guaranteed to be synced. However, there is no such check. If beat() is not called for some time, meaning we cannot determine if the current spread is legit, swap() still operates as usual.
The worst case scenario is that the wall is swapping at a losing price, meaning they can be immediately drained via arbitrage bot.
1. Price is X at the start 2. Oracle stops updating for some reason / no one calls beat() 3. Price drops to Y , where Y < low wall centered around X 4. Attacker can perform arbitrage by buying Ohm at external markets at Y and selling Ohm at low wall price, netting the difference.
Change modifier onlyWhileActive to add a check for beat out of sync:
if (block.timestamp > lastBeat + SYNC_THRESHOLD * frequency())
#0 - Oighty
2022-09-07T00:44:24Z
This is an interesting unintended consequence of a bad price feed or other beat()
issue. Your suggested update makes sense, but we will probably adjust slightly to only manage the bad data threshold in one place.
After originally looking at it, I thought a try/catch block on the external call to PRICE.getLastPrice()
in Operator.operate()
would work, but it doesn't handle cases where operate()
isn't reached by the beat()
function.
π 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
54.3128 DAI - $54.31
Operator's setRegenParams does some validation on the input:
if (wait_ < 1 hours || threshold_ > observe_ || observe_ == 0) revert Operator_InvalidParams();
However, it must also check that threshold_ != 0, otherwise the system will keep regenerating
Operator's setThresholdFactor updates the current threshold used for determining if walls are depleted. However, this threshold will not take effect until after regenerate() is called, which may be in a long time, and before the new threshold is needed. Make sure to update the existing walls, if they are active:
if (_range.high.active == true) { uint256 threshold = (fullCapacity(true) * thresholdFactor) / FACTOR_SCALE; _range.high.threshold = threshold } if (_range.low.active == true) { uint256 threshold = (fullCapacity(false) * thresholdFactor) / FACTOR_SCALE; _range.low.threshold = threshold }