SlowMist: Compound Finance V2 Security Audit Manual

SlowMist
20 min readOct 17, 2024

--

Introduction

With the rapid growth of the DeFi ecosystem, Compound Finance V2 has emerged as a pioneer, attracting a large user base with its innovative lending model. However, like any complex decentralized application, it faces potential security threats, especially given the movement of millions or even hundreds of millions of dollars in funds. Therefore, conducting a comprehensive and detailed security audit of Compound Finance V2 and its forked projects is crucial.

This manual aims to provide developers, security researchers, and DeFi enthusiasts with a thorough security audit guide to help effectively identify and mitigate potential risks.

1. Project Background Overview

Compound Finance V2 is an open lending platform built on the Ethereum blockchain. It allows users to deposit various ERC-20 tokens and earn interest, while also enabling them to borrow tokens by paying interest. By introducing the concept of an “interest rate market,” Compound achieves decentralized liquidity management and automated interest rate adjustments.

2. Project Architecture Analysis

The core components of Compound Finance V2’s architecture include:

Comptroller: Manages the overall system logic, such as interest rate calculations and account status updates.

cToken: A custom ERC-20 token representing users’ shares and rights in the system.

InterestRateModel: A model for calculating deposit and borrowing interest rates.

PriceOracle: Provides real-time price feeds for assets.

Governance: Manages community-related governance functions.

2.1 Comptroller

The Comptroller contract functions as the central nervous system of Compound Finance V2, coordinating the behavior of each cToken instance. Its primary responsibilities include:

  • Managing the market list: Determines which markets are active and available for users.
   function enterMarkets(address[] memory cTokens) override public returns (uint[] memory) {}

function exitMarket(address cTokenAddress) override external returns (uint) {}

...
  • Performing cross-market operation checks: Verifies the health of users’ positions across multiple markets to ensure they meet safety requirements.

function mintAllowed(address cToken, address minter, uint mintAmount) override external returns (uint) {}

function redeemAllowed(address cToken, address redeemer, uint redeemTokens) override external returns (uint) {}

function borrowAllowed(address cToken, address borrower, uint borrowAmount) override external returns (uint) {}

function repayBorrowAllowed(address cToken, address payer, address borrower, uint repayAmount) override external returns (uint) {}

function liquidateBorrowAllowed(address cTokenBorrowed, address cTokenCollateral, address liquidator, address borrower, uint repayAmount) override external returns (uint) {}

...
  • Setting and updating global parameters: Manages key system parameters, such as borrowing limits, collateral factors, and liquidation thresholds.

function _setCloseFactor(uint newCloseFactorMantissa) external returns (uint) {}

function _setCollateralFactor(CToken cToken, uint newCollateralFactorMantissa) external returns (uint) {}

function _setLiquidationIncentive(uint newLiquidationIncentiveMantissa) external returns (uint) {}

function _setMarketBorrowCaps(CToken[] calldata cTokens, uint[] calldata newBorrowCaps) external {}

...

2.2 cToken

Each supported ERC-20 token has a corresponding cToken instance (i.e., CErc20 / CEther contract) that handles all interactions between the token and the project. In addition to implementing basic token transfer functions, each cToken also introduces Compound-specific functions such as lending, accumulating interest, and distributing rewards. Thus, a cToken can be seen as both a certificate of users’ deposited assets in Compound and the entry point for performing lending operations.

When a user deposits the underlying asset token into the contract, the corresponding cToken is minted. The exchange ratio between the cToken and the underlying asset is calculated as follows:

exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply

Note:

  • Borrows: The total amount borrowed.
  • Cash: The balance of the fund pool.
  • Reserves: The reserve fund.

The borrowing interest rate is determined by the utilization rate, and the deposit interest rate is determined by the borrowing interest rate.

Users generally perform lending and borrowing operations across different markets by interacting with their respective cToken contracts:

