SBN

How (Not) to Create a DeFi CDP or Lending Protocol

Introduction

The global cryptocurrency market today has become a trillion-dollar
industry, and over the past few years, there have been numerous
innovations within the space. One of the most significant developments
is the emergence of decentralized finance (DeFi). At the core of DeFi
are collateralized debt positions (CDPs) and lending protocols. These
protocols allow users to lend and borrow cryptocurrencies without
relying on traditional financial institutions and leverage smart
contracts to automate the process. In this post, we will go through the
major hacks on CDPs and lending protocols, emphasize best practices, and
provide actionable advice on how to avoid these security
vulnerabilities.

What Are CDPs and Lending Protocols?

CDPs and lending protocols are fundamental components of the DeFi
ecosystem on the blockchain.

CDPs are smart contracts that allow users to take a loan on an asset in
exchange for depositing collateral of a different asset. The deposited
collateral is then locked in the contract until the loan is repaid. This
way, CDPs mint a number of stablecoins equal to the requested loan
amount. The smart contracts ensure that the stablecoin is always backed
by a sufficient amount of collateral. When the value of the collateral
falls below a certain threshold, decided by the protocol, the user’s
position can be liquidated.

Lending protocols, on the other hand, enable users to lend and borrow
from a pool of assets. Users who provide assets in such pools are
provided rewards that are taken from borrowers as fees.

From a security perspective, there are a lot of things that can go wrong
while creating CDPs or lending protocols. In what follows, we will go
through the most common bugs and how to prevent them.

Common Bugs in CDP and Lending Protocols

1. Price Manipulation

Price manipulation is one of the most exploited issues for CDPs and
lending protocols. These attacks exploit smart contracts’ reliance on
external data sources, known as oracles, to function accurately. Oracles
bridge the gap between the blockchain and the outside world by providing
real-time data such as price information. Price-manipulation attacks
occur when an attacker artificially inflates or deflates the price of a
token within a protocol. For instance, if a DeFi protocol uses a
decentralized exchange (DEX) as its oracle to fetch the price of a
particular asset, an attacker could artificially inflate or deflate the
asset’s price on the DEX. There are many ways to manipulate the price
coming from an oracle depending upon how the price is fetched. Here we
will go through some major hacks and how these can be avoided.

Spot Price Manipulation

Spot price—manipulation attacks are a form of market manipulation where
attackers attempt to artificially alter the spot price of an asset. The
spot price represents the current market price at which an asset can be
instantly bought or sold. In the context of blockchain and DeFi, this
manipulation typically targets the price of a token on a DEX (e.g.,
Uniswap).

Visor Finance Hack

Lost: $500K

Visor Finance got hacked in November 2021 due to reliance on spot prices
from Uniswap. These spot prices can be easily manipulated by swapping
token0 for token1. This takes token1 out of the AMM, raising the
price of token1. During the hack, the attacker took a flash loan to
manipulate the spot price to issue shares and then withdrew more tokens
than expected.

uint160 sqrtPrice = TickMath.getSqrtRatioAtTick(currentTick()); //currentTick fetches the currect tick value
uint256 price = FullMath.mulDiv(uint256(sqrtPrice).mul(uint256(sqrtPrice)), PRECISION, 2**(96 * 2));

Here, the value of price would be the price of the token at current
tick.

It is recommended to use Chainlink price feeds or time-weighted average
price (TWAP) to fetch the price of a token. It is worth noting that
short TWAPs may still be atomically
manipulated
in some
scenarios.

See another hack due to a similar spot price reliance:

bZxLost:
$8M

Liquidity Pool—Token Price Manipulation

Numerous hacks come from using the wrong formula to calculate the price
of liquidity pool (LP) tokens. Although it may seem obvious to calculate
the price of LP tokens by dividing the token value locked in the pool by
the total supply of LP tokens, this is the formula that leads to
million-dollar hacks. The wrong equation would be this:

P_lp=p0r0+p1r1totalSupplyP\_{lp} = \frac{p_0\cdot r_0 + p_1\cdot r_1}{\text{totalSupply}}

where pip_i = price of token i and rir_i = token i reserve amount.

This method of pricing LP tokens is susceptible to manipulation, as
r0r_0 and r1r_1 can be drastically moved with flash loans.

Alpha Venture presented fair Uniswap LP token
pricing
, which
can be used instead of the above formula. The formula is as follows:

