Inverse Finance contest - neumo's results

Rethink the way you borrow.

General Information

Platform: Code4rena

Start Date: 25/10/2022

Pot Size: $50,000 USDC

Total HM: 18

Participants: 127

Period: 5 days

Judge: 0xean

Total Solo HM: 9

Id: 175

League: ETH

Inverse Finance

Findings Distribution

Researcher Performance

Rank: 17/127

Findings: 3

Award: $506.82

QA:
grade-b

🌟 Selected for report: 1

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: neumo

Also found by: BClabs, ladboy233, minhtrng, rvierdiiev

Labels

bug
2 (Med Risk)
disagree with severity
primary issue
sponsor acknowledged
selected for report
M-09

Awards

445.8654 USDC - $445.87

External Links

Lines of code

https://github.com/code-423n4/2022-10-inverse/blob/main/src/Market.sol#L281-L283

Vulnerability details

Impact

If a user creates a market with the INVEscrow implementation as escrowImplementation and false as callOnDepositCallback, the deposits made by users in the escrow (through the market) would not mint xINV tokens for them. As callOnDepositCallback is an immutable variable set in the constructor, this mistake would make the market a failure and the user should deploy a new one (even worse, if the error is detected after any user has deposited funds, some sort of migration of funds should be needed).

Proof of Concept

Both escrowImplementation and callOnDepositCallback are immutable:

...
address public immutable escrowImplementation;
...
bool immutable callOnDepositCallback;
...

and its value is set at creation:

constructor (
        address _gov,
        address _lender,
        address _pauseGuardian,
        address _escrowImplementation,
        IDolaBorrowingRights _dbr,
        IERC20 _collateral,
        IOracle _oracle,
        uint _collateralFactorBps,
        uint _replenishmentIncentiveBps,
        uint _liquidationIncentiveBps,
        bool _callOnDepositCallback
    ) {
	...
	escrowImplementation = _escrowImplementation;
	...
	callOnDepositCallback = _callOnDepositCallback;
	...
 }

When the user deposits collateral, if callOnDepositCallback is true, there is a call to the escrow's onDeposit callback:

function deposit(address user, uint amount) public {
	...
	if(callOnDepositCallback) {
		escrow.onDeposit();
	}
	emit Deposit(user, amount);
}

This is INVEscrow's onDeposit function:

function onDeposit() public {
	uint invBalance = token.balanceOf(address(this));
	if(invBalance > 0) {
		xINV.mint(invBalance); // we do not check return value because we don't want errors to block this call
	}
}

The thing is if callOnDepositCallback is false, this function is never called and the user does not turn his/her collateral (INV) into xINV.

Tools Used

Manual analysis.

Either make callOnDepositCallback a configurable parameter in Market.sol or always call the onDeposit callback (just get rid of the callOnDepositCallback variable) and leave it empty in case there's no extra functionality that needs to be executed for that escrow. In the case that the same escrow has to execute the callback for some markets and not for others, this solution would imply that there should be two escrows, one with the callback to be executed and another with the callback empty.

#0 - c4-judge

2022-11-05T19:41:43Z

0xean marked the issue as duplicate

#1 - c4-judge

2022-12-01T16:03:33Z

0xean marked the issue as selected for report

#2 - c4-judge

2022-12-07T08:22:48Z

Simon-Busch marked the issue as not a duplicate

#3 - c4-judge

2022-12-07T08:22:55Z

Simon-Busch marked the issue as primary issue

#5 - c4-sponsor

2022-12-14T21:15:41Z

08xmt marked the issue as sponsor acknowledged

#6 - c4-sponsor

2022-12-14T21:15:48Z

08xmt marked the issue as disagree with severity

#7 - 08xmt

2022-12-14T21:17:01Z

We acknowledge that markets can be configured incorrectly, but it should generally be assumed that markets will be configured correctly, as this will go through both internal and governance review.

Findings Information

Awards

24.2165 USDC - $24.22

Labels