function mint(uint mintAmount) override external returns (uint) {}
function redeem(uint redeemTokens) override external returns (uint) {}
function redeemUnderlying(uint redeemAmount) override external returns (uint) {}
function borrow(uint borrowAmount) override external returns (uint) {}
function repayBorrow(uint repayAmount) override external returns (uint) {}
function repayBorrowBehalf(address borrower, uint repayAmount) override external returns (uint) {}
function liquidateBorrow(address borrower, uint repayAmount, CTokenInterface cTokenCollateral) override external returns (uint) {}
...

2.3 InterestRateModel

The InterestRateModel contract defines how to calculate interest rates. Different markets may adopt various interest rate models to suit their specific risk preferences and liquidity needs.

Two primary interest rate models are used in the Compound V2 market:

1. Linear Model

2. Inflection Point Model

Linear Model

The formula for calculating the borrowing interest rate in the linear model is:


borrowRate = utilizationRate * (multiplierPerBlock / 1e18) + baseRatePerBlock
// Borrow Rate = Utilization Rate * Slope + Base Interest Rate

The formula for calculating the capital utilization rate is:

utilizationRate = borrows / (cash + borrows - reserves)
// Utilization Rate = Total Borrows / (Pool Cash + Total Borrows - Reserves)

The deposit rate changes linearly with the borrowing rate:

supplyRate = utilizationRate * borrowRate * (1 - reserveFactor)
// Supply Rate = Utilization Rate * Borrow Rate * (1 - Reserve Factor)

As the utilization rate increases, the available funds in the pool decrease. If utilization reaches a critical peak, users may face difficulties depositing and borrowing normally. To mitigate this risk, Compound introduced a second interest rate model — the Inflection Point Model.

Inflection Point Model

The borrowing rate formula remains the same as the linear model before reaching the peak:

borrowRate = utilizationRate * (multiplierPerBlock / 1e18) + baseRatePerBlock
// Borrow Rate = Utilization Rate * Slope + Base Interest Rate

Once the utilization exceeds the peak (kink), the formula changes:

borrowRate = jumpMultiplierPerYear * (utilizationRate - kink)
+ (kink * (multiplierPerBlock / 1e18) + baseRatePerBlock)
// Borrow Rate = Steep Slope * (Utilization Rate - Kink)
// + (Kink * Slope + Base Interest Rate)

When the utilization rate reaches a certain peak — referred to as the inflection point (typically around 80%) — the borrowing and deposit rates increase sharply. This incentivizes users to deposit more and borrow less, helping maintain the utilization rate within a healthy range.

2.4 PriceOracle

The PriceOracle contract is responsible for obtaining external market price information and converting it into numerical values used within the system, which is crucial for accurately calculating the value of a user’s position.

function getUnderlyingPrice(CToken cToken) public override view returns (uint) {        ...    }

2.5 Governance Mechanism and Incentive Model

Compound has introduced a unique governance mechanism that allows users who hold governance tokens (COMP) to participate in voting on important decisions, such as changing certain parameters or adding new asset types. By issuing COMP tokens, Compound incentivizes users to actively participate in platform activities and provides rewards for contributors.

For details, please refer to the official documentation and code repository of Compound:

3. Interaction Process

Next, we use a simple example to illustrate the general process of user interaction on Compound Finance V2:

3.1 Deposit and Redemption Process

If user Alice needs to deposit 1 WBTC into Compound, she will call the mint function of the cWBTC contract to make the deposit. This contract inherits the cToken contract and will first call the accrueInterest function inside the mintInternal function to update the borrowing and deposit rates, and then call mintFresh to perform specific minting operations.

The mintFresh function will externally call the mintAllowed function of the Comptroller contract to check whether the current market allows deposits. It will then transfer Alice’s 1 WBTC to the contract through the doTransferIn function and mint the corresponding number of cWBTC tokens for Alice based on the latest exchange rate at that time. (Assuming the current exchange rate is 0.1, Alice will receive 10 cWBTC tokens.)