P_lp=2r0r1p0p1totalSupply=2Kp0p1totalSupplyP\_{lp} = \frac{2 \sqrt{r_0 \cdot r_1 \cdot p_0′ \cdot p_1′}}{\text{totalSupply}} = \frac{2\sqrt{K\cdot p_0′ \cdot p_1′}}{\text{totalSupply}}

where pip_i’ is the true price of the asset; in other words, it is
not susceptible to spot price manipulation (e.g., from a high-quality
oracle).

Fair LP token pricing evaluates the LP token based on the values of the
fair reserve amounts of the AMM. The fair reserve amounts are
calculated based on the AMM constant KK and the fair price ratio.

Now, let’s go over some hacks due to the wrong LP token price formula.

Warp Finance Hack

Lost: $7.8M

Warp Finance was hacked due to the use of the wrong formula to calculate
the price of LP tokens. Here is the code responsible for the hack:

function getUnderlyingPrice(address _lpToken) public returns (uint256) {
address[] memory oracleAdds = LPAssetTracker[_lpToken];
//retreives the oracle contract addresses for each asset that makes up a LP
UniswapLPOracleInstance oracle1 = UniswapLPOracleInstance(
oracleAdds[0]
);
UniswapLPOracleInstance oracle2 = UniswapLPOracleInstance(
oracleAdds[1]
);

(uint256 reserveA, uint256 reserveB) = UniswapV2Library.getReserves(
factory,
instanceTracker[oracleAdds[0]],
instanceTracker[oracleAdds[1]]
);

uint256 value0 = oracle1.consult(
instanceTracker[oracleAdds[0]],
reserveA
);
uint256 value1 = oracle2.consult(
instanceTracker[oracleAdds[1]],
reserveB
);

// Get the total supply of the pool
IERC20 lpToken = IERC20(_lpToken);
uint256 totalSupplyOfLP = lpToken.totalSupply();

//code skipped..
uint256 totalValue = value0 + value1;
uint16 shiftAmount = supplyDecimals;
uint256 valueShifted = totalValue * uint256(10)**shiftAmount;
uint256 supplyShifted = supply;
uint256 valuePerSupply = valueShifted / supplyShifted;

return valuePerSupply;
}

In the above code, the variable value0 is equal to p0 * r0 and
value1 is p1 * r1. It’s clear that the wrong formula is used to
price the LP tokens.

Inverse Finance Hack

Lost: $1.26M

A similar attack on Inverse Finance was due to the LP token price
manipulation of a tri-pool.

function latestAnswer() public view returns (uint256) {
uint256 crvPoolBtcVal = WBTC.balanceOf(address(CRV3CRYPTO)) * uint256(BTCFeed.latestAnswer()) * 1e2;
uint256 crvPoolWethVal = WETH.balanceOf(address(CRV3CRYPTO)) * uint256(ETHFeed.latestAnswer()) / 1e8;
uint256 crvPoolUsdtVal = USDT.balanceOf(address(CRV3CRYPTO)) * uint256(USDTFeed.latestAnswer()) * 1e4;

uint256 crvLPTokenPrice = (crvPoolBtcVal + crvPoolWethVal + crvPoolUsdtVal) * 1e18 / crv3CryptoLPToken.totalSupply();

return (crvLPTokenPrice * vault.pricePerShare()) / 1e18;
}

Again, the formula used to calculate the LP token price was incorrect,
which led to the hack.

To price the LP tokens, use the
formula
presented by Alpha Venture. It is important to note that if the LP token
is used both as a collateral and borrow token, the formula can still be
manipulated.

Following are some other hacks caused by the use of the same formula:

Cheese
Bank

Lost: $3.3M

Themis
Protocol

Lost: $370k

NXUSD
Protocol

Lost: $371k

Donation-Based Price Manipulation Attacks

Another common way to manipulate the price or exchange rate is to
directly donate to a pool. To illustrate, consider a scenario where the
exchange rate is determined by the ratio of a specific token’s balance
to the total supply. In such a case, a potential threat arises when an
attacker donates tokens to the pool, thereby altering the exchange rate
to their advantage.

A well-known attack for this bug is on Compound forks with markets that
have zero total supply. The underlying issue is also due to a rounding
error in the exchangeRateStoredInternal function:

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));
}

The attack involves donating to the market where totalSupply is zero
and significantly increasing the exchange rate to borrow against it.

