Skip to main content
Nipun Gupta

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

A review of the attack landscape on DeFi borrowing protocols
Article heading

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:

bZx --- Lost: $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 Finance --- Lost: $4.6M

Agave DAO --- Lost: $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 Finance - Lost: $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.