If Alice decides to redeem her deposit in the future, she can redeem cWBTC back to WBTC by calling the redeem function. The exchange rate may have changed (assuming it is 0.15), meaning Alice can redeem 1.5 WBTC, with 0.5 WBTC being interest income.

3.2 Loan and Repayment Process

Alice first needs to call the enterMarkets function of the Comptroller contract to set her cWBTC as collateral before she can borrow funds.

 function enterMarkets(address[] memory cTokens) override public returns (uint[] memory) {
uint len = cTokens.length;

uint[] memory results = new uint[](len);
for (uint i = 0; i < len; i++) {
CToken cToken = CToken(cTokens[i]);

results[i] = uint(addToMarketInternal(cToken, msg.sender));
}

return results;
}

function addToMarketInternal(CToken cToken, address borrower) internal returns (Error) {
Market storage marketToJoin = markets[address(cToken)];

...

marketToJoin.accountMembership[borrower] = true;
accountAssets[borrower].push(cToken);

...
}

Assume Alice chooses to borrow 70 USDC. Since the collateral factor of WBTC is 0.75, Alice can borrow up to 75% of the value of her WBTC, meaning this amount will not exceed her maximum borrowing capacity.

Note: To avoid the risk of liquidation, Alice should maintain a buffer instead of fully utilizing her borrowing limit.

Alice calls the borrow function of the cUSDC contract. This function first triggers the accrueInterest function inside the borrowInternal function to update the borrowing and deposit interest rates. It then calls borrowFresh to execute the specific borrowing operations.

The borrowAllowed function of the Comptroller contract checks the value of Alice’s position. Once validated, the borrowing data is recorded, and the tokens are transferred to Alice through the doTransferOut function.

If Alice needs to repay, she can do so by calling the repayBorrow function of the cUSDC contract, or she can allow someone else to repay on her behalf by using the repayBorrowBehalf function.

3.3 Liquidation Process

If the price of WBTC drops significantly, causing the value of Alice’s collateral to fall below 75% of her borrowing amount, her loan position will become eligible for liquidation.

An external liquidator, such as Bob, can call the liquidateBorrow function in the cUSDC contract to help repay part of Alice’s debt. The process will first update the interest rates of both the cUSDC and the collateral cToken used for repayment through the liquidateBorrowInternal function. It will then call the liquidateBorrowFresh function to carry out the specific liquidation operations.

The liquidateBorrowAllowed function of the Comptroller contract will check if the liquidation is permitted. Once approved, the repayBorrowFresh function transfers USDC to the contract for repayment and updates the borrowing data of the liquidated position. Next, the liquidateCalculateSeizeTokens function of the Comptroller contract calculates the amount of collateral Bob can receive from Alice based on the liquidation value. Finally, the seize function of the relevant cToken contract (such as cWBTC) transfers the appropriate amount of cTokens between Bob and Alice.

https://www.figma.com/board/POkJlvKlWWc7jSccYMddet/Compound-V2?node-id=0-1&node-type=canvas

Bob repays part of Alice’s loan (e.g., 20 USDC) and receives collateral of the corresponding value from Alice (e.g., WBTC). In addition, Bob earns a liquidation incentive (assumed to be 5%). As a result, Bob receives WBTC worth 21 USDC (20 USDC loan repayment + 1 USDC liquidation incentive).

4. Security Vulnerability Checklist

4.1 Rounding Loophole Caused by Empty Market

If the cToken market is empty (i.e., no users are lending or borrowing), the value of the exchangeRate in the exchangeRateStoredInternal function depends solely on the number of underlying asset tokens held by the contract. This makes the cToken price vulnerable to manipulation by transferring a large number of underlying asset tokens to the cToken contract.

 function exchangeRateStoredInternal() virtual internal view returns (uint) {
uint _totalSupply = totalSupply;
if (_totalSupply == 0) {
/*
* If there are no tokens minted:
* exchangeRate = initialExchangeRate
*/
return initialExchangeRateMantissa;
} else {
/*
* Otherwise:
* exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply
*/
uint totalCash = getCashPrior();
uint cashPlusBorrowsMinusReserves = totalCash + totalBorrows - totalReserves;
uint exchangeRate = cashPlusBorrowsMinusReserves * expScale / _totalSupply;

return exchangeRate;
}
}