Some donation-based price manipulation attacks are successful due to the
totalSupply of the pool being zero. This way, the attackers mint one
share to increase the totalSupply to 1 and then donate to the pool to
inflate the exchange rate. An easy way to fix these kind of attacks is
for protocol admins to be the first to mint some shares so that
totalsupply can never be zero. Other similar attacks can possibly be
mitigated by keeping an internal account of tokens to account for the
tokens that are directly donated to the pool.

Here are other hacks due to a similar underlying issue:

C.R.E.A.M. Finance
Lost: $130M

Hundred
Finance

Lost: $7M

Midas
Capital

Lost: $600k

OVIX
Lost: $4M

2. Read-Only Reentrancy

Read-only reentrancy attacks occur when a view function is used to
read an inconsistent state of the protocol while it’s being reentered.
If the view function that is reentered is used to calculate critical
data such as the price of a token, it can be used to manipulate the data
when the function is reentered. As view functions are typically not
protected using non-reentrant modifiers, they can be reentered without
being reverted.

A typical read-only reentrancy attack flow looks like this:

  1. The attacker calls a reentrant contract. The called function (1)
    modifies the contract’s state, then (2) returns control flow to the
    attacker but without finalizing/committing updates to its own
    state yet
    .
  2. When control flow is returned to the attacker, the reentrant
    contract is in an internally inconsistent state and is not safe to
    interact with. However, the reentrant contract itself has reentrancy
    mutex and isn’t a viable attack target.
  3. Instead, the attacker calls a third-party victim contract. The
    victim contract is not aware that the reentrant contract is in an
    unsafe, inconsistent state and interacts with it in a read-only
    manner. No errors are thrown as view functions are typically not
    protected by reentrancy guards.
  4. The victim contract reads erroneous data and relies on it, leading
    to faulty operation of the victim contract for the attacker’s
    benefit.

One common scenario for read-only reentrancy: The reentrant contract is
often a DeFi primitive that’s used as an oracle by another protocol.
This other protocol is generally the victim contract.

Here are some common read-only reentrancy bugs exploited in the past:

Curve’s get_virtual_price

One of the most common functions used to exploit read-only reentrancy in
CDPs and lending protocols is get_virtual_price when smart contracts
integrating with Curve pools are used to estimate the price of LP
tokens. This function can be reentered during a raw_call made by
remove_liquidity . During the raw_call, the control flow would be
transferred to the recipient’s fallback function. If the function
get_virtual_price is reentered during this state, the value of D would
be inconsistent, leading to an inconsistent return value and hence the
price of the LP token. The function get_virtual_price can also be
reentered if one of the tokens removed is an ERC-777/ERC-677 token.

@external
@nonreentrant('lock')
def remove_liquidity(
_amount: uint256,
_min_amounts: uint256[N_COINS],
) -> uint256[N_COINS]:
amounts: uint256[N_COINS] = self._balances()
lp_token: address = self.lp_token
total_supply: uint256 = ERC20(lp_token).totalSupply()
CurveToken(lp_token).burnFrom(msg.sender, _amount) # dev: insufficient funds

for i in range(N_COINS):
value: uint256 = amounts[i] * _amount / total_supply
assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"

amounts[i] = value
if i == 0:
raw_call(msg.sender, b"", value=value)
else:
assert ERC20(self.coins[1]).transfer(msg.sender, value)

log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply - _amount)

return amounts

@view
@external
def get_virtual_price() -> uint256:
"""
@notice The current virtual price of the pool LP token
@dev Useful for calculating profits
@return LP token virtual price normalized to 1e18
"""
D: uint256 = self.get_D(self._balances(), self._A())
token_supply: uint256 = ERC20(self.lp_token).totalSupply()
return D * PRECISION / token_supply

Here are some hacks due to read-only reentrancy in get_virtual_price:

dForce
Protocol

Lost: $3.4M

Midas
Capital

Lost: $660K

Sturdy Finance
Lost: $880K

Jarvis
Network

Lost: ~$660K

Balancer’s _joinOrExit

Balancer’s read-only reentrancy has also been a root cause of several
hacks in the past. The issue is in the function _joinOrExit, where the
transfer (and hence the callback) is made before the pool balance is
updated and thus makes the accounting inconsistent.

Source: PeckShield

