Canto v2 contest
Findings & Analysis Report
2022-10-18
Table of contents
- Summary
- Scope
- Severity Criteria
-
- [H-01] Total supply can be incorrect in
ERC20
- [H-02] Deny of service in
CNote.doTransferOut
- [H-03] Underlying asset price oracle for
CToken
inBaseV1-periphery
is inaccurate - [H-04] Oracle
periodSize
is very low allowing the TWAP price to be easily manipulated - [H-05] The LP pair underlying price quote could be manipulated
- [H-06]
getBorrowRate
returns rate per year instead of per block - [H-07] Deny of service in
AccountantDelegate.sweepInterest
- [H-08] AccountantDelegate: The sweepInterest function sweeps an incorrect number of cnote
- [H-01] Total supply can be incorrect in
-
- [M-01] Stableswap - Deadline do not work
- [M-02] Multiple initialization in
NoteInterest
- [M-03] Non view function is called with staticcall in
CErc20Delegator
- [M-04] Missing zero address check can cause initialize to be called more than once
- [M-05] Admin Can Break All Functionality Through Weth Address
- [M-06] A cap is needed on the amount of Note than can be borrowed
-
Low Risk and Non-Critical Issues
- L-01 Price not scaled by the BASE in
BaseV1Router01
- L-02
_acceptInitialAdminDelegated
will revert inGovernorBravoDelegator
- L-03 Desirable underflow / overflow in
BaseV1Pair
- L-04 Lack of zero address check
- L-05 Oracle failure check in
NoteRateModel
- L-06 Storage for delegate is outside of storage contract in
CNote
- N-01 Cast to
int
without checking inNoteRateModel
- N-02 Meaningless check in
GovernorBravoDelegate
- N-03 Missing param in NatSpec in
CToken
- N-04 Misleading NatSpec in
AccountantDelegator
- N-05 Typos
- L-01 Price not scaled by the BASE in
-
- G-01 Use
abi.encodeWithSelector
instead ofabi.encodeWithSignature
- G-02 Avoid creating unnecessary variables
- G-03 Reorder structure layout
- G-04 Reduce the size of error messages (Long revert Strings)
- G-05
++i
costs less gas compared toi++
ori += 1
- G-06 Use
external
visibility - G-07 There’s no need to set default values for variables
- G-08 Reduce boolean comparison
- G-09 Unused arguments
- G-10 Gas saving using
immutable
- G-11 Unused import
- G-12
unckecked
keyword - G-13 use
constant
instead of storage - G-14 Unrequired method
- G-15 Optimize logic
- G-16 Use
memory
instead ofstorage
- G-01 Use
- Disclosures
Overview
About C4
Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.
A C4 audit contest is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.
During the audit contest outlined in this document, C4 conducted an analysis of the Canto v2 smart contract system written in Solidity. The audit contest took place between June 28—July 2 2022.
Note: this audit contest originally ran under the name New Blockchain v2
.
Wardens
51 Wardens contributed reports to the Canto v2 contest:
- Picodes
- 0x1f8b
- ladboy233
- __141345__
- Lambda
- Critical
- Chom
- cccz
- defsec
- csanuragjain
- 0x52
- TomJ
- rfa
- Funen
- Bnke0x0
- 0x29A (0x4non and rotcivegaf)
- c3phas
- fatherOfBlocks
- Soosh
- ynnad
- grGred
- mrpathfindr
- Sm4rty
- slywaters
- ignacio
- sach1r0
- samruna
- 0v3rf10w
- asutorufos
- m_Rassska
- Waze
- 0xArshia
- ajtra
- durianSausage
- Fitraldys
- Noah3o6
- RedOneN
- Rohan16
- zzzitron
- oyc_109
- AlleyCat
- Meera
- 0xKitsune
- TerrierLover
- JC
- hake
- aysha
- Limbooo
- Tomio
- cRat1st0s
This contest was judged by Alex the Entreprenerd.
Final report assembled by liveactionllama.
Summary
The C4 analysis yielded an aggregated total of 14 unique vulnerabilities. Of these vulnerabilities, 8 received a risk rating in the category of HIGH severity and 6 received a risk rating in the category of MEDIUM severity.
Additionally, C4 analysis included 34 reports detailing issues with a risk rating of LOW severity or non-critical. There were also 34 reports recommending gas optimizations.
All of the issues presented here are linked back to their original finding.
Scope
The code under review can be found within the C4 Canto v2 contest repository, and is composed of 15 smart contracts written in the Solidity programming language and includes 2,459 lines of Solidity code. One Cosmos SDK blockchain is also included.
Severity Criteria
C4 assesses the severity of disclosed vulnerabilities according to a methodology based on OWASP standards.
Vulnerabilities are divided into three primary risk categories: high, medium, and low/non-critical.
High-level considerations for vulnerabilities span the following key areas when conducting assessments:
- Malicious Input Handling
- Escalation of privileges
- Arithmetic
- Gas use
Further information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website.
High Risk Findings (8)
[H-01] Total supply can be incorrect in ERC20
Submitted by Picodes
https://github.com/Plex-Engineer/lending-market-v2/blob/ea5840de72eab58bec837bb51986ac73712fcfde/contracts/ERC20.sol#L33 https://github.com/Plex-Engineer/lending-market/blob/ab31a612be354e252d72faead63d86b844172761/contracts/ERC20.sol#L95
_totalSupply
can be initialized to something different than 0, which would lead to an inaccurate total supply, and could easily break integrations, computations of market cap, etc.
Proof of Concept
If the constructor is called with _initialSupply = 1000, the totalSupply
will be initialized to 1000.
As all the others computations are correct, there will be for ever a discrepancy of 1000 between the real total supply and the one of the contract.
Recommended Mitigation Steps
Remove _initialSupply
.
Alex the Entreprenerd (judge) commented:
Same bug as from Canto V1. Recommend the sponsor to just set to 0 and remove the assignment from the constructor
See: https://github.com/code-423n4/2022-06-canto-findings/issues/108
Please note: the following additional discussions took place after judging and awarding were finalized. As such, this report will leave this finding in its originally assessed risk category as it simply reflects a snapshot in time.
In the provided contracts, v2 repo is included: https://github.com/code-423n4/2022-06-canto-v2
However, in this submission, the second line of code provided links to the v1 repo. The described issue only exists in v1 version. In v2 version the issue does not exist because msg.sender balance is updated along with the total supply: https://github.com/Plex-Engineer/lending-market-v2/blob/ea5840de72eab58bec837bb51986ac73712fcfde/contracts/ERC20.sol#L34
Therefore this finding seems invalid.
Alex the Entreprenerd (judge) commented:
@shung - You’re right, I must have missed the line with the mitigation.
The current code will update the
_totalSupply
and will give the balance to the deployer.This is a mistake on my part and the finding should have been closed as invalid as it was mitigated in the V2 code in scope.
Alex the Entreprenerd (judge) commented:
While a nitpick I’d recommend changing the code to use
_mint
as it the code in scope will not emit an event which may cause issues if you’re tracking via theGraph or similar.Either way I made a mistake here, sorry about that.
[H-02] Deny of service in CNote.doTransferOut
Submitted by 0x1f8b, also found by Lambda
The CNote.doTransferOut
method is susceptible to denial of service.
Proof of Concept
The logic of the doTransferOut
method in CNote
is as follows:
function doTransferOut(address payable to, uint amount) virtual override internal {
require(address(_accountant) != address(0));
EIP20Interface token = EIP20Interface(underlying);
if (to != address(_accountant)) {
uint err = _accountant.supplyMarket(amount);
if (err != 0) { revert AccountantRedeemError(amount); }
}
token.transfer(to, amount);
bool success;
assembly {
switch returndatasize()
case 0 { success := not(0) }
case 32 {
returndatacopy(0, 0, 32)
success := mload(0)
}
default { revert(0, 0) }
}
require(success, "TOKEN_TRANSFER_OUT_FAILED");
require(token.balanceOf(address(this)) == 0, "cNote::doTransferOut: TransferOut Failed"); // <-- ERROR
}
The doTransferOut
method receives an amount
which is transferred to to
, after it the balance of the contract token is checked to be equal to zero or the transaction will be reverted.
In the following cases a denial of service will occur:
- In the case that is used an
amount
different than the balance, the transaction will be reverted. - In the case that an attacker front-runs the transaction and sends one token more than the established by the
_accountant
. - In case of increasing balance tokens like
mDai
that constantly change their balance, the established by the_accountant
will be different when the transaction is persisted.
Recommended Mitigation Steps
- Use balance differences instead of the 0 check.
Alex the Entreprenerd (judge) commented:
The warden has shown how, anyone, via a simple transfer of
underlying
can deny the functionality ofdoTransferOut
.Because the function is used in multiple functions inherited from
CToken
, and the griefing can be easily run by anyone, I believe High Severity to be appropriate.
[H-03] Underlying asset price oracle for CToken
in BaseV1-periphery
is inaccurate
Submitted by ladboy233
Underlying asset price oracle for CToken in BaseV1-periphery is inaccurate.
Proof of Concept
function getUnderlyingPrice(CToken ctoken) external override view returns(uint price) {
IBaseV1Pair pair;
uint8 stable;
bool stablePair;
address underlying;
if (compareStrings(ctoken.symbol(), "cCANTO")) {
stable = 0;
underlying = address(wcanto);
}
//set price statically to 1 when the Comptroller is retrieving Price
else if (compareStrings(ctoken.symbol(), "cNOTE") && msg.sender == Comptroller) {
return 1; // Note price is fixed to 1
}
We should not be return 1. 1 is 1 wei. We should be 10 ** 18
Tools Used
VIM
Recommended Mitigation Steps
We can return 10 ** 18
Alex the Entreprenerd (judge) commented:
The warden has shown what probably is a developer mistake, which will scale down the price of the cNOTE token to 1.
The sponsor confirms.
It should be noted that if cNOTE always returns 1e18 then the math for
diff
will always be zero, meaning the interest will exclusively be dictated bybaseRatePerYear
.Because the sponsor confirms, and because the comments point to values “scaled by 1e18” I believe the finding to be valid, and since the “math is wrong”, I do agree with High Severity.
[H-04] Oracle periodSize
is very low allowing the TWAP price to be easily manipulated
Submitted by 0x52, also found by __141345__, Chom, csanuragjain, and ladboy233
TWAP oracle easily manipulated.
Proof of Concept
periodSize is set to 0 meaning that the oracle will take a new observation every single block, which would allow an attacker to easily flood the TWAP oracle and manipulate the price.
Recommended Mitigation Steps
Increase periodSize to be greater than 0, 1800 is typically standard.
Alex the Entreprenerd (judge) commented:
The warden has identified a constant set to zero for the time in between TWAP observations.
Because the code change:
- Is a mistake (evidenced by the comments)
- Causes the TWAP (already put into question in previous contest) to become a Spot Oracle
- There’s no way to remediate as the variable is constant
- The change will end up resulting in a manipulatable
quote
which will impactgetUnderlyingPrice
I agree with High Severity.
[H-05] The LP pair underlying price quote could be manipulated
Submitted by __141345__
https://github.com/Plex-Engineer/lending-market-v2/blob/ea5840de72eab58bec837bb51986ac73712fcfde/contracts/Stableswap/BaseV1-periphery.sol#L522-L526
https://github.com/Plex-Engineer/lending-market-v2/blob/ea5840de72eab58bec837bb51986ac73712fcfde/contracts/Stableswap/BaseV1-periphery.sol#L198-L217
The underlying price for LP pool pair can be manipulated. This kind of price mainpulation happened before, can be found here: Warp Fincance event.
Which may lead to the exploit of the pool by a malicious user.
Proof of Concept
file: lending-market-v2/contracts/Stableswap/BaseV1-periphery.sol
522-526, 198-217:
uint price0 = (token0 != USDC) ? IBaseV1Pair(pairFor(USDC, token0, stable0)).quote(token0, 1, 8) : 1;
uint price1 = (token1 != USDC) ? IBaseV1Pair(pairFor(USDC, token1, stable1)).quote(token1, 1, 8) : 1;
// how much of each asset is 1 LP token redeemable for
(uint amt0, uint amt1) = quoteRemoveLiquidity(token0, token1, stablePair, 1);
price = amt0 * price0 + amt1 * price1;
function quoteRemoveLiquidity(
address tokenA,
address tokenB,
bool stable,
uint liquidity
) public view returns (uint amountA, uint amountB) {
// create the pair if it doesn"t exist yet
address _pair = IBaseV1Factory(factory).getPair(tokenA, tokenB, stable);
if (_pair == address(0)) {
return (0,0);
}
(uint reserveA, uint reserveB) = getReserves(tokenA, tokenB, stable);
uint _totalSupply = erc20(_pair).totalSupply();
amountA = liquidity * reserveA / _totalSupply; // using balances ensures pro-rata distribution
amountB = liquidity * reserveB / _totalSupply; // using balances ensures pro-rata distribution
}
The price of the LP pair is determined by the TVL of the pool, given by:
amt0 * price0 + amt1 * price1
. However, when a malicious user dumps large amount of any token into the pool, the whole TVL will be significantly increased, which leads to inproper calculation of the price.
Recommended Mitigation Steps
A differenct approach to calculate the LP price can be found here.
Alex the Entreprenerd (judge) commented:
The warden has shown how the LP Token Pricing math is incorrect, this is a mispricing that historically has resulted in total loss of funds and the subject is well known.
Remediation can be attained by following the guide linked: https://cmichel.io/pricing-lp-tokens/
Because the:
- Math is incorrect
- Exploit allows anyone to inflate prices within 1 block (no risk)
High Severity is appropriate.
[H-06] getBorrowRate
returns rate per year instead of per block
Submitted by Lambda, also found by Chom
https://github.com/Plex-Engineer/lending-market-v2/blob/2646a7676b721db8a7754bf5503dcd712eab2f8a/contracts/NoteInterest.sol#L118
https://github.com/Plex-Engineer/lending-market-v2/blob/2646a7676b721db8a7754bf5503dcd712eab2f8a/contracts/CToken.sol#L209
According to the documentation in InterestRateModel
, getBorrowRate
has to return the borrow rate per block and the function borrowRatePerBlock
in CToken
directly returns the value of getBorrowRate
. However, the rate per year is returned for NoteInterest
. Therefore, using NoteInterest
as an interest model will result in completely wrong values.
Recommended Mitigation Steps
Return baseRatePerBlock
.
Alex the Entreprenerd (judge) commented:
The warden has shown that the borrowRate is returning per-year values instead of per-block values.
The effect of this is that the accounting will be magnified massively.
While impact should be mostly loss of value to interest and incorrect yield, due to the math being wrong I do agree with High Severity.
[H-07] Deny of service in AccountantDelegate.sweepInterest
Submitted by 0x1f8b, also found by Critical
The sweepInterest
method is susceptible to denial of service.
Proof of Concept
The logic of the sweepInterest
method relative to the treasury
is as follows:
bool success = cnote.transfer(treasury, amtToSweep);
if (!success) { revert SweepError(treasury , amtToSweep); }
TreasuryInterface Treas = TreasuryInterface(treasury);
Treas.redeem(address(cnote),amtToSweep);
require(cnote.balanceOf(treasury) == 0, "AccountantDelegate::sweepInterestError");
As you can see, amtToSweep
is passed to it and redeem
that amount. Later it is checked that the balance of cnote
in the treasury
address must be 0. However, all calculations related to amtToSweep
come out of the balance of address(this) so if a third party sends a single token cnote
to the address of treasury
the method will be denied.
Recommended Mitigation Steps
- Check that the balance is the same after and before the
bool success = cnote.transfer(treasury, amtToSweep);
Alex the Entreprenerd (judge) commented:
The warden has shown how, due to an incorrect invariant (treasury having zero cNote), any griefer can permanently brick the
sweepInterest
function.The finding shows how a loss of yield can be achieved, so Medium Severity would be in order.
However, because:
- an invariant was broken
- the tokens cannot be withdrawn via an alternative method
I believe High Severity to be more appropriate.
[H-08] AccountantDelegate: The sweepInterest function sweeps an incorrect number of cnote
Submitted by cccz, also found by Critical
In the sweepInterest function of the AccountantDelegate contract, the number of cnote sent to treasury should be cNoteToSweep instead of amtToSweep, as amtToSweep will normally be smaller than cNoteToSweep, which will cause the interest to be locked in the in the contract.
uint amtToSweep = sub_(cNoteAmt, noteDiff); // amount to sweep in Note,
uint cNoteToSweep = div_(amtToSweep, exRate); // amount of cNote to sweep = amtToSweep(Note) / exRate
cNoteToSweep = (cNoteToSweep > cNoteBal) ? cNoteBal : cNoteToSweep;
bool success = cnote.transfer(treasury, amtToSweep);
if (!success) {
revert SweepError(treasury , amtToSweep); //handles if transfer of tokens is not successful
}
TreasuryInterface Treas = TreasuryInterface(treasury);
Treas.redeem(address(cnote),amtToSweep);
Proof of Concept
Recommended Mitigation Steps
uint amtToSweep = sub_(cNoteAmt, noteDiff); // amount to sweep in Note,
uint cNoteToSweep = div_(amtToSweep, exRate); // amount of cNote to sweep = amtToSweep(Note) / exRate
cNoteToSweep = (cNoteToSweep > cNoteBal) ? cNoteBal : cNoteToSweep;
- bool success = cnote.transfer(treasury, amtToSweep);
+ bool success = cnote.transfer(treasury, cNoteToSweep);
if (!success) {
- revert SweepError(treasury , amtToSweep); //handles if transfer of tokens is not successful
+ revert SweepError(treasury , cNoteToSweep); //handles if transfer of tokens is not successful
}
TreasuryInterface Treas = TreasuryInterface(treasury);
- Treas.redeem(address(cnote),amtToSweep);
+ Treas.redeem(address(cnote),cNoteToSweep);
Alex the Entreprenerd (judge) commented:
The warden has shown that the wrong variable is being used as the transferAmount.
Because
cNoteToSweep
<<amtToSweep
there will be many instances in which the function will revert.Because the finding shows incorrect functionality, which can leave the tokens stuck indefinitely, I agree with High Severity.
Medium Risk Findings (6)
[M-01] Stableswap - Deadline do not work
Submitted by Picodes, also found by cccz, Funen, ladboy233, Soosh, and zzzitron
The ensure
modifier is commented, so deadlines will not work when passing orders, breaking this functionality.
Alex the Entreprenerd (judge) commented:
The warden has shown how, due to a comment, the modifier
deadline
doesn’t work.Because Front-running is a key aspect of AMM design,
deadline
is a useful tool to ensure that your tx cannot be “saved for later”.Due to the removal of the check, it may be more profitable for a miner to deny the transaction from being mined until the transaction incurs the maximum amount of slippage.
The lack of deadline means that the tx can be withheld indefinitely at the advantage of the miner.
For those reasons I agree with Medium Severity.
[M-02] Multiple initialization in NoteInterest
Submitted by 0x1f8b, also found by oyc_109
The initialize
method of the contract NoteInterest
can be initialized multiple times.
Proof of Concept
The method initialize
of the contract NoteInterest
looks like this:
function initialize(address cnoteAddr, address oracleAddress) external {
if (msg.sender != admin ) {
revert SenderNotAdmin(msg.sender);
}
address oldPriceOracle = address(oracle);
cNote = CErc20(cnoteAddr);
oracle = PriceOracle(oracleAddress);
emit NewPriceOracle(oldPriceOracle, oracleAddress);
}
Nothing prevents it from being initialized again and altering the initial values of the contract. This allows the government, unnecessarily, to be able to perform attacks such as altering the logic of the updateBaseRate
method.
Recommended Mitigation Steps
Add a require to check that was not already initialized.
nivasan1 (Canto) disputed and commented:
It is not clear how governance would be able to modify the logic in updateBaseRate? All that could be changed is the price oracle that the NoteInterest Model references.
Alex the Entreprenerd (judge) commented:
The warden has shown that the function
initialize
can be called multiple times by governance.This could cause undefined behaviour (e.g. change the token address), which could cause loss of funds.
Because of the system setup, it would be best to make sure that
initialize
can only be called once.Because the finding can cause a loss, under specific circumstances, I believe Medium Severity is appropriate
[M-03] Non view function is called with staticcall in CErc20Delegator
Submitted by zzzitron
Lines of code
https://github.com/Plex-Engineer/lending-market-v2/blob/443a8c0fed3c5018e95f3881a31b81a555c42b2d/contracts/CErc20Delegator.sol#L237
https://github.com/Plex-Engineer/lending-market-v2/blob/443a8c0fed3c5018e95f3881a31b81a555c42b2d/contracts/CErc20Delegator.sol#L246
When using CToken implementation with CErc20Delegator, the functions borrowRatePerBlock
and supplyRatePerBlock
will revert when the underlying functions try to update some states.
Detail
The v1 of borrowRatePerBlock and supplyRatePerBlock were view functions, but they are not anymore. The CErc20Delegator
is still using delegateToViewImplementation
for those functions. Those functions can be used, as long as the implementation does not update any state variables, i.e. the block number increase since the last update is less or equal to the updateFrequency
. However, when these functions are called after sufficient blocks are mined, they are going to revert.
Although one can still call the implementation using delegateToImplementation
, it is not a good usability, especially if those functions are used for external user interface.
Proof Of Concept
The gist shows a simple test. It calls borrowRatePerBlock
and supplyRatePerBlock
first time, it suceeds. Then, it mines for more than 300 times, which is the updateFrequency
parameter. Then it calls again then fails.
Notes on the test file:
- The setup is taken from
tests/Treasury/Accountant.test.ts
-
using
solidity
from ethereum-waffle for chai to usereverted
// in hardhat.config.js import chai from “chai”; import { solidity } from “ethereum-waffle”;chai.use(solidity);
Tools Used
hardhat
Recommended Mitigation Steps
Instead of using delegateToViewImplementation
use delegateToImplementation
. Alternatively, implement view functions to query these rates in NoteInterest.sol
and CToken.sol
. It will enable to query the rates without spending gas.
Alex the Entreprenerd (judge) commented:
The warden has shown how, due to the usage of
delegateToViewImplementation
the function call toborrowRatePerBlock
andsupplyRatePerBlock
will revert, provided some time has passed.There is no more severe loss of availability than a function that doesn’t work, however, at this time, because the functions are
view
and “niche”, the actual underlying functionality is still available viadelegateToViewImplementation
, and the internal accounting of the protocol is still functioning, I believe Medium Severity to be appropriate.
[M-04] Missing zero address check can cause initialize to be called more than once
Submitted by oyc_109
There is potential to call initialize() on AccountantDelegate.sol
more than once because the require statement only checks if the state variables != address(0)
If initialize() was called the first with these parameters as address(0x0), then initialize can be called again
Recommended Mitigation Steps
The require statement should also check if the parameters != zero address, similar to the check performed in TreasuryDelegate.sol
Or use openzepplen initializable.
nivasan1 (Canto) acknowledged and commented:
This will be a function called by admin (Governance), as such, it is the community’s responsibility to review the parameters of the inititalize method.
Alex the Entreprenerd (judge) commented:
In contract to CantoV1, in this version of the code the
initializer
can be called multiple times.This is contingent on the admin calling it, however the impact of changing any variable will most likely be complete breaking of all accounting (an exception may be a complete fresh start).
Given in the code in scope, and the conditionality (admin privilege), I believe Medium Severity to be appropriate.
[M-05] Admin Can Break All Functionality Through Weth Address
Submitted by defsec
On the protocol, almost all functionality is constructed through WETH address. However, if the admin is set to WETH address mistakenly, user could not claim through Comptroller.sol#L1381. Admin can break the protocol.
Proof of Concept
https://github.com/Plex-Engineer/lending-market-v2/blob/main/contracts/Comptroller.sol#L1479
Recommended Mitigation Steps
Set WETH address through initializer or change it through governance.
nivasan1 (Canto) disputed and commented:
The admin of the lending-market and LP will be cosmos-sdk governance, vis-a-vis, the community, as such it is expected that a malicious governance proposal will not be passed.
Alex the Entreprenerd (judge) commented:
Similarly to findings about
admin
re-initializing contracts, the warden has shown how, because the WETH address can be changed, accounting and functionality of the protocol and it’s interactions (in this case emission of rewards and all lending operations triggering a transfer of “COMP”), can be bricked.Because this is contingent on malicious governance, I believe Medium Severity to be appropriate.
[M-06] A cap is needed on the amount of Note than can be borrowed
Submitted by Picodes
https://github.com/Plex-Engineer/lending-market-v2/blob/ea5840de72eab58bec837bb51986ac73712fcfde/contracts/CNote.sol#L33
https://github.com/Plex-Engineer/lending-market-v2/blob/ea5840de72eab58bec837bb51986ac73712fcfde/contracts/Note.sol#L14
The fact that there is no cap on the amount of Note that can be borrowed makes the Oracle Extractable Value unlimited. But as you intend to rely on TWAP, you need to make sure the cost of oracle manipulation is lower than the Oracle Extractable Value.
Proof of Concept
By manipulating the TWAPs of the designated proxy used for Note (USDC ?) and its relative price to a given collateral(which would be highly costly), an attacker could borrow Note without limit, and empty all pools related to Note and all Note-related value, instantly killing the stablecoin.
The value extractable by Oracle Manipulations is usually easily computable as it is the size of the lending market, but here, it’s more difficult to evaluate as it could potentially be any value linked to Note. This makes risk management harder and increase significantly the risk of attack.
Therefore a cap on how many Notes can be borrowed needs to be added to mitigate this risk.
The attack would be:
- Manipulate the USDC / Collateral TWAP to be able to borrow Note with less than 1$ of collateral, which would be costly.
-
Extract all the value possible linked to Note, for example:
- by buying all the tokens from pools Note / token at a discount
- by supplying Notes to the lending platform and borrow collaterals for which the Note price is still at 1$
- etc
Essentially as you have no cap on the amount of Note that could be borrowed in such a scenario, you cannot be sure that the potential attack profits are lower than the attack cost.
Recommended Mitigation Steps
The governance needs to set a limit on how much Note can be borrowed to mitigate risks, or add for example an “hourly” borrowing limit.
Easiest way to do this would be able to mint / burn from the accountant.
Alex the Entreprenerd (judge) decreased severity to Medium and commented:
I don’t think you can manipulate the price of cNOTE per this code
//set price statically to 1 when the Comptroller is retrieving Price else if (compareStrings(ctoken.symbol(), "cNOTE") && msg.sender == Comptroller) { return 1; // Note price is fixed to 1 }
However, you can manipulate the price of another token against USDC
else { stablePair = (stable == 0) ? false : true; pair = IBaseV1Pair(pairFor(USDC, underlying, stablePair)); //get the pair for the USDC/underlying pool price = pair.quote(underlying, 1, 8); //how much USDC is this token redeemable for }
The attack outlined by the warden would require an imbalance in the price of an asset against the given above code.
It would also require oracle manipulation, which requires no external arbitrage nor intervention It would require some value to be extractable from the system.
For those reasons, I think Medium Severity is more appropriate.
Low Risk and Non-Critical Issues
For this contest, 34 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by zzzitron received the top score from the judge.
The following wardens also submitted reports: 0x1f8b, TomJ, AlleyCat, Lambda, Picodes, Meera, aysha, c3phas, 0x29A, __141345__, oyc_109, Bnke0x0, defsec, Chom, fatherOfBlocks, cccz, ladboy233, slywaters, ignacio, JC, rfa, Funen, hake, sach1r0, samruna, ynnad, grGred, TerrierLover, 0v3rf10w, asutorufos, Limbooo, mrpathfindr, and Sm4rty.
[L-01] Price not scaled by the BASE in BaseV1Router01
-
else if (compareStrings(ctoken.symbol(), "cNOTE") && msg.sender == Comptroller) { return 1; // Note price is fixed to 1 }
The getUnderlyingPrice
function returns 1, when Comptroller is calling with CNote
. The Comptroller is using the given price as mantissa, which has 18 decimal precision.
[L-02] _acceptInitialAdminDelegated
will revert in GovernorBravoDelegator
-
delegateTo(implementation, abi.encodeWithSignature("_acceptInitialAdmin()"));
When the GovernorBravoDelegator
is used with GovernorBravoDelegate
as the implementation, the function _acceptInitialAdminDelegated
will revert since the GovernorBravoDelegate
does not have _acceptInitialAdmin
function.
[L-03] Desirable underflow / overflow in BaseV1Pair
- BaseV1-core.sol:160-161
- BaseV1-core.sol:186-187
- BaseV1-core.sol:200-201
-
reserve0CumulativeLast += _reserve0 * timeElapsed; reserve1CumulativeLast += _reserve1 * timeElapsed;
The BaseV1-core is using solidity version 0.8.11, so it will revert when overflow or underflow happens. Upon cumulating the time weighted reserve, it is desired to overflow. Likewise it is desired to underflow calculating price average. It also saves gas usage, to use unchecked block for those calculations.
[L-04] Lack of zero address check
-
NoteInterest.sol:104-105 in
initialize
cNote = CErc20(cnoteAddr); oracle = PriceOracle(oracleAddress);- the
cNote
andoracle
was set without checking zero address
- the
-
NoteInterest.sol:113 in
setAdmin
admin = newAdmin;- it is good to check admin for zero address, otherwise admin access will be lost forever. To renounce the admin, it is safer to add a separate function.
[L-05] Oracle failure check in NoteRateModel
-
uint twapMantissa = oracle.getUnderlyingPrice(cNote); // returns price as mantissa
It is good to check the returned value from oracle is sane (i.e. if it is zero) and prepare for a fallback.
[L-06] Storage for delegate is outside of storage contract in CNote
When updating an implementation for a proxy, it is important to keep the storage structure. Putting non-constant state variables in a dedicated storage contract is less error-prone approach. Although CErc20Delegate has a storage contract in its inheritance tree, a state variable _accountant
was introduced in CNote
. Therefore, extra care should be paid to the state variable.
[N-01] Cast to int
without checking in NoteRateModel
int diff = BASE - int(twapMantissa); //possible annoyance if 1e18 - twapMantissa > 2**255, differ
int InterestAdjust = (diff * int(adjusterCoefficient))/BASE;
int ir = InterestAdjust + int(baseRatePerYear);
The uint256 variables are casted into int
without checking for overflow. It is probably safe in this use case, but to be sure one may use SafeCast.
[N-02] Meaningless check in GovernorBravoDelegate
The check require(initialProposalId == 0, "GovernorBravo::_initiate: can only initiate once");
is not useful, as initialProposalId
is never updated.
[N-03] Missing param in NatSpec in CToken
Although a new argument uint8 stable_
was added, the NatSpec for the parameter is missing.
[N-04] Misleading NatSpec in AccountantDelegator
- AccountantDelegator.sol:50 the NatSpec does not match the function
- AccountantDelegator.sol:59 the NatSpec does not match the function
[N-05] Typos
Alex the Entreprenerd (judge) commented:
[L-01] Price not scaled by the BASE in BaseV1Router01
Low[L-02]
_acceptInitialAdminDelegated
will revert in GovernorBravoDelegator
Valid Low (as the Sponsor uses another function to acceptAdmin)[L-03] Desirable underflow / overflow in BaseV1Pair
Valid Low[L-04] Lack of zero address check
Low[L-05] Oracle failure check in NoteRateModel
Low[L-06] Storage for delegate is outside of storage contract in CNote
Low, there will be no risk unless the Sponsor changes the Delegate contract[N-01] Cast to int without checking in NoteRateModel
NC as it will never overflow but it’s a code-smell[N-02] Meaningless check in GovernorBravoDelegate
R[N-03] & [N-04] Natspec
NC[N-05] Typos
NCReally good report, there’s some personal opinion and style which helps it stand out. Also the quantity of findings is good, will give it bonus points.
Gas Optimizations
For this contest, 34 reports were submitted by wardens detailing gas optimizations. The report highlighted below by 0x1f8b received the top score from the judge.
The following wardens also submitted reports: rfa, 0xKitsune, Bnke0x0, 0x29A, defsec, Picodes, Tomio, TomJ, Funen, ladboy233, Meera, fatherOfBlocks, m_Rassska, oyc_109, TerrierLover, Waze, mrpathfindr, 0xArshia, ajtra, c3phas, Chom, cRat1st0s, durianSausage, Fitraldys, grGred, hake, JC, Lambda, Noah3o6, RedOneN, Rohan16, Sm4rty, and ynnad.
[G-01] Use abi.encodeWithSelector
instead of abi.encodeWithSignature
abi.encodeWithSelector
is much cheaper than abi.encodeWithSignature
because it doesn’t require to compute the selector from the string.
Reference:
- https://docs.soliditylang.org/en/v0.8.15/units-and-global-variables.html#abi-encoding-and-decoding-functions
- https://github.com/ConnorBlockchain/Solidity-Encode-Gas-Comparison
Affected source code:
- AccountantDelegator.sol#L27
- AccountantDelegator.sol#L54
- AccountantDelegator.sol#L63
- AccountantDelegator.sol#L71
- AccountantDelegator.sol#L108
- TreasuryDelegator.sol#L17
- TreasuryDelegator.sol#L45
- TreasuryDelegator.sol#L54
- TreasuryDelegator.sol#L64
- TreasuryDelegator.sol#L68
- TreasuryDelegator.sol#L89
- GovernorBravoDelegator.sol#L15
- GovernorBravoDelegator.sol#L41
- GovernorBravoDelegator.sol#L49
- GovernorBravoDelegator.sol#L57
[G-02] Avoid creating unnecessary variables
The creation of variables has an extra cost due to the required opcodes, in addition to the fact that the evm virtual machine is limited to the number of elements.
Reference:
Affected source code:
- Return
address(this).balance
without createtreasuryCantoBalance
in TreasuryDelegate.sol#L33-L36 - Return
note.balanceOf(address(this))
without createtreasuryNoteBalance
in TreasuryDelegate.sol#L43-L44 - Send
msg.sender
instead of createowner
var in ERC20.sol#L92, ERC20.sol#L116, ERC20.sol#L162 and ERC20.sol#L181 - Send
msg.sender
instead of createsrc
var in CToken.sol#L145
[G-03] Reorder structure layout
The following structs could be optimized moving the position of certains values in order to save slot storages:
struct Proposal {
uint id;
address proposer;
uint eta;
address[] targets;
uint[] values;
string[] signatures;
bytes[] calldatas;
bool canceled; // <- Move these two bool close to the `proposer` address
bool executed;
}
[G-04] Reduce the size of error messages (Long revert Strings)
Shortening revert strings to fit in 32 bytes will decrease deployment time gas and will decrease runtime gas when the revert condition is met.
Revert strings that are longer than 32 bytes require at least one additional mstore, along with additional overhead for computing memory offset, etc.
I suggest shortening the revert strings to fit in 32 bytes, or that using custom errors as described next.
Use Custom Errors instead of Revert Strings to save Gas
Custom errors from Solidity 0.8.4 are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met)
Source Custom Errors in Solidity:
Starting from Solidity v0.8.4, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. Until now, you could already use strings to give more information about failures (e.g., revert(“Insufficient funds.”);), but they are rather expensive, especially when it comes to deploy cost, and it is difficult to use dynamic information in them.
Custom errors are defined using the error statement, which can be used inside and outside of contracts (including interfaces and libraries).
Below are some examples, but the code has a lot of them, it is recommended to change all require
phrases to custom errors.
- GovernorBravoDelegate.sol#L25-L27
- GovernorBravoDelegate.sol#L46-L47
- GovernorBravoDelegate.sol#L76
- GovernorBravoDelegate.sol#L85
- GovernorAlpha.sol#L137-L146
- GovernorAlpha.sol#L178
- GovernorAlpha.sol#L189
- GovernorAlpha.sol#L194
- GovernorAlpha.sol#L205
- GovernorAlpha.sol#L283-L298
- Comp.sol#L234-L238
- ERC20.sol#L248-L251
- ERC20.sol#L276-L277
- CNote.sol#L17
[G-05] ++i
costs less gas compared to i++
or i += 1
++i
costs less gas compared to i++
or i += 1
for unsigned integer, as pre-increment is cheaper (about 5 gas per iteration). This statement is true even with the optimizer enabled.
i++
increments i
and returns the initial value of i
. Which means:
uint i = 1;
i++; // == 1 but i == 2
But ++i
returns the actual incremented value:
uint i = 1;
++i; // == 2 and i == 2 too, so no need for a temporary variable
In the first case, the compiler has to create a temporary variable (when used) for returning 1
instead of 2
.
I suggest using ++i
instead of i++
to increment the value of an uint variable. Same thing for --i
and i--
Affected source code:
- GovernorBravoDelegate.sol#L66
- GovernorBravoDelegate.sol#L88
- GovernorAlpha.sol#L181
- GovernorAlpha.sol#L197
- GovernorAlpha.sol#L211
[G-06] Use external
visibility
The following methods could be improved if external
visibility is used:
[G-07] There’s no need to set default values for variables
If a variable is not set/initialized, the default value is assumed (0, false
, 0x0 … depending on the data type). You are simply wasting gas if you directly initialize it with its default value.
Affected source code:
- GovernorBravoDelegate.sol#L66
- GovernorBravoDelegate.sol#L88
- GovernorAlpha.sol#L181
- GovernorAlpha.sol#L197
- GovernorAlpha.sol#L211
[G-08] Reduce boolean comparison
It’s compared a boolean value using == true
or == false
, instead of using the boolean value. NOT
opcode, it’s cheaper to use EQUAL
or NOTEQUAL
when the value it’s false, or just the value without == true
when it’s true, because it will use less opcodes inside the VM.
Change == false
by !
in:
[G-09] Unused arguments
Is not required to send the nonce
, only is required for compute the signature.
Affected source code:
[G-10] Gas saving using immutable
It’s possible to avoid storage access a save gas using immutable
keyword for the following variables:
It’s also better to remove the initial values, because they will be set during the constructor.
Affected source code:
- Remove
_setupDecimals
method and change_decimals
to immutable. Decimals should never change in ERC20MinterBurnerDecimals.sol#L30 dripStart
,dripRate
,token
andtarget
in Reservoir.sol#L34-L37_name
,_symbol
and_decimals
in WETH.sol#L6-L8_name
and_symbol
in ERC20.sol#L30-L31
[G-11] Unused import
Importing pointless files costs gas during deployment and is a bad coding practice that is important to ignore.
Remove the following imports:
[G-12] unckecked
keyword
It’s possible to save gas using the unckecked
keyword. This will avoid the required checks to ensure that the variable won’t overflow.
Reference:
The balance was checked before, it can be subtracted without checking overflows:
[G-13] use constant
instead of storage
Use constant instead of storage and change from uint256 MAX_INT = 2**256-1;
to uint256 private constant MAX_INT = type(uint256).max;
:
[G-14] Unrequired method
Method not necessary since the implemented logic only calls to obtain the current block.
Affected source code:
[G-15] Optimize logic
The logic of the CToken.transferTokens
method can bypass the startingAllowance
condition and the allowanceNew
variable if the spender
and src
checks are done at the same time the tokens are subtracted.
if (spender == src) {
transferAllowances[src][spender] -= tokens;
}
Affected source code:
[G-16] Use memory
instead of storage
Changing storage
to memory
we can save some storage access.
Affected source code:
Alex the Entreprenerd (judge) commented:
Packing
-> Will save in writing and reading, let’s say 2.1k * 2 for two saved SLOADsImmutables
9* 2100 (ignoring ERC20MinterBurnerDecimals as out of scope)[G-13] Use constant instead of storage
2.1kRest seems minor.
Big gas savings by reducing storage, nice finds.
25200
Disclosures
C4 is an open organization governed by participants in the community.
C4 Contests incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Contest submissions are judged by a knowledgeable security researcher and solidity developer and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.
C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.