...

function getCashPrior() virtual override internal view returns (uint) {
EIP20Interface token = EIP20Interface(underlying);
return token.balanceOf(address(this));
}

Therefore, a small amount of cToken can be used to borrow a large amount of other tokens. Afterward, the redeemUnderlying function of the cToken contract can be called to withdraw the underlying asset token. Due to the rounding down during division, the number of cTokens deducted for the redemption will be much smaller than expected — almost half the amount — enabling potential manipulation.

 // CToken.sol

function redeemFresh(address payable redeemer, uint redeemTokensIn, uint redeemAmountIn) internal {
...

Exp memory exchangeRate = Exp({mantissa: exchangeRateStoredInternal() });

...

if (redeemTokensIn > 0) {
...
} else {
/*
* We get the current exchange rate and calculate the amount to be redeemed:
* redeemTokens = redeemAmountIn / exchangeRate
* redeemAmount = redeemAmountIn
*/
redeemTokens = div_(redeemAmountIn, exchangeRate);
redeemAmount = redeemAmountIn;
}
}

...

// ExponentialNoError.sol

function mul_(uint a, uint b) pure internal returns (uint) {
return a * b;
}

...

function div_(uint a, Exp memory b) pure internal returns (uint) {
return div_(mul_(a, expScale), b.mantissa); // expScale = 1e18
}

...

function div_(uint a, uint b) pure internal returns (uint) {
return a / b;
}

Assume that the number of cTokens held at this time is 2 (which is also the totalSupply). After manipulation, the exchangeRate is raised to 25,015,031,908,500,000,000,000,000,000, and the number of underlying asset tokens to be redeemed becomes 50,030,063,815.

The expected number of cTokens to be deducted should be:

However, the actual number of cTokens calculated is:

As a result, only a very small number of cTokens are required to liquidate, allowing the attacker to obtain a large quantity of asset tokens borrowed from other markets.

You can refer to the Compound fork project Hundred Finance, which was hacked due to this vulnerability: https://optimistic.etherscan.io/tx/0x6e9ebcdebbabda04fa9f2e3bc21ea8b2e4fb4bf4f4670cb8483e2f0b2604f451

Audit Points: During the audit, it is essential to examine whether the exchange rate calculation method is prone to manipulation and whether the rounding method is appropriate. It is also recommended that the project team mint a small amount of cToken immediately after a new market is created to prevent the market from being empty and susceptible to manipulation.

4.2 Reentrancy Vulnerability Caused by ERC677 / ERC777 Tokens

ERC677 and ERC777 are extensions of the ERC20 standard and remain compatible with the ERC20 protocol. These tokens enable the callback function of the receiving address (such as transferAndCall or tokensReceived) to be triggered during the transfer process, provided the receiving address is a contract.

 function transfer(address _to, uint256 _value) public returns (bool) {
require(superTransfer(_to, _value));
callAfterTransfer(msg.sender, _to, _value);
return true;
}

function transferFrom(address _from, address _to, uint256 _value) public returns (bool) {
require(super.transferFrom(_from, _to, _value));
callAfterTransfer(_from, _to, _value);
return true;
}

function callAfterTransfer(address _from, address _to, uint256 _value) internal {
if (AddressUtils.isContract(_to) && !contractFallback(_from, _to, _value, new bytes(0))) {
require(!isBridge(_to));
emit ContractFallbackCallFailed(_from, _to, _value);
}
}

function isBridge(address _address) public view returns (bool) {
return _address == bridgeContractAddr;
}


function contractFallback(address _from, address _to, uint256 _value, bytes _data) private returns (bool) {
return _to.call(abi.encodeWithSelector(ON_TOKEN_TRANSFER, _from, _value, _data));
}