Preventing read-only reentrancy attacks involves careful design and
implementation of the protocol. It is important to ensure that read-only
functions cannot be manipulated to modify state variables. During
protocol integration, it is important to verify that such read-only
functions are reentrancy protected. If not, the reentrancy locks of
these external protcols should be verified to not be in any reentrancy
scenario during any read-only function calls. Given the recent hacks on
the Curve pool resulting from the Vyper compiler bug, it is of utmost
importance to implement robust testing for these contracts. This will
ensure that potential bugs do not impact the protocol.

Here are several hacks due to read-only reentrancy in _joinOrExit:

Sturdy
Finance

Lost: $800K

Sentiment
Lost: $1M

3. Read-Write Reentrancy

Reentrancy was famously exploited in the 2016 DAO hack, where $50
million in Ether was stolen by recursively draining the DAO’s balance.
Reentrancy issues arise when a vulnerable contract calls an external
contract without properly managing the state changes that occur during
its execution. The receiving contract can make a recursive call back to
the sending contract, repeating this process multiple times, which can
either drain tokens or change the state of the contract in an unexpected
manner.

In CDPs and lending protocols, there are many reentrancy and read-only
reentrancy issues. We’ll go over common cases of reentrancy in this
section and delve into read-only reentrancy in the next.

Reentrancy in Compound Forks

Rari Capital

Lost: ~$80M

Rari is a Compound fork, and the protocol Compound had an issue with
reentrancy attacks when cTokens were borrowed through the borrowFresh
function. See below:

doTransferOut(borrower, borrowAmount);

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

The function doTransferOut makes a low-level call to the borrower
address, which can then be used to make a reentrant call to exitMarket
and withdraw the collateral. Here, the checks-effects-interactions (CEI)
pattern is violated, which led to the attack.

See some other hacks due to the same underlying issue:

DeFiPIE
Protocol

Lost: $269K

Paribus
Lost: $67K

Reentrancy Due to ERC-777/ERC-677 Tokens

Caution should be made while using these tokens in the protocol as these
tokens can cause reentrancy attacks. Some Compound forks that used
ERC-777/ERC-677 were hacked due to the combination of two issues. For
example, the CEI pattern was not followed and the protocol used tokens
that have callbacks.

These are some Compound forks hacked due to the use of ERC-777/ERC-677
tokens:

Hundred
Finance

Lost: $6.2M

Voltage FinanceLost:
$4.6M

Agave DAOLost: $5.5M

C.R.E.A.M.
Finance

Lost: $18M

There were a few more hacks (not on Compound forks) due to the use of
such tokens. Here are some examples.

Bacon Protocol Hack

Lost: ~$1M

The root cause of the bug was a reentrancy due to the ERC-777—token
callback function tokensReceived , which led to reentry in the lend
function, as shown below.

It is very important to always follow the CEI pattern and put
non-reentrant modifiers to functions susceptible to reentrancy attacks.
Again, we’d like to bring to attention the
FREI-PI
pattern. The main idea behind this pattern is to write protocol
invariants at the end of functions, such that if they are ever violated
during a transaction, it would be reverted.

See another hack on a lending protocol due to reentrancy issues:

Arcadia
Finance

Lost: $460k

4. Insufficient Input Validation

This bug arises when the smart contract fails to properly validate and
trusts the input data without properly sanitizing user inputs. The
underlying issue is the lack of proper input validation mechanisms. Here
are some of the major hacks due to these issues:

Auctus Hack

Lost: $726K

On March 29th, Auctus was exploited by hackers to profit about $726,000
from users who did not revoke the approvals. Here is the code snippet
that led to the hack:

function write(address acoToken, uint256 collateralAmount, address exchangeAddress, bytes memory exchangeData) 
nonReentrant setExchange(exchangeAddress) public payable
{
require(msg.value > 0, "ACOWriter::write: Invalid msg value");
require(collateralAmount > 0, "ACOWriter::write: Invalid collateral amount");

address _collateral = IACOToken(acoToken).collateral();
if (_isEther(_collateral)) {
IACOToken(acoToken).mintToPayable{value: collateralAmount}(msg.sender);
} else {
_transferFromERC20(_collateral, msg.sender, address(this), collateralAmount);
_approveERC20(_collateral, acoToken, collateralAmount);
IACOToken(acoToken).mintTo(msg.sender, collateralAmount);
}

_sellACOTokens(acoToken, exchangeData);
}

/**
* @dev Internal function to sell the ACO tokens and transfer the premium to the transaction sender.
* @param acoToken Address of the ACO token.
* @param exchangeData Data to be sent to the exchange.
*/
function _sellACOTokens(address acoToken, bytes memory exchangeData) internal {
uint256 acoBalance = _balanceOfERC20(acoToken, address(this));
_approveERC20(acoToken, erc20proxy, acoBalance);
(bool success,) = _exchange.call{value: address(this).balance}(exchangeData);
require(success, "ACOWriter::_sellACOTokens: Error on call the exchange");

address token = IACOToken(acoToken).strikeAsset();
if(_isEther(token)) {
uint256 wethBalance = _balanceOfERC20(weth, address(this));
if (wethBalance > 0) {
IWETH(weth).withdraw(wethBalance);
}
} else {
_transferERC20(token, msg.sender, _balanceOfERC20(token, address(this)));
}

if (address(this).balance > 0) {
msg.sender.transfer(address(this).balance);
}
}

The function write is public, and the parameters are not validated.
The attacker exploited it by setting exchangeAddress to the address of
the USDC token and exchangeData as transferFrom. All the other
conditions can be easily bypassed by creating a fake acoToken and
giving it as input in the write function.

Fortress Protocol Hack

Lost: $3M

Fortress Protocol was hacked on May 9th, 2022, due to a few different
vulnerabilities. Here we will focus only on the insufficient input
validation bug that led to manipulation of the Umbrella Network oracle.

function submit(
uint32 _dataTimestamp,
bytes32 _root,
bytes32[] memory _keys,
uint256[] memory _values,
uint8[] memory _v,
bytes32[] memory _r,
bytes32[] memory _s
) public { // it could be external, but for external we got stack too deep

...
...
for (; i < _v.length; i++) {
address signer = recoverSigner(affidavit, _v[i], _r[i], _s[i]);
uint256 balance = stakingBank.balanceOf(signer);

require(prevSigner < signer, "validator included more than once");
prevSigner = signer;
if (balance == 0) continue;

emit LogVoter(lastBlockId + 1, signer, balance);
power += balance; // no need for safe math, if we overflow then we will not have enough power
}

require(i >= requiredSignatures, "not enough signatures");
// we turn on power once we have proper DPoS
// require(power * 100 / staked >= 66, "not enough power was gathered");

squashedRoots[lastBlockId + 1] = _root.makeSquashedRoot(_dataTimestamp);
blocksCount++;

emit LogMint(msg.sender, lastBlockId + 1, staked, power);
}

The submit function used to update the price can be called by anyone.
The function only verifies if the number of signatures is greater than
requiredSignatures. It does not check the power as that verification
(require(power * 100 / staked >= 66, "not enough power was gathered");)
was commented out from the code.

It is crucial to verify every single parameter that is passed in a
function. Only the parameters expected by the functions should be
allowed, and all other cases should be reverted. Here, we’d also like to
bring attention to the function requirements—effects—interactions +
protocol invariants
(FREI-PI)
pattern.

Other hacks due to similar issues:

Visor FinanceLost: $8.2M

Deus DAO
Hack

Lost: $6.5M

5. Insufficient Access Control

These issues occur when there are insufficient limitations on who can
access or edit critical data within the smart contract and when the
access control mechanisms in the contract are inadequately designed.
This can be fixed by implementing role-based access control and ensuring
that sensitive functions are only accessible by authorized addresses.
This is an example of one such attack on a lending protocol:

Rikkei Finance

Lost: $1.1M

The underlying issue was that the function setOracleData lacked any
access control mechanism and could be called by anyone. The attacker
changed the oracleData mapping to manipulate the price feed of the
protocol.

function setOracleData(address rToken, oracleChainlink _oracle) external { **//vulnerable**
oracleData[rToken] = _oracle;
}

It is recommended to review every function and carefully decide if it is
critical that the function be protected by access control mechanisms so
that only the owner of the contract or governance can access/call such
functions successfully.

The Takeaway

Price manipulation, insufficient access control and input validation,
and reentrancy issues are some of the most major issues for CDPs and
lending protocols. However, the DeFi ecosystem is in a constant state of
change, paving way for more security vulnerabilities to emerge and watch
out for in the future. Neglecting preventative measures against hacks
such as these is the first step in how not to create a CDP or lending
protocol.

About Us

Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.

Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.

Contact us for an audit that’s better than the rest. Real audits, not rubber stamps.

*** This is a Security Bloggers Network syndicated blog from Zellic — Research Blog authored by Zellic — Research Blog. Read the original post at: https://www.zellic.io/blog/how-not-to-create-a-cdp-or-lending-protocol

OSZAR »