bug
2 (Med Risk)
disagree with severity
downgraded by judge
satisfactory
sponsor confirmed
duplicate-533

External Links

Lines of code

https://github.com/code-423n4/2022-10-inverse/blob/main/src/Oracle.sol#L87 https://github.com/code-423n4/2022-10-inverse/blob/main/src/Oracle.sol#L121

Vulnerability details

Impact

The price returned by the Oracle is wrong in certain conditions, leading to prices multiplied or divided by 10^n. This situation makes the calculation of the collateral value, the withdrawal limit and the liquidation reward in Market.sol absolutely wrong, which could potentially be a loss of funds for the protocol. See that the price is supposed to be 18 decimals, and getting it in another number of decimals would be a disaster for the protocol. See for example of use of the getPrice function of the oracle in line 326 of Market.sol:

return collateralBalance * oracle.getPrice(address(collateral), collateralFactorBps) / 1 ether;

how the multiplication is divided by 1 ether (10^18, or one with 18 decimals).

Proof of Concept

In both getPrice and viewPrice functions in Oracle.sol, the error is located when trying to set the price to return as a 18 decimal value.

function viewPrice(address token, uint collateralFactorBps) external view returns (uint) {
	...
	uint price = feeds[token].feed.latestAnswer();
	require(price > 0, "Invalid feed price");
	// normalize price
	uint8 feedDecimals = feeds[token].feed.decimals();
	uint8 tokenDecimals = feeds[token].tokenDecimals;
	uint8 decimals = 36 - feedDecimals - tokenDecimals;
	uint normalizedPrice = price * (10 ** decimals);
	...
}

Feed decimals is the number of decimals of the Chainlink feed. The price that returns the feed is with feedDecimals so the last line above uint normalizedPrice = price * (10 ** decimals); just should add (18 - feedDecimals) to the price, so that normalizedPrice turns into a 18 decimal value. Instead of this, the functions calculate the decimals as uint8 decimals = 36 - feedDecimals - tokenDecimals; which works for tokenDecimals = 18, but won't work otherwise. So let's see the different possibilities here:

  1. If tokenDecimals = 18 the functions will work OK as long as feedDecimals <= 18. If it's greater than 18 the calls to both functions will revert because 36 - 18 - feedDecimals < 0.
  2. If tokenDecimals != 18 the calculation won't work OK. Even worse, if tokenDecimals + feedDecimals > 36 both functions will revert.

The following forge test (which I included in the Market.t.sol file) illustrates some wrong returns by the oracle supposing that the feedDecimals are always 18, for different values of tokenDecimals:

function testWrongPriceDecimalsWith18DecimalsFeed() public {
	// The Eth feed has 18 decimals and is supposed to return a price of 1,600 with 18 decimals
	assertEq(oracle.viewPrice(address(WETH), collateralFactorBps), 1_600e18);

	// We change the feed setting a value of tokenDecimals of 8
	vm.prank(gov);
	oracle.setFeed(address(WETH), IChainlinkFeed(address(ethFeed)), 8);

	// Check that the price returned by the oracle is now 1,600 with 28 decimals
	assertEq(oracle.viewPrice(address(WETH), collateralFactorBps), 1_600e28);

	vm.prank(gov);
	// We change the feed again setting a value of tokenDecimals of 24
	oracle.setFeed(address(WETH), IChainlinkFeed(address(ethFeed)), 24);
	// Check that the call to viewPrice reverts because of the underflow [36 - 18 - 24 = -6]
	vm.expectRevert(stdError.arithmeticError);
	uint price = oracle.viewPrice(address(WETH), collateralFactorBps);
}

To test the cases for when the feed is different than 18 decimals I first changed file EthFeed.sol, precisely these two lines:

...
uint8 decimals_ = 18;
uint price_ = 1600e18;
...

into

...
uint8 decimals_ = 8;
uint price_ = 1600e8;
...

Then added this test to Market.t.sol:

function testWrongPriceDecimalsWith8DecimalsFeed() public {
	// The Eth feed now has 8 decimals, and the token decimals are 18
	// It is supposed to return a price of 1,600 with 18 decimals
	assertEq(oracle.viewPrice(address(WETH), collateralFactorBps), 1_600e18);

	// We change the feed setting a value of tokenDecimals of 8
	vm.prank(gov);
	oracle.setFeed(address(WETH), IChainlinkFeed(address(ethFeed)), 8);

	// Check that the price returned by the oracle is now 1,600 with 28 decimals
	assertEq(oracle.viewPrice(address(WETH), collateralFactorBps), 1_600e28);

	vm.prank(gov);
	// We change the feed again setting a value of tokenDecimals of 30
	oracle.setFeed(address(WETH), IChainlinkFeed(address(ethFeed)), 30);
	// Check that the call to viewPrice reverts because of the underflow [36 - 8 - 30 = -2]
	vm.expectRevert(stdError.arithmeticError);
	uint price = oracle.viewPrice(address(WETH), collateralFactorBps);
}

So it is clear that the returned price has the wrong decimals in some of the cases, and so the calculations in the Market contract would lead to disastrous consequences.

Tools Used

Forge tests and manual analysis

If the oracle is supposed to return the price of the feed with 18 decimals, a simple solution would be changing this lines in both viewPrice and getPrice:

uint8 decimals = 36 - feedDecimals - tokenDecimals;
uint normalizedPrice = price * (10 ** decimals);

with this:

uint normalizedPrice = price;
if(feedDecimals <= 18){
	normalizedPrice *= 10 ** (18 - feedDecimals);
}
else{
	normalizedPrice /= 10 ** (feedDecimals - 18);
}

This way, if price feed has less than 18 decimals, it would be right padded with zeroes up to 18, and if it has more than 18 decimals, the excess decimals would be taken out.

#0 - c4-judge

2022-11-04T23:37:46Z

0xean marked the issue as primary issue

#1 - c4-judge

2022-11-04T23:50:26Z

0xean marked the issue as selected for report

#2 - 08xmt

2022-11-25T12:29:16Z

This issue is only partially correct.

The intention of the oracle is not to return a 18 decimal price, but a price that will give a correct dollar price as if the underlying token had 18 decimals, since we do no decimal math in the market.

Let's take an example:

  • WBTC has 8 decimals.
  • It's Chainlink feed has 8 decimals as well.
  • This returns a price with 20 extra decimals, because 36 - 8 - 8 = 20.
  • Assuming price is 16000 USD per WBTC, the price would come out as 1.6e32
  • A user has 1 WBTC ( 1e8 since the token has 8 decimals) deposited.

Their collateral value is calculated as follows by the market: return collateralBalance * oracle.getPrice(address(collateral), collateralFactorBps) / 1 ether;

Leaving them with 1e8 * 1.6e32 / 1e18 = 1.6e22 collateral value, or what amounts to 16,000 DOLA which is correct.

The part about the oracle failing if the token decimals + feed decimals > 36 is correct, but considered a lower severity issue, is that is highly unorthodox. Severity considered medium for the correct part of the issue.

#3 - c4-sponsor

2022-11-25T12:29:47Z

08xmt marked the issue as disagree with severity

#4 - 08xmt

2022-11-25T13:17:54Z

#5 - c4-sponsor

2022-11-25T13:18:07Z

08xmt marked the issue as sponsor confirmed

#6 - c4-judge

2022-11-28T15:56:59Z

0xean marked the issue as not selected for report

#7 - 0xean

2022-11-28T16:10:00Z

I think M is reasonable here, it requires some pre-existing conditions that are atypical for the issue to occur.

#8 - c4-judge

2022-11-28T16:10:20Z

0xean changed the severity to 2 (Med Risk)

#9 - c4-judge

2022-11-28T19:35:21Z

0xean marked the issue as satisfactory

#10 - c4-judge

2022-12-07T08:18:20Z

Simon-Busch marked the issue as duplicate of #533

1.- Lack of zero address checks:

FileFunctionVariableLine
Market.solconstructor_gov77
constructor_lender78
constructor_pauseGuardian79
constructor_escrowImplementation80
constructor_dbr81
constructor_collateral82
constructor_oracle83
setOracle_oracle118
setGov_gov130
setLender_lender136
setPauseGuardian_pauseGuardian142
createEscrowuser234
borrowInternalborrower395
withdrawInternalfrom463
withdrawInternalto464
repayuser536
forceReplenishuser565
liquidateuser593
Fed.solconstructor_dbr37
constructor_dola38
constructor_gov39
constructor_chair40
DBR.solconstructor_operator39
addMinterminter_82
addMarketmarket_100
BorrowController.solconstructor_operator14
setOperator_operator26
Oracle.solconstructor_operator32
GovTokenEscrow.solinitialize_token33
initialize_beneficiary34
payrecipient45
delegatedelegatee68
INVEscrow.solconstructor_xINV35
initialize_token47
initialize_beneficiary48
payrecipient63
delegatedelegatee92
SimpleERC20Escrow.solinitializetoken28
payrecipient38

2.- In BorrowControler.sol, function setOperator should be a two step process.

Function setOperator changes right away the operator of the contract with the address passed as parameter. In case there's an error in the address passed, the contract will not have operator, which can have disastrous consequences, because functions allow and deny will not be usable. Consider turning the change of operator into a two step process, for example with two functions: setPendingOperator and claimOperator.

3.- In Fed.sol, function changeGov should be a two step process.

Function changeGov changes right away the governor of the contract with the address passed as parameter. In case there's an error in the address passed, the contract will not have governor, which can have disastrous consequences, because functions changeSupplyCeiling and changeChair will not be usable. Furthermore, function takeProfit will send the profit to a wrong address. Consider turning the change of governor into a two step process, for example with two functions: setPendingGov and claimGov.

4.- In Market.sol, function setGov should be a two step process.

Function setGov changes right away the governor of the contract with the address passed as parameter. In case there's an error in the address passed, the contract will not have governor, which can have disastrous consequences, because functions setLender, setPauseGuardian, setCollateralFactorBps, setLiquidationFactorBps, setReplenismentIncentiveBps, setLiquidationIncentiveBps, setLiquidationFeeBps and pauseBorrows (when unpausing) will not be usable. Furthermore, function liquidate will send the fee to a wrong address. Consider turning the change of governor into a two step process, for example with two functions: setPendingGov and claimGov.

5.- Centralization risk.

Functions only callable by centralized addresses can have a big impact in user funds.

FileFunctionLine
Oracle.solsetFeed53
setFixedPrice61
BorrowController.soldeny38
DBR.soladdMinter81
mint349
Fed.solchangeChair66
Market.solsetCollateralFactorBps149
setLiquidationFactorBps161
setReplenismentIncentiveBps172
setLiquidationIncentiveBps183
setLiquidationFeeBps194
pauseBorrows212

6.- Immutable variables persist across proxies.

In INVEscrow.sol there is a comment that says // TODO: Test whether an immutable variable will persist across proxies. When the escrow implementation is deployed, the constructor substitutes in the contract bytecode all instances of xINV whith the address supplied, so it is not stored in the storage. This comment can be deleted, because proxies will all see the xINV address supplied at implementation deployment.

7.- escrowImplementation cannot be modified.

If a bug is found in an already deployed escrowImplementation there is no way of changing it because the escrows are not upgradeable and also because there is no function that allows this in the Market.sol contract. Consider creating an onlyGov function setEscrowImplementation to allow the change (this would introduce a new centralization risk, though).

8.- replenishmentIncentiveBps can be set to 0 at construction but not in the setter.

In the Market.sol constructor, variable replenishmentIncentiveBps can be set to zero, but the setter, setReplenismentIncentiveBps does not allow zero as a possible value (the minimum would be then a 0.01% incentive). Consider using the same constraints in both locations, for coherence.

#0 - c4-judge

2022-11-07T21:37:31Z

0xean marked the issue as grade-b

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