In the old version of Compound Finance V2 code, when a user borrowed money in the cToken market, the borrowed tokens were transferred out first, and then the loan data was recorded.

function borrowFresh(address payable borrower, uint borrowAmount) internal returns (uint) {
...

/*
* We invoke doTransferOut for the borrower and the borrowAmount.
* Note: The cToken must handle variations between ERC-20 and ETH underlying.
* On success, the cToken borrowAmount less of cash.
* doTransferOut reverts if anything goes wrong, since we can't be sure if side effects occurred.
*/
doTransferOut(borrower, borrowAmount);

/* We write the previously calculated values into storage */
accountBorrows[borrower].principal = vars.accountBorrowsNew;
accountBorrows[borrower].interestIndex = borrowIndex;
totalBorrows = vars.totalBorrowsNew;

...
}

If the token lent by the user was an ERC677 or ERC777 token with a callback function, a malicious contract receiving the token could be designed to re-enter the borrow function through the callback function to borrow again. Since the loan data had not yet been recorded during the previous loan, the account health check could be successfully passed, allowing the token to be borrowed again.

You can refer to the transaction where the Compound fork project Hundred Finance was hacked due to this vulnerability: https://blockscout.com/xdai/mainnet/tx/0x534b84f657883ddc1b66a314e8b392feb35024afdec61dfe8e7c510cfac1a098

Audit Highlights : The latest version of the Compound V2 code has fixed the borrowing logic by recording the loan data first, before transferring the borrowed tokens. During the audit, it is essential to ensure that the lending function follows the CFI (Checks-Effects-Interactions) pattern and to assess the impact of tokens with callback functions.

4.3 Risk of Price Manipulation Caused by Inappropriate Oracle Mechanism

Since Compound Finance uses an over-collateralized loan model, the amount a user can borrow depends on whether the value of the collateral is sufficient.

 function borrowAllowed(address cToken, address borrower, uint borrowAmount) override external returns (uint) {
...

(Error err, , uint shortfall) = getHypotheticalAccountLiquidityInternal(borrower, CToken(cToken), 0, borrowAmount);
if (err != Error.NO_ERROR) {
return uint(err);
}
if (shortfall > 0) {
return uint(Error.INSUFFICIENT_LIQUIDITY);
}

...
}

...

function getHypotheticalAccountLiquidityInternal(
address account,
CToken cTokenModify,
uint redeemTokens,
uint borrowAmount) internal view returns (Error, uint, uint) {

...

for (uint i = 0; i < assets.length; i++) {
CToken asset = assets[i];

...

vars.oraclePriceMantissa = oracle.getUnderlyingPrice(asset);
if (vars.oraclePriceMantissa == 0) {
return (Error.PRICE_ERROR, 0, 0);
}
vars.oraclePrice = Exp({mantissa: vars.oraclePriceMantissa});

// Pre-compute a conversion factor from tokens -> ether (normalized price value)
vars.tokensToDenom = mul_(mul_(vars.collateralFactor, vars.exchangeRate), vars.oraclePrice);

...

// Calculate effects of interacting with cTokenModify
if (asset == cTokenModify) {
// redeem effect
// sumBorrowPlusEffects += tokensToDenom * redeemTokens, sumBorrowPlusEffects += oraclePrice * borrowBalance
vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.tokensToDenom, redeemTokens, vars.sumBorrowPlusEffects);

// borrow effect
// sumBorrowPlusEffects += oraclePrice * borrowAmount
vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.oraclePrice, borrowAmount, vars.sumBorrowPlusEffects);
}
}

// sumCollateral += tokensToDenom * cTokenBalance
if (vars.sumCollateral > vars.sumBorrowPlusEffects) {
return (Error.NO_ERROR, vars.sumCollateral - vars.sumBorrowPlusEffects, 0);
} else {
return (Error.NO_ERROR, 0, vars.sumBorrowPlusEffects - vars.sumCollateral);
}
}

