Volt Protocol contest - cmichel's results

Inflation Protected Stablecoin.

General Information

Platform: Code4rena

Start Date: 31/03/2022

Pot Size: $75,000 USDC

Total HM: 7

Participants: 42

Period: 7 days

Judge: Jack the Pug

Total Solo HM: 5

Id: 102

League: ETH

Volt Protocol

Findings Distribution

Researcher Performance

Rank: 1/42

Findings: 6

Award: $42,069.40

🌟 Selected for report: 4

🚀 Solo Findings: 3

Findings Information

🌟 Selected for report: cmichel

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

23950.8585 USDC - $23,950.86

External Links

Lines of code

https://github.com/code-423n4/2022-03-volt/blob/f1210bf3151095e4d371c9e9d7682d9031860bbd/contracts/oracle/ScalingPriceOracle.sol#L136 https://github.com/code-423n4/2022-03-volt/blob/f1210bf3151095e4d371c9e9d7682d9031860bbd/contracts/oracle/ScalingPriceOracle.sol#L113

Vulnerability details

Impact

The oracle does not correctly compound the monthly APRs - it resets on fulfill. Note that the oraclePrice storage variable is only set in _updateCPIData as part of the oracle fulfill callback. It's set to the old price (price from 1 month ago) plus the interpolation from startTime to now. However, startTime is reset in requestCPIData due to the afterTimeInit modifier, and therefore when Chainlink calls fulfill in response to the CPI request, the timeDelta = block.timestamp - startTime is close to zero again and oraclePrice is updated to itself again.

This breaks the core functionality of the protocol as the oracle does not track the CPI, it always resets to 1.0 after every fulfill instead of compounding it. In addition, there should also be a way for an attacker to profit from the sudden drop of the oracle price to 1.0 again.

POC

As an example, assume oraclePrice = 1.0 (1e18), monthlyAPR = 10%. The time elapsed is 14 days. Calling getCurrentOraclePrice() now would return 1.0 + 14/28 * 10% = 1.05.

  • it's now the 15th of the month and one can trigger requestCPIData. This resets startTime = now.
  • Calling getCurrentOraclePrice() now would return 1.0 again as timeDelta (and priceDelta) is zero: oraclePriceInt + priceDelta = oraclePriceInt = 1.0.
  • When fulfill is called it sets oraclePrice = getCurrentOraclePrice() which will be close to 1.0 as the timeDelta is tiny.

The oraclePrice should be updated in requestCPIData() not in fulfill. Cover this scenario of multi-month accumulation in tests.

#1 - ElliotFriedman

2022-04-13T19:18:40Z

This is the only valid critical finding we have seen so far! Great work cmichel!

Findings Information

🌟 Selected for report: cmichel

Also found by: catchup, rayn

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

1940.0195 USDC - $1,940.02

External Links

Lines of code

https://github.com/code-423n4/2022-03-volt/blob/f1210bf3151095e4d371c9e9d7682d9031860bbd/contracts/utils/RateLimited.sol#L142

Vulnerability details

Impact

The RateLimited.setBufferCap function first updates the buffer and then sets the new cap, but does not apply the new cap to the updated buffer. Meaning, the updated buffer value can be larger than the new buffer cap which should never be the case. Actions consuming more than the new buffer cap can be performed.

function _setBufferCap(uint256 newBufferCap) internal {
    // @audit still uses old buffer cap, should set buffer first
    _updateBufferStored();

    uint256 oldBufferCap = bufferCap;
    bufferCap = newBufferCap;


    emit BufferCapUpdate(oldBufferCap, newBufferCap);
}

Update the buffer after setting the new cap:

function _setBufferCap(uint256 newBufferCap) internal {
-   _updateBufferStored();
    uint256 oldBufferCap = bufferCap;
    bufferCap = newBufferCap;

+   _updateBufferStored();

    emit BufferCapUpdate(oldBufferCap, newBufferCap);
}

Findings Information

🌟 Selected for report: robee

Also found by: IllIllI, cmichel, georgypetrov

Labels

duplicate
2 (Med Risk)

Awards

1309.5132 USDC - $1,309.51

External Links

Judge @jack-the-pug is upgrading the following issue from a QA report (issue #30 ) to Medium risk:

Division by zero in isWithinDeviationThreshold if a is zero. This only seems to be the case if the oracle would return 0 for CPI and in this case, something is wrong anyway. Should still handle this error more gracefully.

#0 - CloudEllie

2022-04-27T21:36:14Z

Duplicate of #58

#1 - jack-the-pug

2022-05-03T03:01:55Z

Confirmed! Thanks @CloudEllie

Findings Information

🌟 Selected for report: cmichel

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

7185.2575 USDC - $7,185.26

External Links

Lines of code

https://github.com/code-423n4/2022-03-volt/blob/f1210bf3151095e4d371c9e9d7682d9031860bbd/contracts/refs/OracleRef.sol#L104

Vulnerability details

Impact

The OracleRef assumes that the backup oracle uses the same normalizer as the main oracle. This generally isn't the case as it could be a completely different oracle, not even operated by Chainlink.

If the main oracle fails, the backup oracle could be scaled by a wrong amount and return a wrong price which could lead to users being able to mint volt cheap or redeem volt for inflated underlying amounts.

Should there be two scaling factors, one for each oracle?

#0 - ElliotFriedman

2022-04-07T21:08:26Z

This is a good catch as it exposes some underlying assumptions made about backup oracles, however we can assume that both oracles will use the same scaling factor and thus we will not need a second value for the backup oracle.

Findings Information

🌟 Selected for report: cmichel

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

7185.2575 USDC - $7,185.26

External Links

Lines of code

https://github.com/code-423n4/2022-03-volt/blob/f1210bf3151095e4d371c9e9d7682d9031860bbd/contracts/utils/MultiRateLimited.sol#L280

Vulnerability details

Impact

When the bufferCap is updated for an address in _updateAddress, the address's allowed buffer (bufferStored) is replenished to the entire bufferCap.

The address could frontrun the updateAddress call and spend their entire buffer, then the buffer is replenished and they can spend their entire buffer a second time.

Keep the old buffer value, capped by the new bufferCap:

+ uint256 newBuffer = individualBuffer(rateLimitedAddress);

  rateLimitData.lastBufferUsedTime = block.timestamp.toUint32();
  rateLimitData.bufferCap = _bufferCap;
  rateLimitData.rateLimitPerSecond = _rateLimitPerSecond;
- rateLimitData.bufferStored = _bufferCap;
+ rateLimitData.bufferStored = min(_bufferCap, newBuffer);

#0 - ElliotFriedman

2022-04-07T21:09:40Z

Good catch!

Awards

498.4945 USDC - $498.49

Labels

bug
QA (Quality Assurance)

External Links

QA

#0 - CloudEllie

2022-04-27T21:36:41Z

Per the judge @jack-the-pug, the second issue in this report is being upgraded to Medium, and set as a duplicate of issue #58 .

Please see issue #127 for details.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter