The code under review can be found in morpho-blue.
ID | Description | Severity |
---|---|---|
H-01 | Markets can be created for tokens without code | High |
M-01 | Deviation in oracle price could lead to arbitrage in high LLTV markets | Medium |
M-02 | Users can take advantage of low liquidity markets to inflate the interest rate | Medium |
L-01 | Positions might not have sufficient liquidation incentive in high LLTV markets | Low |
I-01 | Markets can be DOSed upon creation by inflating totalBorrowShares |
Informational |
I-02 | MAX_LIQUIDATION_INCENTIVE_FACTOR is incorrect in documentation |
Informational |
Context:
Description:
Morpho Blue uses the SafeTransferLib
library to perform token transfers, more specifically, to call transfer()
and transferFrom()
.
Since SafeTransferLib
performs these calls using low-level .call()
, both safeTransfer()
and safeTransferFrom()
will not revert if the token
address is a contract with no code.
This responsibility is delegated to the market creator:
/// @dev It is the responsibility of the market creator to make sure that the address of the token has non-zero code.
library SafeTransferLib {
However, this allows an attacker to create fake balances for not-yet-existing ERC20 tokens.
Some protocols deploy their token across multiple networks, and when they do so, a common practice is to deploy the token contract from the same deployer address and with the same nonce so that the token address can be the same for all the networks.
For example:
- 1INCH uses the same token address for both Ethereum and BSC
- Gelato's GEL token uses the same token address for Ethereum, Fantom and Polygon.
An attacker can exploit this to set traps and potentially steal funds from unsuspecting users:
- 1INCH wants to deploy their token on Polygon.
- Before the token is deployed, Alice does the following in Morpho Blue on Polygon:
- Call
createMarket()
with0x111111111117dC0aa78b770fA6A738034120C302
ascollateralToken
, which is the 1INCH token address. - Call
supplyCollateral()
to give herself a large collateral balance.
- Call
- Afterwards, the 1INCH token is deployed.
- Bob wants to use his 1INCH tokens as collateral in Morpho Blue.
- He calls
supplyCollateral()
and deposits his tokens into the market created by Alice.
- He calls
- Alice can now call
withdrawCollateral()
to steal Bob's tokens.
Apart from tokens on multiple chains, another form of tokens that have pre-determined addresses are tokens created from factory contracts.
For example, the addresses of all Uniswap V2 LP tokens are known before deployment, since UniswapV2Pair
is created using CREATE2
in UniswapV2Factory
. Since their addresses are pre-determined, an attacker can perform the same attack mentioned above before the UniswapV2Pair
is deployed to potentially steal funds.
The following POC demonstrates how an attacker can create a market and give himself unlimited supply or collateral for non-existing tokens:
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;
import "test/forge/BaseTest.sol";
contract EOATokenTest is BaseTest {
using MarketParamsLib for MarketParams;
function test_canCreateMarketWithEOAToken() public {
// Create market with loanToken and collateralToken without code
marketParams = MarketParams({
loanToken: address(0xc4fe),
collateralToken: address(0xdead),
oracle: address(oracle),
irm: address(irm),
lltv: DEFAULT_TEST_LLTV
});
id = marketParams.id();
morpho.createMarket(marketParams);
// Give ONBEHALF a large amount of loanToken
morpho.supply(marketParams, 1e30, 0, ONBEHALF, hex"");
assertEq(morpho.market(id).totalSupplyAssets, 1e30);
// Give ONBEHALF a large amount of collateralTOken
morpho.supplyCollateral(marketParams, 1e30, ONBEHALF, hex"");
assertEq(morpho.position(id, ONBEHALF).collateral, 1e30);
}
}
Recommendation:
In createMarket()
, check that loanToken
and collateralToken
contain code:
function createMarket(MarketParams memory marketParams) external {
+ require(marketParams.loanToken.length != 0, ErrorsLib.LOANTOKEN_NO_CODE);
+ require(marketParams.collateralToken.length != 0, ErrorsLib.COLLATERALTOKEN_NO_CODE);
Context:
Description:
In Morpho Blue, the maximum amount a user can borrow is calculated with the conversion rate between loanToken
and collateralToken
returned by an oracle:
uint256 maxBorrow = uint256(position[id][borrower].collateral).mulDivDown(collateralPrice, ORACLE_PRICE_SCALE)
.wMulDown(marketParams.lltv);
collateralPrice
is fetched by calling the oracle's price()
function. For example, the price()
function in ChainlinkOracle.sol
is as such.
function price() external view returns (uint256) {
return SCALE_FACTOR.mulDiv(
VAULT.getAssets(VAULT_CONVERSION_SAMPLE) * BASE_FEED_1.getPrice() * BASE_FEED_2.getPrice(),
QUOTE_FEED_1.getPrice() * QUOTE_FEED_2.getPrice()
);
}
However, all price oracles are susceptible to front-running as their prices tend to lag behind an asset's real-time price. More specifically:
- Chainlink oracles are updated after the change in price crosses a deviation threshold, (eg. 2.5% in ETH / USD), which means a price feed could return a value slightly smaller/larger than an asset's actual price under normal conditions.
- Uniwap V3 TWAP returns the average price over the past X number of blocks, which means it will always lag behind the real-time price.
An attacker could exploit the difference between the price reported by an oracle and the asset's actual price to gain a profit by front-running the oracle's price update.
For Morpho Blue, this becomes profitable when the price deviation is sufficiently large for an attacker to open positions that become bad debt. Mathematically, arbitrage is possible when:
The likelihood of this condition becoming true is significantly increased when ChainlinkOracle.sol
is used as the market's oracle with multiple Chainlink price feeds.
As seen from above, the conversion rate between loanToken
and collateralToken
is calculated with multiple price feeds, with each of them having their own deviation threshold. This amplifies the maximum possible price deviation returned by price()
.
For example:
- Assume a market has WBTC as
collateralToken
and FTM asloanToken
. - Assume the following prices:
- 1 BTC = 40,000 USD
- 1 FTM = 1 USD
- 1 ETH = 2000 USD
ChainlinkOracle
will be set up as such:BASE_FEED_1
- WBTC / BTC, 2% deviation thresholdBASE_FEED_2
- BTC / USD, 0.5% deviation thresholdQUOTE_FEED_1
- FTM / ETH, 3% deviation thresholdQUOTE_FEED_2
- ETH / USD, 0.5% deviation threshold
- Assume that all price feeds are at their deviation threshold:
- WBTC / BTC returns 98% of
1
, which is0.98
. - BTC / USD returns 99.5% of
40000
, which is39800
. - FTM / ETH returns 103% of
1 / 2000
, which is0.000515
. - ETH / USD returns 100.5% of
2000
, which is2010
.
- WBTC / BTC returns 98% of
- The actual conversion rate of WBTC to FTM is:
(0.98 * 39800) / (0.000515 * 2010) = 37680
- i.e. 1 WBTC = 37,680 FTM.
- Compared to 1 WBTC = 40,000 FTM, the maximum price deviation is 5.8%.
To demonstrate how a such a deviation in price could lead to arbitrage:
- Assume the following:
- A market has 95% LLTV, with WBTC as collateral and FTM as
loanToken
. - 1 WBTC is currently worth 40,000 FTM.
- A market has 95% LLTV, with WBTC as collateral and FTM as
- The price of WBTC drops while FTM increases in value, such that 1 WBTC = 37,680 FTM.
- All four Chainlink price feeds happen to be at their respective deviation thresholds as described above, which means the oracle's price is not updated in real time.
- An attacker sees the price discrepancy and front-runs the oracle price update to do the following:
- Deposit 1 WBTC as collateral.
- Borrow 38,000 FTM, which is the maximum he can borrow at 95% LLTV and 1 WBTC = 40,000 FTM conversion rate.
- Afterwards, the oracle's conversion rate is updated to 1 WBTC = 37,680 FTM:
- Attacker's position is now unhealthy as his collateral is worth less than his loaned amount.
- Attacker back-runs the oracle price update to liquidate himself:
- At 95% LLTV, LIF = 100.152%.
- To seize 1 WBTC, he repays 37,115 FTM:
seizedAssets / LIF = 1 WBTC / 1.0152 = 37680 FTM / 1.0152 = 37115 FTM
- He has gained 885 FTM worth of profit using 37,680 FTM, which is a 2.3% arbitrage opportunity.
This example proves the original condition stated above for arbitrage to occur, as:
Note that all profit gained from arbitrage causes a loss of funds for lenders as the remaining bad debt is socialized by them.
Recommendation:
Consider implementing a borrowing fee to mitigate against arbitrage opportunities. Ideally, this fee would be larger than the oracle's maximum price deviation so that it is not possible to profit from arbitrage.
Further possible mitigations have also been explored by other protocols:
Context:
- Morpho.sol#L471-L476
- AdaptiveCurveIrm.sol#L117-L120
- Morpho.sol#L477
- Morpho.sol#L180-L181
- SharesMathLib.sol#L15-L21
Description:
Morpho Blue is meant to work with stateful Interest Rate Models (IRM) - whenever _accrueInterest()
is called, it calls borrowRate()
of the IRM contract:
function _accrueInterest(MarketParams memory marketParams, Id id) internal {
uint256 elapsed = block.timestamp - market[id].lastUpdate;
if (elapsed == 0) return;
uint256 borrowRate = IIrm(marketParams.irm).borrowRate(marketParams, market[id]);
This will adjust the market's interest rate based on the current state of the market. For example, AdaptiveCurveIrm.sol
adjusts the interest rate based on the market's current utilization rate:
function _borrowRate(Id id, Market memory market) private view returns (uint256, int256) {
// Safe "unchecked" cast because the utilization is smaller than 1 (scaled by WAD).
int256 utilization =
int256(market.totalSupplyAssets > 0 ? market.totalBorrowAssets.wDivDown(market.totalSupplyAssets) : 0);
However, this stateful implementation will always call borrowRate()
and adjust the interest rate, even when it should not.
For instance, in AdaptiveCurveIrm.sol
, an attacker can manipulate the market's utilization rate as such:
- Create market with a legitimate
loanToken
,collateralToken
,oracle
and the IRM asAdaptiveCurveIrm.sol
. - Call
supply()
to supply 1 wei ofloanToken
to the market. - Call
supplyCollateral()
to give himself some collateral. - Call
borrow()
to borrow the 1 wei ofloanToken
. - Now, the market's utilization rate is 100%.
- Afterwards, if no one supplies any
loanToken
to the market for a long period of time,AdaptiveCurveIrm.sol
will aggressively increase the market's interest rate.
This is problematic as Morpho Blue's interest compounds based on
uint256 interest = market[id].totalBorrowAssets.wMulDown(borrowRate.wTaylorCompounded(elapsed));
As such, when borrowRate
(the interest rate) increases, interest
will grow at an exponential rate, which could cause the market's totalSupplyAssets
and totalBorrowAssets
to become extremely huge.
This creates a few issues:
- The market will have a huge amount of un-clearable bad debt:
Should a large amount of interest accrue, totalBorrowAssets
will be extremely large, even though totalBorrowShares
is only 1e6
shares. Half of totalBorrowAssets
would have actually accrued to the other 1e6
virtual shares.
As such, after liquidating the attacker's 1e6
shares, half of totalBorrowAssets
will still remain in the market as un-clearable bad debt.
- The market will permanently have a high interest rate:
As mentioned above, AdaptiveCurveIrm.sol
aggressively increased the market's interest rate while there was only 1 wei supplied and borrowed in the market, causing utilization to be 100%.
If other lenders decide to supply loanToken
to the market, borrowers would still be discouraged from borrowing for an extended period of time as AdaptiveCurveIrm.sol
would have to adjust the market's interest rate back down.
- Users who call
supply()
with a small amount of assets might lose funds:
If totalSupplyAssets
is sufficiently large compared to totalSupplyShares
, the market's shares to assets ratio will be huge. This will cause the following the share calculation in supply()
to round down to 0:
if (assets > 0) shares = assets.toSharesDown(market[id].totalSupplyAssets, market[id].totalSupplyShares);
else assets = shares.toAssetsUp(market[id].totalSupplyAssets, market[id].totalSupplyShares);
Should this occur, the user will receive 0 shares when depositing assets, resulting in a loss of funds.
The following PoC demonstrates how the market's interest rate can be inflated, as described above. Note that this PoC has to be placed in the morpho-blue-irm
repository.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "lib/forge-std/src/Test.sol";
import "src/AdaptiveCurveIrm.sol";
import {BaseTest} from "lib/morpho-blue/test/forge/BaseTest.sol";
contract CreamyInflationAttack is BaseTest {
using MarketParamsLib for MarketParams;
int256 constant CURVE_STEEPNESS = 4 ether;
int256 constant ADJUSTMENT_SPEED = int256(20 ether) / 365 days;
int256 constant TARGET_UTILIZATION = 0.9 ether; // 90%
int256 constant INITIAL_RATE_AT_TARGET = int256(0.1 ether) / 365 days; // 10% APR
function setUp() public override {
super.setUp();
// Deploy and enable AdaptiveCurveIrm
AdaptiveCurveIrm irm = new AdaptiveCurveIrm(
address(morpho), CURVE_STEEPNESS ,ADJUSTMENT_SPEED,TARGET_UTILIZATION, INITIAL_RATE_AT_TARGET
);
vm.prank(OWNER);
morpho.enableIrm(address(irm));
// Deploy market with AdaptiveCurveIrm
marketParams = MarketParams({
loanToken: address(loanToken), // Pretend this is USDC
collateralToken: address(collateralToken), // Pretend this is USDT
oracle: address(oracle),
irm: address(irm),
lltv: DEFAULT_TEST_LLTV
});
id = marketParams.id();
morpho.createMarket(marketParams);
}
function testInflateInterestRateWhenLowLiquidity() public {
// Supply and borrow 1 wei
_supply(1);
collateralToken.setBalance(address(this), 2);
morpho.supplyCollateral(marketParams, 2, address(this), "");
morpho.borrow(marketParams, 1, 0, address(this), address(this));
// Accrue interest for 150 days
for (uint i = 0; i < 150; i++) {
skip(1 days);
morpho.accrueInterest(marketParams);
}
// Liquidating only divides assets by 2, the other half accrues to virtual shares
loanToken.setBalance(address(this), 2);
morpho.liquidate(marketParams, address(this), 2, 0, "");
// Shares to assets ratio is now insanely high
console2.log("supplyAssets: %d, supplyShares: %d", morpho.market(id).totalSupplyAssets, morpho.market(id).totalSupplyShares);
console2.log("borrowAssets: %d, borrowShares: %d", morpho.market(id).totalBorrowAssets, morpho.market(id).totalBorrowShares);
// Supply 1M USDC, but gets no shares in return
loanToken.setBalance(address(this), 1_000_000e6);
morpho.supply(marketParams, 1_000_000e6, 0, SUPPLIER, "");
assertEq(morpho.position(id, SUPPLIER).supplyShares, 0);
}
}
Recommendation:
In _accrueInterest()
, consider checking that totalSupplyAssets
is sufficiently large for IIrm.borrowRate()
to be called.
This prevents the IRM from adjusting the interest rate when the utilization rate is "falsely" high (e.g. only 1 wei supplied and borrowed, resulting in 100% utilization rate).
Context:
Description:
In Morpho Blue, markets can be deployed with less than 100% LLTV:
require(lltv < WAD, ErrorsLib.MAX_LLTV_EXCEEDED);
The market's LLTV is used to calculate the additional percentage of collateral that liquidators earn from liquidating unhealthy positions, known as the liquidation incentive factor (LIF):
// The liquidation incentive factor is min(maxLiquidationIncentiveFactor, 1/(1 - cursor*(1 - lltv))).
uint256 liquidationIncentiveFactor = UtilsLib.min(
MAX_LIQUIDATION_INCENTIVE_FACTOR,
WAD.wDivDown(WAD - LIQUIDATION_CURSOR.wMulDown(WAD - marketParams.lltv))
);
The calculation for LIF is shown below, where LIQUIDATION_CURSOR
is 0.3e18
in Morpho Blue:
However, this method of calculating LIF becomes a problem in markets with a high LLTV, since LIF will be extremely small. For example, in a market where LLTV is 95%, LIF will only be ~101.52%, which means liquidators only earn 1.52% when liquidating.
Should the 1.52% earned from liquidation be less than the gas cost of calling liquidate()
, liquidators will no longer have any incentive to liquidate unhealthy positions. For example:
- Assume a market has a 95% LLTV.
- A borrower deposits 100 USD worth of collateral, which allows him to borrow 95 USD worth of loan tokens.
- His collateral depreciates in value, causing it to be worth only 98 USD. His position is now unhealthy.
- Assume that the current gas cost of calling
liquidate()
is 2 USD. - With a ~101.52% LIF, a liquidator will get 99.49 USD worth of collateral in return for repaying his entire position, earning only USD 1.49.
- As such, there is no incentive for anyone to liquidate the position.
An attacker could even take advantage of this by opening multiple small positions rather than a single large one, forcing liquidators to call liquidate()
multiple times and pay more gas for him to be entirely liquidated.
If there are many such unhealthy positions left unliquidated in a market, it could potentially harm:
- Lenders, since debts are not repaid, they will be unable to withdraw.
- Borrowers, since
totalBorrowAssets
will be larger than it should be, causing the interest rate to grow at a faster rate.
Note that markets with a smaller LLTV also face the same problem, just that the size of positions that do not have sufficient liquidation incentive is smaller. For example, at 70% LLTV, LIF will be ~109.9%, which means any position with collateral worth more than 20 USD will be profitable.
Recommendation:
Consider requiring a minimum amount of collateral, which is based on the market's LLTV, for borrowers to open any position. Ideally, this lower bound should be large enough such that any unhealthy position will have sufficient liquidation incentive.
Context:
Description:
When markets are created, the ratio of shares to assets is 1e6
, which means that borrowing 1
asset mints 1e6
shares. This is due to VIRTUAL_SHARES
and VIRTUAL_ASSETS
:
/// @dev The number of virtual shares has been chosen low enough to prevent overflows, and high enough to ensure
/// high precision computations.
uint256 internal constant VIRTUAL_SHARES = 1e6;
/// @dev A number of virtual assets of 1 enforces a conversion rate between shares and assets when a market is
/// empty.
uint256 internal constant VIRTUAL_ASSETS = 1;
In repay()
, whenever shares
is specified, the calculation for asset
rounds up:
else assets = shares.toAssetsUp(market[id].totalBorrowAssets, market[id].totalBorrowShares);
As such, if repay()
is called with shares = 1
, assets
will also round up to 1
, which means that only 1
share is burned when repaying 1
asset.
However, this does not follow the shares to assets ratio as 1
asset is actually worth 1e6
shares. An attacker can exploit this to inflate totalBorrowShares
:
- Assume a market starts with
1e6
shares and1
asset. - Call
borrow()
withassets = 1
, which mints1e6
shares. - Call
repay()
withshares = 1
, which burns1
share and1
asset. - Now, the market has
2e6 - 1
shares and1
asset. - Therefore, the shares to assets ratio has doubled.
By repeating steps 2 and 3, the attacker can inflate totalBorrowShares
until it is close to uint128
max.
This will cause borrow()
to revert with an arithmetic overflow whenever it is called, since borrowing any substantial amount of assets will attempt to mint an amount of shares greater than uint128
max, causing the market to be DOSed.
The following POC demonstrates that an attacker needs to repeat steps 2 and 3 exactly 107 times for borrow()
to always revert when borrowing any amount of assets:
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;
import "test/forge/BaseTest.sol";
contract EOATokenTest is BaseTest {
using MarketParamsLib for MarketParams;
function test_canCreateMarketWithEOAToken() public {
// Add supply and collateral for address(this)
_supply(1e18);
_supplyCollateralForBorrower(address(this));
// Repeatedly repay 1 asset for burning 1 share
for (uint256 i; i < 108; i++) {
morpho.borrow(marketParams, 1, 0, address(this), address(this));
morpho.repay(marketParams, 0, 1, address(this), "");
}
// totalBorrowShares is now larger than 1e38
assertGt(morpho.market(id).totalBorrowShares, 1e38);
// assets * totalShares overflows when borrowing 1 asset
vm.expectRevert(stdError.arithmeticError);
morpho.borrow(marketParams, 1, 0, address(this), address(this));
}
}
Recommendation:
Consider adding a lower bound for the amount of shares that is burned in repay()
.
Context:
Description:
The documentation states that MAX_LIQUIDATION_INCENTIVE_FACTOR
is 20%:
$LI = min(M, \frac{1}{\beta*LLTV+(1-\beta)} -1)$ , with$\beta = 0.3$ and$M= 0.20$
However, it is actually 15% in the code:
/// @dev Max liquidation incentive factor.
uint256 constant MAX_LIQUIDATION_INCENTIVE_FACTOR = 1.15e18;
Recommendation:
Amend the documentation to state that