Therefore, if the oracle price feed mechanism used by the project to calculate collateral value is easy to manipulate, more tokens than expected could be lent out.

For example, in the hack of the Compound fork project Lodestar Finance, the oracle determined the price of the plvGLP collateral token by dividing the number of plsGLP tokens in the plvGLP contract (totalAssets) by the total supply of plvGLP (totalSupply) to calculate the exchange rate. The exchange rate was then multiplied by the price of the GLP token to determine the price of the plvGLP token.

 function getPlutusExchangeRate() public view returns (uint256) {
//retrieve total assets from plvGLP contract
uint256 totalAssets = plvGLPInterface(plvGLP).totalAssets();

//retrieve total supply from plvGLP contract
uint256 totalSupply = EIP20Interface(plvGLP).totalSupply();

//plvGLP/GLP Exchange Rate = Total Assets / Total Supply
uint256 exchangeRate = (totalAssets * BASE) / totalSupply;

return exchangeRate;
}

function getPlvGLPPrice() public view returns (uint256) {
uint256 exchangeRate = getPlutusExchangeRate();

uint256 glpPrice = getGLPPrice();

uint256 price = (exchangeRate * glpPrice) / BASE;

return price;
}

Additionally, plvGLP has a donation function that allows users to donate sGLP to mint corresponding plsGLP tokens for the plvGLP contract.

  function donate(uint256 _assets) external {
sGLP.safeTransferFrom(msg.sender, staker, _assets);
ITokenMinter(minter).mint(vault, _assets);
}

The attacker exploited this by using flash loans to create a large number of plvGLP collateral positions in the Lodestar Finance market. They then used flash loans on GMX to mint a large amount of sGLP and used the donation function to mint plsGLP tokens for the plvGLP contract, increasing the value of totalAssets. As the total assets increased, the exchange rate of plvGLP rose, instantly driving up the price of the plvGLP token. This enabled the attacker to borrow more tokens from the market than expected.

You can refer to the hacked transaction of Lodestar Finance: https://arbiscan.io/tx/0xc523c6307b025ebd9aef155ba792d1ba18d5d83f97c7a846f267d3d9a3004e8c

It is also important to note that Compound Finance or its forked projects may use off-chain oracles, such as Chainlink or Coinbase, to obtain collateral prices. If there are sharp market fluctuations, a price discrepancy between the off-chain and on-chain prices may occur, endangering the financial security of the project.

For example, when the price of LUNA tokens crashed, the Compound fork protocols Venus Protocol and Blizz Finance, both of which use Chainlink oracles, were impacted. The minimum price (minAnswer) of LUNA tokens was hard-coded at $0.10.

When the market price of LUNA dropped below $0.10 (e.g., $0.001), anyone could buy a large quantity of LUNA at market price and use it as collateral (valued at $0.10) to borrow other assets from the platform.

Audit Points: During the audit, it is essential to assess whether the oracle price feed mechanism used to calculate collateral value is vulnerable to manipulation. It is recommended that the project use multiple price sources for comprehensive evaluation to mitigate risks caused by reliance on a single price source.

4.4 Risk of Exchange Rate Manipulation Caused by Multiple Entry Point Tokens

In the Compound code, there is a function called `sweepToken` that allows users to withdraw tokens that were accidentally transferred to the contract. The older version of this function includes a critical security check to ensure that the token parameter passed into it cannot be the underlying asset token of the contract.

   function sweepToken(EIP20NonStandardInterface token) override external {
require(address(token) != underlying, "CErc20::sweepToken: can not sweep underlying token");
uint256 balance = token.balanceOf(address(this));
token.transfer(admin, balance);
}

However, if multiple entry point contracts exist for the underlying asset token in a cToken market (meaning the same underlying balance can be accessed through multiple contract addresses, and external interactions affect the balances of all entry points, following an early proxy-like model), an attacker could exploit this. By passing a different entry point contract as the token parameter to the `sweepToken` function, the attacker could transfer the underlying asset token out of the contract.

Let’s take TUSD as an example. TUSD has two entry point contracts. The auxiliary entry point contract at `0x8dd5fbce` forwards any calls, such as `transfer` or `balanceOf`, to the main contract. This means that interacting with either contract affects the balance data in both contracts, as they share the same balance information.

At this point, assuming the underlying token address set in the market is the main contract address of TUSD, the auxiliary entry point contract address `0x8dd5fbce` can be passed as the token parameter when calling the `sweepToken` function. This allows the check `address(token) != underlying` to pass. As a result, the contract will transfer all the TUSD tokens (the underlying asset) to the administrator’s address.

The exchange rate of TUSD / cTUSD depends on the amount of TUSD held in the cTUSD contract. When all TUSD is transferred to the administrator’s address, the exchange rate will plummet instantly. At this point, the attacker can profit by liquidating other users at the now significantly lower exchange rate or by repaying less than the expected amount of tokens after borrowing.

It is important to note that the latest version of the Compound V2 code adds permission verification to the `sweepToken` function, ensuring that only the administrator role can call it. Furthermore, all markets with multiple entry point tokens have been removed.

    function sweepToken(EIP20NonStandardInterface token) override external {
require(msg.sender == admin, "CErc20::sweepToken: only admin can sweep tokens");
require(address(token) != underlying, "CErc20::sweepToken: can not sweep underlying token");
uint256 balance = token.balanceOf(address(this));
token.transfer(admin, balance);
}

Audit Points: During the audit, it is essential to consider the impact of multiple entry point tokens when reviewing token transfer functions within the contract. It is recommended to:

  • Avoid using multiple entry point tokens.
  • Verify whether the number of underlying asset tokens in the contract changes before and after any token transfer.
  • Implement permission checks on related functions.

4.5 Compatibility Issues Between New and Old Versions of Contract Code

In forked projects based on Compound Finance V2, compatibility issues may arise if some contracts use the new Compound V2 code while others still rely on the old version.

For example, in the InterestRateModel contract of the old version, the `getBorrowRate` function returns two uint values. However, in the new version, the same function only returns one uint value. This inconsistency can cause unexpected behavior or errors when contracts from different versions interact with each other.


// 旧版本的 InterestRateModel 代码
function getBorrowRate(uint cash, uint borrows, uint reserves) external view returns (uint, uint);

// 新版本的 InterestRateModel 代码
function getBorrowRate(uint cash, uint borrows, uint reserves) virtual external view returns (uint);

However, in the Compound Finance V2 fork project Percent Finance, the project used the old version of the cToken contract code, while the InterestRateModel contract used the new version. This caused the accrueInterest function in the cToken contract to fail when calling the getBorrowRate function. Since the accrueInterest function is used in both withdrawals and loans, the withdrawal and lending functions were rendered inoperable, ultimately locking all funds within the contract.

 // 旧版本的 cToken 代码

function accrueInterest() public returns (uint) {
AccrueInterestLocalVars memory vars;

/* Calculate the current borrow interest rate */
(vars.opaqueErr, vars.borrowRateMantissa) = interestRateModel.getBorrowRate(getCashPrior(), totalBorrows, totalReserves);

...
}

Audit Points: During the audit, it is important to ensure that changes in the contract interface, state variables, function signatures, and events in updated code do not disrupt the normal operation of the existing system. Consistency across all contract code version updates should be maintained, or the updated code must be compatible with the old version.

4.6 Hard-Coding Issues Caused by Multi-Chain Deployment

In the Compound Finance V2 code, the constant blocksPerYear represents the estimated number of blocks produced annually, hard-coded as 2,102,400 in the interest rate model contract. This value is based on the assumption that Ethereum’s average block time is 15 seconds.

contract WhitePaperInterestRateModel is InterestRateModel {
...

/**
* @notice The approximate number of blocks per year that is assumed by the interest rate model
*/
uint public constant blocksPerYear = 2102400;

...
}

However, block times vary across different chains, and the estimated number of blocks per year may differ. If a forked Compound project is deployed on another chain without adjusting these hard-coded values to fit the new chain’s parameters, the final calculated interest rate may deviate significantly from expectations. This is because the blocksPerYear value directly influences baseRatePerBlock and multiplierPerBlock, both of which affect the borrowing rate.


contract WhitePaperInterestRateModel is InterestRateModel {
...

constructor(uint baseRatePerYear, uint multiplierPerYear) public {
baseRatePerBlock = baseRatePerYear / blocksPerYear;
multiplierPerBlock = multiplierPerYear / blocksPerYear;

emit NewInterestParams(baseRatePerBlock, multiplierPerBlock);
}

...

function getBorrowRate(uint cash, uint borrows, uint reserves) override public view returns (uint) {
uint ur = utilizationRate(cash, borrows, reserves);
return (ur * multiplierPerBlock / BASE) + baseRatePerBlock;
}
}

For example, on the BSC chain, where the average block time is 3 seconds, the estimated blocks per year should be 10,512,000. If the original blocksPerYear value is not updated before deployment, the borrowing rate would be calculated five times higher than intended.

Audit Points: When auditing, it is crucial to check whether hard-coded constants or variables in the project contracts could cause unexpected outcomes on different chains. It is recommended that the project team adjust these values to align with the characteristics of each specific chain.

Other

Besides the major concerns discussed, Compound V2 fork projects often modify business logic to fit the design of the project team, such as adding interactions with external third-party protocols. During the audit, it is necessary to evaluate whether these modifications impact the core lending model and functionality of Compound Finance V2. This assessment should be based on the specific business logic and design requirements of the project.

Final Thoughts

We hope that this security audit manual for Compound Finance V2 and its fork projects will help users better understand and evaluate the security of these complex systems. As technology evolves, this manual will also be updated and improved to reflect new developments.

Reference:

[1] https://github.com/YAcademy-Residents/defi-fork-bugs

[2] https://medium.com/chainsecurity/trueusd-compound-vulnerability-bc5b696d29e2

[3] https://github.com/code-423n4/2023-05-venus-findings/issues/559

[4] https://learnblockchain.cn/article/2593

[5] https://github.com/compound-finance/compound-protocol

About SlowMist

At SlowMist, we pride ourselves on being a frontrunner in blockchain security, dedicating years to mastering threat intelligence. Our expertise is grounded in providing comprehensive security audits and advanced anti-money laundering tracking to a diverse clientele. We’ve established a robust network for threat intelligence collaboration, positioning ourselves as a key player in the global blockchain security landscape. We offer tailor-made security solutions that span from identifying threats to implementing effective defense mechanisms. This holistic approach has garnered the trust of numerous leading and recognized projects worldwide, including names like Huobi, OKX, Binance, imToken, Crypto.com, Amber Group, Klaytn, EOS, 1inch, PancakeSwap, TUSD, Alpaca Finance, MultiChain, and Cheers UP. Our mission is to ensure the blockchain ecosystem is not only innovative but also secure and reliable.

We offers a variety of services that include but are not limited to security audits, threat intelligence, defense deployment, security consultants, and other security-related services. We also offer AML (Anti-money laundering) solutions, Vulpush (Vulnerability monitoring) , SlowMist Hacked (Crypto hack archives), FireWall.x (Smart contract firewall) , Safe Staking and other SaaS products. We have partnerships with domestic and international firms such as Akamai, BitDefender, FireEye, RC², TianJi Partners, IPIP, etc.

By delivering a comprehensive security solution customized to individual projects, we can identify risks and prevent them from occurring. Our team was able to find and publish several high-risk blockchain security flaws. By doing so, we wish to help spread awareness and raise the security standards in the blockchain ecosystem.

💬Website 🐦Twitter ⌨️GitHub

--

--

SlowMist
SlowMist

Written by SlowMist

SlowMist is a Blockchain security firm established in 2018, providing services such as security audits, security consultants, red teaming, and more.